@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.
Files changed (107) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/cli.js +8106 -7708
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +7 -0
  5. package/dist/types/collab/guest.d.ts +23 -0
  6. package/dist/types/collab/host.d.ts +29 -0
  7. package/dist/types/collab/protocol.d.ts +113 -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 +60 -5
  11. package/dist/types/export/custom-share.d.ts +1 -2
  12. package/dist/types/export/html/index.d.ts +39 -1
  13. package/dist/types/export/share.d.ts +43 -0
  14. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  15. package/dist/types/main.d.ts +2 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +32 -1
  17. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  18. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  19. package/dist/types/modes/components/segment-track.d.ts +11 -6
  20. package/dist/types/modes/components/status-line/component.d.ts +10 -2
  21. package/dist/types/modes/components/status-line/types.d.ts +11 -0
  22. package/dist/types/modes/controllers/event-controller.d.ts +7 -0
  23. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  24. package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
  25. package/dist/types/modes/interactive-mode.d.ts +16 -0
  26. package/dist/types/modes/session-observer-registry.d.ts +7 -0
  27. package/dist/types/modes/theme/theme.d.ts +2 -1
  28. package/dist/types/modes/types.d.ts +20 -0
  29. package/dist/types/session/agent-session.d.ts +13 -0
  30. package/dist/types/session/codex-auto-reset.d.ts +8 -4
  31. package/dist/types/session/session-manager.d.ts +21 -0
  32. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  33. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  34. package/dist/types/task/executor.d.ts +7 -0
  35. package/dist/types/task/types.d.ts +9 -0
  36. package/package.json +14 -13
  37. package/scripts/bench-guard.ts +71 -0
  38. package/scripts/build-binary.ts +4 -0
  39. package/scripts/bundle-dist.ts +4 -0
  40. package/scripts/generate-share-viewer.ts +34 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli-commands.ts +1 -0
  43. package/src/collab/crypto.ts +63 -0
  44. package/src/collab/guest.ts +450 -0
  45. package/src/collab/host.ts +556 -0
  46. package/src/collab/protocol.ts +232 -0
  47. package/src/collab/relay-client.ts +216 -0
  48. package/src/commands/join.ts +39 -0
  49. package/src/config/model-registry.ts +22 -14
  50. package/src/config/settings-schema.ts +67 -5
  51. package/src/config/settings.ts +12 -0
  52. package/src/export/custom-share.ts +1 -1
  53. package/src/export/html/index.ts +122 -17
  54. package/src/export/html/share-loader.js +102 -0
  55. package/src/export/html/template.css +745 -459
  56. package/src/export/html/template.html +6 -3
  57. package/src/export/html/template.js +240 -915
  58. package/src/export/html/tool-views.generated.js +38 -0
  59. package/src/export/share.ts +268 -0
  60. package/src/extensibility/slash-commands.ts +1 -97
  61. package/src/internal-urls/docs-index.generated.ts +74 -73
  62. package/src/main.ts +33 -11
  63. package/src/modes/components/agent-hub.ts +659 -431
  64. package/src/modes/components/assistant-message.ts +126 -6
  65. package/src/modes/components/collab-prompt-message.ts +30 -0
  66. package/src/modes/components/hook-selector.ts +4 -5
  67. package/src/modes/components/segment-track.ts +44 -7
  68. package/src/modes/components/status-line/component.ts +59 -6
  69. package/src/modes/components/status-line/presets.ts +1 -1
  70. package/src/modes/components/status-line/segments.ts +18 -1
  71. package/src/modes/components/status-line/types.ts +12 -0
  72. package/src/modes/components/tips.txt +4 -1
  73. package/src/modes/controllers/command-controller.ts +55 -96
  74. package/src/modes/controllers/event-controller.ts +45 -16
  75. package/src/modes/controllers/input-controller.ts +175 -9
  76. package/src/modes/controllers/selector-controller.ts +13 -15
  77. package/src/modes/controllers/session-focus-controller.ts +112 -0
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +56 -6
  80. package/src/modes/session-observer-registry.ts +11 -0
  81. package/src/modes/theme/theme.ts +6 -0
  82. package/src/modes/types.ts +20 -0
  83. package/src/modes/utils/ui-helpers.ts +23 -13
  84. package/src/prompts/tools/job.md +1 -1
  85. package/src/sdk.ts +239 -36
  86. package/src/session/agent-session.ts +82 -7
  87. package/src/session/codex-auto-reset.ts +23 -11
  88. package/src/session/session-manager.ts +44 -0
  89. package/src/session/snapcompact-inline.ts +9 -3
  90. package/src/slash-commands/builtin-registry.ts +261 -24
  91. package/src/task/executor.ts +14 -0
  92. package/src/task/index.ts +5 -1
  93. package/src/task/render.ts +76 -5
  94. package/src/task/types.ts +9 -0
  95. package/src/tiny/worker.ts +17 -95
  96. package/src/tools/job.ts +6 -9
  97. package/src/tools/read.ts +38 -5
  98. package/src/tools/write.ts +13 -42
  99. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  100. package/dist/types/export/html/template.generated.d.ts +0 -1
  101. package/dist/types/export/html/template.macro.d.ts +0 -5
  102. package/dist/types/tiny/compiled-runtime.d.ts +0 -35
  103. package/scripts/generate-template.ts +0 -33
  104. package/src/bun-imports.d.ts +0 -28
  105. package/src/export/html/template.generated.ts +0 -2
  106. package/src/export/html/template.macro.ts +0 -25
  107. package/src/tiny/compiled-runtime.ts +0 -179
