@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.
- package/app/api/live-shares/[id]/route.ts +19 -4
- package/app/api/live-shares/route.test.ts +23 -2
- package/app/api/live-shares/route.ts +1 -1
- 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/LivePanel.tsx +18 -1
- package/data/live/queries.ts +3 -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/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
- package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
- package/lib/live/clean.ts +6 -0
- package/lib/live/drive.ts +398 -0
- package/lib/live/frames.ts +9 -2
- package/lib/live/inbox.ts +41 -3
- package/lib/live/levels.ts +55 -10
- package/lib/live/runtime.ts +20 -0
- package/lib/live/service.ts +88 -13
- package/lib/live/store.ts +34 -1
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +10 -1
- package/package.json +1 -1
package/lib/live/runtime.ts
CHANGED
|
@@ -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 {
|
package/lib/live/service.ts
CHANGED
|
@@ -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
|
-
|
|
175
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
270
|
-
if (!
|
|
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
|
|
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;
|