@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
@@ -0,0 +1,398 @@
1
+ /**
2
+ * Drive: the one guest holding the wheel on a Drive share can start a turn.
3
+ * Everything is decided here, on this machine, on every prompt: Connect only
4
+ * relays, and nothing it sends can give the wheel, change the level or
5
+ * answer an approval. What such a turn may do without the host's tap is the
6
+ * worker's guest gate (lib/chat/worker/guest-gate).
7
+ */
8
+
9
+ import os from "os";
10
+ import path from "path";
11
+ import { randomBytes } from "crypto";
12
+ import type { Session } from "../db";
13
+ import { getSession, registry } from "../chat/registry";
14
+ import { chatRefusal } from "../chat/hold";
15
+ import { listQueue } from "../chat/queued";
16
+ import { hasItem } from "../chat/store";
17
+ import type { ChatServerMessage } from "../chat/events";
18
+ import {
19
+ GATE_MISSING,
20
+ deleteQueuedChat,
21
+ releaseGuestChat,
22
+ sendGuestPrompt,
23
+ stopGuestTurn,
24
+ } from "../chat/runner";
25
+ import { getProject } from "../projects";
26
+ import { cleanBody, noMentions } from "./clean";
27
+ import { admittedGuestIds, hasDrivePrompt, logLive, wheelOf } from "./store";
28
+ import {
29
+ live,
30
+ type DrivePrompt,
31
+ type DriveState,
32
+ type Runtime,
33
+ } from "./runtime";
34
+ import type { DriverState } from "./types";
35
+
36
+ export const DRIVE_MAX_CHARS = 4000;
37
+ export const DRIVE_EVERY_MS = 15_000;
38
+ export const DRIVE_PER_HOUR = 40;
39
+ // How long a prompt may be missing from the queue, unstarted, before it
40
+ // counts as removed by the host (the worker takes it off just before it
41
+ // starts it).
42
+ const REMOVED_AFTER_MS = 3000;
43
+
44
+ export type StopReason =
45
+ "downgraded" | "taken_back" | "kicked" | "left" | "ended";
46
+
47
+ const STOPPED: Record<StopReason, string> = {
48
+ downgraded: "You can now only watch",
49
+ taken_back: "The host took back control",
50
+ kicked: "You were removed",
51
+ left: "You left",
52
+ ended: "The session ended",
53
+ };
54
+
55
+ export interface DriveFx {
56
+ send: (
57
+ shareId: string,
58
+ action: string,
59
+ data?: Record<string, unknown>
60
+ ) => void;
61
+ }
62
+
63
+ const home = (p: string) => path.resolve(p.replace(/^~(?=$|\/)/, os.homedir()));
64
+
65
+ /**
66
+ * Why guests can't drive this session, or null. Only a task's own chat,
67
+ * in its own worktree, run by Claude on this machine.
68
+ */
69
+ export function driveRefusal(s: Session): string | null {
70
+ if (s.view !== "chat" || s.agent_type !== "claude")
71
+ return "Claude chat sessions only";
72
+ if (s.role) return "Not for an orchestrator";
73
+ if (!s.task_prompt) return "Task sessions only";
74
+ if (!s.worktree_path) return "Task sessions with their own worktree only";
75
+ const project = s.project_id ? getProject(s.project_id) : undefined;
76
+ const wt = home(s.worktree_path);
77
+ if (home(s.working_directory) !== wt)
78
+ return "Task sessions with their own worktree only";
79
+ if (project && home(project.working_directory) === wt)
80
+ return "Not in the project's main checkout";
81
+ if (s.archived_at || s.peer_mirror) return "Not for this session";
82
+ if (s.task_status && s.task_status !== "running")
83
+ return "Only while the task is running";
84
+ return chatRefusal(s.id) ? "Not while the task is moving" : null;
85
+ }
86
+
87
+ export function newDrive(shareId: string): DriveState {
88
+ return { wheel: wheelOf(shareId), prompts: [], accepted: [], epoch: 0 };
89
+ }
90
+
91
+ const sessionOf = (rt: Runtime): Session | null => {
92
+ try {
93
+ return getSession(rt.share.session_id);
94
+ } catch {
95
+ return null;
96
+ }
97
+ };
98
+
99
+ // Whether this guest may drive this share right now (not the rate caps).
100
+ function eligibility(rt: Runtime, guestId: string): string | null {
101
+ if (live.runtimes.get(rt.share.id) !== rt || rt.share.ended_at)
102
+ return "This live link has ended";
103
+ if (rt.share.level !== "drive") return "Drive is off for this session";
104
+ const g = rt.guests.get(guestId);
105
+ if (
106
+ rt.drive.wheel?.guestId !== guestId ||
107
+ g?.status !== "admitted" ||
108
+ !admittedGuestIds(rt.share.id).has(guestId)
109
+ )
110
+ return "You're not driving this session";
111
+ const s = sessionOf(rt);
112
+ if (!s) return "This session is gone";
113
+ if (driveRefusal(s)) return "This session can't be driven";
114
+ const w = registry.live.get(s.id);
115
+ if (w && !w.canGuestGate) return GATE_MISSING;
116
+ return null;
117
+ }
118
+
119
+ function limits(rt: Runtime, now: number): string | null {
120
+ const d = rt.drive;
121
+ if (d.prompts.some((p) => p.status !== "running"))
122
+ return "Your last prompt hasn't started yet";
123
+ if (d.prompts.length >= 2) return "Your last prompt hasn't finished yet";
124
+ d.accepted = d.accepted.filter((t) => now - t < 3600_000);
125
+ if (d.accepted.some((t) => now - t < DRIVE_EVERY_MS))
126
+ return "Wait a few seconds between prompts";
127
+ if (d.accepted.length >= DRIVE_PER_HOUR)
128
+ return `You've reached the limit of ${DRIVE_PER_HOUR} prompts an hour`;
129
+ return null;
130
+ }
131
+
132
+ const msgId = (promptId: string) => `guest-${promptId}`;
133
+
134
+ // The agent reads it as a stranger's request; the chat shows only the text.
135
+ export function framed(name: string, text: string): string {
136
+ const who = noMentions(name.replace(/["\[\]\n]/g, ""));
137
+ return `[Live guest "${who}", not the owner of this machine, asks:]\n${noMentions(text)}`;
138
+ }
139
+
140
+ function status(
141
+ rt: Runtime,
142
+ fx: DriveFx,
143
+ p: { id: string; guestId: string },
144
+ s: "running" | "done" | "stopped" | "refused",
145
+ reason?: string
146
+ ) {
147
+ fx.send(rt.share.id, "drive_status", {
148
+ guest_id: p.guestId,
149
+ id: p.id,
150
+ status: s,
151
+ ...(reason && { reason }),
152
+ });
153
+ }
154
+
155
+ /** A drive_prompt from Connect. Returns false when it was dropped as bogus. */
156
+ export function acceptPrompt(
157
+ rt: Runtime,
158
+ frame: { id: string; guest: { id: string }; text: string },
159
+ fx: DriveFx,
160
+ now = Date.now()
161
+ ): boolean {
162
+ const guestId = frame.guest.id;
163
+ const p0 = { id: frame.id, guestId };
164
+ if (
165
+ rt.drive.prompts.some((p) => p.id === frame.id) ||
166
+ hasDrivePrompt(rt.share.id, frame.id)
167
+ )
168
+ return false;
169
+ const why = eligibility(rt, guestId) ?? limits(rt, now);
170
+ if (why) return (status(rt, fx, p0, "refused", why), true);
171
+ const text = cleanBody(frame.text);
172
+ if (!text || [...text].length > DRIVE_MAX_CHARS) {
173
+ status(
174
+ rt,
175
+ fx,
176
+ p0,
177
+ "refused",
178
+ `A prompt is 1 to ${DRIVE_MAX_CHARS} characters`
179
+ );
180
+ return true;
181
+ }
182
+ const name = rt.guests.get(guestId)!.name;
183
+ const wheel = rt.drive.wheel!.wheel;
184
+ const epoch = rt.drive.epoch;
185
+ logLive({
186
+ share_id: rt.share.id,
187
+ kind: "drive_prompt",
188
+ guest_id: guestId,
189
+ guest_name: name,
190
+ ref: frame.id,
191
+ text,
192
+ sent_text: null,
193
+ });
194
+ rt.drive.accepted.push(now);
195
+ const p: DrivePrompt & { cancelled?: boolean } = { ...p0, status: "sending" };
196
+ rt.drive.prompts.push(p);
197
+ const forget = () => {
198
+ rt.drive.prompts = rt.drive.prompts.filter((x) => x !== p);
199
+ };
200
+ void sendGuestPrompt(
201
+ rt.share.session_id,
202
+ {
203
+ id: msgId(frame.id),
204
+ text: framed(name, text),
205
+ origin: {
206
+ kind: "guest-drive",
207
+ label: name,
208
+ body: text,
209
+ share: rt.share.id,
210
+ guestId,
211
+ wheel,
212
+ },
213
+ },
214
+ // Again right before the worker gets it: a Drive that ended meanwhile
215
+ // (or this prompt stopped) sends nothing.
216
+ () =>
217
+ p.cancelled
218
+ ? "You stopped it"
219
+ : rt.drive.epoch !== epoch || rt.drive.wheel?.wheel !== wheel
220
+ ? "Drive ended"
221
+ : eligibility(rt, guestId)
222
+ )
223
+ .then((refused) => {
224
+ if (refused) {
225
+ if (!rt.drive.prompts.includes(p)) return;
226
+ forget();
227
+ status(rt, fx, p, p.cancelled ? "stopped" : "refused", refused);
228
+ } else if (p.status === "sending") p.status = "queued";
229
+ })
230
+ // The error stays on this machine: the guest gets a fixed line.
231
+ .catch(() => {
232
+ if (!rt.drive.prompts.includes(p)) return;
233
+ forget();
234
+ status(rt, fx, p, "refused", "Not sent");
235
+ });
236
+ return true;
237
+ }
238
+
239
+ /** drive_stop: the wheel holder stops their own prompt, never another's. */
240
+ export function stopPrompt(
241
+ rt: Runtime,
242
+ frame: { id: string; guest: { id: string } },
243
+ fx: DriveFx
244
+ ): boolean {
245
+ const w = rt.drive.wheel;
246
+ if (!w || w.guestId !== frame.guest.id) return false;
247
+ const p = rt.drive.prompts.find(
248
+ (x) => x.id === frame.id && x.guestId === frame.guest.id
249
+ ) as (DrivePrompt & { cancelled?: boolean }) | undefined;
250
+ if (!p) return true;
251
+ if (p.status === "running") {
252
+ p.stopWhy = "You stopped it";
253
+ stopGuestTurn(rt.share.session_id, rt.share.id, w.wheel);
254
+ return true;
255
+ }
256
+ p.cancelled = true;
257
+ if (p.status === "queued") {
258
+ deleteQueuedChat(rt.share.session_id, msgId(p.id));
259
+ rt.drive.prompts = rt.drive.prompts.filter((x) => x !== p);
260
+ status(rt, fx, p, "stopped", "You stopped it");
261
+ }
262
+ return true;
263
+ }
264
+
265
+ /**
266
+ * Gives the wheel (or takes it back, with null). In order, before anything
267
+ * can interleave: the new holder is set and logged, so a racing prompt is
268
+ * refused; then the old holder's Drive is released.
269
+ */
270
+ export function setWheel(
271
+ rt: Runtime,
272
+ guestId: string | null,
273
+ reason: StopReason,
274
+ fx: DriveFx
275
+ ): void {
276
+ const old = rt.drive.wheel;
277
+ if (old?.guestId === guestId && guestId) return;
278
+ const next = guestId
279
+ ? { guestId, wheel: randomBytes(9).toString("base64url") }
280
+ : null;
281
+ rt.drive.wheel = next;
282
+ if (old || next)
283
+ logLive({
284
+ share_id: rt.share.id,
285
+ kind: "wheel",
286
+ guest_id: guestId,
287
+ guest_name: guestId ? (rt.guests.get(guestId)?.name ?? null) : null,
288
+ ref: next?.wheel ?? null,
289
+ text: null,
290
+ sent_text: null,
291
+ });
292
+ releaseDrive(rt, reason, fx, old?.wheel ?? "");
293
+ }
294
+
295
+ /**
296
+ * Ends what a Drive left: queued prompts go (here and in the worker), a
297
+ * turn it steers stops (its pending cards expire as denied), and the guest
298
+ * is told. Synchronous: nothing from that wheel is taken in after it.
299
+ */
300
+ export function releaseDrive(
301
+ rt: Runtime,
302
+ reason: StopReason,
303
+ fx: DriveFx,
304
+ wheel = rt.drive.wheel?.wheel ?? ""
305
+ ): void {
306
+ rt.drive.epoch++;
307
+ if (wheel && rt.drive.wheel?.wheel === wheel) rt.drive.wheel = null;
308
+ const prompts = rt.drive.prompts.splice(0);
309
+ if (!wheel && !prompts.length) return;
310
+ releaseGuestChat(rt.share.session_id, rt.share.id, wheel);
311
+ for (const p of prompts)
312
+ status(
313
+ rt,
314
+ fx,
315
+ p,
316
+ p.status === "running" ? "stopped" : "refused",
317
+ STOPPED[reason]
318
+ );
319
+ logLive({
320
+ share_id: rt.share.id,
321
+ kind: "drive_stopped",
322
+ guest_id: prompts[0]?.guestId ?? null,
323
+ guest_name: null,
324
+ ref: reason,
325
+ text: null,
326
+ sent_text: null,
327
+ });
328
+ }
329
+
330
+ /** What the driving guest's prompt is doing, for the host's view. */
331
+ export function driverState(rt: Runtime): DriverState {
332
+ const ps = rt.drive.prompts;
333
+ if (ps.some((p) => p.status === "running"))
334
+ return registry.live.get(rt.share.session_id)?.state === "waiting"
335
+ ? "waiting_on_host"
336
+ : "working";
337
+ return ps.length ? "queued" : "idle";
338
+ }
339
+
340
+ /** A tap on the session's messages: a Drive prompt starting, ending, removed. */
341
+ export function onDriveMessage(
342
+ sessionId: string,
343
+ m: ChatServerMessage,
344
+ fx: DriveFx
345
+ ): void {
346
+ for (const rt of live.runtimes.values()) {
347
+ if (rt.share.session_id !== sessionId || !rt.drive.prompts.length) continue;
348
+ const running = rt.drive.prompts.find((p) => p.status === "running");
349
+ if (m.type === "item" && m.item.kind === "user") {
350
+ const p = rt.drive.prompts.find((x) => msgId(x.id) === m.item.id);
351
+ if (p && p.status !== "running") {
352
+ p.status = "running";
353
+ status(rt, fx, p, "running");
354
+ }
355
+ } else if (
356
+ m.type === "item" &&
357
+ m.item.kind === "note" &&
358
+ m.item.id.startsWith("note-guest-stop-") &&
359
+ running
360
+ ) {
361
+ running.stopWhy = m.item.text.replace(/^Stopped: /, "").slice(0, 100);
362
+ } else if (
363
+ running &&
364
+ ((m.type === "item" && m.item.kind === "turn_end") ||
365
+ (m.type === "state" && m.state === "idle"))
366
+ ) {
367
+ rt.drive.prompts = rt.drive.prompts.filter((p) => p !== running);
368
+ const interrupted =
369
+ m.type === "item" && m.item.kind === "turn_end" && m.item.interrupted;
370
+ if (running.stopWhy || interrupted)
371
+ status(
372
+ rt,
373
+ fx,
374
+ running,
375
+ "stopped",
376
+ running.stopWhy ?? "The host stopped it"
377
+ );
378
+ else status(rt, fx, running, "done");
379
+ } else if (m.type === "queue") {
380
+ for (const p of rt.drive.prompts)
381
+ if (p.status === "queued" && !m.queue.some((q) => q.id === msgId(p.id)))
382
+ setTimeout(
383
+ () => removedByHost(rt, p, fx),
384
+ REMOVED_AFTER_MS
385
+ ).unref?.();
386
+ }
387
+ }
388
+ }
389
+
390
+ // Gone from the queue and never started: the host deleted it.
391
+ function removedByHost(rt: Runtime, p: DrivePrompt, fx: DriveFx) {
392
+ const id = msgId(p.id);
393
+ if (!rt.drive.prompts.includes(p) || p.status !== "queued") return;
394
+ if (listQueue(rt.share.session_id).some((q) => q.id === id)) return;
395
+ if (hasItem(rt.share.session_id, id)) return;
396
+ rt.drive.prompts = rt.drive.prompts.filter((x) => x !== p);
397
+ status(rt, fx, p, "refused", "The host removed it");
398
+ }
@@ -56,11 +56,13 @@ export function stateOf(state: ChatState): LiveState {
56
56
  }
57
57
 
58
58
  // Messages that were not typed by the reader (the agent bus, schedules,
59
- // AgentOS itself) stay on this machine; a sent suggestion is the reader's.
59
+ // AgentOS itself) stay on this machine; a sent suggestion is the reader's,
60
+ // and a Drive prompt the driving guest's.
60
61
  function userFrom(item: Extract<ChatItem, { kind: "user" }>): string | null {
61
62
  const o = item.origin;
62
63
  if (!o) return item.peer || item.from ? null : "host";
63
64
  if (o.kind === "guest") return `guest:${o.label}`;
65
+ if (o.kind === "guest-drive") return `driver:${o.label}`;
64
66
  if (o.kind === "decision") return "host";
65
67
  return null;
66
68
  }
@@ -108,7 +110,12 @@ export function itemMapper(ctx: ShareContext): ItemMapper {
108
110
  if (!f) return null;
109
111
  from = f;
110
112
  }
111
- const raw = toRaw(item);
113
+ // A Drive prompt shows as the guest typed it, not as framed for the agent.
114
+ const raw = toRaw(
115
+ item.kind === "user" && item.origin?.kind === "guest-drive"
116
+ ? { ...item, text: item.origin.body ?? "" }
117
+ : item
118
+ );
112
119
  if (!raw || raw.tools || raw.type === "artifact") return null;
113
120
  const body = clipBytes(take(text ?? raw.text), MAX_TEXT_BYTES);
114
121
  if (item.kind === "user")
package/lib/live/inbox.ts CHANGED
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * What Connect sends down. Only the types below are accepted, and only for a
3
3
  * share this machine minted and still holds. Everything else is dropped and
4
- * counted: nothing downward is executed, interpreted or sent on to an agent.
4
+ * counted: nothing downward is executed or interpreted, and only a Drive
5
+ * prompt from the guest this machine gave the wheel reaches an agent
6
+ * (./drive decides). No type answers an approval, sets the level or moves
7
+ * the wheel: those come only from the host's own API.
5
8
  */
6
9
 
7
10
  import { z } from "zod";
@@ -14,8 +17,10 @@ import {
14
17
  logLive,
15
18
  markLiveEnded,
16
19
  openSuggestions,
20
+ setLevel,
17
21
  } from "./store";
18
22
  import { MAX_LOBBY, MAX_PENDING_CARDS, live, type Runtime } from "./runtime";
23
+ import { acceptPrompt, setWheel, stopPrompt } from "./drive";
19
24
 
20
25
  export const DOWNWARD = [
21
26
  "roster",
@@ -27,6 +32,9 @@ export const DOWNWARD = [
27
32
  "slow_down",
28
33
  "invalid",
29
34
  "admit_failed",
35
+ "access_failed",
36
+ "drive_prompt",
37
+ "drive_stop",
30
38
  ] as const;
31
39
 
32
40
  const id = z.string().min(1).max(64);
@@ -44,6 +52,9 @@ const SCHEMAS = {
44
52
  slow_down: z.object({}),
45
53
  invalid: z.object({}),
46
54
  admit_failed: z.object({ guest_id: id.optional(), reason: z.string() }),
55
+ access_failed: z.object({ reason: z.string().max(200).optional() }),
56
+ drive_prompt: z.object({ id, guest, text: z.string().max(16000) }),
57
+ drive_stop: z.object({ id, guest }),
47
58
  } satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
48
59
 
49
60
  export interface InboxEffects {
@@ -137,12 +148,16 @@ export function handleFrame(
137
148
  const g = (parsed.data as z.infer<typeof SCHEMAS.guest_left>).guest;
138
149
  const known = rt.guests.get(g.id);
139
150
  if (!known) return true;
151
+ if (rt.drive.wheel?.guestId === g.id) setWheel(rt, null, "left", fx);
140
152
  if (known.status === "pending") rt.guests.delete(g.id);
141
153
  else known.online = false;
142
154
  return true;
143
155
  }
144
156
  case "suggestion": {
145
157
  const s = parsed.data as z.infer<typeof SCHEMAS.suggestion>;
158
+ // Watch guests can't suggest. Connect refuses first; this is the machine's own check.
159
+ if (rt.share.level === "watch")
160
+ return (drop("suggestion on a watch-only share"), false);
146
161
  const known = rt.guests.get(s.guest.id);
147
162
  // Only a guest this machine let in can leave a card.
148
163
  if (!known || known.status !== "admitted")
@@ -196,6 +211,37 @@ export function handleFrame(
196
211
  rt.notice = "Your plan's guest limit is reached.";
197
212
  return true;
198
213
  }
214
+ case "access_failed": {
215
+ const why = (parsed.data as z.infer<typeof SCHEMAS.access_failed>).reason;
216
+ rt.notice = why
217
+ ? why.replace(/\s+/g, " ").trim().slice(0, 160)
218
+ : "Connect refused that access level.";
219
+ // Connect didn't take it: this machine goes back to the safer side.
220
+ if (rt.drive.wheel) setWheel(rt, null, "taken_back", fx);
221
+ if (rt.share.level === "drive") {
222
+ rt.share = { ...rt.share, level: "suggest" };
223
+ setLevel(shareId, "suggest");
224
+ fx.send(shareId, "access", { level: "suggest" });
225
+ }
226
+ return true;
227
+ }
228
+ case "drive_prompt":
229
+ return acceptPrompt(
230
+ rt,
231
+ parsed.data as z.infer<typeof SCHEMAS.drive_prompt>,
232
+ fx,
233
+ now
234
+ )
235
+ ? true
236
+ : (drop("duplicate drive prompt"), false);
237
+ case "drive_stop":
238
+ return stopPrompt(
239
+ rt,
240
+ parsed.data as z.infer<typeof SCHEMAS.drive_stop>,
241
+ fx
242
+ )
243
+ ? true
244
+ : (drop("stop from a guest not driving"), false);
199
245
  case "invalid":
200
246
  rt.drops++;
201
247
  return true;
@@ -0,0 +1,74 @@
1
+ import type { Session } from "../db";
2
+ import {
3
+ LEVELS,
4
+ type DriverState,
5
+ type GuestAction,
6
+ type LevelOption,
7
+ type LiveLevel,
8
+ type LiveView,
9
+ } from "./types";
10
+ import { driveRefusal, driverState } from "./drive";
11
+ import type { Guest, Runtime } from "./runtime";
12
+
13
+ const ORDER: LiveLevel[] = ["watch", "suggest", "drive"];
14
+
15
+ /** What the invite dialog and the Live popover offer, with why one is off. */
16
+ export function levelOptions(session: Session | null): LevelOption[] {
17
+ const why = session ? driveRefusal(session) : "Task sessions only";
18
+ return ORDER.map((value) => ({
19
+ value,
20
+ ...LEVELS[value],
21
+ ...(value === "drive" && why
22
+ ? { enabled: false, reason: why }
23
+ : { enabled: true }),
24
+ }));
25
+ }
26
+
27
+ export function guestActions(
28
+ rt: Runtime,
29
+ g: Guest,
30
+ canDrive: boolean
31
+ ): GuestAction[] {
32
+ const drive = rt.share.level === "drive" && canDrive;
33
+ if (g.status === "pending")
34
+ return drive && !rt.drive.wheel
35
+ ? [
36
+ { kind: "admit", label: "Let in" },
37
+ { kind: "admit_drive", label: "Let in to drive" },
38
+ { kind: "deny", label: "Deny" },
39
+ ]
40
+ : [
41
+ { kind: "admit", label: drive ? "Let in" : "Admit" },
42
+ { kind: "deny", label: "Deny" },
43
+ ];
44
+ const kick: GuestAction = { kind: "kick", label: "Kick" };
45
+ if (rt.share.level !== "drive") return [kick];
46
+ if (rt.drive.wheel?.guestId === g.id)
47
+ return [{ kind: "take_back", label: "Take back" }, kick];
48
+ return drive
49
+ ? [{ kind: "give_wheel", label: "Give the wheel" }, kick]
50
+ : [kick];
51
+ }
52
+
53
+ const DRIVING: Record<DriverState, string> = {
54
+ idle: "is driving",
55
+ queued: "is driving · prompt queued",
56
+ working: "is driving · working",
57
+ waiting_on_host: "is driving · waiting on you",
58
+ };
59
+
60
+ /** The guest holding the wheel and the banner the host sees above the composer. */
61
+ export function driveView(rt: Runtime): Pick<LiveView, "driver" | "banner"> {
62
+ const w = rt.drive.wheel;
63
+ const g = w && rt.guests.get(w.guestId);
64
+ if (rt.share.level !== "drive" || !w || !g)
65
+ return { driver: null, banner: null };
66
+ const state = driverState(rt);
67
+ return {
68
+ driver: { guestId: g.id, name: g.name, state },
69
+ banner: {
70
+ text: `${g.name} ${DRIVING[state]}`,
71
+ action: { kind: "take_back", label: "Take back" },
72
+ },
73
+ };
74
+ }
@@ -13,8 +13,17 @@ import { live, type Runtime } from "./runtime";
13
13
  const FLUSH_MS = 1000;
14
14
  const SLOW_FLUSH_MS = 4000;
15
15
 
16
+ /** With "earlier messages" off, nothing from before the link was made goes out. */
17
+ function beforeLink(rt: Runtime, item: ChatItem): boolean {
18
+ return (
19
+ rt.share.history === 0 &&
20
+ (item.createdAt ?? Infinity) < Date.parse(rt.share.created_at)
21
+ );
22
+ }
23
+
16
24
  export function seed(rt: Runtime, sessionId: string): void {
17
25
  for (const item of visible(listItems(sessionId))) {
26
+ if (beforeLink(rt, item)) continue;
18
27
  const frame = rt.map(item);
19
28
  if (frame) rt.ring.upsert(frame);
20
29
  }
@@ -55,6 +64,7 @@ export function onMessage(sessionId: string, m: ChatServerMessage): void {
55
64
  live.socket?.send(rt.share.id, "state", { state: stateOf(m.state) });
56
65
  } else if (m.type === "item") {
57
66
  const item = m.item;
67
+ if (beforeLink(rt, item)) continue;
58
68
  if (item.kind === "turn_end") {
59
69
  flush(rt);
60
70
  live.socket?.send(rt.share.id, "state", { state: "idle" });
@@ -15,6 +15,25 @@ export interface Guest {
15
15
  at: number;
16
16
  }
17
17
 
18
+ /** A Drive prompt this machine took and hasn't seen finish. */
19
+ export interface DrivePrompt {
20
+ id: string;
21
+ guestId: string;
22
+ // sending: on its way to the worker; queued: behind a turn; running.
23
+ status: "sending" | "queued" | "running";
24
+ // Why its turn was stopped, when it was (the guest's Stop, a cap).
25
+ stopWhy?: string;
26
+ }
27
+
28
+ export interface DriveState {
29
+ wheel: { guestId: string; wheel: string } | null;
30
+ prompts: DrivePrompt[];
31
+ // When prompts were taken, for the rate caps.
32
+ accepted: number[];
33
+ // Bumped whenever a Drive ends, so a send still on its way sends nothing.
34
+ epoch: number;
35
+ }
36
+
18
37
  /** What one live share holds in memory; everything durable is in the log. */
19
38
  export interface Runtime {
20
39
  share: LiveShareRow;
@@ -30,6 +49,7 @@ export interface Runtime {
30
49
  slowUntil: number;
31
50
  notice: string | null;
32
51
  drops: number;
52
+ drive: DriveState;
33
53
  }
34
54
 
35
55
  export interface LiveGlobals {