@@ -0,0 +1,63 @@
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 { ROOM_KEY_BYTES, WRITE_TOKEN_BYTES } from "@oh-my-pi/pi-wire";
8
+ import type { CollabFrame } from "./protocol";
9
+
10
+ const AES_ALGORITHM = "AES-GCM";
11
+ const IV_LENGTH = 12;
12
+ const TEXT_ENCODER = new TextEncoder();
13
+ const TEXT_DECODER = new TextDecoder();
14
+
15
+ export function generateRoomKey(): Uint8Array {
16
+ const key = new Uint8Array(ROOM_KEY_BYTES);
17
+ crypto.getRandomValues(key);
18
+ return key;
19
+ }
20
+
21
+ export function generateWriteToken(): Uint8Array {
22
+ const token = new Uint8Array(WRITE_TOKEN_BYTES);
23
+ crypto.getRandomValues(token);
24
+ return token;
25
+ }
26
+
27
+ export function importRoomKey(raw: Uint8Array): Promise<CryptoKey> {
28
+ if (raw.byteLength !== ROOM_KEY_BYTES) {
29
+ throw new Error(`Room key must be ${ROOM_KEY_BYTES} bytes, got ${raw.byteLength}`);
30
+ }
31
+ return crypto.subtle.importKey("raw", asStrict(raw), AES_ALGORITHM, false, ["encrypt", "decrypt"]);
32
+ }
33
+
34
+ export async function seal(key: CryptoKey, frame: CollabFrame): Promise<Uint8Array> {
35
+ const iv = new Uint8Array(IV_LENGTH);
36
+ crypto.getRandomValues(iv);
37
+ const plaintext = TEXT_ENCODER.encode(JSON.stringify(frame));
38
+ const ciphertext = new Uint8Array(await crypto.subtle.encrypt({ name: AES_ALGORITHM, iv }, key, plaintext));
39
+ const out = new Uint8Array(IV_LENGTH + ciphertext.byteLength);
40
+ out.set(iv, 0);
41
+ out.set(ciphertext, IV_LENGTH);
42
+ return out;
43
+ }
44
+
45
+ /** Inverse of {@link seal}. Throws on auth failure or malformed input. */
46
+ export async function open(key: CryptoKey, data: Uint8Array): Promise<CollabFrame> {
47
+ if (data.byteLength <= IV_LENGTH) {
48
+ throw new Error("Sealed frame too short");
49
+ }
50
+ const iv = asStrict(data.subarray(0, IV_LENGTH));
51
+ const ciphertext = asStrict(data.subarray(IV_LENGTH));
52
+ const plaintext = new Uint8Array(await crypto.subtle.decrypt({ name: AES_ALGORITHM, iv }, key, ciphertext));
53
+ return JSON.parse(TEXT_DECODER.decode(plaintext)) as CollabFrame;
54
+ }
55
+
56
+ function asStrict(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
57
+ if (bytes.buffer instanceof ArrayBuffer && bytes.byteOffset === 0 && bytes.byteLength === bytes.buffer.byteLength) {
58
+ return bytes as Uint8Array<ArrayBuffer>;
59
+ }
60
+ const copy = new Uint8Array(bytes.byteLength);
61
+ copy.set(bytes);
62
+ return copy;
63
+ }
@@ -0,0 +1,450 @@
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
+ /** base64url write token from a full link; absent when joined via a view link. */
66
+ #writeToken: string | undefined;
67
+ /** True when the host marked this peer read-only (view link). */
68
+ #readOnly = false;
69
+ /** False until the first assistant message_start (real or synthesized) since (re)sync. */
70
+ #assistantStreamSynced = false;
71
+ state: CollabSessionState | null = null;
72
+ /** Local mirror of the host's agent ecosystem (refs carry `session: null`). */
73
+ readonly agentRegistry = new AgentRegistry();
74
+ /** Per-agent `hasSessionFile` from the last snapshot; gates remote transcript fetches. */
75
+ #agentHasTranscript = new Map<string, boolean>();
76
+ #pendingTranscripts = new Map<number, (r: { text: string; newSize: number } | null) => void>();
77
+ #nextReqId = 1;
78
+ readonly #hubRemote: AgentHubRemote = {
79
+ chat: (id, text) => {
80
+ if (this.#rejectReadOnly()) return;
81
+ this.#socket?.send({ t: "agent-cmd", cmd: "chat", agentId: id, text });
82
+ },
83
+ kill: id => {
84
+ if (this.#rejectReadOnly()) return;
85
+ this.#socket?.send({ t: "agent-cmd", cmd: "kill", agentId: id });
86
+ },
87
+ revive: id => {
88
+ if (this.#rejectReadOnly()) return;
89
+ this.#socket?.send({ t: "agent-cmd", cmd: "revive", agentId: id });
90
+ },
91
+ readTranscript: (id, fromByte) => {
92
+ const socket = this.#socket;
93
+ if (!socket || this.#agentHasTranscript.get(id) === false) {
94
+ return Promise.resolve(null);
95
+ }
96
+ const reqId = this.#nextReqId++;
97
+ const { promise, resolve } = Promise.withResolvers<{ text: string; newSize: number } | null>();
98
+ const timer = setTimeout(() => {
99
+ this.#pendingTranscripts.delete(reqId);
100
+ resolve(null);
101
+ }, TRANSCRIPT_TIMEOUT_MS);
102
+ this.#pendingTranscripts.set(reqId, result => {
103
+ clearTimeout(timer);
104
+ resolve(result);
105
+ });
106
+ socket.send({ t: "fetch-transcript", reqId, agentId: id, fromByte });
107
+ return promise;
108
+ },
109
+ };
110
+
111
+ /** Agent Hub actions routed to the host over the wire. */
112
+ get hubRemote(): AgentHubRemote {
113
+ return this.#hubRemote;
114
+ }
115
+
116
+ /** True when this guest joined through a read-only (view) link. */
117
+ get readOnly(): boolean {
118
+ return this.#readOnly;
119
+ }
120
+
121
+ /** Shows the read-only status hint when applicable; true when the action must be dropped. */
122
+ #rejectReadOnly(): boolean {
123
+ if (!this.#readOnly) return false;
124
+ this.#ctx.showStatus("This collab link is read-only");
125
+ return true;
126
+ }
127
+
128
+ constructor(ctx: InteractiveModeContext) {
129
+ this.#ctx = ctx;
130
+ }
131
+
132
+ async join(link: string): Promise<void> {
133
+ const parsed = parseCollabLink(link);
134
+ if ("error" in parsed) throw new Error(parsed.error);
135
+ this.#roomId = parsed.roomId;
136
+ this.#writeToken = parsed.writeToken ? Buffer.from(parsed.writeToken).toString("base64url") : undefined;
137
+ const key = await importRoomKey(parsed.key);
138
+
139
+ this.#returnSessionFile = this.#ctx.sessionManager.getSessionFile() ?? null;
140
+
141
+ const socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "guest", key });
142
+ this.#socket = socket;
143
+
144
+ const firstWelcome = Promise.withResolvers<void>();
145
+ let joined = false;
146
+
147
+ socket.onOpen = () => {
148
+ // (Re)connect: re-introduce ourselves; the host answers with a fresh
149
+ // welcome which (re)syncs the replica.
150
+ this.#welcomed = false;
151
+ socket.send({
152
+ t: "hello",
153
+ proto: COLLAB_PROTO,
154
+ name: collabDisplayName(this.#ctx),
155
+ writeToken: this.#writeToken,
156
+ });
157
+ };
158
+ socket.onFrame = frame => {
159
+ this.#applyChain = this.#applyChain
160
+ .then(async () => {
161
+ if (frame.t === "welcome") {
162
+ await this.#applyWelcome(frame, joined);
163
+ if (!joined) {
164
+ joined = true;
165
+ firstWelcome.resolve();
166
+ }
167
+ return;
168
+ }
169
+ if (!this.#welcomed || this.#left) return;
170
+ this.#applyFrame(frame);
171
+ })
172
+ .catch(err => logger.warn("collab guest frame apply failed", { type: frame.t, error: String(err) }));
173
+ };
174
+ socket.onClose = (reason, willReconnect) => {
175
+ this.#flushPendingTranscripts();
176
+ if (this.#left) return;
177
+ if (!joined) {
178
+ firstWelcome.reject(new Error(reason));
179
+ return;
180
+ }
181
+ if (willReconnect) {
182
+ this.#ctx.showStatus(`Collab connection lost (${reason}), reconnecting…`, { dim: true });
183
+ return;
184
+ }
185
+ this.#ctx.showStatus(`Collab session ended (${reason})`);
186
+ void this.#restoreLocalSession();
187
+ };
188
+ socket.connect();
189
+
190
+ const timeout = setTimeout(
191
+ () => firstWelcome.reject(new Error("timed out waiting for the host's welcome")),
192
+ WELCOME_TIMEOUT_MS,
193
+ );
194
+ try {
195
+ await firstWelcome.promise;
196
+ } catch (err) {
197
+ this.#left = true;
198
+ socket.close();
199
+ this.#socket = null;
200
+ throw err;
201
+ } finally {
202
+ clearTimeout(timeout);
203
+ }
204
+
205
+ this.#ctx.collabGuest = this;
206
+ }
207
+
208
+ /** User-initiated leave (or post-disconnect cleanup): restore the previous session. */
209
+ async leave(_reason: string): Promise<void> {
210
+ if (this.#left) return;
211
+ this.#socket?.close();
212
+ await this.#restoreLocalSession();
213
+ }
214
+
215
+ sendPrompt(text: string, images?: ImageContent[]): void {
216
+ if (this.#rejectReadOnly()) return;
217
+ this.#socket?.send({ t: "prompt", text, images: images && images.length > 0 ? images : undefined });
218
+ }
219
+
220
+ sendAbort(): void {
221
+ if (this.#rejectReadOnly()) return;
222
+ this.#socket?.send({ t: "abort" });
223
+ }
224
+
225
+ /** Write the welcome snapshot to the replica file and (re)load it through the resume machinery. */
226
+ async #applyWelcome(frame: WelcomeFrame, isResync: boolean): Promise<void> {
227
+ if (this.#left) return;
228
+ const replicaPath = path.join(getConfigRootDir(), "collab", `${this.#roomId}.jsonl`);
229
+ const lines = [frame.header, ...frame.entries].map(entry => JSON.stringify(entry)).join("\n");
230
+ await Bun.write(replicaPath, `${lines}\n`);
231
+
232
+ // Resume sequence (selector-controller.handleResumeSession) minus
233
+ // applyCwdChange: the guest process never chdirs to a host path. The
234
+ // SessionManager still adopts the header cwd for display/relativization.
235
+ this.#clearTransientUi();
236
+ this.#clearAgentMirror();
237
+ await this.#ctx.session.switchSession(replicaPath);
238
+ this.state = frame.state;
239
+ this.#applyHostState(frame.state);
240
+ this.#ctx.resetObserverRegistry();
241
+ this.#applyAgentSnapshots(frame.agents);
242
+ this.#assistantStreamSynced = false;
243
+ setSessionTerminalTitle(frame.state.sessionName ?? frame.header.title, frame.state.cwd);
244
+ this.#ctx.chatContainer.clear();
245
+ this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
246
+ await this.#ctx.reloadTodos();
247
+ this.#updateStatusSegment();
248
+ this.#readOnly = frame.readOnly === true;
249
+ this.#welcomed = true;
250
+ const suffix = this.#readOnly ? " (read-only)" : "";
251
+ this.#ctx.showStatus(isResync ? `Reconnected to collab session${suffix}` : `Joined collab session${suffix}`);
252
+ }
253
+
254
+ #applyFrame(frame: CollabFrame): void {
255
+ switch (frame.t) {
256
+ case "entry": {
257
+ // Entries are never rendered directly — rendering is events-only
258
+ // (prevents double-render). They keep the replica file, the agent's
259
+ // message array (/dump, context estimates), and todos current.
260
+ this.#ctx.sessionManager.ingestReplicatedEntry(frame.entry);
261
+ if (frame.entry.type === "message") {
262
+ this.#ctx.session.agent.replaceMessages([...this.#ctx.session.messages, frame.entry.message]);
263
+ }
264
+ break;
265
+ }
266
+ case "event":
267
+ this.#applyEvent(frame.event);
268
+ break;
269
+ case "state": {
270
+ this.state = frame.state;
271
+ this.#applyHostState(frame.state);
272
+ setSessionTerminalTitle(frame.state.sessionName, frame.state.cwd);
273
+ this.#updateStatusSegment();
274
+ // Reconciler: events normally drive the loader; clear a stale one if
275
+ // the host reports idle (e.g. events lost across a reconnect).
276
+ if (!frame.state.isStreaming && this.#ctx.loadingAnimation) {
277
+ this.#ctx.loadingAnimation.stop();
278
+ this.#ctx.loadingAnimation = undefined;
279
+ }
280
+ this.#ctx.statusLine.invalidate();
281
+ this.#ctx.ui.requestRender();
282
+ break;
283
+ }
284
+ case "bus":
285
+ // Mirrored host EventBus traffic (task subagent lifecycle/progress)
286
+ // feeding the observer HUD and Agent Hub progress columns.
287
+ this.#ctx.eventBus?.emit(frame.channel, frame.data);
288
+ break;
289
+ case "agents":
290
+ this.#applyAgentSnapshots(frame.agents);
291
+ break;
292
+ case "transcript": {
293
+ const resolve = this.#pendingTranscripts.get(frame.reqId);
294
+ if (resolve) {
295
+ this.#pendingTranscripts.delete(frame.reqId);
296
+ resolve(frame.error ? null : { text: frame.text, newSize: frame.newSize });
297
+ }
298
+ break;
299
+ }
300
+ case "bye": {
301
+ this.#ctx.showStatus(`Collab session ended (${frame.reason})`);
302
+ this.#socket?.close();
303
+ void this.#restoreLocalSession();
304
+ break;
305
+ }
306
+ case "error":
307
+ this.#ctx.showError(`Collab host: ${frame.message}`);
308
+ break;
309
+ default:
310
+ logger.debug("collab guest ignoring unexpected frame", { type: frame.t });
311
+ }
312
+ }
313
+
314
+ #applyEvent(event: AgentSessionEvent): void {
315
+ // Orphan-delta guard: when joining mid-turn the message_start for the
316
+ // in-flight assistant message predates the snapshot. message_update
317
+ // carries the full accumulating message, so synthesize the missing start
318
+ // before the first orphaned update; every other handler is tolerant of
319
+ // unknown anchors (guarded by streamingComponent/pendingTools lookups).
320
+ if (event.type === "message_start" && event.message.role === "assistant") {
321
+ this.#assistantStreamSynced = true;
322
+ } else if (
323
+ event.type === "message_update" &&
324
+ event.message.role === "assistant" &&
325
+ !this.#assistantStreamSynced
326
+ ) {
327
+ this.#assistantStreamSynced = true;
328
+ void this.#ctx.eventController.handleEvent({ type: "message_start", message: event.message });
329
+ }
330
+ void this.#ctx.eventController.handleEvent(event);
331
+ }
332
+
333
+ /**
334
+ * Apply the host's real model/thinking state to the replica agent so model
335
+ * display and context-window math are native (no display-string overrides).
336
+ * Pure agent-state mutation: session.setModel/setThinkingLevel would
337
+ * persist entries and clamp to local credentials.
338
+ */
339
+ #applyHostState(state: CollabSessionState): void {
340
+ const session = this.#ctx.session;
341
+ if (
342
+ state.model &&
343
+ (session.agent.state.model?.id !== state.model.id ||
344
+ session.agent.state.model?.provider !== state.model.provider)
345
+ ) {
346
+ session.agent.setModel(state.model);
347
+ }
348
+ const level = state.thinkingLevel as ThinkingLevel | undefined;
349
+ session.agent.setThinkingLevel(toReasoningEffort(level));
350
+ session.agent.setDisableReasoning(shouldDisableReasoning(level));
351
+ }
352
+
353
+ /** Diff a host agent snapshot into the local registry (refs keep `session: null`). */
354
+ #applyAgentSnapshots(agents: AgentSnapshot[]): void {
355
+ const seen = new Set<string>();
356
+ for (const snap of agents) seen.add(snap.id);
357
+ for (const ref of this.agentRegistry.list()) {
358
+ if (!seen.has(ref.id)) {
359
+ this.agentRegistry.unregister(ref.id);
360
+ this.#agentHasTranscript.delete(ref.id);
361
+ }
362
+ }
363
+ for (const snap of agents) {
364
+ if (this.agentRegistry.get(snap.id)) {
365
+ this.agentRegistry.setStatus(snap.id, snap.status);
366
+ } else {
367
+ this.agentRegistry.register({
368
+ id: snap.id,
369
+ displayName: snap.displayName,
370
+ kind: snap.kind,
371
+ parentId: snap.parentId,
372
+ session: null,
373
+ status: snap.status,
374
+ });
375
+ }
376
+ // Refs are returned by reference: patch host timestamps directly so
377
+ // hub age/activity columns reflect the host, not local registration.
378
+ const ref = this.agentRegistry.get(snap.id);
379
+ if (ref) {
380
+ ref.createdAt = snap.createdAt;
381
+ ref.lastActivity = snap.lastActivity;
382
+ ref.displayName = snap.displayName;
383
+ }
384
+ this.#agentHasTranscript.set(snap.id, snap.hasSessionFile);
385
+ }
386
+ }
387
+
388
+ #clearAgentMirror(): void {
389
+ for (const ref of this.agentRegistry.list()) {
390
+ this.agentRegistry.unregister(ref.id);
391
+ }
392
+ this.#agentHasTranscript.clear();
393
+ }
394
+
395
+ /** Resolve every in-flight transcript request with null (resolvers clear their own timers). */
396
+ #flushPendingTranscripts(): void {
397
+ for (const resolve of this.#pendingTranscripts.values()) {
398
+ resolve(null);
399
+ }
400
+ this.#pendingTranscripts.clear();
401
+ }
402
+
403
+ #clearTransientUi(): void {
404
+ this.#ctx.statusContainer.clear();
405
+ this.#ctx.pendingMessagesContainer.clear();
406
+ this.#ctx.compactionQueuedMessages = [];
407
+ this.#ctx.streamingComponent = undefined;
408
+ this.#ctx.streamingMessage = undefined;
409
+ this.#ctx.pendingTools.clear();
410
+ if (this.#ctx.loadingAnimation) {
411
+ this.#ctx.loadingAnimation.stop();
412
+ this.#ctx.loadingAnimation = undefined;
413
+ }
414
+ }
415
+
416
+ async #restoreLocalSession(): Promise<void> {
417
+ if (this.#left) return;
418
+ this.#left = true;
419
+ this.#socket = null;
420
+ this.#ctx.collabGuest = undefined;
421
+ this.#ctx.statusLine.setCollabStatus(null);
422
+ this.#flushPendingTranscripts();
423
+ this.#clearAgentMirror();
424
+ this.#ctx.resetObserverRegistry();
425
+ this.#clearTransientUi();
426
+ // Replica file stays on disk: it is a valid session file outside the
427
+ // sessions dir, so it never shows up in /resume but remains readable.
428
+ if (this.#returnSessionFile) {
429
+ await this.#ctx.handleResumeSession(this.#returnSessionFile);
430
+ return;
431
+ }
432
+ await this.#ctx.session.newSession();
433
+ setSessionTerminalTitle(this.#ctx.sessionManager.getSessionName(), this.#ctx.sessionManager.getCwd());
434
+ this.#ctx.statusLine.invalidate();
435
+ this.#ctx.statusLine.setSessionStartTime(Date.now());
436
+ this.#ctx.updateEditorTopBorder();
437
+ this.#ctx.updateEditorBorderColor();
438
+ this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
439
+ await this.#ctx.reloadTodos();
440
+ this.#ctx.ui.requestRender(true, { clearScrollback: true });
441
+ }
442
+
443
+ #updateStatusSegment(): void {
444
+ this.#ctx.statusLine.setCollabStatus({
445
+ role: "guest",
446
+ participantCount: this.state?.participants.length ?? 1,
447
+ stateOverride: this.state,
448
+ });
449
+ }
450
+ }