@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.
- package/app/api/live-shares/[id]/route.ts +30 -4
- package/app/api/live-shares/route.test.ts +37 -2
- package/app/api/live-shares/route.ts +8 -2
- package/components/Chat/Approval.tsx +13 -2
- package/components/Chat/EventRow.tsx +5 -0
- package/components/Chat/Queue.tsx +27 -15
- package/components/Live/DriveBanner.tsx +42 -0
- package/components/Live/InviteDialog.tsx +73 -2
- package/components/Live/LiveChip.tsx +68 -1
- package/components/Live/LivePanel.tsx +26 -1
- package/data/live/queries.ts +6 -1
- package/lib/chat/driver.ts +4 -0
- package/lib/chat/drivers/claude-approvals.ts +93 -25
- package/lib/chat/drivers/claude.ts +2 -1
- package/lib/chat/events.ts +21 -1
- package/lib/chat/queued.ts +57 -10
- package/lib/chat/registry.ts +2 -0
- package/lib/chat/runner.ts +65 -2
- package/lib/chat/socket.ts +12 -9
- package/lib/chat/worker/gate-testing.ts +131 -0
- package/lib/chat/worker/guest-gate.ts +511 -0
- package/lib/chat/worker/host.ts +50 -6
- package/lib/chat/worker/main.ts +16 -2
- package/lib/chat/worker/protocol.ts +11 -0
- package/lib/connect/control-api.ts +30 -1
- package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
- package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
- package/lib/db/migrations/20261031T1200_live_share_access_code.ts +14 -0
- package/lib/live/clean.ts +6 -0
- package/lib/live/code-view.ts +56 -0
- package/lib/live/code.ts +25 -0
- package/lib/live/drive.ts +398 -0
- package/lib/live/frames.ts +9 -2
- package/lib/live/inbox.ts +50 -3
- package/lib/live/levels.ts +55 -10
- package/lib/live/runtime.ts +22 -0
- package/lib/live/service.ts +151 -16
- package/lib/live/store.ts +56 -3
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +37 -1
- package/lib/share/connect.ts +20 -0
- 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 {
|
package/lib/share/connect.ts
CHANGED
|
@@ -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
|
+
}
|