@saadnvd1/agent-os 0.3.779 → 0.3.781

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 (42) hide show
  1. package/app/api/live-shares/[id]/route.ts +30 -4
  2. package/app/api/live-shares/route.test.ts +37 -2
  3. package/app/api/live-shares/route.ts +8 -2
  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 +73 -2
  9. package/components/Live/LiveChip.tsx +68 -1
  10. package/components/Live/LivePanel.tsx +26 -1
  11. package/data/live/queries.ts +6 -1
  12. package/lib/chat/driver.ts +4 -0
  13. package/lib/chat/drivers/claude-approvals.ts +93 -25
  14. package/lib/chat/drivers/claude.ts +2 -1
  15. package/lib/chat/events.ts +21 -1
  16. package/lib/chat/queued.ts +57 -10
  17. package/lib/chat/registry.ts +2 -0
  18. package/lib/chat/runner.ts +65 -2
  19. package/lib/chat/socket.ts +12 -9
  20. package/lib/chat/worker/gate-testing.ts +131 -0
  21. package/lib/chat/worker/guest-gate.ts +511 -0
  22. package/lib/chat/worker/host.ts +50 -6
  23. package/lib/chat/worker/main.ts +16 -2
  24. package/lib/chat/worker/protocol.ts +11 -0
  25. package/lib/connect/control-api.ts +30 -1
  26. package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
  27. package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
  28. package/lib/db/migrations/20261031T1200_live_share_access_code.ts +14 -0
  29. package/lib/live/clean.ts +6 -0
  30. package/lib/live/code-view.ts +56 -0
  31. package/lib/live/code.ts +25 -0
  32. package/lib/live/drive.ts +398 -0
  33. package/lib/live/frames.ts +9 -2
  34. package/lib/live/inbox.ts +50 -3
  35. package/lib/live/levels.ts +55 -10
  36. package/lib/live/runtime.ts +22 -0
  37. package/lib/live/service.ts +151 -16
  38. package/lib/live/store.ts +56 -3
  39. package/lib/live/testing.ts +281 -0
  40. package/lib/live/types.ts +37 -1
  41. package/lib/share/connect.ts +20 -0
  42. package/package.json +1 -1
@@ -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,31 @@ 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 CodeActionKind =
33
+ "copy" | "copy_link" | "new_code" | "set_code" | "code_off" | "code_on";
34
+
35
+ /** The access code as the host's screens show it; built on the machine. */
36
+ export interface LiveCodeView {
37
+ on: boolean;
38
+ /** "482-917", or the host's own code in capitals. Null when off. */
39
+ display: string | null;
40
+ /** The link and the code on two lines, for one paste. Null when off. */
41
+ copyText: string | null;
42
+ label: string;
43
+ hint: string;
44
+ /** Set after too many wrong tries; a code change clears it. */
45
+ notice: string | null;
46
+ /** For "Use my own". */
47
+ prompt: { title: string; message: string; placeholder: string };
48
+ actions: { kind: CodeActionKind; label: string }[];
49
+ }
50
+
51
+ export type DriverState = "idle" | "queued" | "working" | "waiting_on_host";
52
+
32
53
  // What the host's screens show of a live share (served by /api/live-shares).
33
54
  export interface LiveView {
34
55
  id: string;
@@ -44,6 +65,14 @@ export interface LiveView {
44
65
  levels: LevelOption[];
45
66
  /** Whether the guests got the chat from before the link was made. */
46
67
  history: boolean;
68
+ code: LiveCodeView;
69
+ /** The guest holding the wheel on a Drive share. */
70
+ driver: { guestId: string; name: string; state: DriverState } | null;
71
+ /** Shown above the composer while a guest drives. */
72
+ banner: {
73
+ text: string;
74
+ action?: { kind: "take_back"; label: string };
75
+ } | null;
47
76
  guests: {
48
77
  id: string;
49
78
  name: string;
@@ -72,6 +101,13 @@ export const EXPIRY_LABEL: Record<LiveExpiry, string> = {
72
101
  export interface LiveInvite {
73
102
  levels: LevelOption[];
74
103
  history: { label: string; default: boolean; count: number };
104
+ code: {
105
+ default: boolean;
106
+ label: string;
107
+ hint: string;
108
+ customLabel: string;
109
+ rules: string;
110
+ };
75
111
  }
76
112
 
77
113
  export interface LiveResponse {
@@ -7,6 +7,7 @@ import {
7
7
  listShares,
8
8
  replaceShare,
9
9
  createLiveShare,
10
+ setLiveAccessCode,
10
11
  revokeShare,
11
12
  uploadShare,
12
13
  type LiveExpiry,
@@ -112,6 +113,7 @@ export async function createLiveRemote(share: {
112
113
  title: string;
113
114
  expiresIn: LiveExpiry;
114
115
  access?: string;
116
+ accessCode?: string;
115
117
  }) {
116
118
  const { config, machineKey } = files();
117
119
  try {
@@ -120,3 +122,21 @@ export async function createLiveRemote(share: {
120
122
  explain(err);
121
123
  }
122
124
  }
125
+
126
+ /** Puts a new access code on Connect (null turns it off). Throws if Connect doesn't confirm. */
127
+ export async function setAccessCodeRemote(
128
+ id: string,
129
+ code: string | null
130
+ ): Promise<void> {
131
+ const { config, machineKey } = files();
132
+ try {
133
+ await setLiveAccessCode(config, machineKey, id, code);
134
+ } catch (err) {
135
+ if (err instanceof ApiError && (err.status === 404 || err.status === 410))
136
+ throw new ShareError("This link has ended.", 404);
137
+ throw new ShareError(
138
+ "Connect didn't confirm the access code, so it is unchanged. Try again.",
139
+ 502
140
+ );
141
+ }
142
+ }
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.781",
4
4
  "description": "Self-hosted web UI for managing Claude Code sessions",
5
5
  "bin": {
6
6
  "agent-os": "scripts/agent-os"