@oh-my-pi/pi-coding-agent 15.11.7 → 15.11.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +30 -2
  2. package/dist/cli.js +363 -356
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +12 -0
  5. package/dist/types/collab/guest.d.ts +21 -0
  6. package/dist/types/collab/host.d.ts +13 -0
  7. package/dist/types/collab/protocol.d.ts +100 -0
  8. package/dist/types/collab/relay-client.d.ts +22 -0
  9. package/dist/types/commands/join.d.ts +12 -0
  10. package/dist/types/config/settings-schema.d.ts +21 -1
  11. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  12. package/dist/types/modes/components/agent-hub.d.ts +13 -0
  13. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  14. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  15. package/dist/types/modes/components/segment-track.d.ts +11 -6
  16. package/dist/types/modes/components/status-line/component.d.ts +4 -1
  17. package/dist/types/modes/components/status-line/types.d.ts +9 -0
  18. package/dist/types/modes/interactive-mode.d.ts +7 -0
  19. package/dist/types/modes/types.d.ts +8 -0
  20. package/dist/types/session/agent-session.d.ts +11 -0
  21. package/dist/types/session/session-manager.d.ts +21 -0
  22. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  23. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  24. package/package.json +14 -12
  25. package/scripts/bench-guard.ts +71 -0
  26. package/src/cli/args.ts +2 -0
  27. package/src/cli-commands.ts +1 -0
  28. package/src/collab/crypto.ts +57 -0
  29. package/src/collab/guest.ts +421 -0
  30. package/src/collab/host.ts +494 -0
  31. package/src/collab/protocol.ts +191 -0
  32. package/src/collab/relay-client.ts +216 -0
  33. package/src/commands/join.ts +39 -0
  34. package/src/config/model-registry.ts +22 -14
  35. package/src/config/settings-schema.ts +27 -1
  36. package/src/extensibility/slash-commands.ts +1 -97
  37. package/src/internal-urls/docs-index.generated.ts +3 -2
  38. package/src/main.ts +11 -2
  39. package/src/modes/components/agent-hub.ts +119 -22
  40. package/src/modes/components/assistant-message.ts +126 -6
  41. package/src/modes/components/collab-prompt-message.ts +30 -0
  42. package/src/modes/components/hook-selector.ts +4 -5
  43. package/src/modes/components/segment-track.ts +44 -7
  44. package/src/modes/components/status-line/component.ts +21 -1
  45. package/src/modes/components/status-line/presets.ts +1 -1
  46. package/src/modes/components/status-line/segments.ts +13 -0
  47. package/src/modes/components/status-line/types.ts +10 -0
  48. package/src/modes/components/tips.txt +2 -1
  49. package/src/modes/controllers/input-controller.ts +72 -6
  50. package/src/modes/controllers/selector-controller.ts +2 -0
  51. package/src/modes/controllers/streaming-reveal.ts +7 -0
  52. package/src/modes/interactive-mode.ts +12 -4
  53. package/src/modes/types.ts +8 -0
  54. package/src/modes/utils/ui-helpers.ts +7 -0
  55. package/src/sdk.ts +239 -36
  56. package/src/session/agent-session.ts +17 -0
  57. package/src/session/session-manager.ts +44 -0
  58. package/src/session/snapcompact-inline.ts +9 -3
  59. package/src/slash-commands/builtin-registry.ts +210 -0
  60. package/src/tools/read.ts +38 -5
  61. package/src/tools/write.ts +13 -42
