@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,71 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Boot-time regression guard (Phase A1 of the boot/TUI perf work).
4
+ *
5
+ * Re-runs the `PI_TIMING=x` cold-boot benchmark under hyperfine and fails when
6
+ * the median regresses past `baseline * THRESHOLD`. `PI_TIMING=x` runs the full
7
+ * pre-paint chain in `runRootCommand` and then `process.exit(0)`, so the
8
+ * never-exiting interactive launch becomes a terminating, benchmarkable boot.
9
+ *
10
+ * Boot wall-clock is MACHINE-RELATIVE: a baseline captured on one machine is
11
+ * meaningless on another (and on CI). This is a LOCAL guard — regenerate the
12
+ * baseline on the machine you measure on, then compare on that same machine.
13
+ * It is intentionally NOT wired into CI for that reason.
14
+ *
15
+ * bun scripts/bench-guard.ts --update # capture/refresh the baseline
16
+ * bun scripts/bench-guard.ts # measure + compare; exit 1 on regression
17
+ *
18
+ * Requires `hyperfine` on PATH.
19
+ */
20
+ import * as fs from "node:fs";
21
+ import * as path from "node:path";
22
+
23
+ const THRESHOLD = 1.05; // 5% regression budget
24
+ const BASELINE_PATH = path.join(import.meta.dir, "..", "bench", "boot-baseline.json");
25
+ const BENCH_COMMAND = "PI_TIMING=x bun src/cli.ts";
26
+ const cwd = path.join(import.meta.dir, "..");
27
+
28
+ function medianOf(hyperfineJson: string): number {
29
+ const parsed = JSON.parse(hyperfineJson) as { results: Array<{ mean: number; median?: number }> };
30
+ const result = parsed.results[0];
31
+ if (!result) throw new Error("hyperfine produced no result");
32
+ return result.median ?? result.mean;
33
+ }
34
+
35
+ async function measure(): Promise<{ seconds: number; raw: string }> {
36
+ const tmp = path.join(import.meta.dir, "..", "bench", `.boot-run-${Date.now()}.json`);
37
+ const proc = Bun.spawn(["hyperfine", "--warmup", "3", "--min-runs", "10", "--export-json", tmp, BENCH_COMMAND], {
38
+ cwd,
39
+ stdout: "inherit",
40
+ stderr: "inherit",
41
+ });
42
+ const code = await proc.exited;
43
+ if (code !== 0) throw new Error(`hyperfine exited ${code}`);
44
+ const raw = await Bun.file(tmp).text();
45
+ fs.rmSync(tmp, { force: true });
46
+ return { seconds: medianOf(raw), raw };
47
+ }
48
+
49
+ const update = process.argv.includes("--update");
50
+ const { seconds, raw } = await measure();
51
+
52
+ if (update) {
53
+ fs.mkdirSync(path.dirname(BASELINE_PATH), { recursive: true });
54
+ await Bun.write(BASELINE_PATH, raw);
55
+ console.log(`Baseline updated: ${(seconds * 1000).toFixed(0)}ms median -> ${BASELINE_PATH}`);
56
+ process.exit(0);
57
+ }
58
+
59
+ if (!fs.existsSync(BASELINE_PATH)) {
60
+ console.error("No baseline found. Run `bun scripts/bench-guard.ts --update` on this machine first.");
61
+ process.exit(2);
62
+ }
63
+
64
+ const baseline = medianOf(await Bun.file(BASELINE_PATH).text());
65
+ const ratio = seconds / baseline;
66
+ const verdict = ratio > THRESHOLD ? "REGRESSION" : "ok";
67
+ console.log(
68
+ `boot median: ${(seconds * 1000).toFixed(0)}ms vs baseline ${(baseline * 1000).toFixed(0)}ms ` +
69
+ `(${((ratio - 1) * 100).toFixed(1)}%, budget ${((THRESHOLD - 1) * 100).toFixed(0)}%) -> ${verdict}`,
70
+ );
71
+ process.exit(ratio > THRESHOLD ? 1 : 0);
package/src/cli/args.ts CHANGED
@@ -32,6 +32,8 @@ export interface Args {
32
32
  sessionDir?: string;
33
33
  providerSessionId?: string;
34
34
  fork?: string;
35
+ /** Collab link to join at startup (set by the `join` subcommand; no CLI flag). */
36
+ join?: string;
35
37
  models?: string[];
36
38
  tools?: string[];
37
39
  noTools?: boolean;
@@ -26,6 +26,7 @@ export const commands: CommandEntry[] = [
26
26
  { name: "gallery", load: () => import("./commands/gallery").then(m => m.default) },
27
27
  { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
28
28
  { name: "install", load: () => import("./commands/install").then(m => m.default) },
29
+ { name: "join", load: () => import("./commands/join").then(m => m.default) },
29
30
  { name: "plugin", load: () => import("./commands/plugin").then(m => m.default) },
30
31
  { name: "setup", load: () => import("./commands/setup").then(m => m.default) },
31
32
  { name: "shell", load: () => import("./commands/shell").then(m => m.default) },
@@ -0,0 +1,57 @@
1
+ /**
2
+ * AES-256-GCM sealing for collab frames.
3
+ *
4
+ * The room key lives only in the link fragment; the relay sees opaque bytes.
5
+ * Sealed layout: `[12B IV][ciphertext+tag]`.
6
+ */
7
+ import type { CollabFrame } from "./protocol";
8
+
9
+ const AES_ALGORITHM = "AES-GCM";
10
+ const IV_LENGTH = 12;
11
+ const KEY_LENGTH = 32;
12
+ const TEXT_ENCODER = new TextEncoder();
13
+ const TEXT_DECODER = new TextDecoder();
14
+
15
+ export function generateRoomKey(): Uint8Array {
16
+ const key = new Uint8Array(KEY_LENGTH);
17
+ crypto.getRandomValues(key);
18
+ return key;
19
+ }
20
+
21
+ export function importRoomKey(raw: Uint8Array): Promise<CryptoKey> {
22
+ if (raw.byteLength !== KEY_LENGTH) {
23
+ throw new Error(`Room key must be ${KEY_LENGTH} bytes, got ${raw.byteLength}`);
24
+ }
25
+ return crypto.subtle.importKey("raw", asStrict(raw), AES_ALGORITHM, false, ["encrypt", "decrypt"]);
26
+ }
27
+
28
+ export async function seal(key: CryptoKey, frame: CollabFrame): Promise<Uint8Array> {
29
+ const iv = new Uint8Array(IV_LENGTH);
30
+ crypto.getRandomValues(iv);
31
+ const plaintext = TEXT_ENCODER.encode(JSON.stringify(frame));
32
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: AES_ALGORITHM, iv }, key, plaintext));
33
+ const out = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
34
+ out.set(iv, 0);
35
+ out.set(ciphertext, IV_LENGTH);
36
+ return out;
37
+ }
38
+
39
+ /** Inverse of {@link seal}. Throws on auth failure or malformed input. */
40
+ export async function open(key: CryptoKey, data: Uint8Array): Promise<CollabFrame> {
41
+ if (data.byteLength <= IV_LENGTH) {
42
+ throw new Error("Sealed frame too short");
43
+ }
44
+ const iv = asStrict(data.subarray(0, IV_LENGTH));
45
+ const ciphertext = asStrict(data.subarray(IV_LENGTH));
46
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: AES_ALGORITHM, iv }, key, ciphertext));
47
+ return JSON.parse(TEXT_DECODER.decode(plaintext)) as CollabFrame;
48
+ }
49
+
50
+ function asStrict(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
51
+ if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
52
+ return bytes as Uint8Array<ArrayBuffer>;
53
+ }
54
+ const copy = new Uint8Array(bytes.byteLength);
55
+ copy.set(bytes);
56
+ return copy;
57
+ }
@@ -0,0 +1,421 @@
1
+ /**
2
+ * Guest side of a collab live session.
3
+ *
4
+ * `/join <link>` writes the host's snapshot to a replica session file and
5
+ * drives it through the normal `/resume` machinery, then applies live frames:
6
+ * entries → SessionManager + agent.replaceMessages, events →
7
+ * EventController.handleEvent, state → status-line overrides plus real
8
+ * model/thinking state applied to the replica agent. The host's subagent
9
+ * ecosystem is mirrored too: agent snapshots populate a local AgentRegistry
10
+ * (Agent Hub), EventBus traffic (observer HUD) is republished, and hub
11
+ * actions (chat/kill/revive/transcript reads) round-trip over the wire.
12
+ * Everything renders through the same components, so ctrl+o, theming, and
13
+ * transcript behavior are native by construction.
14
+ */
15
+ import * as path from "node:path";
16
+ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
17
+ import type { ImageContent } from "@oh-my-pi/pi-ai";
18
+ import { getConfigRootDir, logger } from "@oh-my-pi/pi-utils";
19
+ import type { AgentHubRemote } from "../modes/components/agent-hub";
20
+ import type { InteractiveModeContext } from "../modes/types";
21
+ import { AgentRegistry } from "../registry/agent-registry";
22
+ import type { AgentSessionEvent } from "../session/agent-session";
23
+ import { shouldDisableReasoning, toReasoningEffort } from "../thinking";
24
+ import { setSessionTerminalTitle } from "../utils/title-generator";
25
+ import { importRoomKey } from "./crypto";
26
+ import { collabDisplayName } from "./host";
27
+ import {
28
+ type AgentSnapshot,
29
+ COLLAB_PROTO,
30
+ type CollabFrame,
31
+ type CollabSessionState,
32
+ parseCollabLink,
33
+ } from "./protocol";
34
+ import { CollabSocket } from "./relay-client";
35
+
36
+ /** Commands a guest may run locally; everything else is host-only. */
37
+ export const COLLAB_GUEST_ALLOWED_COMMANDS: Record<string, true> = {
38
+ dump: true,
39
+ export: true,
40
+ copy: true,
41
+ help: true,
42
+ hotkeys: true,
43
+ theme: true,
44
+ settings: true,
45
+ leave: true,
46
+ collab: true,
47
+ exit: true,
48
+ quit: true,
49
+ };
50
+ const WELCOME_TIMEOUT_MS = 30_000;
51
+ const TRANSCRIPT_TIMEOUT_MS = 20_000;
52
+
53
+ type WelcomeFrame = Extract<CollabFrame, { t: "welcome" }>;
54
+
55
+ export class CollabGuestLink {
56
+ #ctx: InteractiveModeContext;
57
+ #socket: CollabSocket | null = null;
58
+ #roomId = "";
59
+ /** Previous session file to restore on leave; null = previous session was unsaved. */
60
+ #returnSessionFile: string | null = null;
61
+ /** Frames apply strictly in arrival order through this chain. */
62
+ #applyChain: Promise<void> = Promise.resolve();
63
+ #welcomed = false;
64
+ #left = false;
65
+ /** False until the first assistant message_start (real or synthesized) since (re)sync. */
66
+ #assistantStreamSynced = false;
67
+ state: CollabSessionState | null = null;
68
+ /** Local mirror of the host's agent ecosystem (refs carry `session: null`). */
69
+ readonly agentRegistry = new AgentRegistry();
70
+ /** Per-agent `hasSessionFile` from the last snapshot; gates remote transcript fetches. */
71
+ #agentHasTranscript = new Map<string, boolean>();
72
+ #pendingTranscripts = new Map<number, (r: { text: string; newSize: number } | null) => void>();
73
+ #nextReqId = 1;
74
+ readonly #hubRemote: AgentHubRemote = {
75
+ chat: (id, text) => {
76
+ this.#socket?.send({ t: "agent-cmd", cmd: "chat", agentId: id, text });
77
+ },
78
+ kill: id => {
79
+ this.#socket?.send({ t: "agent-cmd", cmd: "kill", agentId: id });
80
+ },
81
+ revive: id => {
82
+ this.#socket?.send({ t: "agent-cmd", cmd: "revive", agentId: id });
83
+ },
84
+ readTranscript: (id, fromByte) => {
85
+ const socket = this.#socket;
86
+ if (!socket || this.#agentHasTranscript.get(id) === false) {
87
+ return Promise.resolve(null);
88
+ }
89
+ const reqId = this.#nextReqId++;
90
+ const { promise, resolve } = Promise.withResolvers<{ text: string; newSize: number } | null>();
91
+ const timer = setTimeout(() => {
92
+ this.#pendingTranscripts.delete(reqId);
93
+ resolve(null);
94
+ }, TRANSCRIPT_TIMEOUT_MS);
95
+ this.#pendingTranscripts.set(reqId, result => {
96
+ clearTimeout(timer);
97
+ resolve(result);
98
+ });
99
+ socket.send({ t: "fetch-transcript", reqId, agentId: id, fromByte });
100
+ return promise;
101
+ },
102
+ };
103
+
104
+ /** Agent Hub actions routed to the host over the wire. */
105
+ get hubRemote(): AgentHubRemote {
106
+ return this.#hubRemote;
107
+ }
108
+
109
+ constructor(ctx: InteractiveModeContext) {
110
+ this.#ctx = ctx;
111
+ }
112
+
113
+ async join(link: string): Promise<void> {
114
+ const parsed = parseCollabLink(link);
115
+ if ("error" in parsed) throw new Error(parsed.error);
116
+ this.#roomId = parsed.roomId;
117
+ const key = await importRoomKey(parsed.key);
118
+
119
+ this.#returnSessionFile = this.#ctx.sessionManager.getSessionFile() ?? null;
120
+
121
+ const socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "guest", key });
122
+ this.#socket = socket;
123
+
124
+ const firstWelcome = Promise.withResolvers<void>();
125
+ let joined = false;
126
+
127
+ socket.onOpen = () => {
128
+ // (Re)connect: re-introduce ourselves; the host answers with a fresh
129
+ // welcome which (re)syncs the replica.
130
+ this.#welcomed = false;
131
+ socket.send({ t: "hello", proto: COLLAB_PROTO, name: collabDisplayName(this.#ctx) });
132
+ };
133
+ socket.onFrame = frame => {
134
+ this.#applyChain = this.#applyChain
135
+ .then(async () => {
136
+ if (frame.t === "welcome") {
137
+ await this.#applyWelcome(frame, joined);
138
+ if (!joined) {
139
+ joined = true;
140
+ firstWelcome.resolve();
141
+ }
142
+ return;
143
+ }
144
+ if (!this.#welcomed || this.#left) return;
145
+ this.#applyFrame(frame);
146
+ })
147
+ .catch(err => logger.warn("collab guest frame apply failed", { type: frame.t, error: String(err) }));
148
+ };
149
+ socket.onClose = (reason, willReconnect) => {
150
+ this.#flushPendingTranscripts();
151
+ if (this.#left) return;
152
+ if (!joined) {
153
+ firstWelcome.reject(new Error(reason));
154
+ return;
155
+ }
156
+ if (willReconnect) {
157
+ this.#ctx.showStatus(`Collab connection lost (${reason}), reconnecting…`, { dim: true });
158
+ return;
159
+ }
160
+ this.#ctx.showStatus(`Collab session ended (${reason})`);
161
+ void this.#restoreLocalSession();
162
+ };
163
+ socket.connect();
164
+
165
+ const timeout = setTimeout(
166
+ () => firstWelcome.reject(new Error("timed out waiting for the host's welcome")),
167
+ WELCOME_TIMEOUT_MS,
168
+ );
169
+ try {
170
+ await firstWelcome.promise;
171
+ } catch (err) {
172
+ this.#left = true;
173
+ socket.close();
174
+ this.#socket = null;
175
+ throw err;
176
+ } finally {
177
+ clearTimeout(timeout);
178
+ }
179
+
180
+ this.#ctx.collabGuest = this;
181
+ }
182
+
183
+ /** User-initiated leave (or post-disconnect cleanup): restore the previous session. */
184
+ async leave(_reason: string): Promise<void> {
185
+ if (this.#left) return;
186
+ this.#socket?.close();
187
+ await this.#restoreLocalSession();
188
+ }
189
+
190
+ sendPrompt(text: string, images?: ImageContent[]): void {
191
+ this.#socket?.send({ t: "prompt", text, images: images && images.length > 0 ? images : undefined });
192
+ }
193
+
194
+ sendAbort(): void {
195
+ this.#socket?.send({ t: "abort" });
196
+ }
197
+
198
+ /** Write the welcome snapshot to the replica file and (re)load it through the resume machinery. */
199
+ async #applyWelcome(frame: WelcomeFrame, isResync: boolean): Promise<void> {
200
+ if (this.#left) return;
201
+ const replicaPath = path.join(getConfigRootDir(), "collab", `${this.#roomId}.jsonl`);
202
+ const lines = [frame.header, ...frame.entries].map(entry => JSON.stringify(entry)).join("\n");
203
+ await Bun.write(replicaPath, `${lines}\n`);
204
+
205
+ // Resume sequence (selector-controller.handleResumeSession) minus
206
+ // applyCwdChange: the guest process never chdirs to a host path. The
207
+ // SessionManager still adopts the header cwd for display/relativization.
208
+ this.#clearTransientUi();
209
+ this.#clearAgentMirror();
210
+ await this.#ctx.session.switchSession(replicaPath);
211
+ this.state = frame.state;
212
+ this.#applyHostState(frame.state);
213
+ this.#ctx.resetObserverRegistry();
214
+ this.#applyAgentSnapshots(frame.agents);
215
+ this.#assistantStreamSynced = false;
216
+ setSessionTerminalTitle(frame.state.sessionName ?? frame.header.title, frame.state.cwd);
217
+ this.#ctx.chatContainer.clear();
218
+ this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
219
+ await this.#ctx.reloadTodos();
220
+ this.#updateStatusSegment();
221
+ this.#welcomed = true;
222
+ this.#ctx.showStatus(isResync ? "Reconnected to collab session" : "Joined collab session");
223
+ }
224
+
225
+ #applyFrame(frame: CollabFrame): void {
226
+ switch (frame.t) {
227
+ case "entry": {
228
+ // Entries are never rendered directly — rendering is events-only
229
+ // (prevents double-render). They keep the replica file, the agent's
230
+ // message array (/dump, context estimates), and todos current.
231
+ this.#ctx.sessionManager.ingestReplicatedEntry(frame.entry);
232
+ if (frame.entry.type === "message") {
233
+ this.#ctx.session.agent.replaceMessages([...this.#ctx.session.messages, frame.entry.message]);
234
+ }
235
+ break;
236
+ }
237
+ case "event":
238
+ this.#applyEvent(frame.event);
239
+ break;
240
+ case "state": {
241
+ this.state = frame.state;
242
+ this.#applyHostState(frame.state);
243
+ setSessionTerminalTitle(frame.state.sessionName, frame.state.cwd);
244
+ this.#updateStatusSegment();
245
+ // Reconciler: events normally drive the loader; clear a stale one if
246
+ // the host reports idle (e.g. events lost across a reconnect).
247
+ if (!frame.state.isStreaming && this.#ctx.loadingAnimation) {
248
+ this.#ctx.loadingAnimation.stop();
249
+ this.#ctx.loadingAnimation = undefined;
250
+ }
251
+ this.#ctx.statusLine.invalidate();
252
+ this.#ctx.ui.requestRender();
253
+ break;
254
+ }
255
+ case "bus":
256
+ // Mirrored host EventBus traffic (task subagent lifecycle/progress)
257
+ // feeding the observer HUD and Agent Hub progress columns.
258
+ this.#ctx.eventBus?.emit(frame.channel, frame.data);
259
+ break;
260
+ case "agents":
261
+ this.#applyAgentSnapshots(frame.agents);
262
+ break;
263
+ case "transcript": {
264
+ const resolve = this.#pendingTranscripts.get(frame.reqId);
265
+ if (resolve) {
266
+ this.#pendingTranscripts.delete(frame.reqId);
267
+ resolve(frame.error ? null : { text: frame.text, newSize: frame.newSize });
268
+ }
269
+ break;
270
+ }
271
+ case "bye": {
272
+ this.#ctx.showStatus(`Collab session ended (${frame.reason})`);
273
+ this.#socket?.close();
274
+ void this.#restoreLocalSession();
275
+ break;
276
+ }
277
+ case "error":
278
+ this.#ctx.showError(`Collab host: ${frame.message}`);
279
+ break;
280
+ default:
281
+ logger.debug("collab guest ignoring unexpected frame", { type: frame.t });
282
+ }
283
+ }
284
+
285
+ #applyEvent(event: AgentSessionEvent): void {
286
+ // Orphan-delta guard: when joining mid-turn the message_start for the
287
+ // in-flight assistant message predates the snapshot. message_update
288
+ // carries the full accumulating message, so synthesize the missing start
289
+ // before the first orphaned update; every other handler is tolerant of
290
+ // unknown anchors (guarded by streamingComponent/pendingTools lookups).
291
+ if (event.type === "message_start" && event.message.role === "assistant") {
292
+ this.#assistantStreamSynced = true;
293
+ } else if (
294
+ event.type === "message_update" &&
295
+ event.message.role === "assistant" &&
296
+ !this.#assistantStreamSynced
297
+ ) {
298
+ this.#assistantStreamSynced = true;
299
+ void this.#ctx.eventController.handleEvent({ type: "message_start", message: event.message });
300
+ }
301
+ void this.#ctx.eventController.handleEvent(event);
302
+ }
303
+
304
+ /**
305
+ * Apply the host's real model/thinking state to the replica agent so model
306
+ * display and context-window math are native (no display-string overrides).
307
+ * Pure agent-state mutation: session.setModel/setThinkingLevel would
308
+ * persist entries and clamp to local credentials.
309
+ */
310
+ #applyHostState(state: CollabSessionState): void {
311
+ const session = this.#ctx.session;
312
+ if (
313
+ state.model &&
314
+ (session.agent.state.model?.id !== state.model.id ||
315
+ session.agent.state.model?.provider !== state.model.provider)
316
+ ) {
317
+ session.agent.setModel(state.model);
318
+ }
319
+ const level = state.thinkingLevel as ThinkingLevel | undefined;
320
+ session.agent.setThinkingLevel(toReasoningEffort(level));
321
+ session.agent.setDisableReasoning(shouldDisableReasoning(level));
322
+ }
323
+
324
+ /** Diff a host agent snapshot into the local registry (refs keep `session: null`). */
325
+ #applyAgentSnapshots(agents: AgentSnapshot[]): void {
326
+ const seen = new Set<string>();
327
+ for (const snap of agents) seen.add(snap.id);
328
+ for (const ref of this.agentRegistry.list()) {
329
+ if (!seen.has(ref.id)) {
330
+ this.agentRegistry.unregister(ref.id);
331
+ this.#agentHasTranscript.delete(ref.id);
332
+ }
333
+ }
334
+ for (const snap of agents) {
335
+ if (this.agentRegistry.get(snap.id)) {
336
+ this.agentRegistry.setStatus(snap.id, snap.status);
337
+ } else {
338
+ this.agentRegistry.register({
339
+ id: snap.id,
340
+ displayName: snap.displayName,
341
+ kind: snap.kind,
342
+ parentId: snap.parentId,
343
+ session: null,
344
+ status: snap.status,
345
+ });
346
+ }
347
+ // Refs are returned by reference: patch host timestamps directly so
348
+ // hub age/activity columns reflect the host, not local registration.
349
+ const ref = this.agentRegistry.get(snap.id);
350
+ if (ref) {
351
+ ref.createdAt = snap.createdAt;
352
+ ref.lastActivity = snap.lastActivity;
353
+ ref.displayName = snap.displayName;
354
+ }
355
+ this.#agentHasTranscript.set(snap.id, snap.hasSessionFile);
356
+ }
357
+ }
358
+
359
+ #clearAgentMirror(): void {
360
+ for (const ref of this.agentRegistry.list()) {
361
+ this.agentRegistry.unregister(ref.id);
362
+ }
363
+ this.#agentHasTranscript.clear();
364
+ }
365
+
366
+ /** Resolve every in-flight transcript request with null (resolvers clear their own timers). */
367
+ #flushPendingTranscripts(): void {
368
+ for (const resolve of this.#pendingTranscripts.values()) {
369
+ resolve(null);
370
+ }
371
+ this.#pendingTranscripts.clear();
372
+ }
373
+
374
+ #clearTransientUi(): void {
375
+ this.#ctx.statusContainer.clear();
376
+ this.#ctx.pendingMessagesContainer.clear();
377
+ this.#ctx.compactionQueuedMessages = [];
378
+ this.#ctx.streamingComponent = undefined;
379
+ this.#ctx.streamingMessage = undefined;
380
+ this.#ctx.pendingTools.clear();
381
+ if (this.#ctx.loadingAnimation) {
382
+ this.#ctx.loadingAnimation.stop();
383
+ this.#ctx.loadingAnimation = undefined;
384
+ }
385
+ }
386
+
387
+ async #restoreLocalSession(): Promise<void> {
388
+ if (this.#left) return;
389
+ this.#left = true;
390
+ this.#socket = null;
391
+ this.#ctx.collabGuest = undefined;
392
+ this.#ctx.statusLine.setCollabStatus(null);
393
+ this.#flushPendingTranscripts();
394
+ this.#clearAgentMirror();
395
+ this.#ctx.resetObserverRegistry();
396
+ this.#clearTransientUi();
397
+ // Replica file stays on disk: it is a valid session file outside the
398
+ // sessions dir, so it never shows up in /resume but remains readable.
399
+ if (this.#returnSessionFile) {
400
+ await this.#ctx.handleResumeSession(this.#returnSessionFile);
401
+ return;
402
+ }
403
+ await this.#ctx.session.newSession();
404
+ setSessionTerminalTitle(this.#ctx.sessionManager.getSessionName(), this.#ctx.sessionManager.getCwd());
405
+ this.#ctx.statusLine.invalidate();
406
+ this.#ctx.statusLine.setSessionStartTime(Date.now());
407
+ this.#ctx.updateEditorTopBorder();
408
+ this.#ctx.updateEditorBorderColor();
409
+ this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
410
+ await this.#ctx.reloadTodos();
411
+ this.#ctx.ui.requestRender(true, { clearScrollback: true });
412
+ }
413
+
414
+ #updateStatusSegment(): void {
415
+ this.#ctx.statusLine.setCollabStatus({
416
+ role: "guest",
417
+ participantCount: this.state?.participants.length ?? 1,
418
+ stateOverride: this.state,
419
+ });
420
+ }
421
+ }