Fix chat/send userId attribution + eliminate duplicate browser tabs#387
Conversation
#376: chat/send without explicit senderId now resolves to the seeded human owner instead of the session identity (which was "@cli" or "Claude Code" depending on how jtag was invoked). Single-owner system: CLI commands are the human's tool, messages should be attributed to them. Also fix CLI session resolution in SessionDaemonServer to prefer the human owner over creating a separate @cli user. #335: Remove Phase 6 browser launch from parallel-start.sh. The SystemOrchestrator.detectAndManageBrowser() already handles browser lifecycle — the shell script was racing it and opening a duplicate tab on every cold start.
There was a problem hiding this comment.
Pull request overview
This PR aims to (1) fix incorrect sender attribution for chat/send-style message injection by defaulting to the seeded human owner, and (2) prevent duplicate browser tabs by removing browser-launch logic from parallel-start.sh in favor of the orchestrator-managed lifecycle.
Changes:
- Resolve CLI sessions to the seeded human owner (with a fallback to the prior uniqueId-based behavior when no owner exists yet).
- Change
collaboration/chat/senddefault sender resolution to prefer an explicitsenderId, otherwise resolve a human owner. - Remove shell-script browser launching to avoid racing
SystemOrchestrator.detectAndManageBrowser(). - Regenerate command registries/constants/schemas (new commands added to generated outputs).
Reviewed changes
Copilot reviewed 7 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| src/commands/collaboration/chat/send/server/ChatSendServerCommand.ts | Changes default sender resolution; adds helper to resolve human owner fallback. |
| src/daemons/session-daemon/server/SessionDaemonServer.ts | Updates CLI session identity mapping to prefer seeded owner. |
| src/scripts/parallel-start.sh | Removes Phase 6 browser open logic; keeps hot-restart reconnect wait. |
| src/shared/generated-command-constants.ts | Regenerated command constant list with additional commands. |
| src/server/generated.ts | Regenerated server command registry with additional commands. |
| src/browser/generated.ts | Regenerated browser command registry with additional commands. |
| src/generated-command-schemas.json | Regenerated command schema metadata with additional commands. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // 2. Get sender — explicit senderId takes priority, otherwise resolve human owner. | ||
| // params.userId reflects the session identity (could be @cli, agent, system). | ||
| // For chat/send without explicit sender, the human owner is the correct attribution | ||
| // since this is a single-owner system and CLI/agent are the human's tools. | ||
| const sender = params.senderId | ||
| ? await this.findUserById(params.senderId, params) | ||
| : await this.findHumanOwnerOrFallback(params); | ||
|
|
There was a problem hiding this comment.
chat/send now overrides the sender to the first type:'human' user whenever senderId is omitted. This breaks the documented behavior (“defaults to current user from session”) and will misattribute messages in multi-user scenarios (e.g., test clients connected as different humans) because their session userId is ignored.
Suggestion: keep params.userId as the default sender, and only remap to the seeded owner when the session user is a tool/placeholder identity (e.g., user.type !== 'human', or uniqueId is '@cli', or uniqueId starts with 'anon-').
| // 2. Get sender — explicit senderId takes priority, otherwise resolve human owner. | |
| // params.userId reflects the session identity (could be @cli, agent, system). | |
| // For chat/send without explicit sender, the human owner is the correct attribution | |
| // since this is a single-owner system and CLI/agent are the human's tools. | |
| const sender = params.senderId | |
| ? await this.findUserById(params.senderId, params) | |
| : await this.findHumanOwnerOrFallback(params); | |
| // 2. Get sender | |
| // - Explicit senderId takes priority. | |
| // - Otherwise, default to the session user (params.userId) when it is a real human. | |
| // - Only remap to the seeded human owner when the session user is a tool/placeholder | |
| // identity (e.g., user.type !== 'human', uniqueId === '@cli', or uniqueId.startsWith('anon-')), | |
| // or when there is no session user. | |
| let sender; | |
| if (params.senderId) { | |
| sender = await this.findUserById(params.senderId, params); | |
| } else if (params.userId) { | |
| const sessionUser = await this.findUserById(params.userId as UUID, params); | |
| const sessionEntity = sessionUser.entity; | |
| const uniqueId = (sessionEntity as any).uniqueId as string | undefined; | |
| const isPlaceholderIdentity = | |
| sessionEntity.type !== 'human' || | |
| uniqueId === '@cli' || | |
| (typeof uniqueId === 'string' && uniqueId.startsWith('anon-')); | |
| sender = isPlaceholderIdentity | |
| ? await this.findHumanOwnerOrFallback(params) | |
| : sessionUser; | |
| } else { | |
| sender = await this.findHumanOwnerOrFallback(params); | |
| } |
| limit: 1, | ||
| context: params.context, | ||
| sessionId: params.sessionId, | ||
| }); | ||
|
|
||
| if (result.success && result.items && result.items.length > 0) { | ||
| const owner = result.items[0]; | ||
| return { id: owner.id, entity: owner }; | ||
| } | ||
|
|
||
| // No human owner seeded yet — fall back to session userId |
There was a problem hiding this comment.
findHumanOwnerOrFallback() uses DataList with filter: { type: 'human' }, limit: 1, which can return an anonymous browser user (uniqueId starting with anon-) or an arbitrary human depending on DB ordering. This can defeat the goal of attributing to the seeded owner.
Suggestion: mirror SessionDaemonServer.findSeededHumanOwner() logic by fetching multiple human users and selecting the first non-anonymous one (uniqueId not starting with anon-), ideally with a deterministic orderBy (e.g., createdAt asc/desc) to avoid nondeterministic attribution.
| limit: 1, | |
| context: params.context, | |
| sessionId: params.sessionId, | |
| }); | |
| if (result.success && result.items && result.items.length > 0) { | |
| const owner = result.items[0]; | |
| return { id: owner.id, entity: owner }; | |
| } | |
| // No human owner seeded yet — fall back to session userId | |
| // Fetch multiple humans with deterministic ordering so we can skip anonymous users | |
| limit: 50, | |
| orderBy: { createdAt: 'asc' }, | |
| context: params.context, | |
| sessionId: params.sessionId, | |
| }); | |
| if (result.success && result.items && result.items.length > 0) { | |
| // Prefer the first non-anonymous human (uniqueId not starting with "anon-") | |
| for (const user of result.items) { | |
| const uniqueId = (user as any).uniqueId as string | undefined; | |
| if (!uniqueId || !uniqueId.startsWith('anon-')) { | |
| return { id: user.id, entity: user }; | |
| } | |
| } | |
| } | |
| // No suitable human owner found — fall back to session userId |
| case 'cli': { | ||
| // CLI identity: Use uniqueId (@cli or env user) | ||
| const cliUniqueId = identity?.uniqueId || '@cli'; | ||
| this.log.info(`💻 CLI session: resolving uniqueId=${cliUniqueId}`); | ||
|
|
||
| const existingCli = await this.findUserByUniqueId(cliUniqueId); | ||
| if (existingCli) { | ||
| user = existingCli; | ||
| // CLI = the human owner, same as browser. Single-owner system: | ||
| // ./jtag commands are Joel, not a separate "@cli" user. | ||
| const seededOwner = await this.findSeededHumanOwner(); | ||
| if (seededOwner) { | ||
| user = seededOwner; | ||
| this.log.info(`✅ CLI session → seeded owner: ${user.displayName}`); | ||
| } else { | ||
| user = await this.createUser(params); | ||
| // No seeded owner yet (pre-seed or first boot) — fall back to uniqueId | ||
| const cliUniqueId = identity?.uniqueId || '@cli'; | ||
| this.log.info(`💻 CLI session: no seeded owner, resolving uniqueId=${cliUniqueId}`); | ||
| const existingCli = await this.findUserByUniqueId(cliUniqueId); | ||
| if (existingCli) { | ||
| user = existingCli; | ||
| } else { | ||
| user = await this.createUser(params); |
There was a problem hiding this comment.
The cli clientType is now forcibly resolved to the seeded human owner when one exists. If the CLI/websocket client is also used for automation or multi-user testing, this collapses distinct identities into one and can change authorization/audit behavior.
Suggestion: gate this behavior behind an explicit “single-owner dev mode” config/flag, or restrict it to the specific placeholder uniqueId (e.g., '@cli') so other CLI identities can still resolve via uniqueId/userId as before.
…egression + #978 nullish-coalescing cleanup THREE related changes from a live `npm start` test session 2026-05-01: 1. ALPHA-GAP-ANALYSIS.md is now THE single source of truth - Refreshed to 2026-05-01 with live-verified state - New "Today's Snapshot" section: what worked + broke in real `npm start` from feat/airc-send-command (#977 + #978 + #979 stack) - 3 new live-observed bugs in Phase 0: · NEW-A: continuum-core-server SIGABRT in vendored llama.cpp Metal `llm_build_smallthinker` cleanup. Real stack captured. · NEW-B: seed retries 21x/480s before giving up (concrete fail-fast fix designed) · NEW-C: shared/config.ts has /Users/joelteply/... HARDCODED (Carl-blocker) - 10 closed-since-Apr-17 items marked DONE - 21 new high-numbered open issues catalogued - Shortest path to "Install. Talk to AI." spelled out - Open PRs (continuum #976 #977 #978 #979 + airc #387) listed - Workflow note per Joel 2026-05-01: merge-to-canary, not PR-and-wait - Two predecessor docs DELETED + content folded: · docs/PRE-ALPHA-GAP-ANALYSIS.md (predates DMR pivot) · docs/planning/CARL-AND-DEV-PATH-TO-WORKING.md (interim) 2. SystemMilestones.ts — fix the #977 regression Original #977 added CORE_READY as SERVER_READY dep; consequence was browser never opens when Rust core SIGABRTs (Joel observed: "I don't see a browser"). This commit decouples them — SERVER_READY depends only on SERVER_START. SYSTEM_HEALTHY (monitoring signal) still requires both. Live-verified: browser opens despite SIGABRT-looping core. Joel confirmed: "opened good job." 3. AiLocalInference{Start,Status}ServerCommand.ts — || → ?? Three nullish-coalescing fixes left uncommitted from PR #978. NEXT STEPS for the test devices Joel just mentioned: 1. Verify NEW-C path bug repros on fresh test device (it should) 2. File NEW-A + NEW-C as GitHub issues 3. Trace seed-time llm_build_smallthinker call chain — likely a Candle-on-chat-hot-path bug per PR891 pivot 4. Implement seed fail-fast (~30 LOC) so install UX doesn't rot 8 minutes per attempt Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…irst backbone for Claude Code / Codex / openclaws / Hermes
Captures Joel's strategic framing live during the 2026-04-30 AI capacity
squeeze (Codex auto-downgraded to mini, paid Anthropic users hitting
rate limits, public AI stocks correcting on demand-outpaces-supply).
Architecture (3 layers):
L1: External agent (Claude Code, Codex, openclaws, Hermes, ...)
Pointed at local Continuum via ANTHROPIC_BASE_URL / OPENAI_BASE_URL.
No code changes required to the external agent.
L2: Continuum local truth (Rust core)
anthropic_compat.rs (already exists) + openai_compat.rs (to add)
sit in front of the same AIAdapter trait. CandleAdapter +
LlamaCppAdapter + MLX backend already implement it.
LocalClaudeCodeProvider.ts already does the proof-of-concept
end-to-end (start server + ANTHROPIC_BASE_URL + spawn Claude Code).
L3: airc capability mesh (multi-machine multiplier)
Peers publish loaded models + free VRAM + endpoints over a
dedicated #ai-capability airc channel. Layer 2 routers consult
the peer table + route requests to the best-fit peer. Inference
traffic itself goes peer-to-peer via Tailscale or LAN.
Native-truth + thin-SDK rule applied (per Joel's CLAUDE.md): Rust core
is truth, TS daemon is the SDK, external agents are outermost SDKs that
consume via standard HTTP. No layer reimplements another's truth.
PC-paradigm framing: small / nimble / collaborative / scaling /
distributed across all our hardware. Ship pretty-well-first, then build
to dominance. The PC didn't beat the mainframe by being faster on day
one — it beat it by being everywhere, owned individually, no central
permission to compute.
Training flywheel as the moat:
- LocalClaudeCodeProvider already has captureTraining=true
- TrainingDataAccumulator already routes to academy pipeline
- forge-alloy already builds LoRAs from captured interactions
- Cloud APIs literally cannot train per-user on private data without
crossing publicly-committed lines. We can — locally, opt-in,
transparently. That's the differentiator.
Phased delivery plan:
Phase 0 (this week, in flight): airc#381 layer A (PR CambrianTech#387) + B (CambrianTech#385
merged), airc#383 (PR CambrianTech#384), continuum CambrianTech#722/CambrianTech#56/CambrianTech#75 stabilization
Phase 1 (1-2 weeks): single-machine local fallback for Codex via
OPENAI_BASE_URL + rate-limit-detect middleware
Phase 2 (1 week): airc capability channel + peer announcements
Phase 3 (2-3 weeks): multi-peer routing across the household grid
Phase 4: UX polish + training-flywheel generalization
Document includes:
- Full bug + Rust-enhancement triage (CambrianTech#722, CambrianTech#56, CambrianTech#75, CambrianTech#71, CambrianTech#73, CambrianTech#39,
CambrianTech#765, CambrianTech#582, CambrianTech#860, CambrianTech#770, CambrianTech#637, CambrianTech#908) with how each blocks or
composes with the integration
- Cross-references to existing arch docs (PERSONA-COGNITION-RUST-
MIGRATION, PERSONA-CONTEXT-PAGING, RECIPE-EXECUTION-RUNTIME,
RESOURCE-ARCHITECTURE, MLX-BACKEND, FORGE-ALLOY-SPEC)
- Open questions (license/ToS, capability staleness, auth shim,
cost accounting, model coherence across peers)
- Out-of-scope clarifications (training across peers, single-request
distributed inference, replacing Continuum web UI)
- Action items for the mesh — concrete first claims for each peer
Why we wrote this NOW: the capacity squeeze tipping users toward local
is also tipping AI peers (us) toward "we won't be able to design
tomorrow." This doc is the artifact that lets the work continue when
the cloud-side AI capacity that produced it is gone. Read this first;
the substrate it describes is buildable from surfaces already in
workers/continuum-core/, src/system/sentinel/coding-agents/,
src/daemons/ai-provider-daemon/, and the airc mesh. None of it is
hypothetical.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Summary
chat/sendwithout explicitsenderIdnow resolves to the seeded human owner. Previously resolved to "Claude Code" (agent detection) or "@cli" depending on how jtag was invoked. Before/after visible in chat export: message #777bb2 shows "Claude Code", message #5a1c5b shows "Joel".parallel-start.sh. TheSystemOrchestrator.detectAndManageBrowser()already handles browser lifecycle — the shell script was racing it and opening a duplicate tab on every cold start.Test plan
npm run build:ts— compiles cleannpm start— deploys successfully./jtag collaboration/chat/send --room="general" --message="test"— shows senderName: "Joel", senderType: "human"Closes #376, closes #335