@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.
@@ -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;
@@ -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 { WorkerEvent } from "./worker/protocol";
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) {
@@ -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
- void sendChat(sessionId, { ...send, queue: true }).catch((err) => {
154
- if (id)
155
- reply({
156
- type: "send_failed",
157
- id,
158
- message: err instanceof Error ? err.message : String(err),
159
- });
160
- fail(err);
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
+ }