@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,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";
@@ -11,12 +12,29 @@ import { shareContext } from "../share/context";
11
12
  import { redactForShare } from "../share/redact";
12
13
  import { ShareError, createLiveRemote, revokeRemote } from "../share/connect";
13
14
  import type { LiveExpiry } from "../connect/control-api";
14
- import type { LiveView } from "./types";
15
- import { cleanBody } from "./clean";
15
+ import {
16
+ LEVELS,
17
+ type LiveInvite,
18
+ type LiveLevel,
19
+ type LiveView,
20
+ } from "./types";
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";
31
+ import { listItems } from "../chat/store";
32
+ import { visible } from "../share/snapshot";
33
+ import { cleanBody, noMentions } from "./clean";
16
34
  import { itemMapper } from "./frames";
17
35
  import { handleFrame } from "./inbox";
18
36
  import { onMessage, seed, flush } from "./publisher";
19
- import { Ring } from "./ring";
37
+ import { RING_ITEMS, Ring } from "./ring";
20
38
  import { LOBBY_MS, live, type Guest, type Runtime } from "./runtime";
21
39
  import { LiveSocket, type WsFactory } from "./socket";
22
40
  import {
@@ -27,6 +45,7 @@ import {
27
45
  logLive,
28
46
  markLiveEnded,
29
47
  openSuggestions,
48
+ setLevel,
30
49
  type LiveShareRow,
31
50
  } from "./store";
32
51
 
@@ -54,6 +73,18 @@ export function makeSocket(factory?: WsFactory): LiveSocket {
54
73
 
55
74
  const socket = (): LiveSocket => (live.socket ??= makeSocket());
56
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
+
57
88
  function stateOfSession(sessionId: string) {
58
89
  const l = registry.live.get(sessionId);
59
90
  const s = l?.state;
@@ -78,6 +109,7 @@ function attach(row: LiveShareRow): Runtime {
78
109
  slowUntil: 0,
79
110
  notice: null,
80
111
  drops: 0,
112
+ drive: newDrive(row.id),
81
113
  };
82
114
  live.runtimes.set(row.id, rt);
83
115
  try {
@@ -90,6 +122,7 @@ function attach(row: LiveShareRow): Runtime {
90
122
  /** Stops publishing a share and drops its connection; the row stays as history. */
91
123
  function stop(shareId: string, markEnded = false): void {
92
124
  const rt = live.runtimes.get(shareId);
125
+ if (rt) releaseDrive(rt, "ended", fx);
93
126
  if (markEnded) markLiveEnded(shareId);
94
127
  if (rt?.flushTimer) clearTimeout(rt.flushTimer);
95
128
  live.runtimes.delete(shareId);
@@ -101,6 +134,7 @@ export function startLive(): void {
101
134
  if (live.started) return;
102
135
  live.started = true;
103
136
  addTap(onMessage);
137
+ addTap((sessionId, m) => onDriveMessage(sessionId, m, fx));
104
138
  for (const row of activeLiveShares()) attach(row);
105
139
  setInterval(sweep, SWEEP_MS).unref();
106
140
  }
@@ -126,10 +160,14 @@ function runtimeFor(id: string): Runtime {
126
160
 
127
161
  export async function createLive(
128
162
  sessionId: string,
129
- expiresIn: LiveExpiry
163
+ expiresIn: LiveExpiry,
164
+ level: LiveLevel = "suggest",
165
+ history = true
130
166
  ): Promise<LiveView> {
131
167
  startLive();
132
168
  const session = getSession(sessionId);
169
+ if (level === "drive" && driveRefusal(session))
170
+ throw new ShareError(`Drive: ${driveRefusal(session)}.`, 400);
133
171
  if (activeLiveShareFor(sessionId))
134
172
  throw new ShareError("This session already has a live link.", 409);
135
173
  const ctx = shareContext();
@@ -138,7 +176,7 @@ export async function createLive(
138
176
  .text.replace(/\s+/g, " ")
139
177
  .trim()
140
178
  .slice(0, 120) || "Chat";
141
- const made = await createLiveRemote({ title, expiresIn });
179
+ const made = await createLiveRemote({ title, expiresIn, access: level });
142
180
  const row: LiveShareRow = {
143
181
  id: made.id,
144
182
  session_id: sessionId,
@@ -149,15 +187,79 @@ export async function createLive(
149
187
  created_at: new Date().toISOString(),
150
188
  expires_at: made.expires_at,
151
189
  ended_at: null,
190
+ level,
191
+ history: history ? 1 : 0,
152
192
  };
153
193
  insertLiveShare(row);
154
194
  attach(row);
155
195
  return viewOf(row.id)!;
156
196
  }
157
197
 
198
+ export function changeLevel(id: string, level: LiveLevel): void {
199
+ const rt = runtimeFor(id);
200
+ if (!(level in LEVELS)) throw new ShareError("Unknown level.", 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
+ );
209
+ if (rt.share.level === level) return;
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.
212
+ rt.share = { ...rt.share, level };
213
+ setLevel(id, level);
214
+ if (level !== "drive") setWheel(rt, null, "downgraded", fx);
215
+ rt.notice = null;
216
+ live.socket?.send(id, "access", { level });
217
+ }
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
+
241
+ /** What the invite dialog offers for a session. */
242
+ export function inviteFor(sessionId: string): LiveInvite {
243
+ const count = Math.min(
244
+ visible(listItems(sessionId)).filter(
245
+ (i) => i.kind === "user" || i.kind === "assistant"
246
+ ).length,
247
+ RING_ITEMS
248
+ );
249
+ return {
250
+ levels: levelOptions(sessionOrNull(sessionId)),
251
+ history: {
252
+ label: `Include the ${count} earlier message${count === 1 ? "" : "s"}`,
253
+ default: true,
254
+ count,
255
+ },
256
+ };
257
+ }
258
+
158
259
  /** Ends access: Connect disconnects every guest, then this machine lets go. */
159
260
  export async function endLive(id: string): Promise<void> {
160
- runtimeFor(id);
261
+ // Before anything awaits: no Drive prompt is taken in from here.
262
+ releaseDrive(runtimeFor(id), "ended", fx);
161
263
  await revokeRemote(id);
162
264
  markLiveEnded(id);
163
265
  stop(id);
@@ -181,14 +283,23 @@ function logGuest(rt: Runtime, kind: "admit" | "deny" | "kick", g: Guest) {
181
283
  });
182
284
  }
183
285
 
184
- export function admit(id: string, guestId: string): void {
286
+ export function admit(
287
+ id: string,
288
+ guestId: string,
289
+ opts: { drive?: boolean } = {}
290
+ ): void {
185
291
  const rt = runtimeFor(id);
186
292
  const g = guestOf(rt, guestId);
187
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);
188
298
  logGuest(rt, "admit", g);
189
299
  g.status = "admitted";
190
300
  rt.notice = null;
191
301
  live.socket?.send(id, "admit", { guest_id: g.id });
302
+ if (opts.drive) giveWheel(id, g.id);
192
303
  }
193
304
 
194
305
  export function deny(id: string, guestId: string): void {
@@ -203,6 +314,7 @@ export function deny(id: string, guestId: string): void {
203
314
  export function kick(id: string, guestId: string): void {
204
315
  const rt = runtimeFor(id);
205
316
  const g = guestOf(rt, guestId);
317
+ if (rt.drive.wheel?.guestId === g.id) setWheel(rt, null, "kicked", fx);
206
318
  logGuest(rt, "kick", g);
207
319
  rt.guests.delete(g.id);
208
320
  live.socket?.send(id, "kick", { guest_id: g.id });
@@ -222,8 +334,10 @@ export async function sendSuggestion(
222
334
  const rt = runtimeFor(id);
223
335
  const card = openSuggestions(id).find((s) => s.id === suggestionId);
224
336
  if (!card) throw new ShareError("That suggestion is gone.", 404);
225
- const text = cleanBody(edited ?? card.text);
226
- 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);
227
341
  const key = `${id}:${suggestionId}`;
228
342
  if (sending.has(key)) throw new ShareError("Already sending.", 409);
229
343
  sending.add(key);
@@ -231,6 +345,7 @@ export async function sendSuggestion(
231
345
  await sendChatConfirmed(rt.share.session_id, {
232
346
  text,
233
347
  origin: { kind: "guest", label: card.guestName },
348
+ host: true,
234
349
  });
235
350
  logLive({
236
351
  share_id: id,
@@ -283,6 +398,9 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
283
398
  names.set(k, (names.get(k) ?? 0) + 1);
284
399
  }
285
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;
286
404
  return {
287
405
  id,
288
406
  sessionId: rt.share.session_id,
@@ -293,6 +411,10 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
293
411
  maxGuests: rt.share.max_guests,
294
412
  connected: live.socket?.connected ?? false,
295
413
  notice: rt.notice,
414
+ level: rt.share.level,
415
+ levels,
416
+ history: rt.share.history === 1,
417
+ ...driveView(rt),
296
418
  guests: shown
297
419
  .filter((g) => g.status === "pending" || admitted.has(g.id))
298
420
  .map((g) => ({
@@ -302,6 +424,7 @@ export function viewOf(id: string, now = Date.now()): LiveView | null {
302
424
  online: g.online,
303
425
  duplicateName:
304
426
  (names.get(g.name.normalize("NFKC").toLowerCase()) ?? 0) > 1,
427
+ actions: guestActions(rt, g, canDrive),
305
428
  })),
306
429
  suggestions: openSuggestions(id),
307
430
  };
package/lib/live/store.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { db } from "../db";
2
+ import type { LiveLevel } from "./types";
2
3
 
3
4
  export interface LiveShareRow {
4
5
  id: string;
@@ -10,6 +11,9 @@ export interface LiveShareRow {
10
11
  created_at: string;
11
12
  expires_at: string | null;
12
13
  ended_at: string | null;
14
+ level: LiveLevel;
15
+ /** 1: guests get the chat from before the link was made. */
16
+ history: number;
13
17
  }
14
18
 
15
19
  export type LogKind =
@@ -19,7 +23,14 @@ export type LogKind =
19
23
  | "kick"
20
24
  | "suggestion"
21
25
  | "sent"
22
- | "dismissed";
26
+ | "dismissed"
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";
23
34
 
24
35
  export interface LogRow {
25
36
  id: number;
@@ -36,8 +47,8 @@ export interface LogRow {
36
47
  export function insertLiveShare(row: LiveShareRow): void {
37
48
  db.prepare(
38
49
  `INSERT INTO live_shares
39
- (id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at)
40
- VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at)`
50
+ (id, session_id, title, url, ends_with_session, max_guests, created_at, expires_at, ended_at, level, history)
51
+ VALUES (@id, @session_id, @title, @url, @ends_with_session, @max_guests, @created_at, @expires_at, @ended_at, @level, @history)`
41
52
  ).run(row);
42
53
  }
43
54
 
@@ -70,6 +81,19 @@ export function markLiveEnded(id: string): void {
70
81
  ).run(new Date().toISOString(), id);
71
82
  }
72
83
 
84
+ export function setLevel(id: string, level: LiveLevel): void {
85
+ db.prepare(`UPDATE live_shares SET level = ? WHERE id = ?`).run(level, id);
86
+ logLive({
87
+ share_id: id,
88
+ kind: "level",
89
+ guest_id: null,
90
+ guest_name: null,
91
+ ref: level,
92
+ text: null,
93
+ sent_text: null,
94
+ });
95
+ }
96
+
73
97
  export function logLive(
74
98
  row: Omit<LogRow, "id" | "at"> & { at?: number }
75
99
  ): void {
@@ -130,6 +154,33 @@ export function admittedGuestIds(shareId: string): Set<string> {
130
154
  return out;
131
155
  }
132
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
+
133
184
  /** Whether a suggestion with this id was ever logged for the share. */
134
185
  export function hasSuggestion(shareId: string, ref: string): boolean {
135
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
+ }