@saadnvd1/agent-os 0.3.778 → 0.3.780

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/app/api/live-shares/[id]/route.ts +21 -3
  2. package/app/api/live-shares/route.test.ts +36 -0
  3. package/app/api/live-shares/route.ts +14 -3
  4. package/components/Chat/Approval.tsx +13 -2
  5. package/components/Chat/EventRow.tsx +5 -0
  6. package/components/Chat/Queue.tsx +27 -15
  7. package/components/Live/DriveBanner.tsx +42 -0
  8. package/components/Live/InviteDialog.tsx +44 -13
  9. package/components/Live/LiveChip.tsx +34 -13
  10. package/components/Live/LivePanel.tsx +26 -9
  11. package/components/Live/LobbyCard.tsx +18 -21
  12. package/components/ui/segmented.tsx +2 -1
  13. package/data/live/queries.ts +25 -5
  14. package/lib/chat/driver.ts +4 -0
  15. package/lib/chat/drivers/claude-approvals.ts +93 -25
  16. package/lib/chat/drivers/claude.ts +2 -1
  17. package/lib/chat/events.ts +21 -1
  18. package/lib/chat/queued.ts +57 -10
  19. package/lib/chat/registry.ts +2 -0
  20. package/lib/chat/runner.ts +65 -2
  21. package/lib/chat/socket.ts +12 -9
  22. package/lib/chat/worker/gate-testing.ts +131 -0
  23. package/lib/chat/worker/guest-gate.ts +511 -0
  24. package/lib/chat/worker/host.ts +50 -6
  25. package/lib/chat/worker/main.ts +16 -2
  26. package/lib/chat/worker/protocol.ts +11 -0
  27. package/lib/connect/control-api.ts +6 -2
  28. package/lib/db/migrations/20261030T1200_live_share_level.ts +19 -0
  29. package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
  30. package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
  31. package/lib/live/clean.ts +6 -0
  32. package/lib/live/drive.ts +398 -0
  33. package/lib/live/frames.ts +9 -2
  34. package/lib/live/inbox.ts +47 -1
  35. package/lib/live/levels.ts +74 -0
  36. package/lib/live/publisher.ts +10 -0
  37. package/lib/live/runtime.ts +20 -0
  38. package/lib/live/service.ts +133 -10
  39. package/lib/live/store.ts +54 -3
  40. package/lib/live/testing.ts +281 -0
  41. package/lib/live/types.ts +52 -0
  42. package/lib/share/connect.ts +1 -0
  43. package/package.json +1 -1
@@ -1,8 +1,20 @@
1
1
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2
- import type { LiveExpiry, LiveResponse, LiveView } from "@/lib/live/types";
2
+ import type {
3
+ LiveExpiry,
4
+ LiveInvite,
5
+ LiveLevel,
6
+ LiveResponse,
7
+ LiveView,
8
+ } from "@/lib/live/types";
3
9
  import { liveKeys } from "./keys";
4
10
 
5
- export type { LiveExpiry, LiveResponse, LiveView } from "@/lib/live/types";
11
+ export type {
12
+ LiveExpiry,
13
+ LiveInvite,
14
+ LiveLevel,
15
+ LiveResponse,
16
+ LiveView,
17
+ } from "@/lib/live/types";
6
18
 
7
19
  async function json<T>(res: Response): Promise<T> {
8
20
  const data = await res.json().catch(() => ({}));
@@ -31,6 +43,7 @@ export function useLiveShare(sessionId: string | undefined, enabled = true) {
31
43
  select: (r) => ({
32
44
  linked: r.linked,
33
45
  share: r.shares[0] as LiveView | undefined,
46
+ invite: r.invite as LiveInvite | undefined,
34
47
  }),
35
48
  refetchInterval: (q) => (q.state.data?.shares.length ? 2000 : 60_000),
36
49
  });
