@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
|
@@ -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.
|
|
@@ -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;
|
|
@@ -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")
|
package/lib/live/inbox.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* What Connect sends down. Only the types below are accepted, and only for a
|
|
3
3
|
* share this machine minted and still holds. Everything else is dropped and
|
|
4
|
-
* counted: nothing downward is executed
|
|
4
|
+
* counted: nothing downward is executed or interpreted, and only a Drive
|
|
5
|
+
* prompt from the guest this machine gave the wheel reaches an agent
|
|
6
|
+
* (./drive decides). No type answers an approval, sets the level or moves
|
|
7
|
+
* the wheel: those come only from the host's own API.
|
|
5
8
|
*/
|
|
6
9
|
|
|
7
10
|
import { z } from "zod";
|
|
@@ -14,8 +17,10 @@ import {
|
|
|
14
17
|
logLive,
|
|
15
18
|
markLiveEnded,
|
|
16
19
|
openSuggestions,
|
|
20
|
+
setLevel,
|
|
17
21
|
} from "./store";
|
|
18
22
|
import { MAX_LOBBY, MAX_PENDING_CARDS, live, type Runtime } from "./runtime";
|
|
23
|
+
import { acceptPrompt, setWheel, stopPrompt } from "./drive";
|
|
19
24
|
|
|
20
25
|
export const DOWNWARD = [
|
|
21
26
|
"roster",
|
|
@@ -28,6 +33,8 @@ export const DOWNWARD = [
|
|
|
28
33
|
"invalid",
|
|
29
34
|
"admit_failed",
|
|
30
35
|
"access_failed",
|
|
36
|
+
"drive_prompt",
|
|
37
|
+
"drive_stop",
|
|
31
38
|
] as const;
|
|
32
39
|
|
|
33
40
|
const id = z.string().min(1).max(64);
|
|
@@ -46,6 +53,8 @@ const SCHEMAS = {
|
|
|
46
53
|
invalid: z.object({}),
|
|
47
54
|
admit_failed: z.object({ guest_id: id.optional(), reason: z.string() }),
|
|
48
55
|
access_failed: z.object({ reason: z.string().max(200).optional() }),
|
|
56
|
+
drive_prompt: z.object({ id, guest, text: z.string().max(16000) }),
|
|
57
|
+
drive_stop: z.object({ id, guest }),
|
|
49
58
|
} satisfies Record<(typeof DOWNWARD)[number], z.ZodType>;
|
|
50
59
|
|
|
51
60
|
export interface InboxEffects {
|
|
@@ -139,6 +148,7 @@ export function handleFrame(
|
|
|
139
148
|
const g = (parsed.data as z.infer<typeof SCHEMAS.guest_left>).guest;
|
|
140
149
|
const known = rt.guests.get(g.id);
|
|
141
150
|
if (!known) return true;
|
|
151
|
+
if (rt.drive.wheel?.guestId === g.id) setWheel(rt, null, "left", fx);
|
|
142
152
|
if (known.status === "pending") rt.guests.delete(g.id);
|
|
143
153
|
else known.online = false;
|
|
144
154
|
return true;
|
|
@@ -201,9 +211,37 @@ export function handleFrame(
|
|
|
201
211
|
rt.notice = "Your plan's guest limit is reached.";
|
|
202
212
|
return true;
|
|
203
213
|
}
|
|
204
|
-
case "access_failed":
|
|
205
|
-
|
|
214
|
+
case "access_failed": {
|
|
215
|
+
const why = (parsed.data as z.infer<typeof SCHEMAS.access_failed>).reason;
|
|
216
|
+
rt.notice = why
|
|
217
|
+
? why.replace(/\s+/g, " ").trim().slice(0, 160)
|
|
218
|
+
: "Connect refused that access level.";
|
|
219
|
+
// Connect didn't take it: this machine goes back to the safer side.
|
|
220
|
+
if (rt.drive.wheel) setWheel(rt, null, "taken_back", fx);
|
|
221
|
+
if (rt.share.level === "drive") {
|
|
222
|
+
rt.share = { ...rt.share, level: "suggest" };
|
|
223
|
+
setLevel(shareId, "suggest");
|
|
224
|
+
fx.send(shareId, "access", { level: "suggest" });
|
|
225
|
+
}
|
|
206
226
|
return true;
|
|
227
|
+
}
|
|
228
|
+
case "drive_prompt":
|
|
229
|
+
return acceptPrompt(
|
|
230
|
+
rt,
|
|
231
|
+
parsed.data as z.infer<typeof SCHEMAS.drive_prompt>,
|
|
232
|
+
fx,
|
|
233
|
+
now
|
|
234
|
+
)
|
|
235
|
+
? true
|
|
236
|
+
: (drop("duplicate drive prompt"), false);
|
|
237
|
+
case "drive_stop":
|
|
238
|
+
return stopPrompt(
|
|
239
|
+
rt,
|
|
240
|
+
parsed.data as z.infer<typeof SCHEMAS.drive_stop>,
|
|
241
|
+
fx
|
|
242
|
+
)
|
|
243
|
+
? true
|
|
244
|
+
: (drop("stop from a guest not driving"), false);
|
|
207
245
|
case "invalid":
|
|
208
246
|
rt.drops++;
|
|
209
247
|
return true;
|
package/lib/live/levels.ts
CHANGED
|
@@ -1,29 +1,74 @@
|
|
|
1
|
+
import type { Session } from "../db";
|
|
1
2
|
import {
|
|
2
3
|
LEVELS,
|
|
4
|
+
type DriverState,
|
|
3
5
|
type GuestAction,
|
|
4
6
|
type LevelOption,
|
|
5
7
|
type LiveLevel,
|
|
8
|
+
type LiveView,
|
|
6
9
|
} from "./types";
|
|
10
|
+
import { driveRefusal, driverState } from "./drive";
|
|
7
11
|
import type { Guest, Runtime } from "./runtime";
|
|
8
12
|
|
|
9
13
|
const ORDER: LiveLevel[] = ["watch", "suggest", "drive"];
|
|
10
14
|
|
|
11
15
|
/** What the invite dialog and the Live popover offer, with why one is off. */
|
|
12
|
-
export function levelOptions(): LevelOption[] {
|
|
16
|
+
export function levelOptions(session: Session | null): LevelOption[] {
|
|
17
|
+
const why = session ? driveRefusal(session) : "Task sessions only";
|
|
13
18
|
return ORDER.map((value) => ({
|
|
14
19
|
value,
|
|
15
20
|
...LEVELS[value],
|
|
16
|
-
...(value === "drive"
|
|
17
|
-
? { enabled: false, reason:
|
|
21
|
+
...(value === "drive" && why
|
|
22
|
+
? { enabled: false, reason: why }
|
|
18
23
|
: { enabled: true }),
|
|
19
24
|
}));
|
|
20
25
|
}
|
|
21
26
|
|
|
22
|
-
export function guestActions(
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
27
|
+
export function guestActions(
|
|
28
|
+
rt: Runtime,
|
|
29
|
+
g: Guest,
|
|
30
|
+
canDrive: boolean
|
|
31
|
+
): GuestAction[] {
|
|
32
|
+
const drive = rt.share.level === "drive" && canDrive;
|
|
33
|
+
if (g.status === "pending")
|
|
34
|
+
return drive && !rt.drive.wheel
|
|
35
|
+
? [
|
|
36
|
+
{ kind: "admit", label: "Let in" },
|
|
37
|
+
{ kind: "admit_drive", label: "Let in to drive" },
|
|
38
|
+
{ kind: "deny", label: "Deny" },
|
|
39
|
+
]
|
|
40
|
+
: [
|
|
41
|
+
{ kind: "admit", label: drive ? "Let in" : "Admit" },
|
|
42
|
+
{ kind: "deny", label: "Deny" },
|
|
43
|
+
];
|
|
44
|
+
const kick: GuestAction = { kind: "kick", label: "Kick" };
|
|
45
|
+
if (rt.share.level !== "drive") return [kick];
|
|
46
|
+
if (rt.drive.wheel?.guestId === g.id)
|
|
47
|
+
return [{ kind: "take_back", label: "Take back" }, kick];
|
|
48
|
+
return drive
|
|
49
|
+
? [{ kind: "give_wheel", label: "Give the wheel" }, kick]
|
|
50
|
+
: [kick];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const DRIVING: Record<DriverState, string> = {
|
|
54
|
+
idle: "is driving",
|
|
55
|
+
queued: "is driving · prompt queued",
|
|
56
|
+
working: "is driving · working",
|
|
57
|
+
waiting_on_host: "is driving · waiting on you",
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** The guest holding the wheel and the banner the host sees above the composer. */
|
|
61
|
+
export function driveView(rt: Runtime): Pick<LiveView, "driver" | "banner"> {
|
|
62
|
+
const w = rt.drive.wheel;
|
|
63
|
+
const g = w && rt.guests.get(w.guestId);
|
|
64
|
+
if (rt.share.level !== "drive" || !w || !g)
|
|
65
|
+
return { driver: null, banner: null };
|
|
66
|
+
const state = driverState(rt);
|
|
67
|
+
return {
|
|
68
|
+
driver: { guestId: g.id, name: g.name, state },
|
|
69
|
+
banner: {
|
|
70
|
+
text: `${g.name} ${DRIVING[state]}`,
|
|
71
|
+
action: { kind: "take_back", label: "Take back" },
|
|
72
|
+
},
|
|
73
|
+
};
|
|
29
74
|
}
|