@saadnvd1/agent-os 0.3.778 → 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.
Files changed (43) hide show
  1. package/app/api/live-shares/[id]/route.ts +21 -3
  2. package/app/api/live-shares/route.test.ts +36 -0
  3. package/app/api/live-shares/route.ts +14 -3
  4. package/components/Chat/Approval.tsx +13 -2
  5. package/components/Chat/EventRow.tsx +5 -0
  6. package/components/Chat/Queue.tsx +27 -15
  7. package/components/Live/DriveBanner.tsx +42 -0
  8. package/components/Live/InviteDialog.tsx +44 -13
  9. package/components/Live/LiveChip.tsx +34 -13
  10. package/components/Live/LivePanel.tsx +26 -9
  11. package/components/Live/LobbyCard.tsx +18 -21
  12. package/components/ui/segmented.tsx +2 -1
  13. package/data/live/queries.ts +25 -5
  14. package/lib/chat/driver.ts +4 -0
  15. package/lib/chat/drivers/claude-approvals.ts +93 -25
  16. package/lib/chat/drivers/claude.ts +2 -1
  17. package/lib/chat/events.ts +21 -1
  18. package/lib/chat/queued.ts +57 -10
  19. package/lib/chat/registry.ts +2 -0
  20. package/lib/chat/runner.ts +65 -2
  21. package/lib/chat/socket.ts +12 -9
  22. package/lib/chat/worker/gate-testing.ts +131 -0
  23. package/lib/chat/worker/guest-gate.ts +511 -0
  24. package/lib/chat/worker/host.ts +50 -6
  25. package/lib/chat/worker/main.ts +16 -2
  26. package/lib/chat/worker/protocol.ts +11 -0
  27. package/lib/connect/control-api.ts +6 -2
  28. package/lib/db/migrations/20261030T1200_live_share_level.ts +19 -0
  29. package/lib/db/migrations/20261030T1300_chat_guest_steers.ts +20 -0
  30. package/lib/db/migrations/20261030T1400_chat_guest_steers_ended.ts +14 -0
  31. package/lib/live/clean.ts +6 -0
  32. package/lib/live/drive.ts +398 -0
  33. package/lib/live/frames.ts +9 -2
  34. package/lib/live/inbox.ts +47 -1
  35. package/lib/live/levels.ts +74 -0
  36. package/lib/live/publisher.ts +10 -0
  37. package/lib/live/runtime.ts +20 -0
  38. package/lib/live/service.ts +133 -10
  39. package/lib/live/store.ts +54 -3
  40. package/lib/live/testing.ts +281 -0
  41. package/lib/live/types.ts +52 -0
  42. package/lib/share/connect.ts +1 -0
  43. package/package.json +1 -1
@@ -33,7 +33,14 @@ import {
33
33
  settle,
34
34
  } from "../store";
35
35
  import { handleMcpAuth, stopMcpWatches } from "./mcp-auth";
