@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
|
@@ -0,0 +1,511 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The guest gate. A turn a live guest started (Drive) runs only what the
|
|
3
|
+
* host taps for, whatever the session's access, its allow rules or No
|
|
4
|
+
* approvals mode say: reading non-secret files inside the task's worktree is
|
|
5
|
+
* the one thing that goes ahead alone. It is a PreToolUse hook, first in
|
|
6
|
+
* line, so it decides before any permission rule could allow a call.
|
|
7
|
+
*
|
|
8
|
+
* The steer is sticky: set when a guest's message goes to the agent, and
|
|
9
|
+
* cleared only when a message the host sent themselves starts its turn.
|
|
10
|
+
* Every turn in between (a background task's follow-up, the wind-down of a
|
|
11
|
+
* stopped one) is gated. It is kept on the session (chat_guest_steers), so
|
|
12
|
+
* a worker that restarts picks it up.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "fs";
|
|
16
|
+
import path from "path";
|
|
17
|
+
import type {
|
|
18
|
+
HookCallback,
|
|
19
|
+
HookJSONOutput,
|
|
20
|
+
PermissionResult,
|
|
21
|
+
} from "@anthropic-ai/claude-agent-sdk";
|
|
22
|
+
import type { ChatItem, SentBy } from "../events";
|
|
23
|
+
import { db } from "../../db";
|
|
24
|
+
import { isSecretPath } from "../../secret-paths";
|
|
25
|
+
import { readPlaced } from "../../worktree-placed";
|
|
26
|
+
import { deleteQueuedWhere } from "../queued";
|
|
27
|
+
|
|
28
|
+
export const GUEST_MAX_TOOL_CALLS = 40;
|
|
29
|
+
export const GUEST_TURN_MS = 20 * 60_000;
|
|
30
|
+
// host.ts's HARD_STOP_MS: how long a hard stop's aftermath can run.
|
|
31
|
+
const WIND_DOWN_MS = 30_000;
|
|
32
|
+
|
|
33
|
+
const NOT_ALLOWED = "The host didn't allow this step.";
|
|
34
|
+
const FAILED = "The guest gate failed, so this step didn't run.";
|
|
35
|
+
const OUT_OF_TIME = "This guest's turn is out of time.";
|
|
36
|
+
|
|
37
|
+
export interface GuestSteer {
|
|
38
|
+
shareId: string;
|
|
39
|
+
wheel: string;
|
|
40
|
+
guestId: string;
|
|
41
|
+
name: string;
|
|
42
|
+
toolCalls: number;
|
|
43
|
+
startedAt: number;
|
|
44
|
+
// Its Drive ended (downgrade, take-back, kick, end) while its turn ran.
|
|
45
|
+
endedAt?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What the gate needs of the approval cards (lib/chat/drivers/claude-approvals). */
|
|
49
|
+
export interface GateApprovals {
|
|
50
|
+
askGuest(
|
|
51
|
+
toolName: string,
|
|
52
|
+
input: Record<string, unknown>,
|
|
53
|
+
toolUseID: string,
|
|
54
|
+
signal: AbortSignal,
|
|
55
|
+
guest: { name: string }
|
|
56
|
+
): Promise<PermissionResult>;
|
|
57
|
+
/** The gate let this call through: the permission prompt that may follow is answered. */
|
|
58
|
+
passed(toolUseID: string): void;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export const isGuestDrive = (m: SentBy): boolean =>
|
|
62
|
+
m.origin?.kind === "guest-drive";
|
|
63
|
+
|
|
64
|
+
// Sent by the host themselves (their composer, their own taps). Agents,
|
|
65
|
+
// events, schedules and AgentOS's own resends aren't the host speaking, so
|
|
66
|
+
// they leave a steer as it is.
|
|
67
|
+
const byHost = (m: SentBy): boolean =>
|
|
68
|
+
m.host === true && !m.from && !m.peer && !isGuestDrive(m);
|
|
69
|
+
|
|
70
|
+
type SteerRow = {
|
|
71
|
+
share_id: string;
|
|
72
|
+
wheel: string;
|
|
73
|
+
guest_id: string;
|
|
74
|
+
name: string;
|
|
75
|
+
tool_calls: number;
|
|
76
|
+
started_at: number;
|
|
77
|
+
ended_at: number | null;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
function loadSteer(sessionId: string): GuestSteer | null {
|
|
81
|
+
const r = db
|
|
82
|
+
.prepare(`SELECT * FROM chat_guest_steers WHERE session_id = ?`)
|
|
83
|
+
.get(sessionId) as SteerRow | undefined;
|
|
84
|
+
return r
|
|
85
|
+
? {
|
|
86
|
+
shareId: r.share_id,
|
|
87
|
+
wheel: r.wheel,
|
|
88
|
+
guestId: r.guest_id,
|
|
89
|
+
name: r.name,
|
|
90
|
+
toolCalls: r.tool_calls,
|
|
91
|
+
startedAt: r.started_at,
|
|
92
|
+
...(r.ended_at ? { endedAt: r.ended_at } : {}),
|
|
93
|
+
}
|
|
94
|
+
: null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function saveSteer(sessionId: string, s: GuestSteer | null): void {
|
|
98
|
+
if (!s) {
|
|
99
|
+
db.prepare(`DELETE FROM chat_guest_steers WHERE session_id = ?`).run(
|
|
100
|
+
sessionId
|
|
101
|
+
);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
db.prepare(
|
|
105
|
+
`INSERT OR REPLACE INTO chat_guest_steers (session_id, share_id, wheel, guest_id, name, tool_calls, started_at, ended_at)
|
|
106
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
|
|
107
|
+
).run(
|
|
108
|
+
sessionId,
|
|
109
|
+
s.shareId,
|
|
110
|
+
s.wheel,
|
|
111
|
+
s.guestId,
|
|
112
|
+
s.name,
|
|
113
|
+
s.toolCalls,
|
|
114
|
+
s.startedAt,
|
|
115
|
+
s.endedAt ?? null
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export class GuestGate {
|
|
120
|
+
steer: GuestSteer | null;
|
|
121
|
+
// Wheels whose Drive is over: nothing sent under them is taken in.
|
|
122
|
+
private released = new Set<string>();
|
|
123
|
+
private timer?: NodeJS.Timeout;
|
|
124
|
+
private asking = new Set<AbortController>();
|
|
125
|
+
// Turns started, and the one (by count) the host's own message starts:
|
|
126
|
+
// the steer ends when that one does.
|
|
127
|
+
private starts = 0;
|
|
128
|
+
private clearAt = 0;
|
|
129
|
+
private placed?: Promise<Set<string>>;
|
|
130
|
+
private running = false;
|
|
131
|
+
private endTimer?: NodeJS.Timeout;
|
|
132
|
+
|
|
133
|
+
constructor(
|
|
134
|
+
// Stops the running turn, with a line for the host saying why.
|
|
135
|
+
private onStop: (note: ChatItem) => void,
|
|
136
|
+
private sessionId?: string
|
|
137
|
+
) {
|
|
138
|
+
this.steer = sessionId ? loadSteer(sessionId) : null;
|
|
139
|
+
// A restarted worker has no turn left from a Drive that ended.
|
|
140
|
+
if (this.steer?.endedAt) this.set(null);
|
|
141
|
+
if (this.steer) this.arm(this.steer);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private set(s: GuestSteer | null): void {
|
|
145
|
+
this.steer = s;
|
|
146
|
+
if (!s) {
|
|
147
|
+
clearTimeout(this.timer);
|
|
148
|
+
clearTimeout(this.endTimer);
|
|
149
|
+
}
|
|
150
|
+
if (this.sessionId) saveSteer(this.sessionId, s);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* A steer whose Drive ended goes once its stopped turn is over: no turn
|
|
155
|
+
* running and the hard stop's wind-down (a turn the CLI starts to reply
|
|
156
|
+
* to the interruption, which the host cuts short) past.
|
|
157
|
+
*/
|
|
158
|
+
private endIfOver(): void {
|
|
159
|
+
const s = this.steer;
|
|
160
|
+
if (s?.endedAt && !this.running && Date.now() >= s.endedAt + WIND_DOWN_MS)
|
|
161
|
+
this.set(null);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private arm(s: GuestSteer): void {
|
|
165
|
+
clearTimeout(this.timer);
|
|
166
|
+
this.timer = setTimeout(
|
|
167
|
+
() => this.stop(`${s.name}'s turn ran for 20 minutes`),
|
|
168
|
+
Math.max(0, s.startedAt + GUEST_TURN_MS - Date.now())
|
|
169
|
+
);
|
|
170
|
+
this.timer.unref?.();
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* A message goes to the agent. `midTurn`: it joined a running turn;
|
|
175
|
+
* `ahead`: turns that start before its own (one sent but not started).
|
|
176
|
+
*/
|
|
177
|
+
onSend(m: SentBy, midTurn: boolean, ahead = 0): void {
|
|
178
|
+
if (isGuestDrive(m)) {
|
|
179
|
+
const o = m.origin!;
|
|
180
|
+
const s: GuestSteer = {
|
|
181
|
+
shareId: o.share ?? "",
|
|
182
|
+
wheel: o.wheel ?? "",
|
|
183
|
+
guestId: o.guestId ?? "",
|
|
184
|
+
name: o.label,
|
|
185
|
+
toolCalls: 0,
|
|
186
|
+
startedAt: Date.now(),
|
|
187
|
+
};
|
|
188
|
+
this.set(s);
|
|
189
|
+
this.arm(s);
|
|
190
|
+
this.clearAt = 0;
|
|
191
|
+
} else if (!midTurn && this.steer && byHost(m))
|
|
192
|
+
this.clearAt = this.starts + ahead + 1;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** The agent started a turn: the host's own message's turn ends a steer. */
|
|
196
|
+
onTurnStart(): void {
|
|
197
|
+
this.endIfOver();
|
|
198
|
+
this.running = true;
|
|
199
|
+
this.starts++;
|
|
200
|
+
if (this.clearAt && this.starts >= this.clearAt) {
|
|
201
|
+
this.clearAt = 0;
|
|
202
|
+
this.set(null);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A turn ended (or was stopped). */
|
|
207
|
+
onIdle(): void {
|
|
208
|
+
this.running = false;
|
|
209
|
+
this.endIfOver();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** Where a guest card stops waiting: the guest's 20 minutes are up. */
|
|
213
|
+
deadline(): number {
|
|
214
|
+
return (this.steer?.startedAt ?? 0) + GUEST_TURN_MS;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Files AgentOS copied into the worktree (env files, agentos.json `copy`). */
|
|
218
|
+
placedFiles(cwd: string): Promise<Set<string>> {
|
|
219
|
+
this.placed ??= readPlaced(cwd).then(
|
|
220
|
+
(p) => new Set(Object.keys(p.files).map((f) => path.normalize(f)))
|
|
221
|
+
);
|
|
222
|
+
return this.placed;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A guest's message from a Drive that's over (or naming none). */
|
|
226
|
+
refuses(m: SentBy): boolean {
|
|
227
|
+
return (
|
|
228
|
+
isGuestDrive(m) &&
|
|
229
|
+
(!m.origin?.share || !m.origin.wheel || this.released.has(m.origin.wheel))
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Ends a Drive: its queued prompts go, its pending cards expire as
|
|
235
|
+
* denied, and later messages under that wheel are refused. A steer it
|
|
236
|
+
* holds goes now if no turn runs, else once the stopped turn is over
|
|
237
|
+
* (gated till then), so later turns of the agent's own run by the
|
|
238
|
+
* host's settings, not the guest's.
|
|
239
|
+
*/
|
|
240
|
+
release(
|
|
241
|
+
sessionId: string,
|
|
242
|
+
share: string,
|
|
243
|
+
wheel: string,
|
|
244
|
+
busy = false
|
|
245
|
+
): { purged: boolean; steered: boolean } {
|
|
246
|
+
if (wheel) this.released.add(wheel);
|
|
247
|
+
const purged =
|
|
248
|
+
deleteQueuedWhere(
|
|
249
|
+
sessionId,
|
|
250
|
+
(by) => isGuestDrive(by) && by.origin?.share === share
|
|
251
|
+
).length > 0;
|
|
252
|
+
const steered = this.steeredBy(share);
|
|
253
|
+
if (steered) {
|
|
254
|
+
clearTimeout(this.timer);
|
|
255
|
+
this.clearAt = 0;
|
|
256
|
+
this.expire();
|
|
257
|
+
if (!busy) this.set(null);
|
|
258
|
+
else if (!this.steer!.endedAt) {
|
|
259
|
+
const s = { ...this.steer!, endedAt: Date.now() };
|
|
260
|
+
this.set(s);
|
|
261
|
+
this.endTimer = setTimeout(() => this.endIfOver(), WIND_DOWN_MS + 1);
|
|
262
|
+
this.endTimer.unref?.();
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return { purged, steered: steered && busy };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
steeredBy(share: string, wheel?: string): boolean {
|
|
269
|
+
return (
|
|
270
|
+
!!share &&
|
|
271
|
+
this.steer?.shareId === share &&
|
|
272
|
+
(wheel === undefined || this.steer.wheel === wheel)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** The worker's guest_release and guest_stop. */
|
|
277
|
+
command(
|
|
278
|
+
cmd: { type: "guest_release" | "guest_stop"; share: string; wheel: string },
|
|
279
|
+
sessionId: string,
|
|
280
|
+
fx: {
|
|
281
|
+
queueChanged: () => void;
|
|
282
|
+
stopTurn: () => Promise<void> | void;
|
|
283
|
+
busy: () => boolean;
|
|
284
|
+
}
|
|
285
|
+
): Promise<void> | void {
|
|
286
|
+
if (cmd.type === "guest_stop")
|
|
287
|
+
return this.steeredBy(cmd.share, cmd.wheel) ? fx.stopTurn() : undefined;
|
|
288
|
+
const { purged, steered } = this.release(
|
|
289
|
+
sessionId,
|
|
290
|
+
cmd.share,
|
|
291
|
+
cmd.wheel,
|
|
292
|
+
fx.busy()
|
|
293
|
+
);
|
|
294
|
+
if (purged) fx.queueChanged();
|
|
295
|
+
return steered ? fx.stopTurn() : undefined;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Counts a call of the steered turn; false past the cap, which stops it. */
|
|
299
|
+
count(): boolean {
|
|
300
|
+
const s = this.steer;
|
|
301
|
+
if (!s) return true;
|
|
302
|
+
s.toolCalls++;
|
|
303
|
+
if (this.sessionId) saveSteer(this.sessionId, s);
|
|
304
|
+
if (s.toolCalls <= GUEST_MAX_TOOL_CALLS) return true;
|
|
305
|
+
if (s.toolCalls === GUEST_MAX_TOOL_CALLS + 1)
|
|
306
|
+
this.stop(`${s.name}'s turn reached ${GUEST_MAX_TOOL_CALLS} steps`);
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
track(ac: AbortController): () => void {
|
|
311
|
+
this.asking.add(ac);
|
|
312
|
+
return () => this.asking.delete(ac);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private expire(): void {
|
|
316
|
+
for (const ac of this.asking) ac.abort();
|
|
317
|
+
this.asking.clear();
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
private stop(why: string): void {
|
|
321
|
+
clearTimeout(this.timer);
|
|
322
|
+
this.expire();
|
|
323
|
+
this.onStop({
|
|
324
|
+
id: `note-guest-stop-${Date.now()}`,
|
|
325
|
+
kind: "note",
|
|
326
|
+
tone: "note",
|
|
327
|
+
text: `Stopped: ${why}`,
|
|
328
|
+
createdAt: Date.now(),
|
|
329
|
+
});
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
close(): void {
|
|
333
|
+
clearTimeout(this.timer);
|
|
334
|
+
this.expire();
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// The agent's questions and plans are host cards already (their own hooks).
|
|
339
|
+
const HOST_CARDS = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
340
|
+
|
|
341
|
+
const decide = (
|
|
342
|
+
permissionDecision: "allow" | "deny",
|
|
343
|
+
extra: { updatedInput?: Record<string, unknown>; reason?: string }
|
|
344
|
+
): HookJSONOutput => ({
|
|
345
|
+
hookSpecificOutput: {
|
|
346
|
+
hookEventName: "PreToolUse",
|
|
347
|
+
permissionDecision,
|
|
348
|
+
...(extra.updatedInput && { updatedInput: extra.updatedInput }),
|
|
349
|
+
...(extra.reason && { permissionDecisionReason: extra.reason }),
|
|
350
|
+
},
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
export function guestGateHook(
|
|
354
|
+
approvals: GateApprovals,
|
|
355
|
+
gate: GuestGate,
|
|
356
|
+
cwd: string
|
|
357
|
+
): HookCallback {
|
|
358
|
+
// Any failure denies: an error must never let a guest's step through.
|
|
359
|
+
return async (input, toolUseID, opts) => {
|
|
360
|
+
try {
|
|
361
|
+
return await gateCall(approvals, gate, cwd, input, toolUseID, opts);
|
|
362
|
+
} catch {
|
|
363
|
+
return decide("deny", { reason: FAILED });
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
async function gateCall(
|
|
369
|
+
approvals: GateApprovals,
|
|
370
|
+
gate: GuestGate,
|
|
371
|
+
cwd: string,
|
|
372
|
+
...[input, toolUseID, { signal }]: Parameters<HookCallback>
|
|
373
|
+
): Promise<HookJSONOutput> {
|
|
374
|
+
if (input.hook_event_name !== "PreToolUse") return {};
|
|
375
|
+
const steer = gate.steer;
|
|
376
|
+
if (!steer) return {};
|
|
377
|
+
if (!gate.count())
|
|
378
|
+
return decide("deny", { reason: "This guest's turn is out of steps." });
|
|
379
|
+
const name = input.tool_name;
|
|
380
|
+
const args = (input.tool_input ?? {}) as Record<string, unknown>;
|
|
381
|
+
if (HOST_CARDS.has(name)) return {};
|
|
382
|
+
const id = toolUseID ?? input.tool_use_id;
|
|
383
|
+
// What AgentOS copied in is read (git, so slowly) only for a read that
|
|
384
|
+
// would otherwise go ahead.
|
|
385
|
+
if (
|
|
386
|
+
worktreeRead(name, args, cwd) &&
|
|
387
|
+
worktreeRead(name, args, cwd, await gate.placedFiles(cwd))
|
|
388
|
+
) {
|
|
389
|
+
approvals.passed(id);
|
|
390
|
+
return decide("allow", { updatedInput: args });
|
|
391
|
+
}
|
|
392
|
+
// The card expires (denied) when the turn stops, the Drive ends or the
|
|
393
|
+
// guest's 20 minutes run out, well before the SDK's own hook timeout.
|
|
394
|
+
const left = gate.deadline() - Date.now();
|
|
395
|
+
if (left <= 0) return decide("deny", { reason: OUT_OF_TIME });
|
|
396
|
+
const ac = new AbortController();
|
|
397
|
+
const abort = () => ac.abort();
|
|
398
|
+
if (signal.aborted) ac.abort();
|
|
399
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
400
|
+
const timer = setTimeout(abort, left);
|
|
401
|
+
timer.unref?.();
|
|
402
|
+
const untrack = gate.track(ac);
|
|
403
|
+
try {
|
|
404
|
+
const r = await approvals.askGuest(name, args, id, ac.signal, {
|
|
405
|
+
name: steer.name,
|
|
406
|
+
});
|
|
407
|
+
if (r.behavior !== "allow")
|
|
408
|
+
return decide("deny", { reason: r.message || NOT_ALLOWED });
|
|
409
|
+
approvals.passed(id);
|
|
410
|
+
return decide("allow", { updatedInput: r.updatedInput ?? args });
|
|
411
|
+
} finally {
|
|
412
|
+
clearTimeout(timer);
|
|
413
|
+
untrack();
|
|
414
|
+
signal.removeEventListener("abort", abort);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Reads that need no tap: Read, Glob and Grep whose target resolves (links
|
|
420
|
+
* followed) inside the worktree, outside .git, and is no secret file nor
|
|
421
|
+
* one AgentOS copied in (`placed`); and TodoWrite. A Grep names its path
|
|
422
|
+
* and, over a folder, is narrowed to a file type with no copied file under
|
|
423
|
+
* it; one with a `glob` is a card (rg's --glob overrides --type and
|
|
424
|
+
* .gitignore, so it could reach an env file).
|
|
425
|
+
*/
|
|
426
|
+
export function worktreeRead(
|
|
427
|
+
name: string,
|
|
428
|
+
args: Record<string, unknown>,
|
|
429
|
+
cwd: string,
|
|
430
|
+
placed: Set<string> = new Set()
|
|
431
|
+
): boolean {
|
|
432
|
+
if (name === "TodoWrite") return true;
|
|
433
|
+
if (name !== "Read" && name !== "Glob" && name !== "Grep") return false;
|
|
434
|
+
let root: string;
|
|
435
|
+
try {
|
|
436
|
+
root = fs.realpathSync.native(cwd);
|
|
437
|
+
} catch {
|
|
438
|
+
return false;
|
|
439
|
+
}
|
|
440
|
+
if (name === "Grep" && (args.glob !== undefined || args.path === undefined))
|
|
441
|
+
return false;
|
|
442
|
+
const target = name === "Read" ? args.file_path : (args.path ?? cwd);
|
|
443
|
+
if (typeof target !== "string" || !target) return false;
|
|
444
|
+
const real = inside(root, cwd, target);
|
|
445
|
+
if (!real) return false;
|
|
446
|
+
// Lowercase: macOS folders are case-insensitive, so CONFIG/APP.YML is
|
|
447
|
+
// config/app.yml.
|
|
448
|
+
const rel = real.rel.toLowerCase();
|
|
449
|
+
const copied = real.file
|
|
450
|
+
? [...placed].some((f) => f.toLowerCase() === rel)
|
|
451
|
+
: [...placed].some(
|
|
452
|
+
(f) => !rel || f.toLowerCase().startsWith(rel + path.sep)
|
|
453
|
+
);
|
|
454
|
+
if (name === "Read") return real.file && !copied;
|
|
455
|
+
if (name === "Glob") return safePattern(args.pattern);
|
|
456
|
+
if (copied) return false;
|
|
457
|
+
return real.file || narrowed(args);
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Secret by name, whatever isSecretPath says: every env file (env-setup
|
|
461
|
+
// copies them all in), Wrangler's, Terraform state, cloud keys.
|
|
462
|
+
const SECRET_NAME =
|
|
463
|
+
/^(\.env.*|\.dev\.vars.*|.*\.tfstate(\..*)?|.*service[-_]?account.*\.json|credentials.*\.json|\.npmrc|\.netrc|\.pgpass)$/i;
|
|
464
|
+
|
|
465
|
+
function inside(
|
|
466
|
+
root: string,
|
|
467
|
+
cwd: string,
|
|
468
|
+
target: string
|
|
469
|
+
): { file: boolean; rel: string } | null {
|
|
470
|
+
let real: string;
|
|
471
|
+
let stat: fs.Stats;
|
|
472
|
+
try {
|
|
473
|
+
real = fs.realpathSync.native(path.resolve(cwd, target));
|
|
474
|
+
stat = fs.statSync(real);
|
|
475
|
+
} catch {
|
|
476
|
+
return null;
|
|
477
|
+
}
|
|
478
|
+
const rel = path.relative(root, real);
|
|
479
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
|
|
480
|
+
const parts = rel.split(path.sep);
|
|
481
|
+
if (parts.some((p) => p.toLowerCase() === ".git")) return null;
|
|
482
|
+
if (parts.some((p) => SECRET_NAME.test(p))) return null;
|
|
483
|
+
if ((rel && isSecretPath(rel)) || isSecretPath(real)) return null;
|
|
484
|
+
if (!stat.isFile() && !stat.isDirectory()) return null;
|
|
485
|
+
return { file: stat.isFile(), rel };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// A pattern that stays below its folder: no braces (which can list one).
|
|
489
|
+
function safePattern(p: unknown): boolean {
|
|
490
|
+
return (
|
|
491
|
+
typeof p === "string" &&
|
|
492
|
+
!!p &&
|
|
493
|
+
!path.isAbsolute(p) &&
|
|
494
|
+
!p.startsWith("~") &&
|
|
495
|
+
!/[{}]/.test(p) &&
|
|
496
|
+
!p.includes("..")
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
const SECRET_EXT = /^(env|pem|key|p12|pfx|npmrc|netrc|pgpass)$/i;
|
|
501
|
+
|
|
502
|
+
// Only files of one type, none of them a secret one.
|
|
503
|
+
function narrowed(args: Record<string, unknown>): boolean {
|
|
504
|
+
const type = args.type;
|
|
505
|
+
return (
|
|
506
|
+
typeof type === "string" &&
|
|
507
|
+
/^[a-z0-9]+$/i.test(type) &&
|
|
508
|
+
type !== "all" &&
|
|
509
|
+
!SECRET_EXT.test(type)
|
|
510
|
+
);
|
|
511
|
+
}
|
package/lib/chat/worker/host.ts
CHANGED
|
@@ -33,7 +33,14 @@ import {
|
|
|
33
33
|
settle,
|
|
34
34
|
} from "../store";
|
|
35
35
|
import { handleMcpAuth, stopMcpWatches } from "./mcp-auth";
|
|
36
|
-
import {
|
|
36
|
+
import {
|
|
37
|
+
claimNext,
|
|
38
|
+
enqueue,
|
|
39
|
+
listQueue,
|
|
40
|
+
moveToFront,
|
|
41
|
+
peekNextSentBy,
|
|
42
|
+
} from "../queued";
|
|
43
|
+
import { GuestGate, isGuestDrive } from "./guest-gate";
|
|
37
44
|
import { isHeld } from "../hold";
|
|
38
45
|
import {
|
|
39
46
|
recordCompaction,
|
|
@@ -145,6 +152,8 @@ export class ChatHost {
|
|
|
145
152
|
// need words), and the ones No approvals mode allowed.
|
|
146
153
|
private waitingCards = new Map<string, string>();
|
|
147
154
|
private autoAllowed = new Set<string>();
|
|
155
|
+
// Holds a live guest's turn to the host's taps (./guest-gate).
|
|
156
|
+
private gate: GuestGate;
|
|
148
157
|
// Whether its driver says when that is (lib/chat/driver atRest).
|
|
149
158
|
private atRest = true;
|
|
150
159
|
private restartedNote = false;
|
|
@@ -188,6 +197,11 @@ export class ChatHost {
|
|
|
188
197
|
// What a session's role adds: its brief and its own tools.
|
|
189
198
|
extras: RoleExtras = {}
|
|
190
199
|
) {
|
|
200
|
+
this.gate = new GuestGate((note) => {
|
|
201
|
+
if (!this.busy()) return;
|
|
202
|
+
this.record(note);
|
|
203
|
+
void this.handle({ type: "interrupt", hard: true });
|
|
204
|
+
}, session.id);
|
|
191
205
|
this.usage = startingTotals(session.id, !!session.claude_session_id);
|
|
192
206
|
const driver = chatDriverFor(session.agent_type);
|
|
193
207
|
if (!driver)
|
|
@@ -251,6 +265,7 @@ export class ChatHost {
|
|
|
251
265
|
disallowedTools: extras.disallowedTools,
|
|
252
266
|
permissionMode: extras.permissionMode,
|
|
253
267
|
toolGuard: extras.toolGuard,
|
|
268
|
+
guestGate: this.gate,
|
|
254
269
|
// Not an orchestrator: it starts a fresh conversation instead.
|
|
255
270
|
compactContext: orchestrator
|
|
256
271
|
? undefined
|
|
@@ -294,7 +309,7 @@ export class ChatHost {
|
|
|
294
309
|
// No approvals mode: a tool-permission card is allowed as it appears, and
|
|
295
310
|
// marks itself so the transcript shows who allowed it.
|
|
296
311
|
private markAuto(item: ChatItem): ChatItem {
|
|
297
|
-
if (item.kind !== "approval" || item.questions) return item;
|
|
312
|
+
if (item.kind !== "approval" || item.questions || item.guest) return item;
|
|
298
313
|
if (item.status === "pending") {
|
|
299
314
|
this.waitingCards.set(item.id, item.title);
|
|
300
315
|
return item;
|
|
@@ -432,6 +447,7 @@ export class ChatHost {
|
|
|
432
447
|
// short, so what's sent next isn't queued behind it.
|
|
433
448
|
if (this.hardStopped()) void this.conversation.interrupt();
|
|
434
449
|
this.turnsStarted++;
|
|
450
|
+
this.gate.onTurnStart();
|
|
435
451
|
this.turnStartedAt ??= Date.now();
|
|
436
452
|
if (this.behind) this.behind--;
|
|
437
453
|
clearTimeout(this.restTimer);
|
|
@@ -451,8 +467,10 @@ export class ChatHost {
|
|
|
451
467
|
this.setCompacting(e.active);
|
|
452
468
|
} else if (e.type === "state") {
|
|
453
469
|
// A stop, an error or the end of the turn ends a compaction too.
|
|
454
|
-
if (e.state === "idle" || e.state === "error")
|
|
470
|
+
if (e.state === "idle" || e.state === "error") {
|
|
455
471
|
this.setCompacting(false);
|
|
472
|
+
this.gate.onIdle();
|
|
473
|
+
}
|
|
456
474
|
if (e.state === "idle") {
|
|
457
475
|
flushItems();
|
|
458
476
|
// A turn cut off mid-sentence (Esc, Stop) keeps what it streamed.
|
|
@@ -553,7 +571,12 @@ export class ChatHost {
|
|
|
553
571
|
this.compactingNow
|
|
554
572
|
)
|
|
555
573
|
return;
|
|
556
|
-
|
|
574
|
+
// A guest's message is always its own turn: it waits for this one's end.
|
|
575
|
+
for (
|
|
576
|
+
let next;
|
|
577
|
+
!isGuestDrive(peekNextSentBy(this.session.id) ?? {}) &&
|
|
578
|
+
(next = claimNext(this.session.id));
|
|
579
|
+
) {
|
|
557
580
|
this.emit({ type: "queue" });
|
|
558
581
|
this.sendUser(next);
|
|
559
582
|
}
|
|
@@ -573,6 +596,11 @@ export class ChatHost {
|
|
|
573
596
|
this.hardStopAt = 0;
|
|
574
597
|
// A message sent now stops the turn and is read next: nothing waits.
|
|
575
598
|
const midTurn = this.busy() && !now;
|
|
599
|
+
this.gate.onSend(
|
|
600
|
+
m,
|
|
601
|
+
midTurn,
|
|
602
|
+
this.busy() && this.turnsStarted <= this.turnsEnded ? 1 : 0
|
|
603
|
+
);
|
|
576
604
|
if (this.busy()) this.behind++;
|
|
577
605
|
// Sent now, it stops the running turn, which still ends after this.
|
|
578
606
|
if (!this.busy()) now = false;
|
|
@@ -650,7 +678,7 @@ export class ChatHost {
|
|
|
650
678
|
async handle(cmd: WorkerCommand): Promise<void> {
|
|
651
679
|
switch (cmd.type) {
|
|
652
680
|
case "send": {
|
|
653
|
-
if (this.sent.has(cmd.id)) return;
|
|
681
|
+
if (this.sent.has(cmd.id) || this.gate.refuses(cmd)) return;
|
|
654
682
|
// Sent just as a move or Land took hold (lib/chat/hold): it waits
|
|
655
683
|
// in the queue rather than start a turn the stop would cut short.
|
|
656
684
|
// Or for the current worker that takes over from a retiring one.
|
|
@@ -669,6 +697,7 @@ export class ChatHost {
|
|
|
669
697
|
}
|
|
670
698
|
this.sent.add(cmd.id);
|
|
671
699
|
await this.modeChange.catch(() => {});
|
|
700
|
+
if (this.gate.refuses(cmd)) return;
|
|
672
701
|
const user = {
|
|
673
702
|
id: cmd.id,
|
|
674
703
|
kind: "user" as const,
|
|
@@ -703,6 +732,7 @@ export class ChatHost {
|
|
|
703
732
|
// Another agent's message doesn't speak for the reader.
|
|
704
733
|
if (
|
|
705
734
|
this.stopping &&
|
|
735
|
+
!isGuestDrive(cmd) &&
|
|
706
736
|
(this.hardStopped() || (!cmd.from && !cmd.peer && !cmd.origin)) &&
|
|
707
737
|
this.busy() &&
|
|
708
738
|
!listQueue(this.session.id).length
|
|
@@ -723,7 +753,10 @@ export class ChatHost {
|
|
|
723
753
|
if (this.compactFirst(cmd)) return;
|
|
724
754
|
// A compaction run for an earlier message takes nothing in: what's
|
|
725
755
|
// sent meanwhile goes behind that message.
|
|
726
|
-
if (
|
|
756
|
+
if (
|
|
757
|
+
(cmd.queue || this.compacting || isGuestDrive(cmd)) &&
|
|
758
|
+
(this.busy() || backlog)
|
|
759
|
+
) {
|
|
727
760
|
// Behind the running turn, and behind anything queued before it.
|
|
728
761
|
enqueue(this.session.id, cmd);
|
|
729
762
|
this.emit({ type: "queue" });
|
|
@@ -835,6 +868,16 @@ export class ChatHost {
|
|
|
835
868
|
});
|
|
836
869
|
}
|
|
837
870
|
return;
|
|
871
|
+
case "guest_release":
|
|
872
|
+
case "guest_stop":
|
|
873
|
+
return this.gate.command(cmd, this.session.id, {
|
|
874
|
+
queueChanged: () => this.emit({ type: "queue" }),
|
|
875
|
+
busy: () => this.busy(),
|
|
876
|
+
stopTurn: () =>
|
|
877
|
+
this.busy()
|
|
878
|
+
? this.handle({ type: "interrupt", hard: true })
|
|
879
|
+
: undefined,
|
|
880
|
+
});
|
|
838
881
|
case "retire":
|
|
839
882
|
this.retiring = true;
|
|
840
883
|
return this.retireIfDone();
|
|
@@ -1064,6 +1107,7 @@ export class ChatHost {
|
|
|
1064
1107
|
clearTimeout(this.retireTimer);
|
|
1065
1108
|
clearTimeout(this.restTimer);
|
|
1066
1109
|
this.closed = true;
|
|
1110
|
+
this.gate.close();
|
|
1067
1111
|
this.setCompacting(false);
|
|
1068
1112
|
flushItems();
|
|
1069
1113
|
this.onClose();
|
package/lib/chat/worker/main.ts
CHANGED
|
@@ -9,7 +9,12 @@
|
|
|
9
9
|
import fs from "fs";
|
|
10
10
|
import net from "net";
|
|
11
11
|
import { adoptServerEnv } from "./env";
|
|
12
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
GUEST_GATE_CAP,
|
|
14
|
+
lineReader,
|
|
15
|
+
PROTOCOL_VERSION,
|
|
16
|
+
socketPath,
|
|
17
|
+
} from "./protocol";
|
|
13
18
|
import { buildId } from "../../build";
|
|
14
19
|
import type { WorkerCommand, WorkerEvent } from "./protocol";
|
|
15
20
|
import type { Session } from "../../db";
|
|
@@ -76,7 +81,16 @@ async function main(sessionId: string) {
|
|
|
76
81
|
state: host.state,
|
|
77
82
|
compacting: host.compactingNow,
|
|
78
83
|
streaming: [...host.streaming.values()],
|
|
79
|
-
caps: [
|
|
84
|
+
caps: [
|
|
85
|
+
"plan",
|
|
86
|
+
"queue",
|
|
87
|
+
"retire",
|
|
88
|
+
"fresh",
|
|
89
|
+
"background",
|
|
90
|
+
"mcp_auth",
|
|
91
|
+
// Only the Claude driver runs the guest gate's hook.
|
|
92
|
+
...(session.agent_type === "claude" ? [GUEST_GATE_CAP] : []),
|
|
93
|
+
],
|
|
80
94
|
} satisfies WorkerEvent)}\n`
|
|
81
95
|
);
|
|
82
96
|
client.on(
|