@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
|
@@ -15,6 +15,7 @@ import type {
|
|
|
15
15
|
} from "../events";
|
|
16
16
|
import { toolDiff, toolTitle } from "../tools";
|
|
17
17
|
import type { ToolGuard } from "../driver";
|
|
18
|
+
import { guestGateHook, type GuestGate } from "../worker/guest-gate";
|
|
18
19
|
|
|
19
20
|
type Approval = Extract<ChatItem, { kind: "approval" }>;
|
|
20
21
|
|
|
@@ -101,45 +102,94 @@ export class Approvals {
|
|
|
101
102
|
private planFile: () => string | undefined = () => undefined
|
|
102
103
|
) {}
|
|
103
104
|
|
|
105
|
+
// Who steers the running turn, when a live guest does (lib/chat/worker/
|
|
106
|
+
// guest-gate): every card it raises is theirs, with no Always.
|
|
107
|
+
guestOf: () => { name: string } | null = () => null;
|
|
108
|
+
// Calls the guest gate let through, by tool use id: the permission prompt
|
|
109
|
+
// that may follow for one is already answered.
|
|
110
|
+
private gatePassed = new Set<string>();
|
|
111
|
+
|
|
104
112
|
canUseTool: CanUseTool = (toolName, input, options) => {
|
|
105
113
|
// Already answered on its card by the question hook.
|
|
106
114
|
if (toolName === "AskUserQuestion" && "answers" in input)
|
|
107
115
|
return Promise.resolve({ behavior: "allow", updatedInput: input });
|
|
116
|
+
if (options.toolUseID && this.gatePassed.delete(options.toolUseID))
|
|
117
|
+
return Promise.resolve({ behavior: "allow", updatedInput: input });
|
|
108
118
|
const questions =
|
|
109
119
|
toolName === "AskUserQuestion"
|
|
110
120
|
? ((input as { questions?: ChatQuestion[] }).questions ?? [])
|
|
111
121
|
: undefined;
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
122
|
+
const guest = this.guestOf() ?? undefined;
|
|
123
|
+
return this.ask(
|
|
124
|
+
{
|
|
125
|
+
id: `approval-${options.toolUseID}`,
|
|
126
|
+
kind: "approval",
|
|
127
|
+
toolName,
|
|
128
|
+
title: options.title ?? toolTitle(toolName, input),
|
|
129
|
+
input,
|
|
130
|
+
diff: toolDiff(toolName, input),
|
|
131
|
+
questions,
|
|
132
|
+
canAlways:
|
|
133
|
+
!guest &&
|
|
134
|
+
!questions &&
|
|
135
|
+
!!options.suggestions?.length &&
|
|
136
|
+
!options.suppressAlwaysAllowRule,
|
|
137
|
+
status: "pending",
|
|
138
|
+
createdAt: Date.now(),
|
|
139
|
+
...(guest && { guest }),
|
|
140
|
+
},
|
|
141
|
+
options.signal,
|
|
142
|
+
guest ? undefined : options.suggestions
|
|
143
|
+
);
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// A step of a guest-steered turn: the host's card, never Always.
|
|
147
|
+
askGuest(
|
|
148
|
+
toolName: string,
|
|
149
|
+
input: Record<string, unknown>,
|
|
150
|
+
toolUseID: string,
|
|
151
|
+
signal: AbortSignal,
|
|
152
|
+
guest: { name: string }
|
|
153
|
+
): Promise<PermissionResult> {
|
|
154
|
+
return this.ask(
|
|
155
|
+
{
|
|
156
|
+
id: `approval-${toolUseID}`,
|
|
157
|
+
kind: "approval",
|
|
158
|
+
toolName,
|
|
159
|
+
title: toolTitle(toolName, input),
|
|
160
|
+
input,
|
|
161
|
+
diff: toolDiff(toolName, input),
|
|
162
|
+
canAlways: false,
|
|
163
|
+
status: "pending",
|
|
164
|
+
createdAt: Date.now(),
|
|
165
|
+
guest,
|
|
166
|
+
},
|
|
167
|
+
signal
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
passed(toolUseID: string): void {
|
|
172
|
+
if (toolUseID) this.gatePassed.add(toolUseID);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
private ask(
|
|
176
|
+
item: Approval,
|
|
177
|
+
signal: AbortSignal,
|
|
178
|
+
suggestions?: Parameters<CanUseTool>[2]["suggestions"]
|
|
179
|
+
): Promise<PermissionResult> {
|
|
127
180
|
return new Promise<PermissionResult>((resolve) => {
|
|
128
|
-
this.pending.set(item.id, {
|
|
129
|
-
|
|
130
|
-
suggestions: options.suggestions,
|
|
131
|
-
resolve,
|
|
132
|
-
});
|
|
133
|
-
options.signal.addEventListener("abort", () => {
|
|
181
|
+
this.pending.set(item.id, { item, suggestions, resolve });
|
|
182
|
+
const expire = () =>
|
|
134
183
|
this.settle(item.id, "expired", {
|
|
135
184
|
behavior: "deny",
|
|
136
185
|
message: DENIED,
|
|
137
186
|
});
|
|
138
|
-
|
|
187
|
+
if (signal.aborted) return queueMicrotask(expire);
|
|
188
|
+
signal.addEventListener("abort", expire);
|
|
139
189
|
this.emit({ type: "item", item });
|
|
140
190
|
this.emit({ type: "state", state: "waiting" });
|
|
141
191
|
});
|
|
142
|
-
}
|
|
192
|
+
}
|
|
143
193
|
|
|
144
194
|
// Full access approves every tool before canUseTool is asked, questions
|
|
145
195
|
// included, so the agent's questions come through this hook instead.
|
|
@@ -209,11 +259,14 @@ export class Approvals {
|
|
|
209
259
|
} else if (answer.decision === "deny") {
|
|
210
260
|
this.settle(id, "denied", { behavior: "deny", message: DENIED });
|
|
211
261
|
} else {
|
|
262
|
+
// A guest's card has no Always: an "always" answer is a plain allow.
|
|
212
263
|
this.settle(id, "allowed", {
|
|
213
264
|
behavior: "allow",
|
|
214
265
|
updatedInput: input,
|
|
215
266
|
updatedPermissions:
|
|
216
|
-
answer.decision === "always"
|
|
267
|
+
answer.decision === "always" && !p.item.guest
|
|
268
|
+
? p.suggestions
|
|
269
|
+
: undefined,
|
|
217
270
|
});
|
|
218
271
|
}
|
|
219
272
|
}
|
|
@@ -270,9 +323,24 @@ export function guardHook(guard: ToolGuard): HookCallback {
|
|
|
270
323
|
export function preToolUseHooks(
|
|
271
324
|
approvals: Approvals,
|
|
272
325
|
guard?: ToolGuard,
|
|
273
|
-
subagentModels?: HookCallback | null
|
|
326
|
+
subagentModels?: HookCallback | null,
|
|
327
|
+
// A live guest's turn: every call but a worktree read waits for the host.
|
|
328
|
+
guest?: { gate: GuestGate; cwd: string }
|
|
274
329
|
): HookCallbackMatcher[] {
|
|
330
|
+
if (guest)
|
|
331
|
+
approvals.guestOf = () =>
|
|
332
|
+
guest.gate.steer ? { name: guest.gate.steer.name } : null;
|
|
275
333
|
return [
|
|
334
|
+
// First, and on every tool: no steer, no effect. It waits on the host
|
|
335
|
+
// for as long as the card is up (the turn's own cap ends it sooner).
|
|
336
|
+
...(guest
|
|
337
|
+
? [
|
|
338
|
+
{
|
|
339
|
+
hooks: [guestGateHook(approvals, guest.gate, guest.cwd)],
|
|
340
|
+
timeout: 24 * 60 * 60,
|
|
341
|
+
},
|
|
342
|
+
]
|
|
343
|
+
: []),
|
|
276
344
|
// Sets the model of a subagent call by its type. It only fills in the
|
|
277
345
|
// input, so the guard and permissions below still see the call.
|
|
278
346
|
...(subagentModels
|
|
@@ -222,7 +222,8 @@ export const claudeDriver: ChatDriver = {
|
|
|
222
222
|
PreToolUse: preToolUseHooks(
|
|
223
223
|
approvals,
|
|
224
224
|
options.toolGuard,
|
|
225
|
-
subagentHook(options.env)
|
|
225
|
+
subagentHook(options.env),
|
|
226
|
+
options.guestGate && { gate: options.guestGate, cwd: options.cwd }
|
|
226
227
|
),
|
|
227
228
|
...(options.compactContext && {
|
|
228
229
|
SessionStart: compactedHooks(options.compactContext),
|
package/lib/chat/events.ts
CHANGED
|
@@ -84,7 +84,12 @@ export type ChatOriginKind =
|
|
|
84
84
|
| "device"
|
|
85
85
|
// A suggestion from a guest on a live link, sent by the reader after
|
|
86
86
|
// reading it. Its label is the guest's name.
|
|
87
|
-
| "guest"
|
|
87
|
+
| "guest"
|
|
88
|
+
// A prompt from the guest holding the wheel on a Drive live link: it went
|
|
89
|
+
// in without the host's tap, so every step it takes past reading the
|
|
90
|
+
// task's files waits for one (lib/chat/worker/guest-gate). Its body is
|
|
91
|
+
// the guest's own text; the agent got it framed as a stranger's.
|
|
92
|
+
| "guest-drive";
|
|
88
93
|
|
|
89
94
|
export interface ChatOrigin {
|
|
90
95
|
kind: ChatOriginKind;
|
|
@@ -102,6 +107,11 @@ export interface ChatOrigin {
|
|
|
102
107
|
// Only Away mode's own lines: the turn it starts isn't anyone's activity
|
|
103
108
|
// (lib/away/activity).
|
|
104
109
|
away?: true;
|
|
110
|
+
// A guest-drive message: the live share, the guest it came from, and the
|
|
111
|
+
// wheel it was sent under (a new one each time the host gives it).
|
|
112
|
+
share?: string;
|
|
113
|
+
guestId?: string;
|
|
114
|
+
wheel?: string;
|
|
105
115
|
}
|
|
106
116
|
|
|
107
117
|
// Who sent a message, carried with it through the queue and the worker.
|
|
@@ -109,6 +119,10 @@ export interface SentBy {
|
|
|
109
119
|
from?: string;
|
|
110
120
|
peer?: PeerMessage;
|
|
111
121
|
origin?: ChatOrigin;
|
|
122
|
+
// The host sent it themselves: set only by their own composer and taps
|
|
123
|
+
// (never by an agent, event or schedule). Only such a message ends a live
|
|
124
|
+
// guest's steer (lib/chat/worker/guest-gate).
|
|
125
|
+
host?: true;
|
|
112
126
|
}
|
|
113
127
|
|
|
114
128
|
export interface McpServerView {
|
|
@@ -285,6 +299,9 @@ export type ChatItem =
|
|
|
285
299
|
status: "pending" | "allowed" | "denied" | "answered" | "expired";
|
|
286
300
|
// Allowed by No approvals mode, not by the reader.
|
|
287
301
|
auto?: boolean;
|
|
302
|
+
// Asked in a turn a live guest steered: only the reader's tap answers
|
|
303
|
+
// it (never No approvals mode), and it has no Always.
|
|
304
|
+
guest?: { name: string };
|
|
288
305
|
})
|
|
289
306
|
| (Base & {
|
|
290
307
|
// Work the agent runs alongside the turn: a background shell, a
|
|
@@ -323,6 +340,9 @@ export interface QueuedMessage {
|
|
|
323
340
|
imageCount?: number;
|
|
324
341
|
fileCount?: number;
|
|
325
342
|
createdAt: number;
|
|
343
|
+
// A live guest's Drive prompt: who it's from and what they typed (the
|
|
344
|
+
// text is what the agent gets, framed).
|
|
345
|
+
guest?: { name: string; body: string };
|
|
326
346
|
}
|
|
327
347
|
|
|
328
348
|
// A file or folder the agent would match for an @mention.
|
package/lib/chat/queued.ts
CHANGED
|
@@ -26,7 +26,7 @@ export function listQueue(sessionId: string): QueuedMessage[] {
|
|
|
26
26
|
return (
|
|
27
27
|
db
|
|
28
28
|
.prepare(
|
|
29
|
-
`SELECT id, text, image_count, files, created_at
|
|
29
|
+
`SELECT id, text, image_count, files, sent_by, created_at
|
|
30
30
|
FROM chat_queue WHERE session_id = ? ORDER BY position, created_at`
|
|
31
31
|
)
|
|
32
32
|
.all(sessionId) as {
|
|
@@ -34,15 +34,57 @@ export function listQueue(sessionId: string): QueuedMessage[] {
|
|
|
34
34
|
text: string;
|
|
35
35
|
image_count: number;
|
|
36
36
|
files: string | null;
|
|
37
|
+
sent_by: string | null;
|
|
37
38
|
created_at: number;
|
|
38
39
|
}[]
|
|
39
|
-
).map((r) =>
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
).map((r) => {
|
|
41
|
+
const o = r.sent_by ? (JSON.parse(r.sent_by) as SentBy).origin : undefined;
|
|
42
|
+
return {
|
|
43
|
+
id: r.id,
|
|
44
|
+
text: r.text,
|
|
45
|
+
imageCount: r.image_count || undefined,
|
|
46
|
+
fileCount: r.files
|
|
47
|
+
? (JSON.parse(r.files) as unknown[]).length
|
|
48
|
+
: undefined,
|
|
49
|
+
createdAt: r.created_at,
|
|
50
|
+
...(o?.kind === "guest-drive"
|
|
51
|
+
? { guest: { name: o.label, body: o.body ?? r.text } }
|
|
52
|
+
: {}),
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Who sent the message that goes next, without taking it.
|
|
58
|
+
export function peekNextSentBy(sessionId: string): SentBy | null {
|
|
59
|
+
const row = db
|
|
60
|
+
.prepare(
|
|
61
|
+
`SELECT sent_by FROM chat_queue WHERE session_id = ? ORDER BY position, created_at LIMIT 1`
|
|
62
|
+
)
|
|
63
|
+
.get(sessionId) as { sent_by: string | null } | undefined;
|
|
64
|
+
if (!row) return null;
|
|
65
|
+
return row.sent_by ? (JSON.parse(row.sent_by) as SentBy) : {};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Drops every queued message whose sender matches; returns the ids dropped.
|
|
69
|
+
export function deleteQueuedWhere(
|
|
70
|
+
sessionId: string,
|
|
71
|
+
match: (by: SentBy) => boolean
|
|
72
|
+
): string[] {
|
|
73
|
+
return db.transaction(() => {
|
|
74
|
+
const rows = db
|
|
75
|
+
.prepare(
|
|
76
|
+
`SELECT id, sent_by FROM chat_queue WHERE session_id = ? AND sent_by IS NOT NULL`
|
|
77
|
+
)
|
|
78
|
+
.all(sessionId) as { id: string; sent_by: string }[];
|
|
79
|
+
const ids = rows
|
|
80
|
+
.filter((r) => match(JSON.parse(r.sent_by) as SentBy))
|
|
81
|
+
.map((r) => r.id);
|
|
82
|
+
const del = db.prepare(
|
|
83
|
+
`DELETE FROM chat_queue WHERE session_id = ? AND id = ?`
|
|
84
|
+
);
|
|
85
|
+
for (const id of ids) del.run(sessionId, id);
|
|
86
|
+
return ids;
|
|
87
|
+
})();
|
|
46
88
|
}
|
|
47
89
|
|
|
48
90
|
// At the back. A retried send with the same id is only queued once.
|
|
@@ -56,7 +98,12 @@ export function enqueue(
|
|
|
56
98
|
} & SentBy
|
|
57
99
|
): void {
|
|
58
100
|
const images = m.images?.length ? JSON.stringify(m.images) : null;
|
|
59
|
-
const sentBy: SentBy = {
|
|
101
|
+
const sentBy: SentBy = {
|
|
102
|
+
from: m.from,
|
|
103
|
+
peer: m.peer,
|
|
104
|
+
origin: m.origin,
|
|
105
|
+
host: m.host,
|
|
106
|
+
};
|
|
60
107
|
if (m.text.length > MAX_QUEUED_TEXT)
|
|
61
108
|
throw new Error("That message is too long to queue");
|
|
62
109
|
if (images && images.length > MAX_QUEUED_IMAGES)
|
|
@@ -77,7 +124,7 @@ export function enqueue(
|
|
|
77
124
|
images,
|
|
78
125
|
m.images?.length ?? 0,
|
|
79
126
|
m.files?.length ? JSON.stringify(m.files) : null,
|
|
80
|
-
m.from || m.peer || m.origin ? JSON.stringify(sentBy) : null,
|
|
127
|
+
m.from || m.peer || m.origin || m.host ? JSON.stringify(sentBy) : null,
|
|
81
128
|
Date.now()
|
|
82
129
|
);
|
|
83
130
|
}
|
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
|
+
}
|