@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.
@@ -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 {
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Live shares from the host's side: mint, run, decide on guests, end.
3
3
  * Nothing a guest sends reaches an agent except through sendSuggestion,
4
- * which the host taps.
4
+ * which the host taps, and a Drive prompt from the guest the host gave the
5
+ * wheel (./drive), whose turn the worker's guest gate holds to the host's taps.
5
6
  */
6
7
 
7
8
  import { loadConnect } from "../connect/config";
@@ -17,10 +18,19 @@ import {
17
18
  type LiveLevel,
18
19
  type LiveView,
19
20
  } from "./types";
20
- import { guestActions, levelOptions } from "./levels";
21
+ import { driveView, guestActions, levelOptions } from "./levels";
22
+ import {
23
+ driveRefusal,
24
+ newDrive,
25
+ onDriveMessage,
26
+ releaseDrive,
27
+ setWheel,
28
+ type DriveFx,
29
+ } from "./drive";
30
+ import type { Session } from "../db";
21
31
  import { listItems } from "../chat/store";
22
32
  import { visible } from "../share/snapshot";
23
- import { cleanBody } from "./clean";
33
+ import { cleanBody, noMentions } from "./clean";
24
34
  import { itemMapper } from "./frames";
25
35
  import { handleFrame } from "./inbox";
26
36
  import { onMessage, seed, flush } from "./publisher";
@@ -63,6 +73,18 @@ export function makeSocket(factory?: WsFactory): LiveSocket {
63
73
 
64
74
  const socket = (): LiveSocket => (live.socket ??= makeSocket());
65
75
 
76
+ const fx: DriveFx = {
77
+ send: (id, action, data) => live.socket?.send(id, action, data),
78
+ };
79
+
80
+ const sessionOrNull = (id: string): Session | null => {
81
+ try {
82
+ return getSession(id);
83
+ } catch {
84
+ return null;
85
+ }
86
+ };
87
+
66
88
  function stateOfSession(sessionId: string) {
67
89
  const l = registry.live.get(sessionId);
68
90
  const s = l?.state;
@@ -87,6 +109,7 @@ function attach(row: LiveShareRow): Runtime {
87
109
  slowUntil: 0,
88
110
  notice: null,
89
111
  drops: 0,
112
+ drive: newDrive(row.id),
90
113
  };
91
114
  live.runtimes.set(row.id, rt);
92
115
  try {
@@ -99,6 +122,7 @@ function attach(row: LiveShareRow): Runtime {
99
122
  /** Stops publishing a share and drops its connection; the row stays as history. */
100
123
  function stop(shareId: string, markEnded = false): void {
101
124
  const rt = live.runtimes.get(shareId);
125
+ if (rt) releaseDrive(rt, "ended", fx);
102
126
  if (markEnded) markLiveEnded(shareId);
103
127
  if (rt?.flushTimer) clearTimeout(rt.flushTimer);
104
128
  live.runtimes.delete(shareId);
@@ -110,6 +134,7 @@ export function startLive(): void {
110
134
  if (live.started) return;
111
135
  live.started = true;
112
136
  addTap(onMessage);
137
+ addTap((sessionId, m) => onDriveMessage(sessionId, m, fx));
113
138
  for (const row of activeLiveShares()) attach(row);
114
139
  setInterval(sweep, SWEEP_MS).unref();
115
140
  }
@@ -141,6 +166,8 @@ export async function createLive(
141
166
  ): Promise<LiveView> {
142
167
  startLive();
143
168
  const session = getSession(sessionId);
169
+ if (level === "drive" && driveRefusal(session))
170
+ throw new ShareError(`Drive: ${driveRefusal(session)}.`, 400);
144
171
  if (activeLiveShareFor(sessionId))
145
172
  throw new ShareError("This session already has a live link.", 409);
146
173
  const ctx = shareContext();
@@ -171,16 +198,46 @@ export async function createLive(
171
198
  export function changeLevel(id: string, level: LiveLevel): void {
172
199
  const rt = runtimeFor(id);
173
200
  if (!(level in LEVELS)) throw new ShareError("Unknown level.", 400);
174
- if (!levelOptions().find((o) => o.value === level)?.enabled)
175
- throw new ShareError(`${LEVELS[level].label} isn't available.`, 400);
201
+ const option = levelOptions(sessionOrNull(rt.share.session_id)).find(
202
+ (o) => o.value === level
203
+ );
204
+ if (!option?.enabled)
205
+ throw new ShareError(
206
+ `${LEVELS[level].label} isn't available${option?.reason ? `: ${option.reason}` : ""}.`,
207
+ 400
208
+ );
176
209
  if (rt.share.level === level) return;
177
- // Synchronous and first: from here the machine treats the share as `level`.
210
+ // Synchronous and first: from here the machine treats the share as
211
+ // `level`, so a racing Drive prompt is refused. Then what Drive left goes.
178
212
  rt.share = { ...rt.share, level };
179
213
  setLevel(id, level);
214
+ if (level !== "drive") setWheel(rt, null, "downgraded", fx);
180
215
  rt.notice = null;
181
216
  live.socket?.send(id, "access", { level });
182
217
  }
183
218
 
219
+ /** Gives the wheel to an admitted guest of a Drive share, or takes it back (null). */
220
+ export function giveWheel(id: string, guestId: string | null): void {
221
+ const rt = runtimeFor(id);
222
+ if (rt.share.level !== "drive")
223
+ throw new ShareError("Drive is off for this share.", 409);
224
+ if (guestId) {
225
+ const g = rt.guests.get(guestId);
226
+ if (g?.status !== "admitted" || !admittedGuestIds(id).has(guestId))
227
+ throw new ShareError("That guest isn't in the session.", 404);
228
+ const s = sessionOrNull(rt.share.session_id);
229
+ const why = s ? driveRefusal(s) : "This session is gone";
230
+ if (why) throw new ShareError(`Drive: ${why}.`, 409);
231
+ }
232
+ if ((rt.drive.wheel?.guestId ?? null) === guestId) return;
233
+ setWheel(rt, guestId, "taken_back", fx);
234
+ rt.notice = null;
235
+ live.socket?.send(id, "access", {
236
+ level: "drive",
237
+ driver_guest_id: guestId,
238
+ });
239
+ }
240
+
184
241
  /** What the invite dialog offers for a session. */
185
242
  export function inviteFor(sessionId: string): LiveInvite {
186
243
  const count = Math.min(
@@ -190,7 +247,7 @@ export function inviteFor(sessionId: string): LiveInvite {
190
247
  RING_ITEMS
191
248
  );
192
249
  return {
193
- levels: levelOptions(),
250
+ levels: levelOptions(sessionOrNull(sessionId)),
194
251
  history: {
195
252
  label: `Include the ${count} earlier message${count === 1 ? "" : "s"}`,
196
253
  default: true,
@@ -201,7 +258,8 @@ export function inviteFor(sessionId: string): LiveInvite {
201
258
 
202
259
  /** Ends access: Connect disconnects every guest, then this machine lets go. */
203
260
  export async function endLive(id: string): Promise<void> {
204
- runtimeFor(id);
261
+ // Before anything awaits: no Drive prompt is taken in from here.
262
+ releaseDrive(runtimeFor(id), "ended", fx);
205
263
  await revokeRemote(id);
206
264
  markLiveEnded(id);
207
265
  stop(id);
@@ -225,14 +283,23 @@ function logGuest(rt: Runtime, kind: "admit" | "deny" | "kick", g: Guest) {
225
283
  });
226
284
  }
227
285
 
228
- export function admit(id: string, guestId: string): void {
286
+ export function admit(
287
+ id: string,
288
+ guestId: string,
289
+ opts: { drive?: boolean } = {}
290
+ ): void {
229
291
  const rt = runtimeFor(id);
230
292
  const g = guestOf(rt, guestId);
231
293
  if (g.status !== "pending") throw new ShareError("Already let in.", 409);
294
+ if (opts.drive && rt.share.level !== "drive")
295
+ throw new ShareError("Drive is off for this share.", 409);
296
+ if (opts.drive && rt.drive.wheel)
297
+ throw new ShareError("Someone else is driving.", 409);
232
298
  logGuest(rt, "admit", g);
233
299
  g.status = "admitted";
234
300
  rt.notice = null;
235
301
  live.socket?.send(id, "admit", { guest_id: g.id });
302
+ if (opts.drive) giveWheel(id, g.id);
236
303
  }
237
304
 
238
305
  export function deny(id: string, guestId: string): void {
@@ -247,6 +314,7 @@ export function deny(id: string, guestId: string): void {
247
314
  export function kick(id: string, guestId: string): void {
248
315
  const rt = runtimeFor(id);
249
316
  const g = guestOf(rt, guestId);
317
+ if (rt.drive.wheel?.guestId === g.id) setWheel(rt, null, "kicked", fx);
250
318
  logGuest(rt, "kick", g);
251
319
  rt.guests.delete(g.id);
252
320
  live.socket?.send(id, "kick", { guest_id: g.id });
@@ -266,8 +334,10 @@ export async function sendSuggestion(
266
334
  const rt = runtimeFor(id);
267
335
  const card = openSuggestions(id).find((s) => s.id === suggestionId);
268
336
  if (!card) throw new ShareError("That suggestion is gone.", 404);
269
- const text = cleanBody(edited ?? card.text);
270
- if (!text) throw new ShareError("The message is empty.", 400);
337
+ const body = cleanBody(edited ?? card.text);
338
+ if (!body) throw new ShareError("The message is empty.", 400);
339
+ // It started as a guest's text, edited or not.
340
+ const text = noMentions(body);
271
341
  const key = `${id}:${suggestionId}`;
272
342
  if (sending.has(key)) throw new ShareError("Already sending.", 409);
273
343
  sending.add(key);
@@ -275,6 +345,7 @@ export async function sendSuggestion(
275
345
  await sendChatConfirmed(rt.share.session_id, {
276
346
  text,
277
347
  origin: { kind: "guest", label: card.guestName },
348
+ host: true,
278
349
  });
279
350
  logLive({
280
351
  share_id: id,
@@ -327,6 +398,9 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
327
398
  names.set(k, (names.get(k) ?? 0) + 1);
328
399
  }
329
400
  const admitted = admittedGuestIds(id);
401
+ const session = sessionOrNull(rt.share.session_id);
402
+ const levels = levelOptions(session);
403
+ const canDrive = !!levels.find((l) => l.value === "drive")?.enabled;
330
404
  return {
331
405
  id,
332
406
  sessionId: rt.share.session_id,
@@ -338,8 +412,9 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
338
412
  connected: live.socket?.connected ?? false,
339
413
  notice: rt.notice,
340
414
  level: rt.share.level,
341
- levels: levelOptions(),
415
+ levels,
342
416
  history: rt.share.history === 1,
417
+ ...driveView(rt),
343
418
  guests: shown
344
419
  .filter((g) => g.status === "pending" || admitted.has(g.id))
345
420
  .map((g) => ({
@@ -349,7 +424,7 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
349
424
  online: g.online,
350
425
  duplicateName:
351
426
  (names.get(g.name.normalize("NFKC").toLowerCase()) ?? 0) > 1,
352
- actions: guestActions(rt, g),
427
+ actions: guestActions(rt, g, canDrive),
353
428
  })),
354
429
  suggestions: openSuggestions(id),
355
430
  };
package/lib/live/store.ts CHANGED
@@ -24,7 +24,13 @@ export type LogKind =
24
24
  | "suggestion"
25
25
  | "sent"
26
26
  | "dismissed"
27
- | "level";
27
+ | "level"
28
+ // Drive: who holds the wheel (guest_id, ref = the wheel's own id; both
29
+ // null when taken back), a prompt they sent (ref = its id), and why a
30
+ // Drive's turn or queue was stopped (ref = the reason).
31
+ | "wheel"
32
+ | "drive_prompt"
33
+ | "drive_stopped";
28
34
 
29
35
  export interface LogRow {
30
36
  id: number;
@@ -148,6 +154,33 @@ export function admittedGuestIds(shareId: string): Set<string> {
148
154
  return out;
149
155
  }
150
156
 
157
+ /** Who holds the wheel, from the log: a kick or a level other than Drive drops it. */
158
+ export function wheelOf(
159
+ shareId: string
160
+ ): { guestId: string; wheel: string } | null {
161
+ let w: { guestId: string; wheel: string } | null = null;
162
+ for (const l of listLog(shareId)) {
163
+ if (l.kind === "wheel")
164
+ w = l.guest_id && l.ref ? { guestId: l.guest_id, wheel: l.ref } : null;
165
+ else if (l.kind === "level" && l.ref !== "drive") w = null;
166
+ else if (
167
+ (l.kind === "kick" || l.kind === "deny") &&
168
+ l.guest_id === w?.guestId
169
+ )
170
+ w = null;
171
+ }
172
+ return w;
173
+ }
174
+
175
+ /** Whether a Drive prompt with this id was ever logged for the share. */
176
+ export function hasDrivePrompt(shareId: string, ref: string): boolean {
177
+ return !!db
178
+ .prepare(
179
+ `SELECT 1 FROM live_share_log WHERE share_id = ? AND kind = 'drive_prompt' AND ref = ? LIMIT 1`
180
+ )
181
+ .get(shareId, ref);
182
+ }
183
+
151
184
  /** Whether a suggestion with this id was ever logged for the share. */
152
185
  export function hasSuggestion(shareId: string, ref: string): boolean {
153
186
  return !!db
@@ -0,0 +1,281 @@
1
+ /**
2
+ * For tests: a fake Connect (its control API and its cable) and a Drive-ready
3
+ * task session, so a test can mint a live share, play Connect's side of the
4
+ * socket and watch what goes up. Import it before ./service.
5
+ */
6
+
7
+ import crypto from "crypto";
8
+ import fs from "fs";
9
+ import http from "http";
10
+ import path from "path";
11
+ import { randomUUID } from "crypto";
12
+ import type { AddressInfo } from "net";
13
+ import { tempDir } from "@/lib/test-temp";
14
+ import type { WorkerCommand } from "@/lib/chat/worker/protocol";
15
+
16
+ export const SHARE = "live00000001";
17
+
18
+ export async function liveHarness() {
19
+ const dir = tempDir("agentos-live-");
20
+ const keys = crypto.generateKeyPairSync("ed25519");
21
+ const seen: { method: string; url: string; body: string }[] = [];
22
+ const server = http.createServer((req, res) => {
23
+ let body = "";
24
+ req.on("data", (c) => (body += c));
25
+ req.on("end", () => {
26
+ seen.push({ method: req.method!, url: req.url!, body });
27
+ const mint = req.method === "POST" && req.url!.endsWith("/live-shares");
28
+ res.writeHead(mint ? 201 : 200, { "Content-Type": "application/json" });
29
+ res.end(
30
+ JSON.stringify(
31
+ mint
32
+ ? {
33
+ id: SHARE,
34
+ url: "https://runagentos.com/s/tok",
35
+ status: "active",
36
+ expires_at: new Date(Date.now() + 3600_000).toISOString(),
37
+ ends_with_session: false,
38
+ max_guests: 5,
39
+ }
40
+ : { id: SHARE, status: "revoked" }
41
+ )
42
+ );
43
+ });
44
+ });
45
+ await new Promise<void>((r) => server.listen(0, "127.0.0.1", r));
46
+ process.env.AGENTOS_CONNECT_DIR = dir;
47
+ fs.writeFileSync(
48
+ path.join(dir, "connect.json"),
49
+ JSON.stringify({
50
+ machineId: "abcd1234",
51
+ hostname: "m-0123456789abcdef.runagentos.com",
52
+ apiUrl: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
53
+ enabled: true,
54
+ }),
55
+ { mode: 0o600 }
56
+ );
57
+ fs.writeFileSync(
58
+ path.join(dir, "machine.key"),
59
+ keys.privateKey.export({ type: "pkcs8", format: "pem" }).toString(),
60
+ { mode: 0o600 }
61
+ );
62
+
63
+ const { live } = await import("./runtime");
64
+ const svc = await import("./service");
65
+ const { getDb } = await import("@/lib/db");
66
+ const { createProject } = await import("@/lib/projects");
67
+ const { seedSession } = await import("@/lib/orchestrator/testing");
68
+ const { writePart } = await import("@/lib/db/session-parts");
69
+ const { partOf } = await import("@/lib/db/session-parts/columns");
70
+
71
+ /** Sets session columns, wherever each one lives. */
72
+ function setColumns(id: string, set: Record<string, unknown>) {
73
+ for (const [k, v] of Object.entries(set)) {
74
+ const part = partOf(k);
75
+ if (part) writePart(getDb(), part, id, { [k]: v } as never);
76
+ else
77
+ getDb().prepare(`UPDATE sessions SET ${k} = ? WHERE id = ?`).run(v, id);
78
+ }
79
+ }
80
+
81
+ interface Fake {
82
+ out: Record<string, unknown>[];
83
+ handlers: Record<string, (d?: unknown) => void>;
84
+ }
85
+ let fake: Fake | null = null;
86
+ const factory = () => {
87
+ const f: Fake = { out: [], handlers: {} };
88
+ fake = f;
89
+ return {
90
+ readyState: 1,
91
+ send: (d: string) => f.out.push(JSON.parse(d)),
92
+ close: () => {},
93
+ terminate: () => {},
94
+ on: (e: string, fn: (d?: unknown) => void) => {
95
+ f.handlers[e] = fn;
96
+ },
97
+ };
98
+ };
99
+ const ident = JSON.stringify({ channel: "LiveHostChannel", share_id: SHARE });
100
+
101
+ /** A frame from Connect, as it arrives on the cable. */
102
+ const down = (frame: Record<string, unknown>) =>
103
+ fake!.handlers.message(
104
+ JSON.stringify({
105
+ identifier: ident,
106
+ message: { share_id: SHARE, ...frame },
107
+ })
108
+ );
109
+
110
+ /** What this machine sent up, by action. */
111
+ const up = (action?: string) =>
112
+ fake!.out
113
+ .filter((m) => m.command === "message")
114
+ .map((m) => JSON.parse(m.data as string) as Record<string, unknown>)
115
+ .filter((m) => !action || m.action === action);
116
+
117
+ /** A Claude task chat in its own worktree: what Drive needs. */
118
+ function taskChat(
119
+ cwd = tempDir("agentos-wt-"),
120
+ change: Record<string, unknown> = {}
121
+ ): string {
122
+ const project = createProject({
123
+ name: `p-${randomUUID().slice(0, 6)}`,
124
+ workingDirectory: tempDir("agentos-main-"),
125
+ });
126
+ const id = seedSession({
127
+ projectId: project.id,
128
+ name: "Pairing",
129
+ view: "chat",
130
+ task: true,
131
+ });
132
+ setColumns(id, {
133
+ agent_type: "claude",
134
+ task_prompt: "build the thing",
135
+ worktree_path: cwd,
136
+ working_directory: cwd,
137
+ ...change,
138
+ });
139
+ return id;
140
+ }
141
+
142
+ async function open(
143
+ sessionId: string,
144
+ level: "watch" | "suggest" | "drive" = "drive"
145
+ ) {
146
+ const view = await svc.createLive(sessionId, "1h", level, true);
147
+ fake!.handlers.message(JSON.stringify({ type: "welcome" }));
148
+ fake!.handlers.message(
149
+ JSON.stringify({ type: "confirm_subscription", identifier: ident })
150
+ );
151
+ return view;
152
+ }
153
+
154
+ function admit(gid = "g00000000001", name = "Ahmed", drive = false) {
155
+ down({ type: "join_request", guest: { id: gid, name } });
156
+ svc.admit(SHARE, gid, { drive });
157
+ down({ type: "presence", guest: { id: gid, name }, online: true });
158
+ }
159
+
160
+ const { gatedHost, tick } = await import("@/lib/chat/worker/gate-testing");
161
+ const { registry, getSession } = await import("@/lib/chat/registry");
162
+ const { listQueue } = await import("@/lib/chat/queued");
163
+ const { onDriveMessage } = await import("./drive");
164
+ const publisher = await import("./publisher");
165
+ const fx = {
166
+ send: (id: string, action: string, data?: Record<string, unknown>) =>
167
+ live.socket?.send(id, action, data),
168
+ };
169
+ const settle = async () => {
170
+ for (let i = 0; i < 6; i++) await tick();
171
+ };
172
+
173
+ /**
174
+ * A task chat with its live share at `level`, a guest admitted (at the
175
+ * wheel unless suggest), and a real chat worker over a stub agent
176
+ * registered as its live one. The test file mocks the drivers with
177
+ * gate-testing's. `publish` also sends its items up, as the publisher
178
+ * would (paced, so statuses can arrive late).
179
+ */
180
+ async function driving(
181
+ opts: {
182
+ level?: "suggest" | "drive";
183
+ guestId?: string;
184
+ publish?: boolean;
185
+ } = {}
186
+ ) {
187
+ const guestId = opts.guestId ?? "g00000000001";
188
+ const sessionId = taskChat();
189
+ const session = getSession(sessionId);
190
+ const commands: WorkerCommand[] = [];
191
+ const w = await gatedHost(session, session.worktree_path!, (e) => {
192
+ const entry = registry.live.get(sessionId);
193
+ if (e.type === "state" && entry) entry.state = e.state;
194
+ if (e.type === "item" || e.type === "state") {
195
+ onDriveMessage(sessionId, e, fx);
196
+ if (opts.publish) publisher.onMessage(sessionId, e);
197
+ }
198
+ if (e.type === "queue")
199
+ onDriveMessage(
200
+ sessionId,
201
+ { type: "queue", queue: listQueue(sessionId) },
202
+ fx
203
+ );
204
+ });
205
+ registry.live.set(sessionId, {
206
+ worker: {
207
+ command: (c: WorkerCommand) => {
208
+ commands.push(c);
209
+ void w.host.handle(c);
210
+ },
211
+ },
212
+ state: "idle",
213
+ streaming: new Map(),
214
+ activity: null,
215
+ canGuestGate: true,
216
+ } as never);
217
+ await open(sessionId, opts.level ?? "drive");
218
+ admit(guestId, "Ahmed", opts.level !== "suggest");
219
+ const prompt = async (
220
+ id: string,
221
+ text = "add a test for the parser",
222
+ gid = guestId
223
+ ) => {
224
+ down({
225
+ type: "drive_prompt",
226
+ id,
227
+ guest: { id: gid, name: "Ahmed" },
228
+ text,
229
+ });
230
+ await settle();
231
+ };
232
+ const statuses = (id?: string) =>
233
+ up("drive_status").filter((s) => !id || s.id === id);
234
+ const sends = () => commands.filter((c) => c.type === "send");
235
+ const done = () => {
236
+ w.host.close();
237
+ registry.live.delete(sessionId);
238
+ };
239
+ return {
240
+ sessionId,
241
+ w,
242
+ commands,
243
+ sends,
244
+ prompt,
245
+ statuses,
246
+ done,
247
+ rt: () => live.runtimes.get(SHARE)!,
248
+ };
249
+ }
250
+
251
+ function reset() {
252
+ for (const id of [...live.runtimes.keys()]) live.runtimes.delete(id);
253
+ live.socket?.close();
254
+ live.socket = svc.makeSocket(factory as never);
255
+ fake = null;
256
+ seen.length = 0;
257
+ getDb().prepare(`DELETE FROM live_shares`).run();
258
+ getDb().prepare(`DELETE FROM live_share_log`).run();
259
+ }
260
+
261
+ function close() {
262
+ live.socket?.close();
263
+ server.close();
264
+ }
265
+
266
+ return {
267
+ live,
268
+ svc,
269
+ seen,
270
+ down,
271
+ up,
272
+ taskChat,
273
+ setColumns,
274
+ open,
275
+ driving,
276
+ settle,
277
+ admit,
278
+ reset,
279
+ close,
280
+ };
281
+ }
package/lib/live/types.ts CHANGED
@@ -25,10 +25,12 @@ export interface LevelOption {
25
25
  }
26
26
 
27
27
  export interface GuestAction {
28
- kind: "kick" | "admit" | "deny";
28
+ kind: "kick" | "admit" | "admit_drive" | "deny" | "give_wheel" | "take_back";
29
29
  label: string;
30
30
  }
31
31
 
32
+ export type DriverState = "idle" | "queued" | "working" | "waiting_on_host";
33
+
32
34
  // What the host's screens show of a live share (served by /api/live-shares).
33
35
  export interface LiveView {
34
36
  id: string;
@@ -44,6 +46,13 @@ export interface LiveView {
44
46
  levels: LevelOption[];
45
47
  /** Whether the guests got the chat from before the link was made. */
46
48
  history: boolean;
49
+ /** The guest holding the wheel on a Drive share. */
50
+ driver: { guestId: string; name: string; state: DriverState } | null;
51
+ /** Shown above the composer while a guest drives. */
52
+ banner: {
53
+ text: string;
54
+ action?: { kind: "take_back"; label: string };
55
+ } | null;
47
56
  guests: {
48
57
  id: string;
49
58
  name: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@saadnvd1/agent-os",
3
- "version": "0.3.779",
3
+ "version": "0.3.780",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"