@@ -0,0 +1,494 @@
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
+ import * as fs from "node:fs/promises";
12
+ import * as os from "node:os";
13
+ import type { ImageContent, TextContent } from "@oh-my-pi/pi-ai";
14
+ import { logger } from "@oh-my-pi/pi-utils";
15
+ import type { BusChannel, AgentEvent as WireAgentEvent, SessionEntry as WireSessionEntry } from "@oh-my-pi/pi-wire";
16
+ import type { InteractiveModeContext } from "../modes/types";
17
+ import { AgentLifecycleManager } from "../registry/agent-lifecycle";
18
+ import { AgentRegistry } from "../registry/agent-registry";
19
+ import type { AgentSessionEvent } from "../session/agent-session";
20
+ import { stripImagesFromMessage, USER_INTERRUPT_LABEL } from "../session/messages";
21
+ import type { SessionEntry as StoredSessionEntry } from "../session/session-manager";
22
+ import { TASK_SUBAGENT_LIFECYCLE_CHANNEL, TASK_SUBAGENT_PROGRESS_CHANNEL } from "../task";
23
+ import { generateRoomKey, importRoomKey } from "./crypto";
24
+ import {
25
+ type AgentSnapshot,
26
+ COLLAB_PROMPT_MESSAGE_TYPE,
27
+ COLLAB_PROTO,
28
+ type CollabFrame,
29
+ type CollabParticipant,
30
+ type CollabSessionState,
31
+ formatCollabLink,
32
+ generateRoomId,
33
+ parseCollabLink,
34
+ } from "./protocol";
35
+ import { CollabSocket } from "./relay-client";
36
+
37
+ /** Events that change the footer state guests render. */
38
+ const STATE_TRIGGER_EVENTS: Record<string, true> = {
39
+ agent_start: true,
40
+ agent_end: true,
41
+ message_end: true,
42
+ tool_execution_end: true,
43
+ thinking_level_changed: true,
44
+ auto_compaction_end: true,
45
+ };
46
+
47
+ const STATE_DEBOUNCE_MS = 100;
48
+ const AGENTS_DEBOUNCE_MS = 100;
49
+ const STREAMING_STATE_INTERVAL_MS = 2000;
50
+ const WELCOME_IMAGE_STRIP_THRESHOLD = 24 * 1024 * 1024;
51
+ const WIRE_AGENT_EVENT_TYPES: Record<WireAgentEvent["type"], true> = {
52
+ agent_start: true,
53
+ agent_end: true,
54
+ turn_start: true,
55
+ turn_end: true,
56
+ message_start: true,
57
+ message_update: true,
58
+ message_end: true,
59
+ tool_execution_start: true,
60
+ tool_execution_update: true,
61
+ tool_execution_end: true,
62
+ notice: true,
63
+ auto_compaction_start: true,
64
+ auto_compaction_end: true,
65
+ auto_retry_start: true,
66
+ auto_retry_end: true,
67
+ thinking_level_changed: true,
68
+ };
69
+
70
+ const WIRE_SESSION_ENTRY_TYPES: Record<WireSessionEntry["type"], true> = {
71
+ message: true,
72
+ custom_message: true,
73
+ compaction: true,
74
+ branch_summary: true,
75
+ model_change: true,
76
+ thinking_level_change: true,
77
+ };
78
+ const COLLAB_BUS_CHANNELS = [
79
+ TASK_SUBAGENT_LIFECYCLE_CHANNEL,
80
+ TASK_SUBAGENT_PROGRESS_CHANNEL,
81
+ ] as const satisfies readonly BusChannel[];
82
+
83
+ function isWireAgentEvent(event: AgentSessionEvent): event is AgentSessionEvent & WireAgentEvent {
84
+ return event.type in WIRE_AGENT_EVENT_TYPES;
85
+ }
86
+
87
+ function isWireSessionEntry(entry: StoredSessionEntry): entry is StoredSessionEntry & WireSessionEntry {
88
+ return entry.type in WIRE_SESSION_ENTRY_TYPES;
89
+ }
90
+ const CONNECT_TIMEOUT_MS = 15_000;
91
+ /** Max bytes served per fetch-transcript reply (guest re-requests from `newSize`). */
92
+ const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
93
+
94
+ /** Display name for this process's user in collab sessions. */
95
+ export function collabDisplayName(ctx: InteractiveModeContext): string {
96
+ const configured = (ctx.settings.get("collab.displayName") ?? "").trim();
97
+ if (configured) return configured;
98
+ try {
99
+ return os.userInfo().username;
100
+ } catch {
101
+ return "anonymous";
102
+ }
103
+ }
104
+
105
+ export class CollabHost {
106
+ #ctx: InteractiveModeContext;
107
+ #socket: CollabSocket | null = null;
108
+ #link = "";
109
+ #sessionId = "";
110
+ #unsubscribe?: () => void;
111
+ #peers = new Map<number, string>();
112
+ #lastStateJson = "";
113
+ #stateDebounce: Timer | null = null;
114
+ #streamingInterval: Timer | null = null;
115
+ #agentsDebounce: Timer | null = null;
116
+ #busUnsubscribers: (() => void)[] = [];
117
+ #registryUnsubscribe?: () => void;
118
+ #stopped = false;
119
+
120
+ constructor(ctx: InteractiveModeContext) {
121
+ this.#ctx = ctx;
122
+ }
123
+
124
+ get link(): string {
125
+ return this.#link;
126
+ }
127
+
128
+ get participants(): CollabParticipant[] {
129
+ const list: CollabParticipant[] = [{ name: collabDisplayName(this.#ctx), role: "host" }];
130
+ for (const name of this.#peers.values()) list.push({ name, role: "guest" });
131
+ return list;
132
+ }
133
+
134
+ async start(relayUrl: string): Promise<void> {
135
+ const rawKey = generateRoomKey();
136
+ const roomId = generateRoomId();
137
+ this.#link = formatCollabLink(relayUrl, roomId, rawKey);
138
+ const parsed = parseCollabLink(this.#link);
139
+ if ("error" in parsed) throw new Error(parsed.error);
140
+ const key = await importRoomKey(rawKey);
141
+
142
+ const socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "host", key });
143
+ this.#socket = socket;
144
+ this.#sessionId = this.#ctx.sessionManager.getSessionId();
145
+
146
+ const firstOpen = Promise.withResolvers<void>();
147
+ let opened = false;
148
+ socket.onOpen = () => {
149
+ if (!opened) {
150
+ opened = true;
151
+ firstOpen.resolve();
152
+ }
153
+ };
154
+ socket.onFrame = (frame, fromPeer) => this.#handleFrame(frame, fromPeer);
155
+ socket.onControl = msg => {
156
+ if (msg.t === "peer-left") this.#handlePeerLeft(msg.peer);
157
+ };
158
+ socket.onClose = (reason, willReconnect) => {
159
+ if (this.#stopped) return;
160
+ if (!opened) {
161
+ firstOpen.reject(new Error(reason));
162
+ return;
163
+ }
164
+ if (willReconnect) {
165
+ this.#ctx.showStatus(`Collab relay connection lost (${reason}), reconnecting…`, { dim: true });
166
+ } else {
167
+ void this.#teardown();
168
+ this.#ctx.session.emitNotice("warning", `Collab ended: ${reason}`, "collab");
169
+ }
170
+ };
171
+ socket.connect();
172
+
173
+ const timeout = setTimeout(
174
+ () => firstOpen.reject(new Error("timed out connecting to relay")),
175
+ CONNECT_TIMEOUT_MS,
176
+ );
177
+ try {
178
+ await firstOpen.promise;
179
+ } catch (err) {
180
+ this.#stopped = true;
181
+ socket.close();
182
+ this.#socket = null;
183
+ throw err;
184
+ } finally {
185
+ clearTimeout(timeout);
186
+ }
187
+
188
+ this.#unsubscribe = this.#ctx.session.subscribe(event => {
189
+ if (isWireAgentEvent(event)) this.#broadcast({ t: "event", event });
190
+ this.#onEventForState(event);
191
+ });
192
+ const bus = this.#ctx.eventBus;
193
+ if (bus) {
194
+ for (const channel of COLLAB_BUS_CHANNELS) {
195
+ this.#busUnsubscribers.push(bus.on(channel, data => this.#broadcast({ t: "bus", channel, data })));
196
+ }
197
+ }
198
+ this.#registryUnsubscribe = AgentRegistry.global().onChange(() => this.#scheduleAgentsBroadcast());
199
+ this.#ctx.sessionManager.onEntryAppended = entry => {
200
+ if (isWireSessionEntry(entry)) this.#broadcast({ t: "entry", entry });
201
+ // Model/thinking/title changes land as entries while idle; refresh
202
+ // guest state promptly (debounce + JSON diff dedupe).
203
+ this.#scheduleStateBroadcast();
204
+ };
205
+ this.#updateStatusSegment();
206
+ }
207
+
208
+ /** Broadcast a goodbye, detach all taps, and close the socket. */
209
+ async stop(reason: string): Promise<void> {
210
+ if (this.#stopped) return;
211
+ this.#socket?.send({ t: "bye", reason });
212
+ await this.#teardown();
213
+ }
214
+
215
+ async #teardown(): Promise<void> {
216
+ if (this.#stopped) return;
217
+ this.#stopped = true;
218
+ this.#ctx.sessionManager.onEntryAppended = undefined;
219
+ this.#unsubscribe?.();
220
+ this.#unsubscribe = undefined;
221
+ for (const unsubscribe of this.#busUnsubscribers) unsubscribe();
222
+ this.#busUnsubscribers = [];
223
+ this.#registryUnsubscribe?.();
224
+ this.#registryUnsubscribe = undefined;
225
+ clearTimeout(this.#stateDebounce ?? undefined);
226
+ this.#stateDebounce = null;
227
+ clearTimeout(this.#agentsDebounce ?? undefined);
228
+ this.#agentsDebounce = null;
229
+ clearInterval(this.#streamingInterval ?? undefined);
230
+ this.#streamingInterval = null;
231
+ this.#peers.clear();
232
+ this.#socket?.close();
233
+ this.#socket = null;
234
+ this.#ctx.collabHost = undefined;
235
+ this.#ctx.statusLine.setCollabStatus(null);
236
+ this.#ctx.ui.requestRender();
237
+ }
238
+
239
+ #broadcast(frame: CollabFrame): void {
240
+ if (this.#stopped || !this.#socket) return;
241
+ if (this.#ctx.sessionManager.getSessionId() !== this.#sessionId) {
242
+ void this.stop("session switched");
243
+ this.#ctx.session.emitNotice("warning", "Collab ended: session switched", "collab");
244
+ return;
245
+ }
246
+ this.#socket.send(frame);
247
+ }
248
+
249
+ #handleFrame(frame: CollabFrame, fromPeer: number): void {
250
+ switch (frame.t) {
251
+ case "hello":
252
+ this.#handleHello(frame.name, frame.proto, fromPeer);
253
+ break;
254
+ case "prompt":
255
+ this.#handlePrompt(frame.text, frame.images, fromPeer);
256
+ break;
257
+ case "abort":
258
+ this.#handleAbort(fromPeer);
259
+ break;
260
+ case "agent-cmd":
261
+ this.#handleAgentCmd(frame.cmd, frame.agentId, frame.text, fromPeer);
262
+ break;
263
+ case "fetch-transcript":
264
+ void this.#handleFetchTranscript(frame.reqId, frame.agentId, frame.fromByte, fromPeer);
265
+ break;
266
+ default:
267
+ logger.debug("collab host ignoring unexpected frame", { type: frame.t, fromPeer });
268
+ }
269
+ }
270
+
271
+ #handleHello(name: string, proto: number, fromPeer: number): void {
272
+ if (proto !== COLLAB_PROTO) {
273
+ this.#socket?.send(
274
+ { t: "error", message: `protocol mismatch: host speaks v${COLLAB_PROTO}, guest sent v${proto}` },
275
+ fromPeer,
276
+ );
277
+ return;
278
+ }
279
+ const cleanName = name.trim().slice(0, 64) || `guest-${fromPeer}`;
280
+ this.#peers.set(fromPeer, cleanName);
281
+
282
+ // Snapshot and send synchronously: no awaits between snapshot and send, so
283
+ // later entries/events queue behind the welcome on the same socket and the
284
+ // guest never sees a gap.
285
+ const snapshot = this.#ctx.sessionManager.snapshotForReplication();
286
+ if (JSON.stringify(snapshot).length > WELCOME_IMAGE_STRIP_THRESHOLD) {
287
+ let stripped = 0;
288
+ for (const entry of snapshot.entries) {
289
+ if (entry.type === "message") stripped += stripImagesFromMessage(entry.message);
290
+ }
291
+ logger.info("collab welcome exceeded size threshold; stripped images", { stripped });
292
+ }
293
+ const entries = snapshot.entries.filter(isWireSessionEntry);
294
+ this.#socket?.send(
295
+ {
296
+ t: "welcome",
297
+ proto: COLLAB_PROTO,
298
+ header: snapshot.header,
299
+ entries,
300
+ state: this.#buildState(),
301
+ agents: this.#snapshotAgents(),
302
+ },
303
+ fromPeer,
304
+ );
305
+ this.#ctx.session.emitNotice("info", `${cleanName} joined the collab session`, "collab");
306
+ this.#updateStatusSegment();
307
+ this.#scheduleStateBroadcast();
308
+ }
309
+
310
+ #handlePrompt(text: string, images: ImageContent[] | undefined, fromPeer: number): void {
311
+ const name = this.#peers.get(fromPeer) ?? `guest-${fromPeer}`;
312
+ const content: string | (TextContent | ImageContent)[] =
313
+ images && images.length > 0 ? [{ type: "text", text }, ...images] : text;
314
+ this.#ctx.session
315
+ .promptCustomMessage(
316
+ {
317
+ customType: COLLAB_PROMPT_MESSAGE_TYPE,
318
+ content,
319
+ display: true,
320
+ details: { from: name },
321
+ attribution: "user",
322
+ },
323
+ { streamingBehavior: "steer" },
324
+ )
325
+ .catch(err => {
326
+ logger.warn("collab guest prompt failed", { error: String(err) });
327
+ this.#socket?.send({ t: "error", message: `prompt failed: ${String(err)}` }, fromPeer);
328
+ });
329
+ }
330
+
331
+ #handleAbort(fromPeer: number): void {
332
+ const name = this.#peers.get(fromPeer) ?? `guest-${fromPeer}`;
333
+ void this.#ctx.session
334
+ .abort()
335
+ .then(() => this.#ctx.session.emitNotice("info", `${name} interrupted`, "collab"))
336
+ .catch(err => logger.warn("collab guest abort failed", { error: String(err) }));
337
+ }
338
+
339
+ #handlePeerLeft(peer: number): void {
340
+ const name = this.#peers.get(peer);
341
+ this.#peers.delete(peer);
342
+ if (name) this.#ctx.session.emitNotice("info", `${name} left the collab session`, "collab");
343
+ this.#updateStatusSegment();
344
+ this.#scheduleStateBroadcast();
345
+ }
346
+
347
+ #buildState(): CollabSessionState {
348
+ const session = this.#ctx.session;
349
+ // Context numbers come from the status line's breakdown — not
350
+ // session.getContextUsage() — so guests render exactly what the host's
351
+ // own footer shows.
352
+ const breakdown = this.#ctx.statusLine.getCachedContextBreakdown();
353
+ return {
354
+ isStreaming: session.isStreaming,
355
+ queuedMessageCount: session.queuedMessageCount,
356
+ sessionName: session.sessionName,
357
+ cwd: this.#ctx.sessionManager.getCwd(),
358
+ model: session.model,
359
+ thinkingLevel: session.thinkingLevel,
360
+ contextUsage: {
361
+ tokens: breakdown.usedTokens,
362
+ contextWindow: breakdown.contextWindow,
363
+ percent: breakdown.contextWindow > 0 ? (breakdown.usedTokens / breakdown.contextWindow) * 100 : null,
364
+ },
365
+ participants: this.participants,
366
+ };
367
+ }
368
+
369
+ #onEventForState(event: AgentSessionEvent): void {
370
+ if (!STATE_TRIGGER_EVENTS[event.type]) return;
371
+ this.#scheduleStateBroadcast();
372
+ if (event.type === "agent_start" && !this.#streamingInterval) {
373
+ this.#streamingInterval = setInterval(() => this.#scheduleStateBroadcast(), STREAMING_STATE_INTERVAL_MS);
374
+ } else if (event.type === "agent_end" && this.#streamingInterval) {
375
+ clearInterval(this.#streamingInterval);
376
+ this.#streamingInterval = null;
377
+ }
378
+ }
379
+
380
+ #snapshotAgents(): AgentSnapshot[] {
381
+ return AgentRegistry.global()
382
+ .list()
383
+ .map(ref => ({
384
+ id: ref.id,
385
+ displayName: ref.displayName,
386
+ kind: ref.kind,
387
+ parentId: ref.parentId,
388
+ status: ref.status,
389
+ hasSessionFile: !!ref.sessionFile,
390
+ createdAt: ref.createdAt,
391
+ lastActivity: ref.lastActivity,
392
+ }));
393
+ }
394
+
395
+ #scheduleAgentsBroadcast(): void {
396
+ if (this.#stopped || this.#agentsDebounce) return;
397
+ this.#agentsDebounce = setTimeout(() => {
398
+ this.#agentsDebounce = null;
399
+ this.#broadcast({ t: "agents", agents: this.#snapshotAgents() });
400
+ }, AGENTS_DEBOUNCE_MS);
401
+ }
402
+
403
+ #handleAgentCmd(cmd: "chat" | "kill" | "revive", agentId: string, text: string | undefined, fromPeer: number): void {
404
+ const fail = (err: unknown) => {
405
+ logger.warn("collab agent-cmd failed", { cmd, agentId, error: String(err) });
406
+ this.#socket?.send({ t: "error", message: `agent ${agentId}: ${String(err)}` }, fromPeer);
407
+ };
408
+ switch (cmd) {
409
+ case "chat": {
410
+ const trimmed = text?.trim();
411
+ if (!trimmed) {
412
+ this.#socket?.send({ t: "error", message: `agent ${agentId}: empty chat message` }, fromPeer);
413
+ return;
414
+ }
415
+ // Mirrors the hub's #submitChatMessage: revive if parked, steer if mid-turn.
416
+ AgentLifecycleManager.global()
417
+ .ensureLive(agentId)
418
+ .then(session => session.prompt(trimmed, { streamingBehavior: "steer" }))
419
+ .catch(fail);
420
+ break;
421
+ }
422
+ case "kill": {
423
+ const kill = async () => {
424
+ const ref = AgentRegistry.global().get(agentId);
425
+ if (ref && ref.status === "running" && ref.session) {
426
+ await ref.session.abort({ reason: USER_INTERRUPT_LABEL });
427
+ }
428
+ await AgentLifecycleManager.global().release(agentId);
429
+ };
430
+ kill().catch(fail);
431
+ break;
432
+ }
433
+ case "revive":
434
+ AgentLifecycleManager.global().ensureLive(agentId).catch(fail);
435
+ break;
436
+ }
437
+ }
438
+
439
+ /** Incremental transcript read mirroring the hub's readFileIncremental contract. */
440
+ async #handleFetchTranscript(reqId: number, agentId: string, fromByte: number, fromPeer: number): Promise<void> {
441
+ const reply = (text: string, newSize: number, error?: string) =>
442
+ this.#socket?.send({ t: "transcript", reqId, text, newSize, error }, fromPeer);
443
+ const file = AgentRegistry.global().get(agentId)?.sessionFile;
444
+ if (!file) {
445
+ reply("", fromByte, "no transcript available");
446
+ return;
447
+ }
448
+ try {
449
+ const stat = await fs.stat(file);
450
+ if (stat.size <= fromByte) {
451
+ reply("", stat.size);
452
+ return;
453
+ }
454
+ const want = Math.min(stat.size - fromByte, TRANSCRIPT_READ_CAP);
455
+ const handle = await fs.open(file, "r");
456
+ let bytesRead: number;
457
+ const buf = Buffer.allocUnsafe(want);
458
+ try {
459
+ ({ bytesRead } = await handle.read(buf, 0, want, fromByte));
460
+ } finally {
461
+ await handle.close();
462
+ }
463
+ let slice = buf.subarray(0, bytesRead);
464
+ const reachedEof = fromByte + bytesRead >= stat.size;
465
+ if (!reachedEof) {
466
+ // Trim to the last complete JSONL line so no line or UTF-8 char is split.
467
+ const lastNewline = slice.lastIndexOf(0x0a);
468
+ slice = slice.subarray(0, lastNewline >= 0 ? lastNewline + 1 : 0);
469
+ }
470
+ reply(slice.toString("utf-8"), reachedEof ? stat.size : fromByte + slice.byteLength);
471
+ } catch (err) {
472
+ logger.debug("collab transcript read failed", { agentId, error: String(err) });
473
+ reply("", fromByte, String(err));
474
+ }
475
+ }
476
+
477
+ #scheduleStateBroadcast(): void {
478
+ if (this.#stopped || this.#stateDebounce) return;
479
+ this.#stateDebounce = setTimeout(() => {
480
+ this.#stateDebounce = null;
481
+ const state = this.#buildState();
482
+ const json = JSON.stringify(state);
483
+ if (json === this.#lastStateJson) return;
484
+ this.#lastStateJson = json;
485
+ this.#broadcast({ t: "state", state });
486
+ }, STATE_DEBOUNCE_MS);
487
+ }
488
+
489
+ #updateStatusSegment(): void {
490
+ this.#ctx.statusLine.setCollabStatus({ role: "host", participantCount: this.#peers.size + 1 });
491
+ this.#ctx.statusLine.invalidate();
492
+ this.#ctx.ui.requestRender();
493
+ }
494
+ }
@@ -0,0 +1,191 @@
1
+ /**
2
+ * Collab live-session wire protocol.
3
+ *
4
+ * Hub topology: the host is authoritative, guests never peer. All session
5
+ * payloads (`CollabFrame`) travel AES-256-GCM sealed; the relay only sees the
6
+ * plaintext envelope (`[4B uint32 BE peerId][sealed payload]`) plus TEXT JSON
7
+ * control messages that carry no session data.
8
+ */
9
+
10
+ import type { ImageContent, Model } from "@oh-my-pi/pi-ai";
11
+ import type {
12
+ BusChannel,
13
+ GuestFrame,
14
+ ParsedCollabLink,
15
+ Participant,
16
+ SessionState,
17
+ AgentSnapshot as WireAgentSnapshot,
18
+ } from "@oh-my-pi/pi-wire";
19
+ import { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES } from "@oh-my-pi/pi-wire";
20
+ import type { ContextUsage } from "../extensibility/extensions/types";
21
+ import type { AgentSessionEvent } from "../session/agent-session";
22
+ import type { SessionEntry, SessionHeader } from "../session/session-manager";
23
+
24
+ export type {
25
+ CollabPromptDetails,
26
+ ParsedCollabLink,
27
+ RelayControlMessage,
28
+ RelayControlToGuest,
29
+ RelayControlToHost,
30
+ } from "@oh-my-pi/pi-wire";
31
+ export { COLLAB_PROMPT_MESSAGE_TYPE, COLLAB_PROTO } from "@oh-my-pi/pi-wire";
32
+ export { DEFAULT_RELAY_URL, ENVELOPE_HEADER_LENGTH, ROOM_ID_BYTES };
33
+
34
+ export type CollabParticipant = Participant;
35
+ export type AgentSnapshot = WireAgentSnapshot;
36
+
37
+ /** Debounced footer snapshot broadcast by the host. */
38
+ export type CollabSessionState = SessionState & {
39
+ /**
40
+ * Host model (full catalog object). Guests apply it to their replica
41
+ * agent state so model display and context-window math are native.
42
+ */
43
+ model?: Model;
44
+ /** Host status-line context numbers (guest system prompt/tools differ, so local estimates drift). */
45
+ contextUsage?: ContextUsage;
46
+ };
47
+
48
+ /**
49
+ * Encrypted payload frames (inside AES-GCM, JSON). The wire package pins the
50
+ * JSON skeleton (`WireFrame`); host-side frames carry the rich session types
51
+ * that serialize into those shapes.
52
+ */
53
+ export type CollabFrame =
54
+ // guest -> host (hello/abort/agent-cmd/fetch-transcript are taken verbatim from the wire grammar)
55
+ | Exclude<GuestFrame, { t: "prompt" }>
56
+ | { t: "prompt"; text: string; images?: ImageContent[] }
57
+ // host -> guest
58
+ | {
59
+ t: "welcome";
60
+ proto: number;
61
+ header: SessionHeader;
62
+ entries: SessionEntry[];
63
+ state: CollabSessionState;
64
+ agents: AgentSnapshot[];
65
+ }
66
+ | { t: "entry"; entry: SessionEntry }
67
+ | { t: "event"; event: AgentSessionEvent }
68
+ | { t: "state"; state: CollabSessionState }
69
+ /** Mirrored EventBus traffic (task subagent lifecycle/progress channels only). */
70
+ | { t: "bus"; channel: BusChannel; data: unknown }
71
+ /** Full agent-registry snapshot (debounced on registry change). */
72
+ | { t: "agents"; agents: AgentSnapshot[] }
73
+ /** Targeted reply to fetch-transcript; `text` is decoded JSONL from `fromByte`, `newSize` the next offset base. */
74
+ | { t: "transcript"; reqId: number; text: string; newSize: number; error?: string }
75
+ | { t: "bye"; reason: string }
76
+ | { t: "error"; message: string };
77
+
78
+ // ═══════════════════════════════════════════════════════════════════════════
79
+ // Wire envelope: [4B uint32 BE peerId][sealed payload]
80
+ // Host→relay: peerId 0 broadcasts to all guests; peerId N targets guest N.
81
+ // Guest→relay: always 0; the relay rewrites it to the sender's id.
82
+ // ═══════════════════════════════════════════════════════════════════════════
83
+
84
+ export function packEnvelope(peerId: number, sealed: Uint8Array): Uint8Array {
85
+ const out = new Uint8Array(ENVELOPE_HEADER_LENGTH + sealed.byteLength);
86
+ new DataView(out.buffer).setUint32(0, peerId, false);
87
+ out.set(sealed, ENVELOPE_HEADER_LENGTH);
88
+ return out;
89
+ }
90
+
91
+ export function unpackEnvelope(data: Uint8Array): { peerId: number; payload: Uint8Array } | null {
92
+ if (data.byteLength < ENVELOPE_HEADER_LENGTH) return null;
93
+ const peerId = new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).getUint32(0, false);
94
+ return { peerId, payload: data.subarray(ENVELOPE_HEADER_LENGTH) };
95
+ }
96
+
97
+ /** Rewrite the peerId in place without copying the payload. */
98
+ export function rewriteEnvelopePeer(data: Uint8Array, peerId: number): void {
99
+ new DataView(data.buffer, data.byteOffset, ENVELOPE_HEADER_LENGTH).setUint32(0, peerId, false);
100
+ }
101
+
102
+ // ═══════════════════════════════════════════════════════════════════════════
103
+ // Link format: wss://<host[:port]>/r/<roomId>#<base64url-32-byte-key>
104
+ // ═══════════════════════════════════════════════════════════════════════════
105
+
106
+ const ROOM_PATH_RE = /^\/r\/([A-Za-z0-9_-]{10,64})$/;
107
+ const BARE_LINK_RE = /^([A-Za-z0-9_-]{10,64})#([A-Za-z0-9_-]+)$/;
108
+ const B64URL_RE = /^[A-Za-z0-9_-]+$/;
109
+ const LOCAL_HOSTNAMES: Record<string, true> = { localhost: true, "127.0.0.1": true, "::1": true, "[::1]": true };
110
+
111
+ export function generateRoomId(): string {
112
+ const bytes = new Uint8Array(ROOM_ID_BYTES);
113
+ crypto.getRandomValues(bytes);
114
+ return Buffer.from(bytes).toString("base64url");
115
+ }
116
+
117
+ /** Normalize a relay base URL (ws/wss/http/https) into a ws/wss origin, or an error. */
118
+ function normalizeRelayOrigin(relayUrl: string): { origin: string } | { error: string } {
119
+ let url: URL;
120
+ try {
121
+ url = new URL(relayUrl);
122
+ } catch {
123
+ return { error: `Invalid relay URL: ${relayUrl}` };
124
+ }
125
+ let scheme: string;
126
+ switch (url.protocol) {
127
+ case "wss:":
128
+ case "https:":
129
+ scheme = "wss:";
130
+ break;
131
+ case "ws:":
132
+ case "http:":
133
+ scheme = "ws:";
134
+ break;
135
+ default:
136
+ return { error: `Unsupported relay URL scheme: ${url.protocol}` };
137
+ }
138
+ if (scheme === "ws:" && !LOCAL_HOSTNAMES[url.hostname]) {
139
+ return { error: "relay link must be wss:// (plain ws:// is only allowed for localhost)" };
140
+ }
141
+ const port = url.port ? `:${url.port}` : "";
142
+ return { origin: `${scheme}//${url.hostname}${port}` };
143
+ }
144
+
145
+ /**
146
+ * Render the shareable link. Compact forms: the default relay collapses to
147
+ * `<roomId>#<key>`, other wss relays drop the scheme (`host[:port]/r/…`);
148
+ * only localhost ws:// links keep their full URL so parsing cannot
149
+ * mis-infer wss.
150
+ */
151
+ export function formatCollabLink(relayUrl: string, roomId: string, key: Uint8Array): string {
152
+ const normalized = normalizeRelayOrigin(relayUrl);
153
+ if ("error" in normalized) throw new Error(normalized.error);
154
+ const keyText = Buffer.from(key).toString("base64url");
155
+ if (normalized.origin === DEFAULT_RELAY_URL) return `${roomId}#${keyText}`;
156
+ const compact = normalized.origin.startsWith("wss://")
157
+ ? normalized.origin.slice("wss://".length)
158
+ : normalized.origin;
159
+ return `${compact}/r/${roomId}#${keyText}`;
160
+ }
161
+
162
+ export function parseCollabLink(link: string): ParsedCollabLink | { error: string } {
163
+ let text = link.trim();
164
+ // Bare `<roomId>#<key>` → default relay.
165
+ const bare = BARE_LINK_RE.exec(text);
166
+ if (bare) text = `${DEFAULT_RELAY_URL}/r/${bare[1]}#${bare[2]}`;
167
+ // Scheme-less `host[:port]/r/…` → wss.
168
+ else if (!text.includes("://")) text = `wss://${text}`;
169
+ let url: URL;
170
+ try {
171
+ url = new URL(text);
172
+ } catch {
173
+ return { error: `Invalid collab link: ${link}` };
174
+ }
175
+ const normalized = normalizeRelayOrigin(url.origin);
176
+ if ("error" in normalized) return normalized;
177
+ const match = ROOM_PATH_RE.exec(url.pathname);
178
+ if (!match) {
179
+ return { error: "Collab link must contain a /r/<roomId> path" };
180
+ }
181
+ const roomId = match[1]!;
182
+ const fragment = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash;
183
+ if (!fragment) {
184
+ return { error: "Collab link is missing the #<key> fragment" };
185
+ }
186
+ const key = B64URL_RE.test(fragment) ? new Uint8Array(Buffer.from(fragment, "base64url")) : null;
187
+ if (key?.byteLength !== 32) {
188
+ return { error: "Collab link key must be 32 base64url bytes" };
189
+ }
190
+ return { wsUrl: `${normalized.origin}/r/${roomId}`, roomId, key };
191
+ }