@oh-my-pi/pi-coding-agent 15.11.7 → 15.12.0
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/CHANGELOG.md +63 -1
- package/dist/cli.js +8106 -7708
- package/dist/types/cli/args.d.ts +2 -0
- package/dist/types/collab/crypto.d.ts +7 -0
- package/dist/types/collab/guest.d.ts +23 -0
- package/dist/types/collab/host.d.ts +29 -0
- package/dist/types/collab/protocol.d.ts +113 -0
- package/dist/types/collab/relay-client.d.ts +22 -0
- package/dist/types/commands/join.d.ts +12 -0
- package/dist/types/config/settings-schema.d.ts +60 -5
- package/dist/types/export/custom-share.d.ts +1 -2
- package/dist/types/export/html/index.d.ts +39 -1
- package/dist/types/export/share.d.ts +43 -0
- package/dist/types/extensibility/slash-commands.d.ts +1 -11
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/agent-hub.d.ts +32 -1
- package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
- package/dist/types/modes/components/hook-selector.d.ts +4 -6
- package/dist/types/modes/components/segment-track.d.ts +11 -6
- package/dist/types/modes/components/status-line/component.d.ts +10 -2
- package/dist/types/modes/components/status-line/types.d.ts +11 -0
- package/dist/types/modes/controllers/event-controller.d.ts +7 -0
- package/dist/types/modes/controllers/input-controller.d.ts +1 -1
- package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
- package/dist/types/modes/interactive-mode.d.ts +16 -0
- package/dist/types/modes/session-observer-registry.d.ts +7 -0
- package/dist/types/modes/theme/theme.d.ts +2 -1
- package/dist/types/modes/types.d.ts +20 -0
- package/dist/types/session/agent-session.d.ts +13 -0
- package/dist/types/session/codex-auto-reset.d.ts +8 -4
- package/dist/types/session/session-manager.d.ts +21 -0
- package/dist/types/session/snapcompact-inline.d.ts +6 -3
- package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
- package/dist/types/task/executor.d.ts +7 -0
- package/dist/types/task/types.d.ts +9 -0
- package/package.json +14 -13
- package/scripts/bench-guard.ts +71 -0
- package/scripts/build-binary.ts +4 -0
- package/scripts/bundle-dist.ts +4 -0
- package/scripts/generate-share-viewer.ts +34 -0
- package/src/cli/args.ts +2 -0
- package/src/cli-commands.ts +1 -0
- package/src/collab/crypto.ts +63 -0
- package/src/collab/guest.ts +450 -0
- package/src/collab/host.ts +556 -0
- package/src/collab/protocol.ts +232 -0
- package/src/collab/relay-client.ts +216 -0
- package/src/commands/join.ts +39 -0
- package/src/config/model-registry.ts +22 -14
- package/src/config/settings-schema.ts +67 -5
- package/src/config/settings.ts +12 -0
- package/src/export/custom-share.ts +1 -1
- package/src/export/html/index.ts +122 -17
- package/src/export/html/share-loader.js +102 -0
- package/src/export/html/template.css +745 -459
- package/src/export/html/template.html +6 -3
- package/src/export/html/template.js +240 -915
- package/src/export/html/tool-views.generated.js +38 -0
- package/src/export/share.ts +268 -0
- package/src/extensibility/slash-commands.ts +1 -97
- package/src/internal-urls/docs-index.generated.ts +74 -73
- package/src/main.ts +33 -11
- package/src/modes/components/agent-hub.ts +659 -431
- package/src/modes/components/assistant-message.ts +126 -6
- package/src/modes/components/collab-prompt-message.ts +30 -0
- package/src/modes/components/hook-selector.ts +4 -5
- package/src/modes/components/segment-track.ts +44 -7
- package/src/modes/components/status-line/component.ts +59 -6
- package/src/modes/components/status-line/presets.ts +1 -1
- package/src/modes/components/status-line/segments.ts +18 -1
- package/src/modes/components/status-line/types.ts +12 -0
- package/src/modes/components/tips.txt +4 -1
- package/src/modes/controllers/command-controller.ts +55 -96
- package/src/modes/controllers/event-controller.ts +45 -16
- package/src/modes/controllers/input-controller.ts +175 -9
- package/src/modes/controllers/selector-controller.ts +13 -15
- package/src/modes/controllers/session-focus-controller.ts +112 -0
- package/src/modes/controllers/streaming-reveal.ts +7 -0
- package/src/modes/interactive-mode.ts +56 -6
- package/src/modes/session-observer-registry.ts +11 -0
- package/src/modes/theme/theme.ts +6 -0
- package/src/modes/types.ts +20 -0
- package/src/modes/utils/ui-helpers.ts +23 -13
- package/src/prompts/tools/job.md +1 -1
- package/src/sdk.ts +239 -36
- package/src/session/agent-session.ts +82 -7
- package/src/session/codex-auto-reset.ts +23 -11
- package/src/session/session-manager.ts +44 -0
- package/src/session/snapcompact-inline.ts +9 -3
- package/src/slash-commands/builtin-registry.ts +261 -24
- package/src/task/executor.ts +14 -0
- package/src/task/index.ts +5 -1
- package/src/task/render.ts +76 -5
- package/src/task/types.ts +9 -0
- package/src/tiny/worker.ts +17 -95
- package/src/tools/job.ts +6 -9
- package/src/tools/read.ts +38 -5
- package/src/tools/write.ts +13 -42
- package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
- package/dist/types/export/html/template.generated.d.ts +0 -1
- package/dist/types/export/html/template.macro.d.ts +0 -5
- package/dist/types/tiny/compiled-runtime.d.ts +0 -35
- package/scripts/generate-template.ts +0 -33
- package/src/bun-imports.d.ts +0 -28
- package/src/export/html/template.generated.ts +0 -2
- package/src/export/html/template.macro.ts +0 -25
- package/src/tiny/compiled-runtime.ts +0 -179
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Host side of a collab live session.
|
|
3
|
+
*
|
|
4
|
+
* Taps the host session's event stream and SessionManager append chokepoint,
|
|
5
|
+
* broadcasting entries/events/state to guests through the relay. Guests prompt
|
|
6
|
+
* and abort through us; the host machine runs the agent and tools. The host's
|
|
7
|
+
* subagent ecosystem is mirrored too: task EventBus traffic (observer HUD),
|
|
8
|
+
* agent-registry snapshots (Agent Hub table), hub chat/kill/revive commands,
|
|
9
|
+
* and incremental subagent-transcript reads.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { timingSafeEqual } from "node:crypto";
|
|
13
|
+
import * as fs from "node:fs/promises";
|
|
14
|
+
import * as os from "node:os";
|
|
15
|
+
import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
|
|
16
|
+
import { logger } from "@oh-my-pi/pi-utils";
|
|
17
|
+
import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire";
|
|
18
|
+
import type { InteractiveModeContext } from "../modes/types";
|
|
19
|
+
import { AgentLifecycleManager } from "../registry/agent-lifecycle";
|
|
20
|
+
import { AgentRegistry } from "../registry/agent-registry";
|
|
21
|
+
import type { AgentSessionEvent } from "../session/agent-session";
|
|
22
|
+
import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages";
|
|
23
|
+
import type { SessionEntry as StoredSessionEntry } from "../session/session-manager";
|
|
24
|
+
import { TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL } from "../task";
|
|
25
|
+
import { generateRoomKey, generateWriteToken, importRoomKey } from "./crypto";
|
|
26
|
+
import {
|
|
27
|
+
type AgentSnapshot,
|
|
28
|
+
COLLAB_PROMPT_MESSAGE_TYPE,
|
|
29
|
+
COLLAB_PROTO,
|
|
30
|
+
type CollabFrame,
|
|
31
|
+
type CollabParticipant,
|
|
32
|
+
type CollabSessionState,
|
|
33
|
+
formatCollabLink,
|
|
34
|
+
formatCollabWebLink,
|
|
35
|
+
generateRoomId,
|
|
36
|
+
parseCollabLink,
|
|
37
|
+
} from "./protocol";
|
|
38
|
+
import { CollabSocket } from "./relay-client";
|
|
39
|
+
|
|
40
|
+
/** Events that change the footer state guests render. */
|
|
41
|
+
const STATE_TRIGGER_EVENTS: Record<string, true> = {
|
|
42
|
+
agent_start: true,
|
|
43
|
+
agent_end: true,
|
|
44
|
+
message_end: true,
|
|
45
|
+
tool_execution_end: true,
|
|
46
|
+
thinking_level_changed: true,
|
|
47
|
+
auto_compaction_end: true,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const STATE_DEBOUNCE_MS = 100;
|
|
51
|
+
const AGENTS_DEBOUNCE_MS = 100;
|
|
52
|
+
const STREAMING_STATE_INTERVAL_MS = 2000;
|
|
53
|
+
const WELCOME_IMAGE_STRIP_THRESHOLD = 24 * 1024 * 1024;
|
|
54
|
+
const WIRE_AGENT_EVENT_TYPES: Record<WireAgentEvent["type"], true> = {
|
|
55
|
+
agent_start: true,
|
|
56
|
+
agent_end: true,
|
|
57
|
+
turn_start: true,
|
|
58
|
+
turn_end: true,
|
|
59
|
+
message_start: true,
|
|
60
|
+
message_update: true,
|
|
61
|
+
message_end: true,
|
|
62
|
+
tool_execution_start: true,
|
|
63
|
+
tool_execution_update: true,
|
|
64
|
+
tool_execution_end: true,
|
|
65
|
+
notice: true,
|
|
66
|
+
auto_compaction_start: true,
|
|
67
|
+
auto_compaction_end: true,
|
|
68
|
+
auto_retry_start: true,
|
|
69
|
+
auto_retry_end: true,
|
|
70
|
+
thinking_level_changed: true,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const WIRE_SESSION_ENTRY_TYPES: Record<WireSessionEntry["type"], true> = {
|
|
74
|
+
message: true,
|
|
75
|
+
custom_message: true,
|
|
76
|
+
compaction: true,
|
|
77
|
+
branch_summary: true,
|
|
78
|
+
model_change: true,
|
|
79
|
+
thinking_level_change: true,
|
|
80
|
+
};
|
|
81
|
+
const COLLAB_BUS_CHANNELS = [
|
|
82
|
+
TASK_SUBAGENT_LIFECYCLE_CHANNEL,
|
|
83
|
+
TASK_SUBAGENT_PROGRESS_CHANNEL,
|
|
84
|
+
] as const satisfies readonly BusChannel[];
|
|
85
|
+
|
|
86
|
+
function isWireAgentEvent(event: AgentSessionEvent): event is AgentSessionEvent & WireAgentEvent {
|
|
87
|
+
return event.type in WIRE_AGENT_EVENT_TYPES;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isWireSessionEntry(entry: StoredSessionEntry): entry is StoredSessionEntry & WireSessionEntry {
|
|
91
|
+
return entry.type in WIRE_SESSION_ENTRY_TYPES;
|
|
92
|
+
}
|
|
93
|
+
const CONNECT_TIMEOUT_MS = 15_000;
|
|
94
|
+
/** Max bytes served per fetch-transcript reply (guest re-requests from `newSize`). */
|
|
95
|
+
const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
|
|
96
|
+
|
|
97
|
+
/** Display name for this process's user in collab sessions. */
|
|
98
|
+
export function collabDisplayName(ctx: InteractiveModeContext): string {
|
|
99
|
+
const configured = (ctx.settings.get("collab.displayName") ?? "").trim();
|
|
100
|
+
if (configured) return configured;
|
|
101
|
+
try {
|
|
102
|
+
return os.userInfo().username;
|
|
103
|
+
} catch {
|
|
104
|
+
return "anonymous";
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class CollabHost {
|
|
109
|
+
#ctx: InteractiveModeContext;
|
|
110
|
+
#socket: CollabSocket | null = null;
|
|
111
|
+
#link = "";
|
|
112
|
+
#webLink = "";
|
|
113
|
+
#viewLink = "";
|
|
114
|
+
#webViewLink = "";
|
|
115
|
+
#writeToken: Uint8Array | null = null;
|
|
116
|
+
#sessionId = "";
|
|
117
|
+
#unsubscribe?: () => void;
|
|
118
|
+
#peers = new Map<number, { name: string; canWrite: boolean }>();
|
|
119
|
+
#lastStateJson = "";
|
|
120
|
+
#stateDebounce: Timer | null = null;
|
|
121
|
+
#streamingInterval: Timer | null = null;
|
|
122
|
+
#agentsDebounce: Timer | null = null;
|
|
123
|
+
#busUnsubscribers: (() => void)[] = [];
|
|
124
|
+
#registryUnsubscribe?: () => void;
|
|
125
|
+
#stopped = false;
|
|
126
|
+
|
|
127
|
+
constructor(ctx: InteractiveModeContext) {
|
|
128
|
+
this.#ctx = ctx;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get link(): string {
|
|
132
|
+
return this.#link;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Browser deep link (`https://<relay>/#<link>`) — the relay serves the web client at `/`. */
|
|
136
|
+
get webLink(): string {
|
|
137
|
+
return this.#webLink;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Read-only variant of {@link link}: bare room key, no write token. */
|
|
141
|
+
get viewLink(): string {
|
|
142
|
+
return this.#viewLink;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Read-only variant of {@link webLink}. */
|
|
146
|
+
get webViewLink(): string {
|
|
147
|
+
return this.#webViewLink;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
get participants(): CollabParticipant[] {
|
|
151
|
+
const list: CollabParticipant[] = [{ name: collabDisplayName(this.#ctx), role: "host" }];
|
|
152
|
+
for (const peer of this.#peers.values()) {
|
|
153
|
+
list.push({ name: peer.name, role: "guest", readOnly: peer.canWrite ? undefined : true });
|
|
154
|
+
}
|
|
155
|
+
return list;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async start(relayUrl: string): Promise<void> {
|
|
159
|
+
const rawKey = generateRoomKey();
|
|
160
|
+
const writeToken = generateWriteToken();
|
|
161
|
+
const roomId = generateRoomId();
|
|
162
|
+
this.#writeToken = writeToken;
|
|
163
|
+
this.#link = formatCollabLink(relayUrl, roomId, rawKey, writeToken);
|
|
164
|
+
this.#webLink = formatCollabWebLink(relayUrl, roomId, rawKey, writeToken);
|
|
165
|
+
this.#viewLink = formatCollabLink(relayUrl, roomId, rawKey);
|
|
166
|
+
this.#webViewLink = formatCollabWebLink(relayUrl, roomId, rawKey);
|
|
167
|
+
const parsed = parseCollabLink(this.#link);
|
|
168
|
+
if ("error" in parsed) throw new Error(parsed.error);
|
|
169
|
+
const key = await importRoomKey(rawKey);
|
|
170
|
+
|
|
171
|
+
const socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "host", key });
|
|
172
|
+
this.#socket = socket;
|
|
173
|
+
this.#sessionId = this.#ctx.sessionManager.getSessionId();
|
|
174
|
+
|
|
175
|
+
const firstOpen = Promise.withResolvers<void>();
|
|
176
|
+
let opened = false;
|
|
177
|
+
socket.onOpen = () => {
|
|
178
|
+
if (!opened) {
|
|
179
|
+
opened = true;
|
|
180
|
+
firstOpen.resolve();
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
socket.onFrame = (frame, fromPeer) => this.#handleFrame(frame, fromPeer);
|
|
184
|
+
socket.onControl = msg => {
|
|
185
|
+
if (msg.t === "peer-left") this.#handlePeerLeft(msg.peer);
|
|
186
|
+
};
|
|
187
|
+
socket.onClose = (reason, willReconnect) => {
|
|
188
|
+
if (this.#stopped) return;
|
|
189
|
+
if (!opened) {
|
|
190
|
+
firstOpen.reject(new Error(reason));
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
if (willReconnect) {
|
|
194
|
+
this.#ctx.showStatus(`Collab relay connection lost (${reason}), reconnecting…`, { dim: true });
|
|
195
|
+
} else {
|
|
196
|
+
void this.#teardown();
|
|
197
|
+
this.#ctx.session.emitNotice("warning", `Collab ended: ${reason}`, "collab");
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
socket.connect();
|
|
201
|
+
|
|
202
|
+
const timeout = setTimeout(
|
|
203
|
+
() => firstOpen.reject(new Error("timed out connecting to relay")),
|
|
204
|
+
CONNECT_TIMEOUT_MS,
|
|
205
|
+
);
|
|
206
|
+
try {
|
|
207
|
+
await firstOpen.promise;
|
|
208
|
+
} catch (err) {
|
|
209
|
+
this.#stopped = true;
|
|
210
|
+
socket.close();
|
|
211
|
+
this.#socket = null;
|
|
212
|
+
throw err;
|
|
213
|
+
} finally {
|
|
214
|
+
clearTimeout(timeout);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
this.#unsubscribe = this.#ctx.session.subscribe(event => {
|
|
218
|
+
if (isWireAgentEvent(event)) this.#broadcast({ t: "event", event });
|
|
219
|
+
this.#onEventForState(event);
|
|
220
|
+
});
|
|
221
|
+
const bus = this.#ctx.eventBus;
|
|
222
|
+
if (bus) {
|
|
223
|
+
for (const channel of COLLAB_BUS_CHANNELS) {
|
|
224
|
+
this.#busUnsubscribers.push(bus.on(channel, data => this.#broadcast({ t: "bus", channel, data })));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
this.#registryUnsubscribe = AgentRegistry.global().onChange(() => this.#scheduleAgentsBroadcast());
|
|
228
|
+
this.#ctx.sessionManager.onEntryAppended = entry => {
|
|
229
|
+
if (isWireSessionEntry(entry)) this.#broadcast({ t: "entry", entry });
|
|
230
|
+
// Model/thinking/title changes land as entries while idle; refresh
|
|
231
|
+
// guest state promptly (debounce + JSON diff dedupe).
|
|
232
|
+
this.#scheduleStateBroadcast();
|
|
233
|
+
};
|
|
234
|
+
this.#updateStatusSegment();
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Broadcast a goodbye, detach all taps, and close the socket. */
|
|
238
|
+
async stop(reason: string): Promise<void> {
|
|
239
|
+
if (this.#stopped) return;
|
|
240
|
+
this.#socket?.send({ t: "bye", reason });
|
|
241
|
+
await this.#teardown();
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
async #teardown(): Promise<void> {
|
|
245
|
+
if (this.#stopped) return;
|
|
246
|
+
this.#stopped = true;
|
|
247
|
+
this.#ctx.sessionManager.onEntryAppended = undefined;
|
|
248
|
+
this.#unsubscribe?.();
|
|
249
|
+
this.#unsubscribe = undefined;
|
|
250
|
+
for (const unsubscribe of this.#busUnsubscribers) unsubscribe();
|
|
251
|
+
this.#busUnsubscribers = [];
|
|
252
|
+
this.#registryUnsubscribe?.();
|
|
253
|
+
this.#registryUnsubscribe = undefined;
|
|
254
|
+
clearTimeout(this.#stateDebounce ?? undefined);
|
|
255
|
+
this.#stateDebounce = null;
|
|
256
|
+
clearTimeout(this.#agentsDebounce ?? undefined);
|
|
257
|
+
this.#agentsDebounce = null;
|
|
258
|
+
clearInterval(this.#streamingInterval ?? undefined);
|
|
259
|
+
this.#streamingInterval = null;
|
|
260
|
+
this.#peers.clear();
|
|
261
|
+
this.#socket?.close();
|
|
262
|
+
this.#socket = null;
|
|
263
|
+
this.#ctx.collabHost = undefined;
|
|
264
|
+
this.#ctx.statusLine.setCollabStatus(null);
|
|
265
|
+
this.#ctx.ui.requestRender();
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
#broadcast(frame: CollabFrame): void {
|
|
269
|
+
if (this.#stopped || !this.#socket) return;
|
|
270
|
+
if (this.#ctx.sessionManager.getSessionId() !== this.#sessionId) {
|
|
271
|
+
void this.stop("session switched");
|
|
272
|
+
this.#ctx.session.emitNotice("warning", "Collab ended: session switched", "collab");
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
this.#socket.send(frame);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
#handleFrame(frame: CollabFrame, fromPeer: number): void {
|
|
279
|
+
switch (frame.t) {
|
|
280
|
+
case "hello":
|
|
281
|
+
this.#handleHello(frame.name, frame.proto, frame.writeToken, fromPeer);
|
|
282
|
+
break;
|
|
283
|
+
case "prompt":
|
|
284
|
+
this.#handlePrompt(frame.text, frame.images, fromPeer);
|
|
285
|
+
break;
|
|
286
|
+
case "abort":
|
|
287
|
+
this.#handleAbort(fromPeer);
|
|
288
|
+
break;
|
|
289
|
+
case "agent-cmd":
|
|
290
|
+
this.#handleAgentCmd(frame.cmd, frame.agentId, frame.text, fromPeer);
|
|
291
|
+
break;
|
|
292
|
+
case "fetch-transcript":
|
|
293
|
+
void this.#handleFetchTranscript(frame.reqId, frame.agentId, frame.fromByte, fromPeer);
|
|
294
|
+
break;
|
|
295
|
+
default:
|
|
296
|
+
logger.debug("collab host ignoring unexpected frame", { type: frame.t, fromPeer });
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Timing-safe write-token check; peers without a valid token are read-only. */
|
|
301
|
+
#verifyWriteToken(token: string | undefined): boolean {
|
|
302
|
+
const expected = this.#writeToken;
|
|
303
|
+
if (!expected || !token) return false;
|
|
304
|
+
const bytes = Buffer.from(token, "base64url");
|
|
305
|
+
return bytes.byteLength === expected.byteLength && timingSafeEqual(bytes, expected);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Reject a mutating frame from a read-only peer with a targeted error. */
|
|
309
|
+
#rejectReadOnly(action: string, fromPeer: number): void {
|
|
310
|
+
this.#socket?.send({ t: "error", message: `${action} is disabled on a read-only link` }, fromPeer);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
#handleHello(name: string, proto: number, writeToken: string | undefined, fromPeer: number): void {
|
|
314
|
+
if (proto !== COLLAB_PROTO) {
|
|
315
|
+
this.#socket?.send(
|
|
316
|
+
{ t: "error", message: `protocol mismatch: host speaks v${COLLAB_PROTO}, guest sent v${proto}` },
|
|
317
|
+
fromPeer,
|
|
318
|
+
);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
const cleanName = name.trim().slice(0, 64) || `guest-${fromPeer}`;
|
|
322
|
+
const canWrite = this.#verifyWriteToken(writeToken);
|
|
323
|
+
this.#peers.set(fromPeer, { name: cleanName, canWrite });
|
|
324
|
+
|
|
325
|
+
// Snapshot and send synchronously: no awaits between snapshot and send, so
|
|
326
|
+
// later entries/events queue behind the welcome on the same socket and the
|
|
327
|
+
// guest never sees a gap.
|
|
328
|
+
const snapshot = this.#ctx.sessionManager.snapshotForReplication();
|
|
329
|
+
if (JSON.stringify(snapshot).length > WELCOME_IMAGE_STRIP_THRESHOLD) {
|
|
330
|
+
let stripped = 0;
|
|
331
|
+
for (const entry of snapshot.entries) {
|
|
332
|
+
if (entry.type === "message") stripped += stripImagesFromMessage(entry.message);
|
|
333
|
+
}
|
|
334
|
+
logger.info("collab welcome exceeded size threshold; stripped images", { stripped });
|
|
335
|
+
}
|
|
336
|
+
const entries = snapshot.entries.filter(isWireSessionEntry);
|
|
337
|
+
this.#socket?.send(
|
|
338
|
+
{
|
|
339
|
+
t: "welcome",
|
|
340
|
+
proto: COLLAB_PROTO,
|
|
341
|
+
header: snapshot.header,
|
|
342
|
+
entries,
|
|
343
|
+
state: this.#buildState(),
|
|
344
|
+
agents: this.#snapshotAgents(),
|
|
345
|
+
readOnly: canWrite ? undefined : true,
|
|
346
|
+
},
|
|
347
|
+
fromPeer,
|
|
348
|
+
);
|
|
349
|
+
this.#ctx.session.emitNotice(
|
|
350
|
+
"info",
|
|
351
|
+
`${cleanName} joined the collab session${canWrite ? "" : " (read-only)"}`,
|
|
352
|
+
"collab",
|
|
353
|
+
);
|
|
354
|
+
this.#updateStatusSegment();
|
|
355
|
+
this.#scheduleStateBroadcast();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#handlePrompt(text: string, images: ImageContent[] | undefined, fromPeer: number): void {
|
|
359
|
+
const peer = this.#peers.get(fromPeer);
|
|
360
|
+
if (!peer?.canWrite) {
|
|
361
|
+
this.#rejectReadOnly("prompting", fromPeer);
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
const name = peer.name;
|
|
365
|
+
const content: string | (TextContent | ImageContent)[] =
|
|
366
|
+
images && images.length > 0 ? [{ type: "text", text }, ...images] : text;
|
|
367
|
+
this.#ctx.session
|
|
368
|
+
.promptCustomMessage(
|
|
369
|
+
{
|
|
370
|
+
customType: COLLAB_PROMPT_MESSAGE_TYPE,
|
|
371
|
+
content,
|
|
372
|
+
display: true,
|
|
373
|
+
details: { from: name },
|
|
374
|
+
attribution: "user",
|
|
375
|
+
},
|
|
376
|
+
{ streamingBehavior: "steer" },
|
|
377
|
+
)
|
|
378
|
+
.catch(err => {
|
|
379
|
+
logger.warn("collab guest prompt failed", { error: String(err) });
|
|
380
|
+
this.#socket?.send({ t: "error", message: `prompt failed: ${String(err)}` }, fromPeer);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
#handleAbort(fromPeer: number): void {
|
|
385
|
+
const peer = this.#peers.get(fromPeer);
|
|
386
|
+
if (!peer?.canWrite) {
|
|
387
|
+
this.#rejectReadOnly("interrupting", fromPeer);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
const name = peer.name;
|
|
391
|
+
void this.#ctx.session
|
|
392
|
+
.abort()
|
|
393
|
+
.then(() => this.#ctx.session.emitNotice("info", `${name} interrupted`, "collab"))
|
|
394
|
+
.catch(err => logger.warn("collab guest abort failed", { error: String(err) }));
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
#handlePeerLeft(peer: number): void {
|
|
398
|
+
const name = this.#peers.get(peer)?.name;
|
|
399
|
+
this.#peers.delete(peer);
|
|
400
|
+
if (name) this.#ctx.session.emitNotice("info", `${name} left the collab session`, "collab");
|
|
401
|
+
this.#updateStatusSegment();
|
|
402
|
+
this.#scheduleStateBroadcast();
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
#buildState(): CollabSessionState {
|
|
406
|
+
const session = this.#ctx.session;
|
|
407
|
+
// Context numbers come from the status line's breakdown — not
|
|
408
|
+
// session.getContextUsage() — so guests render exactly what the host's
|
|
409
|
+
// own footer shows.
|
|
410
|
+
const breakdown = this.#ctx.statusLine.getCachedContextBreakdown();
|
|
411
|
+
return {
|
|
412
|
+
isStreaming: session.isStreaming,
|
|
413
|
+
queuedMessageCount: session.queuedMessageCount,
|
|
414
|
+
sessionName: session.sessionName,
|
|
415
|
+
cwd: this.#ctx.sessionManager.getCwd(),
|
|
416
|
+
model: session.model,
|
|
417
|
+
thinkingLevel: session.thinkingLevel,
|
|
418
|
+
contextUsage: {
|
|
419
|
+
tokens: breakdown.usedTokens,
|
|
420
|
+
contextWindow: breakdown.contextWindow,
|
|
421
|
+
percent: breakdown.contextWindow > 0 ? (breakdown.usedTokens / breakdown.contextWindow) * 100 : null,
|
|
422
|
+
},
|
|
423
|
+
participants: this.participants,
|
|
424
|
+
};
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
#onEventForState(event: AgentSessionEvent): void {
|
|
428
|
+
if (!STATE_TRIGGER_EVENTS[event.type]) return;
|
|
429
|
+
this.#scheduleStateBroadcast();
|
|
430
|
+
if (event.type === "agent_start" && !this.#streamingInterval) {
|
|
431
|
+
this.#streamingInterval = setInterval(() => this.#scheduleStateBroadcast(), STREAMING_STATE_INTERVAL_MS);
|
|
432
|
+
} else if (event.type === "agent_end" && this.#streamingInterval) {
|
|
433
|
+
clearInterval(this.#streamingInterval);
|
|
434
|
+
this.#streamingInterval = null;
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#snapshotAgents(): AgentSnapshot[] {
|
|
439
|
+
return AgentRegistry.global()
|
|
440
|
+
.list()
|
|
441
|
+
.map(ref => ({
|
|
442
|
+
id: ref.id,
|
|
443
|
+
displayName: ref.displayName,
|
|
444
|
+
kind: ref.kind,
|
|
445
|
+
parentId: ref.parentId,
|
|
446
|
+
status: ref.status,
|
|
447
|
+
hasSessionFile: !!ref.sessionFile,
|
|
448
|
+
createdAt: ref.createdAt,
|
|
449
|
+
lastActivity: ref.lastActivity,
|
|
450
|
+
}));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
#scheduleAgentsBroadcast(): void {
|
|
454
|
+
if (this.#stopped || this.#agentsDebounce) return;
|
|
455
|
+
this.#agentsDebounce = setTimeout(() => {
|
|
456
|
+
this.#agentsDebounce = null;
|
|
457
|
+
this.#broadcast({ t: "agents", agents: this.#snapshotAgents() });
|
|
458
|
+
}, AGENTS_DEBOUNCE_MS);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
#handleAgentCmd(cmd: "chat" | "kill" | "revive", agentId: string, text: string | undefined, fromPeer: number): void {
|
|
462
|
+
if (!this.#peers.get(fromPeer)?.canWrite) {
|
|
463
|
+
this.#rejectReadOnly("agent control", fromPeer);
|
|
464
|
+
return;
|
|
465
|
+
}
|
|
466
|
+
const fail = (err: unknown) => {
|
|
467
|
+
logger.warn("collab agent-cmd failed", { cmd, agentId, error: String(err) });
|
|
468
|
+
this.#socket?.send({ t: "error", message: `agent ${agentId}: ${String(err)}` }, fromPeer);
|
|
469
|
+
};
|
|
470
|
+
switch (cmd) {
|
|
471
|
+
case "chat": {
|
|
472
|
+
const trimmed = text?.trim();
|
|
473
|
+
if (!trimmed) {
|
|
474
|
+
this.#socket?.send({ t: "error", message: `agent ${agentId}: empty chat message` }, fromPeer);
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
// Mirrors the hub's #submitChatMessage: revive if parked, steer if mid-turn.
|
|
478
|
+
AgentLifecycleManager.global()
|
|
479
|
+
.ensureLive(agentId)
|
|
480
|
+
.then(session => session.prompt(trimmed, { streamingBehavior: "steer" }))
|
|
481
|
+
.catch(fail);
|
|
482
|
+
break;
|
|
483
|
+
}
|
|
484
|
+
case "kill": {
|
|
485
|
+
const kill = async () => {
|
|
486
|
+
const ref = AgentRegistry.global().get(agentId);
|
|
487
|
+
if (ref && ref.status === "running" && ref.session) {
|
|
488
|
+
await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
|
|
489
|
+
}
|
|
490
|
+
await AgentLifecycleManager.global().release(agentId);
|
|
491
|
+
};
|
|
492
|
+
kill().catch(fail);
|
|
493
|
+
break;
|
|
494
|
+
}
|
|
495
|
+
case "revive":
|
|
496
|
+
AgentLifecycleManager.global().ensureLive(agentId).catch(fail);
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
/** Incremental transcript read mirroring the hub's readFileIncremental contract. */
|
|
502
|
+
async #handleFetchTranscript(reqId: number, agentId: string, fromByte: number, fromPeer: number): Promise<void> {
|
|
503
|
+
const reply = (text: string, newSize: number, error?: string) =>
|
|
504
|
+
this.#socket?.send({ t: "transcript", reqId, text, newSize, error }, fromPeer);
|
|
505
|
+
const file = AgentRegistry.global().get(agentId)?.sessionFile;
|
|
506
|
+
if (!file) {
|
|
507
|
+
reply("", fromByte, "no transcript available");
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
try {
|
|
511
|
+
const stat = await fs.stat(file);
|
|
512
|
+
if (stat.size <= fromByte) {
|
|
513
|
+
reply("", stat.size);
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const want = Math.min(stat.size - fromByte, TRANSCRIPT_READ_CAP);
|
|
517
|
+
const handle = await fs.open(file, "r");
|
|
518
|
+
let bytesRead: number;
|
|
519
|
+
const buf = Buffer.allocUnsafe(want);
|
|
520
|
+
try {
|
|
521
|
+
({ bytesRead } = await handle.read(buf, 0, want, fromByte));
|
|
522
|
+
} finally {
|
|
523
|
+
await handle.close();
|
|
524
|
+
}
|
|
525
|
+
let slice = buf.subarray(0, bytesRead);
|
|
526
|
+
const reachedEof = fromByte + bytesRead >= stat.size;
|
|
527
|
+
if (!reachedEof) {
|
|
528
|
+
// Trim to the last complete JSONL line so no line or UTF-8 char is split.
|
|
529
|
+
const lastNewline = slice.lastIndexOf(0x0a);
|
|
530
|
+
slice = slice.subarray(0, lastNewline >= 0 ? lastNewline + 1 : 0);
|
|
531
|
+
}
|
|
532
|
+
reply(slice.toString("utf-8"), reachedEof ? stat.size : fromByte + slice.byteLength);
|
|
533
|
+
} catch (err) {
|
|
534
|
+
logger.debug("collab transcript read failed", { agentId, error: String(err) });
|
|
535
|
+
reply("", fromByte, String(err));
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
#scheduleStateBroadcast(): void {
|
|
540
|
+
if (this.#stopped || this.#stateDebounce) return;
|
|
541
|
+
this.#stateDebounce = setTimeout(() => {
|
|
542
|
+
this.#stateDebounce = null;
|
|
543
|
+
const state = this.#buildState();
|
|
544
|
+
const json = JSON.stringify(state);
|
|
545
|
+
if (json === this.#lastStateJson) return;
|
|
546
|
+
this.#lastStateJson = json;
|
|
547
|
+
this.#broadcast({ t: "state", state });
|
|
548
|
+
}, STATE_DEBOUNCE_MS);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
#updateStatusSegment(): void {
|
|
552
|
+
this.#ctx.statusLine.setCollabStatus({ role: "host", participantCount: this.#peers.size + 1 });
|
|
553
|
+
this.#ctx.statusLine.invalidate();
|
|
554
|
+
this.#ctx.ui.requestRender();
|
|
555
|
+
}
|
|
556
|
+
}
|