36
- import { claimNext, enqueue, listQueue, moveToFront } from "../queued";
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
- for (let next; (next = claimNext(this.session.id));) {
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 ((cmd.queue || this.compacting) && (this.busy() || backlog)) {
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();
@@ -9,7 +9,12 @@
9
9
  import fs from "fs";
10
10
  import net from "net";
11
11
  import { adoptServerEnv } from "./env";
12
- import { lineReader, PROTOCOL_VERSION, socketPath } from "./protocol";
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: ["plan", "queue", "retire", "fresh", "background", "mcp_auth"],
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(
@@ -25,6 +25,10 @@ import type { CompactFirst } from "../stale";
25
25
 
26
26
  export const PROTOCOL_VERSION = 1;
27
27
 
28
+ // In a worker's hello caps: it holds a live guest's turns to the host's taps
29
+ // (./guest-gate). Without it no Drive prompt is sent to it.
30
+ export const GUEST_GATE_CAP = "guest-gate";
31
+
28
32
  // Server -> worker. A send carries the id its user item gets, so a send
29
33
  // retried after a dropped connection is never run twice.
30
34
  export type WorkerCommand =
@@ -37,6 +41,7 @@ export type WorkerCommand =
37
41
  from?: string;
38
42
  peer?: PeerMessage;
39
43
  origin?: ChatOrigin;
44
+ host?: true;
40
45
  // From the composer: waits its turn behind a running one, in the
41
46
  // queue, rather than joining it.
42
47
  queue?: boolean;
@@ -79,6 +84,12 @@ export type WorkerCommand =
79
84
  // (now, if idle) the agent's conversation ends and the next worker
80
85
  // starts a new one with today's brief and permissions.
81
86
  | { type: "fresh" }
87
+ // A live share's Drive is over (downgrade, take-back, kick, end): its
88
+ // queued prompts go, a turn it steers stops, and nothing more from it is
89
+ // taken in (lib/chat/worker/guest-gate).
90
+ | { type: "guest_release"; share: string; wheel: string }
91
+ // The driving guest's Stop: ends a turn their wheel steers, never another.
92
+ | { type: "guest_stop"; share: string; wheel: string }
82
93
  | { type: "close" };
83
94
 
84
95
  // Worker -> server: what's live right now on connecting, then events.
@@ -488,12 +488,16 @@ export interface LiveShare {
488
488
  export async function createLiveShare(
489
489
  config: ConnectConfig,
490
490
  privateKey: string,
491
- share: { title: string; expiresIn: LiveExpiry }
491
+ share: { title: string; expiresIn: LiveExpiry; access?: string }
492
492
  ): Promise<LiveShare> {
493
493
  const res = await signed(config, privateKey)(
494
494
  "POST",
495
495
  `/api/v1/machines/${config.machineId}/live-shares`,
496
- { title: share.title, expires_in: share.expiresIn }
496
+ {
497
+ title: share.title,
498
+ expires_in: share.expiresIn,
499
+ ...(share.access ? { access: share.access } : {}),
500
+ }
497
501
  );
498
502
  const r = (await res.json().catch(() => null)) as Record<
499
503
  string,
@@ -0,0 +1,19 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261030T1200_live_share_level",
5
+ up: (db) => {
6
+ const cols = (
7
+ db.prepare(`PRAGMA table_info(live_shares)`).all() as { name: string }[]
8
+ ).map((c) => c.name);
9
+ // Existing links keep what they always did: suggest, with the whole chat.
10
+ if (!cols.includes("level"))
11
+ db.exec(
12
+ `ALTER TABLE live_shares ADD COLUMN level TEXT NOT NULL DEFAULT 'suggest'`
13
+ );
14
+ if (!cols.includes("history"))
15
+ db.exec(
16
+ `ALTER TABLE live_shares ADD COLUMN history INTEGER NOT NULL DEFAULT 1`
17
+ );
18
+ },
19
+ } satisfies Migration;
@@ -0,0 +1,20 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261030T1300_chat_guest_steers",
5
+ up: (db) => {
6
+ // A live guest's steer on a chat (lib/chat/worker/guest-gate): kept
7
+ // here so a worker that restarts mid-Drive still gates its turns.
8
+ db.exec(
9
+ `CREATE TABLE IF NOT EXISTS chat_guest_steers (
10
+ session_id TEXT PRIMARY KEY,
11
+ share_id TEXT NOT NULL,
12
+ wheel TEXT NOT NULL,
13
+ guest_id TEXT NOT NULL,
14
+ name TEXT NOT NULL,
15
+ tool_calls INTEGER NOT NULL DEFAULT 0,
16
+ started_at INTEGER NOT NULL
17
+ )`
18
+ );
19
+ },
20
+ } satisfies Migration;
@@ -0,0 +1,14 @@
1
+ import type { Migration } from "../migration-types";
2
+
3
+ export default {
4
+ key: "20261030T1400_chat_guest_steers_ended",
5
+ up: (db) => {
6
+ // When the steer's Drive ended: a worker that restarts after it has no
7
+ // guest turn left to gate.
8
+ const cols = db.prepare(`PRAGMA table_info(chat_guest_steers)`).all() as {
9
+ name: string;
10
+ }[];
11
+ if (!cols.some((c) => c.name === "ended_at"))
12
+ db.exec(`ALTER TABLE chat_guest_steers ADD COLUMN ended_at INTEGER`);
13
+ },
14
+ } satisfies Migration;
package/lib/live/clean.ts CHANGED
@@ -38,6 +38,12 @@ export function cleanName(input: unknown): string | null {
38
38
  return s;
39
39
  }
40
40
 
41
+ /**
42
+ * Claude Code reads an `@path` in a message as a file to attach, with no
43
+ * tool call a gate could stop: a guest's `@` can't be one.
44
+ */
45
+ export const noMentions = (t: string) => t.replace(/@/g, "\uff20");
46
+
41
47
  /** A block of text (a suggestion): newlines and tabs kept, or null. */
42
48
  export function cleanBody(input: unknown, max = MAX_SUGGESTION): string | null {
43
49
  if (typeof input !== "string" || !wellFormed(input)) return null;