@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/chat/registry.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface Live {
|
|
|
45
45
|
canBackground?: boolean;
|
|
46
46
|
// Whether it takes "mcp_auth" (sign in to an MCP server).
|
|
47
47
|
canMcpAuth?: boolean;
|
|
48
|
+
// Whether it holds a live guest's turns to the host's taps (Drive).
|
|
49
|
+
canGuestGate?: boolean;
|
|
48
50
|
// From an older build and idle: retired once its guess at the next
|
|
49
51
|
// message is in, or this fires.
|
|
50
52
|
retiring?: NodeJS.Timeout;
|
package/lib/chat/runner.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
ApprovalDecision,
|
|
22
22
|
ChatFile,
|
|
23
23
|
ChatImage,
|
|
24
|
+
ChatOrigin,
|
|
24
25
|
ChatServerMessage,
|
|
25
26
|
ChatState,
|
|
26
27
|
Compacting,
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
import { noteToolResult } from "../health/env/recordHit";
|
|
48
49
|
import {
|
|
49
50
|
deleteQueued,
|
|
51
|
+
deleteQueuedWhere,
|
|
50
52
|
editQueued,
|
|
51
53
|
enqueue,
|
|
52
54
|
listQueue,
|
|
@@ -78,7 +80,7 @@ import {
|
|
|
78
80
|
waitForExit,
|
|
79
81
|
} from "./worker/client";
|
|
80
82
|
import { buildId, isStaleWorker } from "../build";
|
|
81
|
-
import type
|
|
83
|
+
import { GUEST_GATE_CAP, type WorkerEvent } from "./worker/protocol";
|
|
82
84
|
import type { WorkerClient } from "./worker/client";
|
|
83
85
|
import { DEMO_REFUSAL, demoMode } from "../security/demo";
|
|
84
86
|
import { chatHold, chatRefusal } from "./hold";
|
|
@@ -118,6 +120,7 @@ export async function carryOutPlan(
|
|
|
118
120
|
id: `user-carry-${planId}`,
|
|
119
121
|
text: CARRY_OUT,
|
|
120
122
|
origin: { kind: "decision", label: "Saad" },
|
|
123
|
+
host: true,
|
|
121
124
|
},
|
|
122
125
|
timeoutMs
|
|
123
126
|
);
|
|
@@ -348,6 +351,7 @@ async function ensureLive(sessionId: string, spawn = true): Promise<Live> {
|
|
|
348
351
|
canFresh: !!hello.caps?.includes("fresh"),
|
|
349
352
|
canBackground: !!hello.caps?.includes("background"),
|
|
350
353
|
canMcpAuth: !!hello.caps?.includes("mcp_auth"),
|
|
354
|
+
canGuestGate: !!hello.caps?.includes(GUEST_GATE_CAP),
|
|
351
355
|
};
|
|
352
356
|
// From an older build and busy, or running background work: it goes at
|
|
353
357
|
// its next turn boundary, by itself if it can. Messages meanwhile wait
|
|
@@ -402,10 +406,11 @@ function queuedIfHeld(
|
|
|
402
406
|
return true;
|
|
403
407
|
}
|
|
404
408
|
|
|
405
|
-
const sentBy = ({ from, peer, origin }: SentBy): SentBy => ({
|
|
409
|
+
const sentBy = ({ from, peer, origin, host }: SentBy): SentBy => ({
|
|
406
410
|
from,
|
|
407
411
|
peer,
|
|
408
412
|
origin,
|
|
413
|
+
host,
|
|
409
414
|
});
|
|
410
415
|
|
|
411
416
|
// Already a message in the chat or its queue.
|
|
@@ -460,6 +465,64 @@ export async function sendChat(
|
|
|
460
465
|
});
|
|
461
466
|
}
|
|
462
467
|
|
|
468
|
+
export const GATE_MISSING = "Reopen this chat on the host to let guests drive";
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A live guest's Drive prompt (lib/live/drive): always its own turn, behind
|
|
472
|
+
* anything running. `check` runs again right before the worker gets it, in
|
|
473
|
+
* the same tick, so a Drive that ended meanwhile sends nothing. Returns why
|
|
474
|
+
* it wasn't sent, or null.
|
|
475
|
+
*/
|
|
476
|
+
export async function sendGuestPrompt(
|
|
477
|
+
sessionId: string,
|
|
478
|
+
m: { id: string; text: string; origin: ChatOrigin },
|
|
479
|
+
check: () => string | null
|
|
480
|
+
): Promise<string | null> {
|
|
481
|
+
const held = () =>
|
|
482
|
+
chatRefusal(sessionId) ??
|
|
483
|
+
chatHold(sessionId) ??
|
|
484
|
+
(settingUp(sessionId) || launchPending(sessionId)
|
|
485
|
+
? "The task isn't running yet"
|
|
486
|
+
: null);
|
|
487
|
+
if (held()) return held();
|
|
488
|
+
const live = await ensureLive(sessionId);
|
|
489
|
+
if (!live.canGuestGate) return GATE_MISSING;
|
|
490
|
+
const why = held() ?? check();
|
|
491
|
+
if (why) return why;
|
|
492
|
+
if (taken(sessionId, m.id)) return "Already sent";
|
|
493
|
+
live.worker.command({ type: "send", ...m, queue: true });
|
|
494
|
+
return null;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// A live share's Drive ended: its queued prompts go here and in the worker
|
|
498
|
+
// (which also stops a turn it steers and refuses that wheel from now on).
|
|
499
|
+
export function releaseGuestChat(
|
|
500
|
+
sessionId: string,
|
|
501
|
+
share: string,
|
|
502
|
+
wheel: string
|
|
503
|
+
): void {
|
|
504
|
+
const gone = deleteQueuedWhere(
|
|
505
|
+
sessionId,
|
|
506
|
+
(by) => by.origin?.kind === "guest-drive" && by.origin.share === share
|
|
507
|
+
);
|
|
508
|
+
if (gone.length) emitQueue(sessionId);
|
|
509
|
+
registry.live
|
|
510
|
+
.get(sessionId)
|
|
511
|
+
?.worker.command({ type: "guest_release", share, wheel });
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// The driving guest's Stop: the worker ends the turn only if their wheel
|
|
515
|
+
// steers it.
|
|
516
|
+
export function stopGuestTurn(
|
|
517
|
+
sessionId: string,
|
|
518
|
+
share: string,
|
|
519
|
+
wheel: string
|
|
520
|
+
): void {
|
|
521
|
+
registry.live
|
|
522
|
+
.get(sessionId)
|
|
523
|
+
?.worker.command({ type: "guest_stop", share, wheel });
|
|
524
|
+
}
|
|
525
|
+
|
|
463
526
|
// The queue lives in SQLite, so it can be changed with no worker running;
|
|
464
527
|
// a worker reads it only when it sends the next message.
|
|
465
528
|
export function editQueuedChat(sessionId: string, id: string, text: string) {
|
package/lib/chat/socket.ts
CHANGED
|
@@ -150,15 +150,18 @@ function onChatMessage(
|
|
|
150
150
|
const id = send.id;
|
|
151
151
|
// Taken in now: a new worker can take seconds to start.
|
|
152
152
|
if (id) reply({ type: "accepted", id });
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
153
|
+
// Typed in the host's own composer (this socket is theirs).
|
|
154
|
+
void sendChat(sessionId, { ...send, queue: true, host: true }).catch(
|
|
155
|
+
(err) => {
|
|
156
|
+
if (id)
|
|
157
|
+
reply({
|
|
158
|
+
type: "send_failed",
|
|
159
|
+
id,
|
|
160
|
+
message: err instanceof Error ? err.message : String(err),
|
|
161
|
+
});
|
|
162
|
+
fail(err);
|
|
163
|
+
}
|
|
164
|
+
);
|
|
162
165
|
// "Session 4" is named after its first message.
|
|
163
166
|
run(titleChatFromMessage(sessionId, send.text));
|
|
164
167
|
} else if (msg.type === "queue_edit" && typeof msg.text === "string")
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* For tests: a real chat worker (ChatHost) over a fake agent, with the guest
|
|
3
|
+
* gate's PreToolUse hook driven as the SDK would and real approval cards.
|
|
4
|
+
* A test file mocks the drivers with this module's `drivers`:
|
|
5
|
+
* vi.mock("@/lib/chat/drivers", async () => (await import("@/lib/chat/worker/gate-testing")).drivers)
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { randomUUID } from "crypto";
|
|
9
|
+
import { vi } from "vitest";
|
|
10
|
+
import type { HookJSONOutput } from "@anthropic-ai/claude-agent-sdk";
|
|
11
|
+
import type { Session } from "@/lib/db";
|
|
12
|
+
import type { ChatConversation, ChatStartOptions } from "../driver";
|
|
13
|
+
import type { ChatItem, DriverEvent } from "../events";
|
|
14
|
+
import { InputQueue } from "../queue";
|
|
15
|
+
import { Approvals, preToolUseHooks } from "../drivers/claude-approvals";
|
|
16
|
+
import type { WorkerEvent } from "./protocol";
|
|
17
|
+
|
|
18
|
+
export function fakeConversation() {
|
|
19
|
+
const events = new InputQueue<DriverEvent>();
|
|
20
|
+
return {
|
|
21
|
+
send: vi.fn(() => "checkpoint-1"),
|
|
22
|
+
runLocal: vi.fn(() => null),
|
|
23
|
+
interrupt: vi.fn(async () => {}),
|
|
24
|
+
setModel: vi.fn(async () => {}),
|
|
25
|
+
setAccess: vi.fn(async () => {}),
|
|
26
|
+
setPlan: vi.fn(async () => {}),
|
|
27
|
+
respond: vi.fn(),
|
|
28
|
+
stopTask: vi.fn(async () => {}),
|
|
29
|
+
backgroundTask: vi.fn(async () => true),
|
|
30
|
+
undo: vi.fn(),
|
|
31
|
+
close: vi.fn(() => events.end()),
|
|
32
|
+
events,
|
|
33
|
+
} satisfies ChatConversation;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fake: {
|
|
37
|
+
current?: ReturnType<typeof fakeConversation>;
|
|
38
|
+
started?: ChatStartOptions;
|
|
39
|
+
} = {};
|
|
40
|
+
|
|
41
|
+
export const drivers = {
|
|
42
|
+
chatDriverFor: () => ({
|
|
43
|
+
id: "fake",
|
|
44
|
+
atRest: true,
|
|
45
|
+
start: (options: ChatStartOptions) => {
|
|
46
|
+
fake.started = options;
|
|
47
|
+
return fake.current!;
|
|
48
|
+
},
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export const tick = () => new Promise((r) => setTimeout(r, 0));
|
|
53
|
+
|
|
54
|
+
// The host is imported late: it imports the drivers, which this module mocks.
|
|
55
|
+
export async function gatedHost(
|
|
56
|
+
session: Session,
|
|
57
|
+
cwd: string,
|
|
58
|
+
onEvent: (e: WorkerEvent) => void = () => {}
|
|
59
|
+
) {
|
|
60
|
+
const { ChatHost } = await import("./host");
|
|
61
|
+
fake.current = fakeConversation();
|
|
62
|
+
const conversation = fake.current;
|
|
63
|
+
const emitted: ChatItem[] = [];
|
|
64
|
+
const host = new ChatHost(session, (e) => {
|
|
65
|
+
if (e.type === "item") emitted.push(e.item);
|
|
66
|
+
onEvent(e);
|
|
67
|
+
});
|
|
68
|
+
const approvals = new Approvals((e) => conversation.events.push(e));
|
|
69
|
+
conversation.respond.mockImplementation((cardId, answer) =>
|
|
70
|
+
approvals.respond(cardId, answer)
|
|
71
|
+
);
|
|
72
|
+
const gate = fake.started!.guestGate!;
|
|
73
|
+
const [first] = preToolUseHooks(approvals, undefined, null, { gate, cwd });
|
|
74
|
+
const hook = first.hooks[0];
|
|
75
|
+
const results = new Map<string, HookJSONOutput>();
|
|
76
|
+
const call = (tool: string, input: Record<string, unknown>) => {
|
|
77
|
+
const useId = `tu-${randomUUID().slice(0, 8)}`;
|
|
78
|
+
const ac = new AbortController();
|
|
79
|
+
void hook(
|
|
80
|
+
{
|
|
81
|
+
hook_event_name: "PreToolUse",
|
|
82
|
+
tool_name: tool,
|
|
83
|
+
tool_input: input,
|
|
84
|
+
tool_use_id: useId,
|
|
85
|
+
session_id: "x",
|
|
86
|
+
transcript_path: "",
|
|
87
|
+
cwd,
|
|
88
|
+
} as never,
|
|
89
|
+
useId,
|
|
90
|
+
{ signal: ac.signal }
|
|
91
|
+
).then((r) => results.set(useId, r));
|
|
92
|
+
return { useId, abort: () => ac.abort() };
|
|
93
|
+
};
|
|
94
|
+
/** allow, deny, none (no decision), or pending (still waiting on the host). */
|
|
95
|
+
const decision = async (useId: string) => {
|
|
96
|
+
try {
|
|
97
|
+
await gate.placedFiles(cwd);
|
|
98
|
+
} catch {
|
|
99
|
+
// A test that makes it throw.
|
|
100
|
+
}
|
|
101
|
+
await tick();
|
|
102
|
+
await tick();
|
|
103
|
+
const r = results.get(useId) as
|
|
104
|
+
{ hookSpecificOutput?: { permissionDecision?: string } } | undefined;
|
|
105
|
+
return (
|
|
106
|
+
r?.hookSpecificOutput?.permissionDecision ?? (r ? "none" : "pending")
|
|
107
|
+
);
|
|
108
|
+
};
|
|
109
|
+
const card = (useId: string) =>
|
|
110
|
+
emitted
|
|
111
|
+
.filter(
|
|
112
|
+
(i): i is Extract<ChatItem, { kind: "approval" }> =>
|
|
113
|
+
i.kind === "approval" && i.id === `approval-${useId}`
|
|
114
|
+
)
|
|
115
|
+
.at(-1);
|
|
116
|
+
const idle = async () => {
|
|
117
|
+
conversation.events.push({ type: "state", state: "idle" });
|
|
118
|
+
await tick();
|
|
119
|
+
};
|
|
120
|
+
return {
|
|
121
|
+
host,
|
|
122
|
+
gate,
|
|
123
|
+
approvals,
|
|
124
|
+
conversation,
|
|
125
|
+
call,
|
|
126
|
+
decision,
|
|
127
|
+
card,
|
|
128
|
+
idle,
|
|
129
|
+
emitted,
|
|
130
|
+
};
|
|
131
|
+
}
|