@saadnvd1/agent-os 0.3.779 → 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.
@@ -25,6 +25,10 @@ import type { CompactFirst } from "../stale";
25
25
 
26
26
  export const PROTOCOL_VERSION = 1;
27
27
 
28
+ // In a worker's hello caps: it holds a live guest's turns to the host's taps
29
+ // (./guest-gate). Without it no Drive prompt is sent to it.
30
+ export const GUEST_GATE_CAP = "guest-gate";
31
+
28
32
  // Server -> worker. A send carries the id its user item gets, so a send
29
33
  // retried after a dropped connection is never run twice.
30
34
  export type WorkerCommand =
@@ -37,6 +41,7 @@ export type WorkerCommand =
37
41
  from?: string;
38
42
  peer?: PeerMessage;
39
43
  origin?: ChatOrigin;
44
+ host?: true;
40
45
  // From the composer: waits its turn behind a running one, in the
41
46
  // queue, rather than joining it.
42
47
  queue?: boolean;
@@ -79,6 +84,12 @@ export type WorkerCommand =
79
84
  // (now, if idle) the agent's conversation ends and the next worker
80
85
  // starts a new one with today's brief and permissions.
81
86
  | { type: "fresh" }
87
+ // A live share's Drive is over (downgrade, take-back, kick, end): its
88
+ // queued prompts go, a turn it steers stops, and nothing more from it is
89
+ // taken in (lib/chat/worker/guest-gate).
90
+ | { type: "guest_release"; share: string; wheel: string }
91
+ // The driving guest's Stop: ends a turn their wheel steers, never another.
92
+ | { type: "guest_stop"; share: string; wheel: string }
82
93
  | { type: "close" };
83
94
 
84
95
  // Worker -> server: what's live right now on connecting, then events.
@@ -0,0 +1,20 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261030T1300_chat_guest_steers",
5
+ up: (db) => {
6
+ // A live guest's steer on a chat (lib/chat/worker/guest-gate): kept
7
+ // here so a worker that restarts mid-Drive still gates its turns.
8
+ db.exec(
9
+ `CREATE TABLE IF NOT EXISTS chat_guest_steers (
10
+ session_id TEXT PRIMARY KEY,
11
+ share_id TEXT NOT NULL,
12
+ wheel TEXT NOT NULL,
13
+ guest_id TEXT NOT NULL,
14
+ name TEXT NOT NULL,
15
+ tool_calls INTEGER NOT NULL DEFAULT 0,
16
+ started_at INTEGER NOT NULL
17
+ )`
18
+ );
19
+ },
20
+ } satisfies Migration;
@@ -0,0 +1,14 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261030T1400_chat_guest_steers_ended",
5
+ up: (db) => {
6
+ // When the steer's Drive ended: a worker that restarts after it has no
7
+ // guest turn left to gate.
8
+ const cols = db.prepare(`PRAGMA table_info(chat_guest_steers)`).all() as {
9
+ name: string;
10
+ }[];
11
+ if (!cols.some((c) => c.name === "ended_at"))
12
+ db.exec(`ALTER TABLE chat_guest_steers ADD COLUMN ended_at INTEGER`);
13
+ },
14
+ } satisfies Migration;
package/lib/live/clean.ts CHANGED
@@ -38,6 +38,12 @@ export function cleanName(input: unknown): string | null {
38
38
  return s;
39
39
  }
40
40
 
41
+ /**
42
+ * Claude Code reads an `@path` in a message as a file to attach, with no
43
+ * tool call a gate could stop: a guest's `@` can't be one.
44
+ */
45
+ export const noMentions = (t: string) => t.replace(/@/g, "\uff20");
46
+
41
47
  /** A block of text (a suggestion): newlines and tabs kept, or null. */
42
48
  export function cleanBody(input: unknown, max = MAX_SUGGESTION): string | null {
43
49
  if (typeof input !== "string" || !wellFormed(input)) return null;
@@ -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",
@@ -28,6 +33,8 @@ export const DOWNWARD = [
28
33
  "invalid",
29
34
  "admit_failed",
30
35
  "access_failed",
36
+ "drive_prompt",
37
+ "drive_stop",
31
38
  ] as const;
32
39
 
33
40
  const id = z.string().min(1).max(64);
@@ -46,6 +53,8 @@ const SCHEMAS = {
46
53
  invalid: z.object({}),
47
54
  admit_failed: z.object({ guest_id: id.optional(), reason: z.string() }),
48
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 }),
49
58
  } satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
50
59
 
51
60
  export interface InboxEffects {
@@ -139,6 +148,7 @@ export function handleFrame(
139
148
  const g = (parsed.data as z.infer<typeof SCHEMAS.guest_left>).guest;
140
149
  const known = rt.guests.get(g.id);
141
150
  if (!known) return true;
151
+ if (rt.drive.wheel?.guestId === g.id) setWheel(rt, null, "left", fx);
142
152
  if (known.status === "pending") rt.guests.delete(g.id);
143
153
  else known.online = false;
144
154
  return true;
@@ -201,9 +211,37 @@ export function handleFrame(
201
211
  rt.notice = "Your plan's guest limit is reached.";
202
212
  return true;
203
213
  }
204
- case "access_failed":
205
- rt.notice = "Connect refused that access level.";
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
+ }
206
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);
207
245
  case "invalid":
208
246
  rt.drops++;
209
247
  return true;
@@ -1,29 +1,74 @@
1
+ import type { Session } from "../db";
1
2
  import {
2
3
  LEVELS,
4
+ type DriverState,
3
5
  type GuestAction,
4
6
  type LevelOption,
5
7
  type LiveLevel,
8
+ type LiveView,
6
9
  } from "./types";
10
+ import { driveRefusal, driverState } from "./drive";
7
11
  import type { Guest, Runtime } from "./runtime";
8
12
 
9
13
  const ORDER: LiveLevel[] = ["watch", "suggest", "drive"];
10
14
 
11
15
  /** What the invite dialog and the Live popover offer, with why one is off. */
12
- export function levelOptions(): LevelOption[] {
16
+ export function levelOptions(session: Session | null): LevelOption[] {
17
+ const why = session ? driveRefusal(session) : "Task sessions only";
13
18
  return ORDER.map((value) => ({
14
19
  value,
15
20
  ...LEVELS[value],
16
- ...(value === "drive"
17
- ? { enabled: false, reason: "Coming soon" }
21
+ ...(value === "drive" && why
22
+ ? { enabled: false, reason: why }
18
23
  : { enabled: true }),
19
24
  }));
20
25
  }
21
26
 
22
- export function guestActions(_rt: Runtime, g: Guest): GuestAction[] {
23
- return g.status === "pending"
24
- ? [
25
- { kind: "admit", label: "Admit" },
26
- { kind: "deny", label: "Deny" },
27
- ]
28
- : [{ kind: "kick", label: "Kick" }];
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
+ };
29
74
  }