@@ -39,14 +52,21 @@ export function useLiveShare(sessionId: string | undefined, enabled = true) {
39
52
  export function useCreateLive() {
40
53
  const qc = useQueryClient();
41
54
  return useMutation({
42
- mutationFn: async (req: { sessionId: string; expiresIn: LiveExpiry }) =>
43
- json<LiveView>(await send("/api/live-shares", "POST", req)),
55
+ mutationFn: async (req: {
56
+ sessionId: string;
57
+ expiresIn: LiveExpiry;
58
+ level: LiveLevel;
59
+ history: boolean;
60
+ }) => json<LiveView>(await send("/api/live-shares", "POST", req)),
44
61
  onSuccess: () => qc.invalidateQueries({ queryKey: liveKeys.all }),
45
62
  });
46
63
  }
47
64
 
48
65
  export type LiveAction =
49
- | { action: "admit" | "deny" | "kick"; guestId: string }
66
+ | { action: "admit"; guestId: string; drive?: boolean }
67
+ | { action: "deny" | "kick"; guestId: string }
68
+ | { action: "wheel"; guestId: string | null }
69
+ | { action: "level"; level: LiveLevel }
50
70
  | { action: "send"; suggestionId: string; text?: string }
51
71
  | { action: "dismiss"; suggestionId: string };
52
72
 
@@ -2,6 +2,7 @@ import type {
2
2
  McpServerConfig,
3
3
  PermissionMode,
4
4
  } from "@anthropic-ai/claude-agent-sdk";
5
+ import type { GuestGate } from "./worker/guest-gate";
5
6
  import type {
6
7
  ApprovalDecision,
7
8
  ChatAccess,
@@ -60,6 +61,9 @@ export interface ChatStartOptions {
60
61
  // refused, null to let it go on to them, or a warning the agent is shown
61
62
  // while the call still goes on.
62
63
  toolGuard?: ToolGuard;
64
+ // Holds a live guest's turn to the host's taps (./worker/guest-gate). A
65
+ // driver without it can't take Drive prompts (the worker's caps say so).
66
+ guestGate?: GuestGate;
63
67
  // The note handed back to the agent right after its context is compacted
64
68
  // (lib/chat/compact-context), empty for none. Only Claude Code has a hook
65
69
  // for it; other drivers ignore it.
@@ -15,6 +15,7 @@ import type {
15
15
  } from "../events";
16
16
  import { toolDiff, toolTitle } from "../tools";
17
17
  import type { ToolGuard } from "../driver";
18
+ import { guestGateHook, type GuestGate } from "../worker/guest-gate";
18
19
 
19
20
  type Approval = Extract<ChatItem, { kind: "approval" }>;
20
21
 
@@ -101,45 +102,94 @@ export class Approvals {
101
102
  private planFile: () => string | undefined = () => undefined
102
103
  ) {}
103
104
 
105
+ // Who steers the running turn, when a live guest does (lib/chat/worker/
106
+ // guest-gate): every card it raises is theirs, with no Always.
107
+ guestOf: () => { name: string } | null = () => null;
108
+ // Calls the guest gate let through, by tool use id: the permission prompt
109
+ // that may follow for one is already answered.
110
+ private gatePassed = new Set<string>();
111
+
104
112
  canUseTool: CanUseTool = (toolName, input, options) => {
105
113
  // Already answered on its card by the question hook.
106
114
  if (toolName === "AskUserQuestion" && "answers" in input)
107
115
  return Promise.resolve({ behavior: "allow", updatedInput: input });
116
+ if (options.toolUseID && this.gatePassed.delete(options.toolUseID))
117
+ return Promise.resolve({ behavior: "allow", updatedInput: input });
108
118
  const questions =
109
119
  toolName === "AskUserQuestion"
110
120
  ? ((input as { questions?: ChatQuestion[] }).questions ?? [])
111
121
  : undefined;
112
- const item: Approval = {
113
- id: `approval-${options.toolUseID}`,
114
- kind: "approval",
115
- toolName,
116
- title: options.title ?? toolTitle(toolName, input),
117
- input,
118
- diff: toolDiff(toolName, input),
119
- questions,
120
- canAlways:
121
- !questions &&
122
- !!options.suggestions?.length &&
123
- !options.suppressAlwaysAllowRule,
124
- status: "pending",
125
- createdAt: Date.now(),
126
- };
122
+ const guest = this.guestOf() ?? undefined;
123
+ return this.ask(
124
+ {
125
+ id: `approval-${options.toolUseID}`,
126
+ kind: "approval",
127
+ toolName,
128
+ title: options.title ?? toolTitle(toolName, input),
129
+ input,
130
+ diff: toolDiff(toolName, input),
131
+ questions,
132
+ canAlways:
133
+ !guest &&
134
+ !questions &&
135
+ !!options.suggestions?.length &&
136
+ !options.suppressAlwaysAllowRule,
137
+ status: "pending",
138
+ createdAt: Date.now(),
139
+ ...(guest && { guest }),
140
+ },
141
+ options.signal,
142
+ guest ? undefined : options.suggestions
143
+ );
144
+ };
145
+
146
+ // A step of a guest-steered turn: the host's card, never Always.
147
+ askGuest(
148
+ toolName: string,
149
+ input: Record<string, unknown>,
150
+ toolUseID: string,
151
+ signal: AbortSignal,
152
+ guest: { name: string }
153
+ ): Promise<PermissionResult> {
154
+ return this.ask(
155
+ {
156
+ id: `approval-${toolUseID}`,
157
+ kind: "approval",
158
+ toolName,
159
+ title: toolTitle(toolName, input),
160
+ input,
161
+ diff: toolDiff(toolName, input),
162
+ canAlways: false,
163
+ status: "pending",
164
+ createdAt: Date.now(),
165
+ guest,
166
+ },
167
+ signal
168
+ );
169
+ }
170
+
171
+ passed(toolUseID: string): void {
172
+ if (toolUseID) this.gatePassed.add(toolUseID);
173
+ }
174
+
175
+ private ask(
176
+ item: Approval,
177
+ signal: AbortSignal,
178
+ suggestions?: Parameters<CanUseTool>[2]["suggestions"]
179
+ ): Promise<PermissionResult> {
127
180
  return new Promise<PermissionResult>((resolve) => {
128
- this.pending.set(item.id, {
129
- item,
130
- suggestions: options.suggestions,
131
- resolve,
132
- });
133
- options.signal.addEventListener("abort", () => {
181
+ this.pending.set(item.id, { item, suggestions, resolve });
182
+ const expire = () =>
134
183
  this.settle(item.id, "expired", {
135
184
  behavior: "deny",
136
185
  message: DENIED,
137
186
  });
138
- });
187
+ if (signal.aborted) return queueMicrotask(expire);
188
+ signal.addEventListener("abort", expire);
139
189
  this.emit({ type: "item", item });
140
190
  this.emit({ type: "state", state: "waiting" });
141
191
  });
142
- };
192
+ }
143
193
 
144
194
  // Full access approves every tool before canUseTool is asked, questions
145
195
  // included, so the agent's questions come through this hook instead.
@@ -209,11 +259,14 @@ export class Approvals {
209
259
  } else if (answer.decision === "deny") {
210
260
  this.settle(id, "denied", { behavior: "deny", message: DENIED });
211
261
  } else {
262
+ // A guest's card has no Always: an "always" answer is a plain allow.
212
263
  this.settle(id, "allowed", {
213
264
  behavior: "allow",
214
265
  updatedInput: input,
215
266
  updatedPermissions:
216
- answer.decision === "always" ? p.suggestions : undefined,
267
+ answer.decision === "always" && !p.item.guest
268
+ ? p.suggestions
269
+ : undefined,
217
270
  });
218
271
  }
219
272
  }
@@ -270,9 +323,24 @@ export function guardHook(guard: ToolGuard): HookCallback {
270
323
  export function preToolUseHooks(
271
324
  approvals: Approvals,
272
325
  guard?: ToolGuard,
273
- subagentModels?: HookCallback | null
326
+ subagentModels?: HookCallback | null,
327
+ // A live guest's turn: every call but a worktree read waits for the host.
328
+ guest?: { gate: GuestGate; cwd: string }
274
329
  ): HookCallbackMatcher[] {
330
+ if (guest)
331
+ approvals.guestOf = () =>
332
+ guest.gate.steer ? { name: guest.gate.steer.name } : null;
275
333
  return [
334
+ // First, and on every tool: no steer, no effect. It waits on the host
335
+ // for as long as the card is up (the turn's own cap ends it sooner).
336
+ ...(guest
337
+ ? [
338
+ {
339
+ hooks: [guestGateHook(approvals, guest.gate, guest.cwd)],
340
+ timeout: 24 * 60 * 60,
341
+ },
342
+ ]
343
+ : []),
276
344
  // Sets the model of a subagent call by its type. It only fills in the
277
345
  // input, so the guard and permissions below still see the call.
278
346
  ...(subagentModels
@@ -222,7 +222,8 @@ export const claudeDriver: ChatDriver = {
222
222
  PreToolUse: preToolUseHooks(
223
223
  approvals,
224
224
  options.toolGuard,
225
- subagentHook(options.env)
225
+ subagentHook(options.env),
226
+ options.guestGate && { gate: options.guestGate, cwd: options.cwd }
226
227
  ),
227
228
  ...(options.compactContext && {
228
229
  SessionStart: compactedHooks(options.compactContext),
@@ -84,7 +84,12 @@ export type ChatOriginKind =
84
84
  | "device"
85
85
  // A suggestion from a guest on a live link, sent by the reader after
86
86
  // reading it. Its label is the guest's name.
87
- | "guest";
87
+ | "guest"
88
+ // A prompt from the guest holding the wheel on a Drive live link: it went
89
+ // in without the host's tap, so every step it takes past reading the
90
+ // task's files waits for one (lib/chat/worker/guest-gate). Its body is
91
+ // the guest's own text; the agent got it framed as a stranger's.
92
+ | "guest-drive";
88
93
 
89
94
  export interface ChatOrigin {
90
95
  kind: ChatOriginKind;
@@ -102,6 +107,11 @@ export interface ChatOrigin {
102
107
  // Only Away mode's own lines: the turn it starts isn't anyone's activity
103
108
  // (lib/away/activity).
104
109
  away?: true;
110
+ // A guest-drive message: the live share, the guest it came from, and the
111
+ // wheel it was sent under (a new one each time the host gives it).
112
+ share?: string;
113
+ guestId?: string;
114
+ wheel?: string;
105
115
  }
106
116
 
107
117
  // Who sent a message, carried with it through the queue and the worker.
@@ -109,6 +119,10 @@ export interface SentBy {
109
119
  from?: string;
110
120
  peer?: PeerMessage;
111
121
  origin?: ChatOrigin;
122
+ // The host sent it themselves: set only by their own composer and taps
123
+ // (never by an agent, event or schedule). Only such a message ends a live
124
+ // guest's steer (lib/chat/worker/guest-gate).
125
+ host?: true;
112
126
  }
113
127
 
114
128
  export interface McpServerView {
@@ -285,6 +299,9 @@ export type ChatItem =
285
299
  status: "pending" | "allowed" | "denied" | "answered" | "expired";
286
300
  // Allowed by No approvals mode, not by the reader.
287
301
  auto?: boolean;
302
+ // Asked in a turn a live guest steered: only the reader's tap answers
303
+ // it (never No approvals mode), and it has no Always.
304
+ guest?: { name: string };
288
305
  })
289
306
  | (Base & {
290
307
  // Work the agent runs alongside the turn: a background shell, a
@@ -323,6 +340,9 @@ export interface QueuedMessage {
323
340
  imageCount?: number;
324
341
  fileCount?: number;
325
342
  createdAt: number;
343
+ // A live guest's Drive prompt: who it's from and what they typed (the
344
+ // text is what the agent gets, framed).
345
+ guest?: { name: string; body: string };
326
346
  }
327
347
 
328
348
  // A file or folder the agent would match for an @mention.
@@ -26,7 +26,7 @@ export function listQueue(sessionId: string): QueuedMessage[] {
26
26
  return (
27
27
  db
28
28
  .prepare(
29
- `SELECT id, text, image_count, files, created_at
29
+ `SELECT id, text, image_count, files, sent_by, created_at
30
30
  FROM chat_queue WHERE session_id = ? ORDER BY position, created_at`
31
31
  )
32
32
  .all(sessionId) as {
@@ -34,15 +34,57 @@ export function listQueue(sessionId: string): QueuedMessage[] {
34
34
  text: string;
35
35
  image_count: number;
36
36
  files: string | null;
37
+ sent_by: string | null;
37
38
  created_at: number;
38
39
  }[]
39
- ).map((r) => ({
40
- id: r.id,
41
- text: r.text,
42
- imageCount: r.image_count || undefined,
43
- fileCount: r.files ? (JSON.parse(r.files) as unknown[]).length : undefined,
44
- createdAt: r.created_at,
45
- }));
40
+ ).map((r) => {
41
+ const o = r.sent_by ? (JSON.parse(r.sent_by) as SentBy).origin : undefined;
42
+ return {
43
+ id: r.id,
44
+ text: r.text,
45
+ imageCount: r.image_count || undefined,
46
+ fileCount: r.files
47
+ ? (JSON.parse(r.files) as unknown[]).length
48
+ : undefined,
49
+ createdAt: r.created_at,
50
+ ...(o?.kind === "guest-drive"
51
+ ? { guest: { name: o.label, body: o.body ?? r.text } }
52
+ : {}),
53
+ };
54
+ });
55
+ }
56
+
57
+ // Who sent the message that goes next, without taking it.
58
+ export function peekNextSentBy(sessionId: string): SentBy | null {
59
+ const row = db
60
+ .prepare(
61
+ `SELECT sent_by FROM chat_queue WHERE session_id = ? ORDER BY position, created_at LIMIT 1`
62
+ )
63
+ .get(sessionId) as { sent_by: string | null } | undefined;
64
+ if (!row) return null;
65
+ return row.sent_by ? (JSON.parse(row.sent_by) as SentBy) : {};
66
+ }
67
+
68
+ // Drops every queued message whose sender matches; returns the ids dropped.
69
+ export function deleteQueuedWhere(
70
+ sessionId: string,
71
+ match: (by: SentBy) => boolean
72
+ ): string[] {
73
+ return db.transaction(() => {
74
+ const rows = db
75
+ .prepare(
76
+ `SELECT id, sent_by FROM chat_queue WHERE session_id = ? AND sent_by IS NOT NULL`
77
+ )
78
+ .all(sessionId) as { id: string; sent_by: string }[];
79
+ const ids = rows
80
+ .filter((r) => match(JSON.parse(r.sent_by) as SentBy))
81
+ .map((r) => r.id);
82
+ const del = db.prepare(
83
+ `DELETE FROM chat_queue WHERE session_id = ? AND id = ?`
84
+ );
85
+ for (const id of ids) del.run(sessionId, id);
86
+ return ids;
87
+ })();
46
88
  }
47
89
 
48
90
  // At the back. A retried send with the same id is only queued once.
@@ -56,7 +98,12 @@ export function enqueue(
56
98
  } & SentBy
57
99
  ): void {
58
100
  const images = m.images?.length ? JSON.stringify(m.images) : null;
59
- const sentBy: SentBy = { from: m.from, peer: m.peer, origin: m.origin };
101
+ const sentBy: SentBy = {
102
+ from: m.from,
103
+ peer: m.peer,
104
+ origin: m.origin,
105
+ host: m.host,
106
+ };
60
107
  if (m.text.length > MAX_QUEUED_TEXT)
61
108
  throw new Error("That message is too long to queue");
62
109
  if (images && images.length > MAX_QUEUED_IMAGES)
@@ -77,7 +124,7 @@ export function enqueue(
77
124
  images,
78
125
  m.images?.length ?? 0,
79
126
  m.files?.length ? JSON.stringify(m.files) : null,
80
- m.from || m.peer || m.origin ? JSON.stringify(sentBy) : null,
127
+ m.from || m.peer || m.origin || m.host ? JSON.stringify(sentBy) : null,
81
128
  Date.now()
82
129
  );
83
130
  }
@@ -45,6 +45,8 @@ export interface Live {
45
45
  canBackground?: boolean;
46
46
  // Whether it takes "mcp_auth" (sign in to an MCP server).
47
47
  canMcpAuth?: boolean;
48
+ // Whether it holds a live guest's turns to the host's taps (Drive).
49
+ canGuestGate?: boolean;
48
50
  // From an older build and idle: retired once its guess at the next
49
51
  // message is in, or this fires.
50
52
  retiring?: NodeJS.Timeout;
@@ -21,6 +21,7 @@ import type {
21
21
  ApprovalDecision,
22
22
  ChatFile,
23
23
  ChatImage,
24
+ ChatOrigin,
24
25
  ChatServerMessage,
25
26
  ChatState,
26
27
  Compacting,
@@ -47,6 +48,7 @@ import {
47
48
  import { noteToolResult } from "../health/env/recordHit";
48
49
  import {
49
50
  deleteQueued,
51
+ deleteQueuedWhere,
50
52
  editQueued,
51
53
  enqueue,
52
54
  listQueue,
@@ -78,7 +80,7 @@ import {
78
80
  waitForExit,
79
81
  } from "./worker/client";
80
82
  import { buildId, isStaleWorker } from "../build";
81
- import type { WorkerEvent } from "./worker/protocol";
83
+ import { GUEST_GATE_CAP, type WorkerEvent } from "./worker/protocol";
82
84
  import type { WorkerClient } from "./worker/client";
83
85
  import { DEMO_REFUSAL, demoMode } from "../security/demo";
84
86
  import { chatHold, chatRefusal } from "./hold";
@@ -118,6 +120,7 @@ export async function carryOutPlan(
118
120
  id: `user-carry-${planId}`,
119
121
  text: CARRY_OUT,
120
122
  origin: { kind: "decision", label: "Saad" },
123
+ host: true,
121
124
  },
122
125
  timeoutMs
123
126
  );
@@ -348,6 +351,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
348
351
  canFresh: !!hello.caps?.includes("fresh"),
349
352
  canBackground: !!hello.caps?.includes("background"),
350
353
  canMcpAuth: !!hello.caps?.includes("mcp_auth"),
354
+ canGuestGate: !!hello.caps?.includes(GUEST_GATE_CAP),
351
355
  };
352
356
  // From an older build and busy, or running background work: it goes at
353
357
  // its next turn boundary, by itself if it can. Messages meanwhile wait
@@ -402,10 +406,11 @@ function queuedIfHeld(
402
406
  return true;
403
407
  }
404
408
 
405
- const sentBy = ({ from, peer, origin }: SentBy): SentBy => ({
409
+ const sentBy = ({ from, peer, origin, host }: SentBy): SentBy => ({
406
410
  from,
407
411
  peer,
408
412
  origin,
413
+ host,
409
414
  });
410
415
 
411
416
  // Already a message in the chat or its queue.
@@ -460,6 +465,64 @@ export async function sendChat(
460
465
  });
461
466
  }
462
467
 
468
+ export const GATE_MISSING = "Reopen this chat on the host to let guests drive";
469
+
470
+ /**
471
+ * A live guest's Drive prompt (lib/live/drive): always its own turn, behind
472
+ * anything running. `check` runs again right before the worker gets it, in
473
+ * the same tick, so a Drive that ended meanwhile sends nothing. Returns why
474
+ * it wasn't sent, or null.
475
+ */
476
+ export async function sendGuestPrompt(
477
+ sessionId: string,
478
+ m: { id: string; text: string; origin: ChatOrigin },
479
+ check: () => string | null
480
+ ): Promise<string | null> {
481
+ const held = () =>
482
+ chatRefusal(sessionId) ??
483
+ chatHold(sessionId) ??
484
+ (settingUp(sessionId) || launchPending(sessionId)
485
+ ? "The task isn't running yet"
486
+ : null);
487
+ if (held()) return held();
488
+ const live = await ensureLive(sessionId);
489
+ if (!live.canGuestGate) return GATE_MISSING;
490
+ const why = held() ?? check();
491
+ if (why) return why;
492
+ if (taken(sessionId, m.id)) return "Already sent";
493
+ live.worker.command({ type: "send", ...m, queue: true });
494
+ return null;
495
+ }
496
+
497
+ // A live share's Drive ended: its queued prompts go here and in the worker
498
+ // (which also stops a turn it steers and refuses that wheel from now on).
499
+ export function releaseGuestChat(
500
+ sessionId: string,
501
+ share: string,
502
+ wheel: string
503
+ ): void {
504
+ const gone = deleteQueuedWhere(
505
+ sessionId,
506
+ (by) => by.origin?.kind === "guest-drive" && by.origin.share === share
507
+ );
508
+ if (gone.length) emitQueue(sessionId);
509
+ registry.live
510
+ .get(sessionId)
511
+ ?.worker.command({ type: "guest_release", share, wheel });
512
+ }
513
+
514
+ // The driving guest's Stop: the worker ends the turn only if their wheel
515
+ // steers it.
516
+ export function stopGuestTurn(
517
+ sessionId: string,
518
+ share: string,
519
+ wheel: string
520
+ ): void {
521
+ registry.live
522
+ .get(sessionId)
523
+ ?.worker.command({ type: "guest_stop", share, wheel });
524
+ }
525
+
463
526
  // The queue lives in SQLite, so it can be changed with no worker running;
464
527
  // a worker reads it only when it sends the next message.
465
528
  export function editQueuedChat(sessionId: string, id: string, text: string) {
@@ -150,15 +150,18 @@ function onChatMessage(
150
150
  const id = send.id;
151
151
  // Taken in now: a new worker can take seconds to start.
152
152
  if (id) reply({ type: "accepted", id });
153
- void sendChat(sessionId, { ...send, queue: true }).catch((err) => {
154
- if (id)
155
- reply({
156
- type: "send_failed",
157
- id,
158
- message: err instanceof Error ? err.message : String(err),
159
- });
160
- fail(err);
161
- });
153
+ // Typed in the host's own composer (this socket is theirs).
154
+ void sendChat(sessionId, { ...send, queue: true, host: true }).catch(
155
+ (err) => {
156
+ if (id)
157
+ reply({
158
+ type: "send_failed",
159
+ id,
160
+ message: err instanceof Error ? err.message : String(err),
161
+ });
162
+ fail(err);
163
+ }
164
+ );
162
165
  // "Session 4" is named after its first message.
163
166
  run(titleChatFromMessage(sessionId, send.text));
164
167
  } else if (msg.type === "queue_edit" && typeof msg.text === "string")