@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
|
@@ -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.
|
|
@@ -482,13 +482,20 @@ export interface LiveShare {
|
|
|
482
482
|
expires_at: string | null;
|
|
483
483
|
ends_with_session: boolean;
|
|
484
484
|
max_guests: number;
|
|
485
|
+
/** Connect confirmed it holds an access code for the share. */
|
|
486
|
+
access_code: boolean;
|
|
485
487
|
}
|
|
486
488
|
|
|
487
489
|
/** Mints a live link for one session; the answer carries the link, once. */
|
|
488
490
|
export async function createLiveShare(
|
|
489
491
|
config: ConnectConfig,
|
|
490
492
|
privateKey: string,
|
|
491
|
-
share: {
|
|
493
|
+
share: {
|
|
494
|
+
title: string;
|
|
495
|
+
expiresIn: LiveExpiry;
|
|
496
|
+
access?: string;
|
|
497
|
+
accessCode?: string;
|
|
498
|
+
}
|
|
492
499
|
): Promise<LiveShare> {
|
|
493
500
|
const res = await signed(config, privateKey)(
|
|
494
501
|
"POST",
|
|
@@ -497,6 +504,7 @@ export async function createLiveShare(
|
|
|
497
504
|
title: share.title,
|
|
498
505
|
expires_in: share.expiresIn,
|
|
499
506
|
...(share.access ? { access: share.access } : {}),
|
|
507
|
+
...(share.accessCode ? { access_code: share.accessCode } : {}),
|
|
500
508
|
}
|
|
501
509
|
);
|
|
502
510
|
const r = (await res.json().catch(() => null)) as Record<
|
|
@@ -517,5 +525,26 @@ export async function createLiveShare(
|
|
|
517
525
|
expires_at: typeof r.expires_at === "string" ? r.expires_at : null,
|
|
518
526
|
ends_with_session: r.ends_with_session === true,
|
|
519
527
|
max_guests: typeof r.max_guests === "number" ? r.max_guests : 0,
|
|
528
|
+
access_code: r.access_code === true,
|
|
520
529
|
};
|
|
521
530
|
}
|
|
531
|
+
|
|
532
|
+
/** Sets (a code) or clears (null) a live share's access code; Connect must say it did. */
|
|
533
|
+
export async function setLiveAccessCode(
|
|
534
|
+
config: ConnectConfig,
|
|
535
|
+
privateKey: string,
|
|
536
|
+
id: string,
|
|
537
|
+
code: string | null
|
|
538
|
+
): Promise<void> {
|
|
539
|
+
if (!/^[a-z0-9]{12}$/.test(id)) throw new Error("not a share id");
|
|
540
|
+
const res = await signed(config, privateKey)(
|
|
541
|
+
"PUT",
|
|
542
|
+
`/api/v1/machines/${config.machineId}/live-shares/${id}/access-code`,
|
|
543
|
+
{ access_code: code }
|
|
544
|
+
);
|
|
545
|
+
const r = (await res.json().catch(() => null)) as {
|
|
546
|
+
access_code?: unknown;
|
|
547
|
+
} | null;
|
|
548
|
+
if (r?.access_code !== (code !== null))
|
|
549
|
+
throw new Error("the Connect service did not confirm the access code");
|
|
550
|
+
}
|
|
@@ -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;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Migration } from "../migration-types";
|
|
2
|
+
|
|
3
|
+
export default {
|
|
4
|
+
key: "20261031T1200_live_share_access_code",
|
|
5
|
+
up: (db) => {
|
|
6
|
+
const cols = (
|
|
7
|
+
db.prepare(`PRAGMA table_info(live_shares)`).all() as { name: string }[]
|
|
8
|
+
).map((c) => c.name);
|
|
9
|
+
// Null is off. The code is kept in plain text so the host can see it again;
|
|
10
|
+
// Connect holds only a keyed hash of it.
|
|
11
|
+
if (!cols.includes("access_code"))
|
|
12
|
+
db.exec(`ALTER TABLE live_shares ADD COLUMN access_code TEXT`);
|
|
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;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { LiveCodeView, LiveInvite } from "./types";
|
|
2
|
+
import { displayCode } from "./code";
|
|
3
|
+
import type { Runtime } from "./runtime";
|
|
4
|
+
|
|
5
|
+
const HINT =
|
|
6
|
+
"Guests type this before they reach your lobby. You still let each one in.";
|
|
7
|
+
|
|
8
|
+
const RULES = "6 to 32 letters or digits.";
|
|
9
|
+
|
|
10
|
+
/** The access code block of a share, with the actions it offers right now. */
|
|
11
|
+
export function codeView(rt: Runtime, now = Date.now()): LiveCodeView {
|
|
12
|
+
const code = rt.share.access_code;
|
|
13
|
+
const locked = rt.codeLockedUntil && rt.codeLockedUntil > now;
|
|
14
|
+
return {
|
|
15
|
+
on: !!code,
|
|
16
|
+
display: code ? displayCode(code) : null,
|
|
17
|
+
copyText: code
|
|
18
|
+
? `${rt.share.url}\nAccess code: ${displayCode(code)}`
|
|
19
|
+
: null,
|
|
20
|
+
label: "Access code",
|
|
21
|
+
hint: HINT,
|
|
22
|
+
notice:
|
|
23
|
+
code && locked
|
|
24
|
+
? `Too many wrong codes. New guests can join again at ${new Date(
|
|
25
|
+
rt.codeLockedUntil!
|
|
26
|
+
).toLocaleTimeString([], {
|
|
27
|
+
hour: "numeric",
|
|
28
|
+
minute: "2-digit",
|
|
29
|
+
})}, or change the code now.`
|
|
30
|
+
: null,
|
|
31
|
+
prompt: {
|
|
32
|
+
title: "Use my own code",
|
|
33
|
+
message: RULES,
|
|
34
|
+
placeholder: "Your code",
|
|
35
|
+
},
|
|
36
|
+
actions: code
|
|
37
|
+
? [
|
|
38
|
+
{ kind: "copy", label: "Copy link and code" },
|
|
39
|
+
{ kind: "copy_link", label: "Copy link" },
|
|
40
|
+
{ kind: "new_code", label: "New code" },
|
|
41
|
+
{ kind: "set_code", label: "Use my own…" },
|
|
42
|
+
{ kind: "code_off", label: "Turn off" },
|
|
43
|
+
]
|
|
44
|
+
: [{ kind: "code_on", label: "Turn on" }],
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function inviteCode(): LiveInvite["code"] {
|
|
49
|
+
return {
|
|
50
|
+
default: false,
|
|
51
|
+
label: "Require an access code",
|
|
52
|
+
hint: HINT,
|
|
53
|
+
customLabel: "Use my own code",
|
|
54
|
+
rules: RULES,
|
|
55
|
+
};
|
|
56
|
+
}
|
package/lib/live/code.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import crypto from "crypto";
|
|
2
|
+
|
|
3
|
+
/** The code is never logged, so nothing here prints it. */
|
|
4
|
+
|
|
5
|
+
/** Six digits, zero-padded so 123 is "000123". */
|
|
6
|
+
export function generateCode(): string {
|
|
7
|
+
return String(crypto.randomInt(0, 1_000_000)).padStart(6, "0");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The shape Connect checks: spaces and dashes dropped, upper-cased. */
|
|
11
|
+
export function normalizeCode(code: string): string {
|
|
12
|
+
return code.replace(/[\s-]/g, "").toUpperCase();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Why a host's own code is refused, or null when it's fine. */
|
|
16
|
+
export function codeRefusal(code: string): string | null {
|
|
17
|
+
return /^[A-Z0-9]{6,32}$/.test(normalizeCode(code))
|
|
18
|
+
? null
|
|
19
|
+
: "The access code must be 6 to 32 letters or digits.";
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** What the host sees and copies: "482-917" for six digits, else as typed in capitals. */
|
|
23
|
+
export function displayCode(code: string): string {
|
|
24
|
+
return /^\d{6}$/.test(code) ? `${code.slice(0, 3)}-${code.slice(3)}` : code;
|
|
25
|
+
}
|
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Drive: the one guest holding the wheel on a Drive share can start a turn.
|
|
3
|
+
* Everything is decided here, on this machine, on every prompt: Connect only
|
|
4
|
+
* relays, and nothing it sends can give the wheel, change the level or
|
|
5
|
+
* answer an approval. What such a turn may do without the host's tap is the
|
|
6
|
+
* worker's guest gate (lib/chat/worker/guest-gate).
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import os from "os";
|
|
10
|
+
import path from "path";
|
|
11
|
+
import { randomBytes } from "crypto";
|
|
12
|
+
import type { Session } from "../db";
|
|
13
|
+
import { getSession, registry } from "../chat/registry";
|
|
14
|
+
import { chatRefusal } from "../chat/hold";
|
|
15
|
+
import { listQueue } from "../chat/queued";
|
|
16
|
+
import { hasItem } from "../chat/store";
|
|
17
|
+
import type { ChatServerMessage } from "../chat/events";
|
|
18
|
+
import {
|
|
19
|
+
GATE_MISSING,
|
|
20
|
+
deleteQueuedChat,
|
|
21
|
+
releaseGuestChat,
|
|
22
|
+
sendGuestPrompt,
|
|
23
|
+
stopGuestTurn,
|
|
24
|
+
} from "../chat/runner";
|
|
25
|
+
import { getProject } from "../projects";
|
|
26
|
+
import { cleanBody, noMentions } from "./clean";
|
|
27
|
+
import { admittedGuestIds, hasDrivePrompt, logLive, wheelOf } from "./store";
|
|
28
|
+
import {
|
|
29
|
+
live,
|
|
30
|
+
type DrivePrompt,
|
|
31
|
+
type DriveState,
|
|
32
|
+
type Runtime,
|
|
33
|
+
} from "./runtime";
|
|
34
|
+
import type { DriverState } from "./types";
|
|
35
|
+
|
|
36
|
+
export const DRIVE_MAX_CHARS = 4000;
|
|
37
|
+
export const DRIVE_EVERY_MS = 15_000;
|
|
38
|
+
export const DRIVE_PER_HOUR = 40;
|
|
39
|
+
// How long a prompt may be missing from the queue, unstarted, before it
|
|
40
|
+
// counts as removed by the host (the worker takes it off just before it
|
|
41
|
+
// starts it).
|
|
42
|
+
const REMOVED_AFTER_MS = 3000;
|
|
43
|
+
|
|
44
|
+
export type StopReason =
|
|
45
|
+
"downgraded" | "taken_back" | "kicked" | "left" | "ended";
|
|
46
|
+
|
|
47
|
+
const STOPPED: Record<StopReason, string> = {
|
|
48
|
+
downgraded: "You can now only watch",
|
|
49
|
+
taken_back: "The host took back control",
|
|
50
|
+
kicked: "You were removed",
|
|
51
|
+
left: "You left",
|
|
52
|
+
ended: "The session ended",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export interface DriveFx {
|
|
56
|
+
send: (
|
|
57
|
+
shareId: string,
|
|
58
|
+
action: string,
|
|
59
|
+
data?: Record<string, unknown>
|
|
60
|
+
) => void;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const home = (p: string) => path.resolve(p.replace(/^~(?=$|\/)/, os.homedir()));
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Why guests can't drive this session, or null. Only a task's own chat,
|
|
67
|
+
* in its own worktree, run by Claude on this machine.
|
|
68
|
+
*/
|
|
69
|
+
export function driveRefusal(s: Session): string | null {
|
|
70
|
+
if (s.view !== "chat" || s.agent_type !== "claude")
|
|
71
|
+
return "Claude chat sessions only";
|
|
72
|
+
if (s.role) return "Not for an orchestrator";
|
|
73
|
+
if (!s.task_prompt) return "Task sessions only";
|
|
74
|
+
if (!s.worktree_path) return "Task sessions with their own worktree only";
|
|
75
|
+
const project = s.project_id ? getProject(s.project_id) : undefined;
|
|
76
|
+
const wt = home(s.worktree_path);
|
|
77
|
+
if (home(s.working_directory) !== wt)
|
|
78
|
+
return "Task sessions with their own worktree only";
|
|
79
|
+
if (project && home(project.working_directory) === wt)
|
|
80
|
+
return "Not in the project's main checkout";
|
|
81
|
+
if (s.archived_at || s.peer_mirror) return "Not for this session";
|
|
82
|
+
if (s.task_status && s.task_status !== "running")
|
|
83
|
+
return "Only while the task is running";
|
|
84
|
+
return chatRefusal(s.id) ? "Not while the task is moving" : null;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function newDrive(shareId: string): DriveState {
|
|
88
|
+
return { wheel: wheelOf(shareId), prompts: [], accepted: [], epoch: 0 };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const sessionOf = (rt: Runtime): Session | null => {
|
|
92
|
+
try {
|
|
93
|
+
return getSession(rt.share.session_id);
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
// Whether this guest may drive this share right now (not the rate caps).
|
|
100
|
+
function eligibility(rt: Runtime, guestId: string): string | null {
|
|
101
|
+
if (live.runtimes.get(rt.share.id) !== rt || rt.share.ended_at)
|
|
102
|
+
return "This live link has ended";
|
|
103
|
+
if (rt.share.level !== "drive") return "Drive is off for this session";
|
|
104
|
+
const g = rt.guests.get(guestId);
|
|
105
|
+
if (
|
|
106
|
+
rt.drive.wheel?.guestId !== guestId ||
|
|
107
|
+
g?.status !== "admitted" ||
|
|
108
|
+
!admittedGuestIds(rt.share.id).has(guestId)
|
|
109
|
+
)
|
|
110
|
+
return "You're not driving this session";
|
|
111
|
+
const s = sessionOf(rt);
|
|
112
|
+
if (!s) return "This session is gone";
|
|
113
|
+
if (driveRefusal(s)) return "This session can't be driven";
|
|
114
|
+
const w = registry.live.get(s.id);
|
|
115
|
+
if (w && !w.canGuestGate) return GATE_MISSING;
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function limits(rt: Runtime, now: number): string | null {
|
|
120
|
+
const d = rt.drive;
|
|
121
|
+
if (d.prompts.some((p) => p.status !== "running"))
|
|
122
|
+
return "Your last prompt hasn't started yet";
|
|
123
|
+
if (d.prompts.length >= 2) return "Your last prompt hasn't finished yet";
|
|
124
|
+
d.accepted = d.accepted.filter((t) => now - t < 3600_000);
|
|
125
|
+
if (d.accepted.some((t) => now - t < DRIVE_EVERY_MS))
|
|
126
|
+
return "Wait a few seconds between prompts";
|
|
127
|
+
if (d.accepted.length >= DRIVE_PER_HOUR)
|
|
128
|
+
return `You've reached the limit of ${DRIVE_PER_HOUR} prompts an hour`;
|
|
129
|
+
return null;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const msgId = (promptId: string) => `guest-${promptId}`;
|
|
133
|
+
|
|
134
|
+
// The agent reads it as a stranger's request; the chat shows only the text.
|
|
135
|
+
export function framed(name: string, text: string): string {
|
|
136
|
+
const who = noMentions(name.replace(/["\[\]\n]/g, ""));
|
|
137
|
+
return `[Live guest "${who}", not the owner of this machine, asks:]\n${noMentions(text)}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function status(
|
|
141
|
+
rt: Runtime,
|
|
142
|
+
fx: DriveFx,
|
|
143
|
+
p: { id: string; guestId: string },
|
|
144
|
+
s: "running" | "done" | "stopped" | "refused",
|
|
145
|
+
reason?: string
|
|
146
|
+
) {
|
|
147
|
+
fx.send(rt.share.id, "drive_status", {
|
|
148
|
+
guest_id: p.guestId,
|
|
149
|
+
id: p.id,
|
|
150
|
+
status: s,
|
|
151
|
+
...(reason && { reason }),
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A drive_prompt from Connect. Returns false when it was dropped as bogus. */
|
|
156
|
+
export function acceptPrompt(
|
|
157
|
+
rt: Runtime,
|
|
158
|
+
frame: { id: string; guest: { id: string }; text: string },
|
|
159
|
+
fx: DriveFx,
|
|
160
|
+
now = Date.now()
|
|
161
|
+
): boolean {
|
|
162
|
+
const guestId = frame.guest.id;
|
|
163
|
+
const p0 = { id: frame.id, guestId };
|
|
164
|
+
if (
|
|
165
|
+
rt.drive.prompts.some((p) => p.id === frame.id) ||
|
|
166
|
+
hasDrivePrompt(rt.share.id, frame.id)
|
|
167
|
+
)
|
|
168
|
+
return false;
|
|
169
|
+
const why = eligibility(rt, guestId) ?? limits(rt, now);
|
|
170
|
+
if (why) return (status(rt, fx, p0, "refused", why), true);
|
|
171
|
+
const text = cleanBody(frame.text);
|
|
172
|
+
if (!text || [...text].length > DRIVE_MAX_CHARS) {
|
|
173
|
+
status(
|
|
174
|
+
rt,
|
|
175
|
+
fx,
|
|
176
|
+
p0,
|
|
177
|
+
"refused",
|
|
178
|
+
`A prompt is 1 to ${DRIVE_MAX_CHARS} characters`
|
|
179
|
+
);
|
|
180
|
+
return true;
|
|
181
|
+
}
|
|
182
|
+
const name = rt.guests.get(guestId)!.name;
|
|
183
|
+
const wheel = rt.drive.wheel!.wheel;
|
|
184
|
+
const epoch = rt.drive.epoch;
|
|
185
|
+
logLive({
|
|
186
|
+
share_id: rt.share.id,
|
|
187
|
+
kind: "drive_prompt",
|
|
188
|
+
guest_id: guestId,
|
|
189
|
+
guest_name: name,
|
|
190
|
+
ref: frame.id,
|
|
191
|
+
text,
|
|
192
|
+
sent_text: null,
|
|
193
|
+
});
|
|
194
|
+
rt.drive.accepted.push(now);
|
|
195
|
+
const p: DrivePrompt & { cancelled?: boolean } = { ...p0, status: "sending" };
|
|
196
|
+
rt.drive.prompts.push(p);
|
|
197
|
+
const forget = () => {
|
|
198
|
+
rt.drive.prompts = rt.drive.prompts.filter((x) => x !== p);
|
|
199
|
+
};
|
|
200
|
+
void sendGuestPrompt(
|
|
201
|
+
rt.share.session_id,
|
|
202
|
+
{
|
|
203
|
+
id: msgId(frame.id),
|
|
204
|
+
text: framed(name, text),
|
|
205
|
+
origin: {
|
|
206
|
+
kind: "guest-drive",
|
|
207
|
+
label: name,
|
|
208
|
+
body: text,
|
|
209
|
+
share: rt.share.id,
|
|
210
|
+
guestId,
|
|
211
|
+
wheel,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
// Again right before the worker gets it: a Drive that ended meanwhile
|
|
215
|
+
// (or this prompt stopped) sends nothing.
|
|
216
|
+
() =>
|
|
217
|
+
p.cancelled
|
|
218
|
+
? "You stopped it"
|
|
219
|
+
: rt.drive.epoch !== epoch || rt.drive.wheel?.wheel !== wheel
|
|
220
|
+
? "Drive ended"
|
|
221
|
+
: eligibility(rt, guestId)
|
|
222
|
+
)
|
|
223
|
+
.then((refused) => {
|
|
224
|
+
if (refused) {
|
|
225
|
+
if (!rt.drive.prompts.includes(p)) return;
|
|
226
|
+
forget();
|
|
227
|
+
status(rt, fx, p, p.cancelled ? "stopped" : "refused", refused);
|
|
228
|
+
} else if (p.status === "sending") p.status = "queued";
|
|
229
|
+
})
|
|
230
|
+
// The error stays on this machine: the guest gets a fixed line.
|
|
231
|
+
.catch(() => {
|
|
232
|
+
if (!rt.drive.prompts.includes(p)) return;
|
|
233
|
+
forget();
|
|
234
|
+
status(rt, fx, p, "refused", "Not sent");
|
|
235
|
+
});
|
|
236
|
+
return true;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** drive_stop: the wheel holder stops their own prompt, never another's. */
|
|
240
|
+
export function stopPrompt(
|
|
241
|
+
rt: Runtime,
|
|
242
|
+
frame: { id: string; guest: { id: string } },
|
|
243
|
+
fx: DriveFx
|
|
244
|
+
): boolean {
|
|
245
|
+
const w = rt.drive.wheel;
|
|
246
|
+
if (!w || w.guestId !== frame.guest.id) return false;
|
|
247
|
+
const p = rt.drive.prompts.find(
|
|
248
|
+
(x) => x.id === frame.id && x.guestId === frame.guest.id
|
|
249
|
+
) as (DrivePrompt & { cancelled?: boolean }) | undefined;
|
|
250
|
+
if (!p) return true;
|
|
251
|
+
if (p.status === "running") {
|
|
252
|
+
p.stopWhy = "You stopped it";
|
|
253
|
+
stopGuestTurn(rt.share.session_id, rt.share.id, w.wheel);
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
p.cancelled = true;
|
|
257
|
+
if (p.status === "queued") {
|
|
258
|
+
deleteQueuedChat(rt.share.session_id, msgId(p.id));
|
|
259
|
+
rt.drive.prompts = rt.drive.prompts.filter((x) => x !== p);
|
|
260
|
+
status(rt, fx, p, "stopped", "You stopped it");
|
|
261
|
+
}
|
|
262
|
+
return true;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Gives the wheel (or takes it back, with null). In order, before anything
|
|
267
|
+
* can interleave: the new holder is set and logged, so a racing prompt is
|
|
268
|
+
* refused; then the old holder's Drive is released.
|
|
269
|
+
*/
|
|
270
|
+
export function setWheel(
|
|
271
|
+
rt: Runtime,
|
|
272
|
+
guestId: string | null,
|
|
273
|
+
reason: StopReason,
|
|
274
|
+
fx: DriveFx
|
|
275
|
+
): void {
|
|
276
|
+
const old = rt.drive.wheel;
|
|
277
|
+
if (old?.guestId === guestId && guestId) return;
|
|
278
|
+
const next = guestId
|
|
279
|
+
? { guestId, wheel: randomBytes(9).toString("base64url") }
|
|
280
|
+
: null;
|
|
281
|
+
rt.drive.wheel = next;
|
|
282
|
+
if (old || next)
|
|
283
|
+
logLive({
|
|
284
|
+
share_id: rt.share.id,
|
|
285
|
+
kind: "wheel",
|
|
286
|
+
guest_id: guestId,
|
|
287
|
+
guest_name: guestId ? (rt.guests.get(guestId)?.name ?? null) : null,
|
|
288
|
+
ref: next?.wheel ?? null,
|
|
289
|
+
text: null,
|
|
290
|
+
sent_text: null,
|
|
291
|
+
});
|
|
292
|
+
releaseDrive(rt, reason, fx, old?.wheel ?? "");
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Ends what a Drive left: queued prompts go (here and in the worker), a
|
|
297
|
+
* turn it steers stops (its pending cards expire as denied), and the guest
|
|
298
|
+
* is told. Synchronous: nothing from that wheel is taken in after it.
|
|
299
|
+
*/
|
|
300
|
+
export function releaseDrive(
|
|
301
|
+
rt: Runtime,
|
|
302
|
+
reason: StopReason,
|
|
303
|
+
fx: DriveFx,
|
|
304
|
+
wheel = rt.drive.wheel?.wheel ?? ""
|
|
305
|
+
): void {
|
|
306
|
+
rt.drive.epoch++;
|
|
307
|
+
if (wheel && rt.drive.wheel?.wheel === wheel) rt.drive.wheel = null;
|
|
308
|
+
const prompts = rt.drive.prompts.splice(0);
|
|
309
|
+
if (!wheel && !prompts.length) return;
|
|
310
|
+
releaseGuestChat(rt.share.session_id, rt.share.id, wheel);
|
|
311
|
+
for (const p of prompts)
|
|
312
|
+
status(
|
|
313
|
+
rt,
|
|
314
|
+
fx,
|
|
315
|
+
p,
|
|
316
|
+
p.status === "running" ? "stopped" : "refused",
|
|
317
|
+
STOPPED[reason]
|
|
318
|
+
);
|
|
319
|
+
logLive({
|
|
320
|
+
share_id: rt.share.id,
|
|
321
|
+
kind: "drive_stopped",
|
|
322
|
+
guest_id: prompts[0]?.guestId ?? null,
|
|
323
|
+
guest_name: null,
|
|
324
|
+
ref: reason,
|
|
325
|
+
text: null,
|
|
326
|
+
sent_text: null,
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/** What the driving guest's prompt is doing, for the host's view. */
|
|
331
|
+
export function driverState(rt: Runtime): DriverState {
|
|
332
|
+
const ps = rt.drive.prompts;
|
|
333
|
+
if (ps.some((p) => p.status === "running"))
|
|
334
|
+
return registry.live.get(rt.share.session_id)?.state === "waiting"
|
|
335
|
+
? "waiting_on_host"
|
|
336
|
+
: "working";
|
|
337
|
+
return ps.length ? "queued" : "idle";
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** A tap on the session's messages: a Drive prompt starting, ending, removed. */
|
|
341
|
+
export function onDriveMessage(
|
|
342
|
+
sessionId: string,
|
|
343
|
+
m: ChatServerMessage,
|
|
344
|
+
fx: DriveFx
|
|
345
|
+
): void {
|
|
346
|
+
for (const rt of live.runtimes.values()) {
|
|
347
|
+
if (rt.share.session_id !== sessionId || !rt.drive.prompts.length) continue;
|
|
348
|
+
const running = rt.drive.prompts.find((p) => p.status === "running");
|
|
349
|
+
if (m.type === "item" && m.item.kind === "user") {
|
|
350
|
+
const p = rt.drive.prompts.find((x) => msgId(x.id) === m.item.id);
|
|
351
|
+
if (p && p.status !== "running") {
|
|
352
|
+
p.status = "running";
|
|
353
|
+
status(rt, fx, p, "running");
|
|
354
|
+
}
|
|
355
|
+
} else if (
|
|
356
|
+
m.type === "item" &&
|
|
357
|
+
m.item.kind === "note" &&
|
|
358
|
+
m.item.id.startsWith("note-guest-stop-") &&
|
|
359
|
+
running
|
|
360
|
+
) {
|
|
361
|
+
running.stopWhy = m.item.text.replace(/^Stopped: /, "").slice(0, 100);
|
|
362
|
+
} else if (
|
|
363
|
+
running &&
|
|
364
|
+
((m.type === "item" && m.item.kind === "turn_end") ||
|
|
365
|
+
(m.type === "state" && m.state === "idle"))
|
|
366
|
+
) {
|
|
367
|
+
rt.drive.prompts = rt.drive.prompts.filter((p) => p !== running);
|
|
368
|
+
const interrupted =
|
|
369
|
+
m.type === "item" && m.item.kind === "turn_end" && m.item.interrupted;
|
|
370
|
+
if (running.stopWhy || interrupted)
|
|
371
|
+
status(
|
|
372
|
+
rt,
|
|
373
|
+
fx,
|
|
374
|
+
running,
|
|
375
|
+
"stopped",
|
|
376
|
+
running.stopWhy ?? "The host stopped it"
|
|
377
|
+
);
|
|
378
|
+
else status(rt, fx, running, "done");
|
|
379
|
+
} else if (m.type === "queue") {
|
|
380
|
+
for (const p of rt.drive.prompts)
|
|
381
|
+
if (p.status === "queued" && !m.queue.some((q) => q.id === msgId(p.id)))
|
|
382
|
+
setTimeout(
|
|
383
|
+
() => removedByHost(rt, p, fx),
|
|
384
|
+
REMOVED_AFTER_MS
|
|
385
|
+
).unref?.();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Gone from the queue and never started: the host deleted it.
|
|
391
|
+
function removedByHost(rt: Runtime, p: DrivePrompt, fx: DriveFx) {
|
|
392
|
+
const id = msgId(p.id);
|
|
393
|
+
if (!rt.drive.prompts.includes(p) || p.status !== "queued") return;
|
|
394
|
+
if (listQueue(rt.share.session_id).some((q) => q.id === id)) return;
|
|
395
|
+
if (hasItem(rt.share.session_id, id)) return;
|
|
396
|
+
rt.drive.prompts = rt.drive.prompts.filter((x) => x !== p);
|
|
397
|
+
status(rt, fx, p, "refused", "The host removed it");
|
|
398
|
+
}
|
package/lib/live/frames.ts
CHANGED
|
@@ -56,11 +56,13 @@ export function stateOf(state: ChatState): LiveState {
|
|
|
56
56
|
}
|
|
57
57
|
|
|
58
58
|
// Messages that were not typed by the reader (the agent bus, schedules,
|
|
59
|
-
// AgentOS itself) stay on this machine; a sent suggestion is the reader's
|
|
59
|
+
// AgentOS itself) stay on this machine; a sent suggestion is the reader's,
|
|
60
|
+
// and a Drive prompt the driving guest's.
|
|
60
61
|
function userFrom(item: Extract<ChatItem, { kind: "user" }>): string | null {
|
|
61
62
|
const o = item.origin;
|
|
62
63
|
if (!o) return item.peer || item.from ? null : "host";
|
|
63
64
|
if (o.kind === "guest") return `guest:${o.label}`;
|
|
65
|
+
if (o.kind === "guest-drive") return `driver:${o.label}`;
|
|
64
66
|
if (o.kind === "decision") return "host";
|
|
65
67
|
return null;
|
|
66
68
|
}
|
|
@@ -108,7 +110,12 @@ export function itemMapper(ctx: ShareContext): ItemMapper {
|
|
|
108
110
|
if (!f) return null;
|
|
109
111
|
from = f;
|
|
110
112
|
}
|
|
111
|
-
|
|
113
|
+
// A Drive prompt shows as the guest typed it, not as framed for the agent.
|
|
114
|
+
const raw = toRaw(
|
|
115
|
+
item.kind === "user" && item.origin?.kind === "guest-drive"
|
|
116
|
+
? { ...item, text: item.origin.body ?? "" }
|
|
117
|
+
: item
|
|
118
|
+
);
|
|
112
119
|
if (!raw || raw.tools || raw.type === "artifact") return null;
|
|
113
120
|
const body = clipBytes(take(text ?? raw.text), MAX_TEXT_BYTES);
|
|
114
121
|
if (item.kind === "user")
|