@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.
- package/app/api/live-shares/[id]/route.ts +21 -3
- package/app/api/live-shares/route.test.ts +36 -0
- package/app/api/live-shares/route.ts +14 -3
- 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 +44 -13
- package/components/Live/LiveChip.tsx +34 -13
- package/components/Live/LivePanel.tsx +26 -9
- package/components/Live/LobbyCard.tsx +18 -21
- package/components/ui/segmented.tsx +2 -1
- package/data/live/queries.ts +25 -5
- 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 +6 -2
- package/lib/db/migrations/20261030T1200_live_share_level.ts +19 -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 +47 -1
- package/lib/live/levels.ts +74 -0
- package/lib/live/publisher.ts +10 -0
- package/lib/live/runtime.ts +20 -0
- package/lib/live/service.ts +133 -10
- package/lib/live/store.ts +54 -3
- package/lib/live/testing.ts +281 -0
- package/lib/live/types.ts +52 -0
- package/lib/share/connect.ts +1 -0
- package/package.json +1 -1
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(
|
|
@@ -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
|
-
{
|
|
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;
|