@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
@@ -14,43 +14,62 @@
14
14
  * Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
15
15
  */
16
16
  import * as fs from "node:fs";
17
- import type { ToolResultMessage } from "@oh-my-pi/pi-ai";
18
- import { Container, Editor, Markdown, type MarkdownTheme, matchesKey, ScrollView } from "@oh-my-pi/pi-tui";
19
- import { formatAge, formatDuration, formatNumber, logger } from "@oh-my-pi/pi-utils";
17
+ import * as path from "node:path";
18
+ import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
19
+ import { Container, Editor, matchesKey, ScrollView, Text, type TUI } from "@oh-my-pi/pi-tui";
20
+ import { formatAge, formatBytes, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils";
21
+ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
20
22
  import type { KeyId } from "../../config/keybindings";
23
+ import { settings } from "../../config/settings";
24
+ import type { MessageRenderer } from "../../extensibility/extensions/types";
21
25
  import { IrcBus } from "../../irc/bus";
22
26
  import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
23
27
  import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
24
28
  import type { AgentSession } from "../../session/agent-session";
25
- import { isSilentAbort, USER_INTERRUPT_LABEL } from "../../session/messages";
29
+ import {
30
+ type CustomMessage,
31
+ isSilentAbort,
32
+ LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
33
+ resolveAbortLabel,
34
+ SKILL_PROMPT_MESSAGE_TYPE,
35
+ type SkillPromptDetails,
36
+ USER_INTERRUPT_LABEL,
37
+ } from "../../session/messages";
26
38
  import type { SessionMessageEntry } from "../../session/session-manager";
27
39
  import { parseSessionEntries } from "../../session/session-manager";
28
- import { PREVIEW_LIMITS, replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
29
- import { toPathList } from "../../tools/search";
40
+ import { createIrcMessageCard } from "../../tools/irc";
41
+ import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
30
42
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
31
- import { getEditorTheme, getMarkdownTheme, theme } from "../theme/theme";
43
+ import { getEditorTheme, theme } from "../theme/theme";
32
44
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
45
+ import { AssistantMessageComponent } from "./assistant-message";
46
+ import { BashExecutionComponent } from "./bash-execution";
47
+ import { BranchSummaryMessageComponent } from "./branch-summary-message";
48
+ import { CollabPromptMessageComponent } from "./collab-prompt-message";
49
+ import { CompactionSummaryMessageComponent } from "./compaction-summary-message";
50
+ import { CustomMessageComponent } from "./custom-message";
33
51
  import { DynamicBorder } from "./dynamic-border";
52
+ import { EvalExecutionComponent } from "./eval-execution";
53
+ import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
54
+ import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group";
55
+ import { SkillMessageComponent } from "./skill-message";
34
56
  import { formatContextUsage } from "./status-line/context-thresholds";
57
+ import { ToolExecutionComponent } from "./tool-execution";
58
+ import { TranscriptBlock, TranscriptContainer } from "./transcript-container";
59
+ import { UserMessageComponent } from "./user-message";
35
60
 
36
- /** Max thinking characters in collapsed state */
37
- const MAX_THINKING_CHARS_COLLAPSED = 200;
38
- /** Max thinking characters in expanded state */
39
- const MAX_THINKING_CHARS_EXPANDED = 4000;
40
- /** Max tool args characters to display */
41
- const MAX_TOOL_ARGS_CHARS = 500;
42
61
  /** Lines per page for PageUp/PageDown */
43
62
  const PAGE_SIZE = 15;
44
- /** Left indent for content under entry headers */
45
- const INDENT = " ";
46
63
  /** Refresh cadence for the relative-time column */
47
64
  const AGE_TICK_MS = 5_000;
48
65
  /** Debounce for live-session transcript refreshes */
49
66
  const CHAT_REFRESH_DEBOUNCE_MS = 80;
67
+ /** Double-tap window for the left-left "go to parent" gesture (matches the editor's). */
68
+ const LEFT_TAP_WINDOW_MS = 500;
50
69
 
51
- /** Compute the max content width for the current terminal, accounting for indent and chrome. */
52
- function contentWidth(indent = INDENT): number {
53
- return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - indent.length - 2);
70
+ /** Compute the max content width for the current terminal, accounting for chrome. */
71
+ function contentWidth(): number {
72
+ return Math.max(TRUNCATE_LENGTHS.SHORT, (process.stdout.columns || 80) - 6);
54
73
  }
55
74
 
56
75
  /** Sanitize a line for TUI display: replace tabs, then truncate to viewport width. */
@@ -58,13 +77,6 @@ function sanitizeLine(text: string, maxWidth?: number): string {
58
77
  return truncateToWidth(replaceTabs(text), maxWidth ?? contentWidth());
59
78
  }
60
79
 
61
- /** Represents a rendered entry in the viewer for selection/expand tracking */
62
- interface ViewerEntry {
63
- lineStart: number;
64
- lineCount: number;
65
- kind: "thinking" | "text" | "toolCall" | "user";
66
- }
67
-
68
80
  const STATUS_ORDER: Record<AgentStatus, number> = { running: 0, idle: 1, parked: 2, aborted: 3 };
69
81
 
70
82
  /** Glyph + status word, colored per theme status conventions. */
@@ -81,6 +93,47 @@ function statusBadge(status: AgentStatus): string {
81
93
  }
82
94
  }
83
95
 
96
+ function registerPersistedSubagents(registry: AgentRegistry, sessionFile: string | null | undefined): void {
97
+ if (!sessionFile?.endsWith(".jsonl")) return;
98
+ const root = sessionFile.slice(0, -6);
99
+ registerPersistedSubagentsFromDir(registry, root, undefined);
100
+ }
101
+
102
+ function registerPersistedSubagentsFromDir(registry: AgentRegistry, dir: string, parentId: string | undefined): void {
103
+ let entries: fs.Dirent[];
104
+ try {
105
+ entries = fs.readdirSync(dir, { withFileTypes: true });
106
+ } catch {
107
+ return;
108
+ }
109
+ for (const entry of entries) {
110
+ if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue;
111
+ const id = entry.name.slice(0, -6);
112
+ const sessionFile = path.join(dir, entry.name);
113
+ if (!registry.get(id)) {
114
+ registry.register({
115
+ id,
116
+ displayName: id,
117
+ kind: "sub",
118
+ parentId: parentId ?? MAIN_AGENT_ID,
119
+ session: null,
120
+ sessionFile,
121
+ status: "parked",
122
+ });
123
+ }
124
+ registerPersistedSubagentsFromDir(registry, path.join(dir, id), id);
125
+ }
126
+ }
127
+
128
+ /** Guest-side proxy for hub actions executed on the collab host. */
129
+ export interface AgentHubRemote {
130
+ chat(id: string, text: string): void;
131
+ kill(id: string): void;
132
+ revive(id: string): void;
133
+ /** Mirrors readFileIncremental: text from fromByte (complete JSONL lines), newSize = next fromByte base; null = unavailable. */
134
+ readTranscript(id: string, fromByte: number): Promise<{ text: string; newSize: number } | null>;
135
+ }
136
+
84
137
  export interface AgentHubDeps {
85
138
  /** Progress/status snapshot source (task lifecycle + progress channels). */
86
139
  observers: SessionObserverRegistry;
@@ -94,6 +147,24 @@ export interface AgentHubDeps {
94
147
  lifecycle?: AgentLifecycleManager;
95
148
  /** Injectable for tests; defaults to the process-global bus. */
96
149
  irc?: IrcBus;
150
+ /** TUI handle for transcript components; tests omit it and get a render-only stub. */
151
+ ui?: TUI;
152
+ /** Tool lookup for transcript renderers (labels, custom render functions). */
153
+ getTool?: (name: string) => AgentTool | undefined;
154
+ /** Extension message renderers for custom messages in the transcript. */
155
+ getMessageRenderer?: (customType: string) => MessageRenderer | undefined;
156
+ /** Cwd used by tool renderers for path shortening; defaults to the project dir. */
157
+ cwd?: string;
158
+ /** Mirrors the main transcript's thinking-block visibility. */
159
+ hideThinkingBlock?: () => boolean;
160
+ /** Keys toggling tool output expansion (app.tools.expand). */
161
+ expandKeys?: KeyId[];
162
+ /** Focus the main view on this agent's live session (ctx.focusAgentSession). When absent (collab guest, tests), Enter opens the in-hub chat view instead. */
163
+ focusAgent?: (id: string) => Promise<void>;
164
+ /** Current main session file; used to seed parked historical subagents after restart. */
165
+ sessionFile?: string | null;
166
+ /** Collab guest: route actions/transcripts to the host instead of local sessions. */
167
+ remote?: AgentHubRemote;
97
168
  }
98
169
 
99
170
  export class AgentHubOverlayComponent extends Container {
@@ -106,6 +177,11 @@ export class AgentHubOverlayComponent extends Container {
106
177
  #hubKeys: KeyId[];
107
178
  #unsubscribers: Array<() => void> = [];
108
179
  #ageTimer: NodeJS.Timeout | undefined;
180
+ #remote: AgentHubRemote | undefined;
181
+ #remoteFetchInFlight = false;
182
+ /** Invalidates stale in-flight fetch callbacks after openChat resets the cache. */
183
+ #remoteFetchToken = 0;
184
+ #remoteTranscriptUnavailable = false;
109
185
 
110
186
  // Table state
111
187
  #view: "table" | "chat" = "table";
@@ -121,16 +197,33 @@ export class AgentHubOverlayComponent extends Container {
121
197
  #chatRefreshTimer: NodeJS.Timeout | undefined;
122
198
  #transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined;
123
199
 
124
- // Transcript viewer state (absorbed from the session observer overlay)
200
+ // Chat transcript: the same component renderers as the main session
201
+ // transcript, assembled incrementally from the persisted JSONL entries.
202
+ #ui: TUI;
203
+ #getTool: ((name: string) => AgentTool | undefined) | undefined;
204
+ #getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined;
205
+ #cwd: string;
206
+ #hideThinkingBlock: (() => boolean) | undefined;
207
+ #expandKeys: KeyId[];
208
+ #focusAgent: ((id: string) => Promise<void>) | undefined;
209
+ #chatLog = new TranscriptContainer();
210
+ #chatEntriesRef: SessionMessageEntry[] | undefined;
211
+ #chatBuiltCount = 0;
212
+ #chatPendingTools = new Map<string, ToolExecutionComponent | ReadToolGroupComponent>();
213
+ #chatReadArgs = new Map<string, Record<string, unknown>>();
214
+ #chatReadGroup: ReadToolGroupComponent | null = null;
215
+ #chatWaitingPoll: ToolExecutionComponent | null = null;
216
+ #chatExpandables: Array<{ setExpanded(expanded: boolean): void }> = [];
217
+ #chatExpanded = false;
218
+ #chatPlaceholder: string | undefined;
219
+
220
+ // Viewport state
125
221
  #scrollOffset = 0;
126
- #renderedLines: string[] = [];
222
+ #lastMaxScroll = 0;
127
223
  #viewportHeight = 20;
128
224
  #wasAtBottom = true;
129
- #viewerEntries: ViewerEntry[] = [];
130
- #selectedEntryIndex = 0;
131
- #expandedEntries = new Set<number>();
132
225
  #viewerHeaderLines: string[] = [];
133
- #mdTheme: MarkdownTheme = getMarkdownTheme();
226
+ #lastLeftTap = 0;
134
227
 
135
228
  constructor(deps: AgentHubDeps) {
136
229
  super();
@@ -143,6 +236,19 @@ export class AgentHubOverlayComponent extends Container {
143
236
  this.#onDone = deps.onDone;
144
237
  this.#requestRender = deps.requestRender;
145
238
  this.#hubKeys = deps.hubKeys;
239
+ this.#remote = deps.remote;
240
+ this.#ui =
241
+ deps.ui ??
242
+ ({
243
+ requestRender: () => deps.requestRender(),
244
+ requestComponentRender: () => deps.requestRender(),
245
+ } as unknown as TUI);
246
+ this.#getTool = deps.getTool;
247
+ this.#getMessageRenderer = deps.getMessageRenderer;
248
+ this.#cwd = deps.cwd ?? getProjectDir();
249
+ this.#hideThinkingBlock = deps.hideThinkingBlock;
250
+ this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
251
+ this.#focusAgent = deps.focusAgent;
146
252
 
147
253
  this.#editor = new Editor(getEditorTheme());
148
254
  this.#editor.setMaxHeight(4);
@@ -153,6 +259,7 @@ export class AgentHubOverlayComponent extends Container {
153
259
  this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS);
154
260
  this.#ageTimer.unref?.();
155
261
 
262
+ if (!this.#remote) registerPersistedSubagents(this.#registry, deps.sessionFile);
156
263
  this.#refreshRows();
157
264
  }
158
265
 
@@ -168,6 +275,7 @@ export class AgentHubOverlayComponent extends Container {
168
275
  this.#chatRefreshTimer = undefined;
169
276
  }
170
277
  this.#detachLiveSession();
278
+ this.#resetChatLog();
171
279
  }
172
280
 
173
281
  override render(width: number): readonly string[] {
@@ -196,18 +304,16 @@ export class AgentHubOverlayComponent extends Container {
196
304
  this.#chatAgentId = id;
197
305
  this.#notice = undefined;
198
306
  this.#transcriptCache = undefined;
307
+ this.#remoteTranscriptUnavailable = false;
308
+ this.#remoteFetchInFlight = false;
309
+ this.#remoteFetchToken++;
310
+ this.#resetChatLog();
199
311
  this.#scrollOffset = 0;
200
- this.#selectedEntryIndex = 0;
201
- this.#expandedEntries.clear();
202
312
  this.#wasAtBottom = true;
313
+ this.#lastLeftTap = 0;
203
314
  this.#editor.setText("");
204
315
  this.#attachLiveSession();
205
316
  this.#rebuildChatContent();
206
- // Auto-scroll to bottom and select last entry on open
207
- if (this.#viewerEntries.length > 0) {
208
- this.#selectedEntryIndex = this.#viewerEntries.length - 1;
209
- this.#rebuildChatContent();
210
- }
211
317
  this.#requestRender();
212
318
  }
213
319
 
@@ -238,6 +344,8 @@ export class AgentHubOverlayComponent extends Container {
238
344
 
239
345
  /** Subscribe to the chat agent's live session (if any) for transcript refreshes. Idempotent per session. */
240
346
  #attachLiveSession(): void {
347
+ // Remote refs carry no live session handle; refreshes come from observer onChange.
348
+ if (this.#remote) return;
241
349
  const session = this.#chatAgentId ? (this.#registry.get(this.#chatAgentId)?.session ?? undefined) : undefined;
242
350
  if (session === this.#attachedSession) return;
243
351
  this.#detachLiveSession();
@@ -261,12 +369,7 @@ export class AgentHubOverlayComponent extends Container {
261
369
  this.#chatRefreshTimer = setTimeout(() => {
262
370
  this.#chatRefreshTimer = undefined;
263
371
  if (this.#view !== "chat") return;
264
- // Keep auto-scrolling to bottom unless the user navigated away
265
- this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
266
372
  this.#rebuildChatContent();
267
- if (this.#wasAtBottom && this.#viewerEntries.length > 0) {
268
- this.#selectedEntryIndex = this.#viewerEntries.length - 1;
269
- }
270
373
  this.#requestRender();
271
374
  }, CHAT_REFRESH_DEBOUNCE_MS);
272
375
  this.#chatRefreshTimer.unref?.();
@@ -313,7 +416,7 @@ export class AgentHubOverlayComponent extends Container {
313
416
  lines.push(` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`);
314
417
  }
315
418
  lines.push("");
316
- lines.push(` ${theme.fg("dim", "j/k:select Enter:chat r:revive x:kill Esc:close")}`);
419
+ lines.push(` ${theme.fg("dim", "j/k:select Enter:open r:revive x:kill Esc/←←:close")}`);
317
420
  lines.push(...new DynamicBorder().render(width));
318
421
  return lines;
319
422
  }
@@ -353,6 +456,16 @@ export class AgentHubOverlayComponent extends Container {
353
456
  this.#onDone();
354
457
  return;
355
458
  }
459
+ if (matchesKey(keyData, "left")) {
460
+ const now = Date.now();
461
+ if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
462
+ this.#lastLeftTap = 0;
463
+ this.#onDone();
464
+ } else {
465
+ this.#lastLeftTap = now;
466
+ }
467
+ return;
468
+ }
356
469
  if (keyData === "j" || matchesSelectDown(keyData)) {
357
470
  if (this.#rows.length > 0) {
358
471
  this.#selectedRow = Math.min(this.#selectedRow + 1, this.#rows.length - 1);
@@ -369,7 +482,7 @@ export class AgentHubOverlayComponent extends Container {
369
482
  }
370
483
  if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
371
484
  const selected = this.#rows[this.#selectedRow];
372
- if (selected) this.openChat(selected.id);
485
+ if (selected) this.#activateAgent(selected);
373
486
  return;
374
487
  }
375
488
  if (keyData === "r") {
@@ -382,6 +495,30 @@ export class AgentHubOverlayComponent extends Container {
382
495
  }
383
496
  }
384
497
 
498
+ /**
499
+ * Enter on a row: focus the main view on the agent's live session and close
500
+ * the hub. The transcript then renders through the regular session pipeline —
501
+ * exact parity by construction. Collab guests (no local sessions) keep the
502
+ * in-hub chat view.
503
+ */
504
+ #activateAgent(ref: AgentRef): void {
505
+ this.#notice = undefined;
506
+ const focusAgent = this.#focusAgent;
507
+ if (this.#remote || !focusAgent) {
508
+ this.openChat(ref.id);
509
+ return;
510
+ }
511
+ void (async () => {
512
+ try {
513
+ await focusAgent(ref.id); // ensureLive inside revives parked agents; no parking, no session files
514
+ this.#onDone();
515
+ } catch (error) {
516
+ this.#notice = error instanceof Error ? error.message : String(error);
517
+ this.#requestRender();
518
+ }
519
+ })();
520
+ }
521
+
385
522
  #reviveSelected(): void {
386
523
  const ref = this.#rows[this.#selectedRow];
387
524
  if (!ref) return;
@@ -391,6 +528,11 @@ export class AgentHubOverlayComponent extends Container {
391
528
  return;
392
529
  }
393
530
  this.#notice = undefined;
531
+ if (this.#remote) {
532
+ this.#remote.revive(ref.id);
533
+ this.#requestRender();
534
+ return;
535
+ }
394
536
  // Fire-and-forget; failures surface as an inline notice
395
537
  this.#lifecycle()
396
538
  .ensureLive(ref.id)
@@ -405,6 +547,12 @@ export class AgentHubOverlayComponent extends Container {
405
547
  const ref = this.#rows[this.#selectedRow];
406
548
  if (!ref) return;
407
549
  this.#notice = undefined;
550
+ if (this.#remote) {
551
+ this.#remote.kill(ref.id);
552
+ this.#refreshRows();
553
+ this.#requestRender();
554
+ return;
555
+ }
408
556
  void (async () => {
409
557
  try {
410
558
  if (ref.status === "running" && ref.session) {
@@ -438,7 +586,12 @@ export class AgentHubOverlayComponent extends Container {
438
586
  const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1;
439
587
  this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
440
588
 
441
- const maxScroll = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
589
+ const contentLines: readonly string[] = this.#chatPlaceholder
590
+ ? [theme.fg("dim", this.#chatPlaceholder)]
591
+ : this.#chatLog.render(innerWidth);
592
+
593
+ const maxScroll = Math.max(0, contentLines.length - this.#viewportHeight);
594
+ this.#lastMaxScroll = maxScroll;
442
595
  if (this.#wasAtBottom) this.#scrollOffset = maxScroll;
443
596
  this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
444
597
 
@@ -450,11 +603,11 @@ export class AgentHubOverlayComponent extends Container {
450
603
  lines.push(...new DynamicBorder().render(width));
451
604
 
452
605
  const scrollView = new ScrollView(
453
- this.#renderedLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
606
+ contentLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
454
607
  {
455
608
  height: this.#viewportHeight,
456
609
  scrollbar: "auto",
457
- totalRows: this.#renderedLines.length,
610
+ totalRows: contentLines.length,
458
611
  theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
459
612
  },
460
613
  );
@@ -473,7 +626,9 @@ export class AgentHubOverlayComponent extends Container {
473
626
  const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined;
474
627
  const statsLine = this.#buildStatsLine(observed);
475
628
  if (statsLine) lines.push(` ${statsLine}`);
476
- lines.push(` ${theme.fg("dim", "Enter:send Esc:back empty input: j/k:scroll Enter:expand g/G:top/bottom")}`);
629
+ lines.push(
630
+ ` ${theme.fg("dim", `Enter:send Esc:back ←←:parent ${this.#expandKeys[0] ?? "ctrl+o"}:expand empty input: j/k:scroll g/G:top/bottom`)}`,
631
+ );
477
632
  return lines;
478
633
  }
479
634
 
@@ -505,14 +660,17 @@ export class AgentHubOverlayComponent extends Container {
505
660
  return parts.join(theme.sep.dot);
506
661
  }
507
662
 
508
- /** Rebuild the chat header + transcript content lines */
663
+ /** Rebuild the chat header and sync transcript components from new entries */
509
664
  #rebuildChatContent(): void {
510
665
  const id = this.#chatAgentId;
511
666
  const ref = id ? this.#registry.get(id) : undefined;
512
667
 
513
668
  // Load transcript first so model info is available for the header
514
669
  let messageEntries: SessionMessageEntry[] | null = null;
515
- if (ref?.sessionFile) {
670
+ if (this.#remote) {
671
+ if (id) this.#fetchRemoteTranscript(id);
672
+ messageEntries = this.#transcriptCache?.entries ?? [];
673
+ } else if (ref?.sessionFile) {
516
674
  messageEntries = this.#loadTranscript(ref.sessionFile);
517
675
  }
518
676
 
@@ -526,20 +684,24 @@ export class AgentHubOverlayComponent extends Container {
526
684
  this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`);
527
685
  }
528
686
 
529
- const contentLines: string[] = [];
530
- this.#viewerEntries = [];
531
687
  if (!ref) {
532
- contentLines.push(theme.fg("dim", "Agent no longer registered."));
533
- } else if (!ref.sessionFile) {
534
- contentLines.push(theme.fg("dim", "No session file available yet."));
688
+ this.#chatPlaceholder = "Agent no longer registered.";
689
+ } else if (!this.#remote && !ref.sessionFile) {
690
+ this.#chatPlaceholder = "No session file available yet.";
535
691
  } else if (!messageEntries) {
536
- contentLines.push(theme.fg("dim", "Unable to read session file."));
692
+ this.#chatPlaceholder = "Unable to read session file.";
537
693
  } else if (messageEntries.length === 0) {
538
- contentLines.push(theme.fg("dim", "No messages yet."));
694
+ if (this.#remote && this.#remoteTranscriptUnavailable) {
695
+ this.#chatPlaceholder = "Transcript lives on the host — not available.";
696
+ } else if (this.#remote && !this.#transcriptCache) {
697
+ this.#chatPlaceholder = "Loading transcript from host…";
698
+ } else {
699
+ this.#chatPlaceholder = "No messages yet.";
700
+ }
539
701
  } else {
540
- this.#buildTranscriptLines(messageEntries, contentLines);
702
+ this.#chatPlaceholder = undefined;
703
+ this.#syncChatComponents(messageEntries);
541
704
  }
542
- this.#renderedLines = contentLines;
543
705
  }
544
706
 
545
707
  #handleChatInput(keyData: string): void {
@@ -555,8 +717,31 @@ export class AgentHubOverlayComponent extends Container {
555
717
  return;
556
718
  }
557
719
 
558
- // Navigation mirrors the old observer overlay while the input is empty;
559
- // once the user starts typing, the editor owns every key.
720
+ // Tool output expansion mirrors the main transcript's app.tools.expand toggle.
721
+ for (const key of this.#expandKeys) {
722
+ if (matchesKey(keyData, key)) {
723
+ this.#chatExpanded = !this.#chatExpanded;
724
+ for (const component of this.#chatExpandables) component.setExpanded(this.#chatExpanded);
725
+ this.#requestRender();
726
+ return;
727
+ }
728
+ }
729
+
730
+ // Double-tap left on an empty editor hops to the parent session —
731
+ // the inverse of the main editor's double-left opening the hub.
732
+ if (editorEmpty && matchesKey(keyData, "left")) {
733
+ const now = Date.now();
734
+ if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
735
+ this.#lastLeftTap = 0;
736
+ this.#openParent();
737
+ } else {
738
+ this.#lastLeftTap = now;
739
+ }
740
+ return;
741
+ }
742
+
743
+ // Scrolling works while the input is empty; once the user starts
744
+ // typing, the editor owns every key.
560
745
  if (editorEmpty && this.#handleViewerNavigation(keyData)) {
561
746
  return;
562
747
  }
@@ -565,11 +750,23 @@ export class AgentHubOverlayComponent extends Container {
565
750
  this.#requestRender();
566
751
  }
567
752
 
753
+ /** Open the chat for the agent's parent, or close the hub when the parent is the main session. */
754
+ #openParent(): void {
755
+ const ref = this.#chatAgentId ? this.#registry.get(this.#chatAgentId) : undefined;
756
+ const parentId = ref?.parentId;
757
+ if (parentId && parentId !== MAIN_AGENT_ID && this.#registry.get(parentId)) {
758
+ this.openChat(parentId);
759
+ return;
760
+ }
761
+ this.#onDone();
762
+ }
763
+
568
764
  #closeChat(): void {
569
765
  this.#view = "table";
570
766
  this.#chatAgentId = undefined;
571
767
  this.#notice = undefined;
572
768
  this.#detachLiveSession();
769
+ this.#resetChatLog();
573
770
  this.#refreshRows();
574
771
  this.#requestRender();
575
772
  }
@@ -580,6 +777,12 @@ export class AgentHubOverlayComponent extends Container {
580
777
  if (!id || !trimmed) return;
581
778
  this.#editor.setText("");
582
779
  this.#notice = undefined;
780
+ if (this.#remote) {
781
+ this.#remote.chat(id, trimmed);
782
+ this.#scheduleChatRefresh();
783
+ this.#requestRender();
784
+ return;
785
+ }
583
786
  void (async () => {
584
787
  try {
585
788
  // Revives a parked agent; returns the live session for running/idle.
@@ -596,415 +799,391 @@ export class AgentHubOverlayComponent extends Container {
596
799
  this.#requestRender();
597
800
  }
598
801
 
599
- /** Viewer navigation (selection, paging, expand) for the chat transcript. Returns true when handled. */
802
+ /** Viewport scrolling for the chat transcript. Returns true when handled. */
600
803
  #handleViewerNavigation(keyData: string): boolean {
601
- const entryCount = this.#viewerEntries.length;
602
-
804
+ const maxScroll = this.#lastMaxScroll;
805
+ const scrollBy = (delta: number) => {
806
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset + delta, maxScroll));
807
+ this.#wasAtBottom = this.#scrollOffset >= maxScroll;
808
+ this.#requestRender();
809
+ };
603
810
  if (keyData === "j" || matchesSelectDown(keyData)) {
604
- if (entryCount > 0) {
605
- this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 1, entryCount - 1);
606
- }
607
- this.#rebuildAndScroll();
811
+ scrollBy(1);
608
812
  return true;
609
813
  }
610
814
  if (keyData === "k" || matchesSelectUp(keyData)) {
611
- if (entryCount > 0) {
612
- this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 1, 0);
613
- }
614
- this.#rebuildAndScroll();
815
+ scrollBy(-1);
615
816
  return true;
616
817
  }
617
818
  if (matchesKey(keyData, "pageDown")) {
618
- if (entryCount > 0) {
619
- const prevIndex = this.#selectedEntryIndex;
620
- this.#selectedEntryIndex = Math.min(this.#selectedEntryIndex + 5, entryCount - 1);
621
- if (this.#selectedEntryIndex === prevIndex) {
622
- this.#scrollOffset = Math.min(
623
- this.#scrollOffset + PAGE_SIZE,
624
- Math.max(0, this.#renderedLines.length - this.#viewportHeight),
625
- );
626
- }
627
- } else {
628
- this.#scrollOffset = Math.min(
629
- this.#scrollOffset + PAGE_SIZE,
630
- Math.max(0, this.#renderedLines.length - this.#viewportHeight),
631
- );
632
- }
633
- this.#rebuildAndScroll();
819
+ scrollBy(PAGE_SIZE);
634
820
  return true;
635
821
  }
636
822
  if (matchesKey(keyData, "pageUp")) {
637
- if (entryCount > 0) {
638
- const prevIndex = this.#selectedEntryIndex;
639
- this.#selectedEntryIndex = Math.max(this.#selectedEntryIndex - 5, 0);
640
- if (this.#selectedEntryIndex === prevIndex) {
641
- this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
642
- }
643
- } else {
644
- this.#scrollOffset = Math.max(this.#scrollOffset - PAGE_SIZE, 0);
645
- }
646
- this.#rebuildAndScroll();
647
- return true;
648
- }
649
- if (matchesKey(keyData, "enter") || keyData === "\r" || keyData === "\n") {
650
- if (entryCount > 0 && this.#selectedEntryIndex < entryCount) {
651
- if (this.#expandedEntries.has(this.#selectedEntryIndex)) {
652
- this.#expandedEntries.delete(this.#selectedEntryIndex);
653
- } else {
654
- this.#expandedEntries.add(this.#selectedEntryIndex);
655
- }
656
- this.#rebuildAndScroll();
657
- }
823
+ scrollBy(-PAGE_SIZE);
658
824
  return true;
659
825
  }
660
826
  if (keyData === "G") {
661
- if (entryCount > 0) this.#selectedEntryIndex = entryCount - 1;
662
- this.#scrollOffset = Math.max(0, this.#renderedLines.length - this.#viewportHeight);
663
- this.#rebuildAndScroll();
827
+ this.#scrollOffset = maxScroll;
828
+ this.#wasAtBottom = true;
829
+ this.#requestRender();
664
830
  return true;
665
831
  }
666
832
  if (keyData === "g") {
667
- this.#selectedEntryIndex = 0;
668
833
  this.#scrollOffset = 0;
669
- this.#rebuildAndScroll();
834
+ this.#wasAtBottom = maxScroll === 0;
835
+ this.#requestRender();
670
836
  return true;
671
837
  }
672
838
  return false;
673
839
  }
674
840
 
675
- /** Rebuild transcript lines (which depend on selectedEntryIndex/expandedEntries) and scroll to selection */
676
- #rebuildAndScroll(): void {
677
- // Resume auto-scrolling once selection returns to the last entry
678
- this.#wasAtBottom = this.#selectedEntryIndex >= this.#viewerEntries.length - 1;
679
- this.#rebuildChatContent();
680
- this.#scrollToSelectedEntry();
681
- this.#requestRender();
682
- }
683
-
684
- #scrollToSelectedEntry(): void {
685
- if (this.#viewerEntries.length === 0) return;
686
- const entry = this.#viewerEntries[this.#selectedEntryIndex];
687
- if (!entry) return;
841
+ // ========================================================================
842
+ // Transcript assembly — the same components as the main session transcript
843
+ // (mirrors UiHelpers.renderSessionContext / addMessageToChat).
844
+ // ========================================================================
688
845
 
689
- const entryTop = entry.lineStart;
690
- const entryBottom = entry.lineStart + entry.lineCount;
846
+ /** Tear down transcript components (sealing pending spinners) and reset build state. */
847
+ #resetChatLog(): void {
848
+ for (const pending of this.#chatPendingTools.values()) pending.seal();
849
+ this.#chatPendingTools.clear();
850
+ this.#chatReadArgs.clear();
851
+ this.#chatReadGroup = null;
852
+ this.#chatWaitingPoll = null;
853
+ this.#chatExpandables = [];
854
+ this.#chatLog.dispose();
855
+ this.#chatLog.clear();
856
+ this.#chatEntriesRef = undefined;
857
+ this.#chatBuiltCount = 0;
858
+ this.#chatPlaceholder = undefined;
859
+ }
691
860
 
692
- if (entry.lineCount >= this.#viewportHeight) {
693
- // Entry taller than viewport: only snap when it's completely out of view.
694
- if (this.#scrollOffset + this.#viewportHeight <= entryTop) {
695
- this.#scrollOffset = Math.max(0, entryTop - 1);
696
- } else if (this.#scrollOffset >= entryBottom) {
697
- this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight);
698
- }
699
- } else {
700
- // Entry fits in viewport: ensure it's fully visible
701
- if (entryTop < this.#scrollOffset) {
702
- this.#scrollOffset = Math.max(0, entryTop - 1);
703
- }
704
- if (entryBottom > this.#scrollOffset + this.#viewportHeight) {
705
- this.#scrollOffset = Math.max(0, entryBottom - this.#viewportHeight + 1);
706
- }
861
+ /** Append components for entries not yet materialized. Rebuilds from scratch when the cache was replaced (agent switch, file rotation). */
862
+ #syncChatComponents(entries: SessionMessageEntry[]): void {
863
+ if (this.#chatEntriesRef !== entries) {
864
+ this.#resetChatLog();
865
+ this.#chatEntriesRef = entries;
866
+ }
867
+ for (let i = this.#chatBuiltCount; i < entries.length; i++) {
868
+ this.#appendChatMessage(entries[i].message);
707
869
  }
870
+ this.#chatBuiltCount = entries.length;
708
871
  }
709
872
 
710
- // ========================================================================
711
- // Transcript rendering (absorbed from the session observer overlay)
712
- // ========================================================================
873
+ #trackExpandable(component: { setExpanded(expanded: boolean): void }): void {
874
+ component.setExpanded(this.#chatExpanded);
875
+ this.#chatExpandables.push(component);
876
+ }
713
877
 
714
- #buildTranscriptLines(messageEntries: SessionMessageEntry[], lines: string[]): void {
715
- // Build a tool call ID -> tool result map
716
- const toolResults = new Map<string, ToolResultMessage>();
717
- for (const entry of messageEntries) {
718
- if (entry.message.role === "toolResult") {
719
- toolResults.set(entry.message.toolCallId, entry.message);
720
- }
721
- }
722
-
723
- let entryIndex = 0;
724
- for (const entry of messageEntries) {
725
- const msg = entry.message;
726
-
727
- if (msg.role === "assistant") {
728
- // Handle error messages with empty content
729
- if (msg.content.length === 0 && msg.errorMessage && !isSilentAbort(msg.errorMessage)) {
730
- const startLine = lines.length;
731
- const isSelected = entryIndex === this.#selectedEntryIndex;
732
- const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " ";
733
- lines.push("");
734
- const errorLines = msg.errorMessage.split("\n");
735
- const maxWidth = contentWidth();
736
- lines.push(`${cursor} ${theme.fg("error", `✗ Error: ${sanitizeLine(errorLines[0], maxWidth)}`)}`);
737
- for (let i = 1; i < errorLines.length; i++) {
738
- lines.push(`${INDENT}${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
739
- }
740
- this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "text" });
741
- entryIndex++;
742
- } else {
743
- for (const content of msg.content) {
744
- if (content.type === "thinking" && content.thinking.trim()) {
745
- const startLine = lines.length;
746
- const isExpanded = this.#expandedEntries.has(entryIndex);
747
- const isSelected = entryIndex === this.#selectedEntryIndex;
748
- this.#renderThinkingLines(lines, content.thinking.trim(), isExpanded, isSelected);
749
- this.#viewerEntries.push({
750
- lineStart: startLine,
751
- lineCount: lines.length - startLine,
752
- kind: "thinking",
753
- });
754
- entryIndex++;
755
- } else if (content.type === "text" && content.text.trim()) {
756
- const startLine = lines.length;
757
- const isExpanded = this.#expandedEntries.has(entryIndex);
758
- const isSelected = entryIndex === this.#selectedEntryIndex;
759
- this.#renderTextLines(lines, content.text.trim(), isExpanded, isSelected);
760
- this.#viewerEntries.push({
761
- lineStart: startLine,
762
- lineCount: lines.length - startLine,
763
- kind: "text",
764
- });
765
- entryIndex++;
766
- } else if (content.type === "toolCall") {
767
- const startLine = lines.length;
768
- const isExpanded = this.#expandedEntries.has(entryIndex);
769
- const isSelected = entryIndex === this.#selectedEntryIndex;
770
- const result = toolResults.get(content.id);
771
- this.#renderToolCallLines(lines, content, result, isExpanded, isSelected);
772
- this.#viewerEntries.push({
773
- lineStart: startLine,
774
- lineCount: lines.length - startLine,
775
- kind: "toolCall",
776
- });
777
- entryIndex++;
778
- }
779
- }
780
- }
781
- } else if (msg.role === "user" || msg.role === "developer") {
782
- const text =
783
- typeof msg.content === "string"
784
- ? msg.content
785
- : msg.content
786
- .filter((b): b is { type: "text"; text: string } => b.type === "text")
787
- .map(b => b.text)
788
- .join("\n");
789
- if (text.trim()) {
790
- const startLine = lines.length;
791
- const isSelected = entryIndex === this.#selectedEntryIndex;
792
- const isExpanded = this.#expandedEntries.has(entryIndex);
793
- const label = msg.role === "developer" ? "System" : "User";
794
- const cursor = isSelected ? theme.fg("accent", theme.nav.cursor) : " ";
795
- lines.push("");
796
- if (isExpanded) {
797
- lines.push(`${cursor} ${theme.fg("dim", `[${label}]`)}`);
798
- const mdLines = this.#renderMarkdownToLines(text.trim());
799
- for (const ml of mdLines) {
800
- lines.push(ml);
801
- }
802
- } else {
803
- const firstLine = text.trim().split("\n")[0];
804
- const totalLines = text.trim().split("\n").length;
805
- const hint = totalLines > 1 ? theme.fg("dim", ` (${totalLines} lines)`) : "";
806
- lines.push(
807
- `${cursor} ${theme.fg("dim", `[${label}]`)} ${theme.fg("muted", sanitizeLine(firstLine, TRUNCATE_LENGTHS.TITLE))}${hint}`,
808
- );
809
- }
810
- this.#viewerEntries.push({ lineStart: startLine, lineCount: lines.length - startLine, kind: "user" });
811
- entryIndex++;
812
- }
813
- }
878
+ /** A `job` poll showing all-running is displaced by the next `job` call (mirrors the rebuild path). */
879
+ #resolveWaitingPoll(nextToolName?: string): void {
880
+ const previous = this.#chatWaitingPoll;
881
+ if (!previous) return;
882
+ this.#chatWaitingPoll = null;
883
+ if (nextToolName === "job" && previous.isDisplaceableBlock()) {
884
+ this.#chatLog.removeChild(previous);
814
885
  }
886
+ previous.seal();
815
887
  }
816
888
 
817
- /** Render markdown text into indented lines using the theme's markdown renderer */
818
- #renderMarkdownToLines(text: string, indent: string = INDENT): string[] {
819
- const width = Math.max(40, (process.stdout.columns || 80) - indent.length - 4);
820
- const md = new Markdown(text, 0, 0, this.#mdTheme);
821
- const rendered = md.render(width);
822
- return rendered.map(line => `${indent}${line.trimEnd()}`);
889
+ #ensureReadGroup(): ReadToolGroupComponent {
890
+ if (!this.#chatReadGroup) {
891
+ this.#chatReadGroup = new ReadToolGroupComponent({
892
+ showContentPreview: settings.get("read.toolResultPreview"),
893
+ });
894
+ this.#trackExpandable(this.#chatReadGroup);
895
+ this.#chatLog.addChild(this.#chatReadGroup);
896
+ }
897
+ return this.#chatReadGroup;
823
898
  }
824
899
 
825
- #renderThinkingLines(lines: string[], thinking: string, expanded: boolean, selected: boolean): void {
826
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
827
- const maxChars = expanded ? MAX_THINKING_CHARS_EXPANDED : MAX_THINKING_CHARS_COLLAPSED;
828
- const truncated = thinking.length > maxChars;
829
- const expandLabel = !expanded && truncated ? theme.fg("dim", " ↵") : "";
830
-
831
- lines.push("");
832
- lines.push(`${cursor} ${theme.fg("dim", "💭 Thinking")}${expandLabel}`);
833
-
834
- const displayText = truncated ? `${thinking.slice(0, maxChars)}...` : thinking;
835
- if (expanded) {
836
- // Expanded thinking: render as markdown for readable formatting
837
- const mdLines = this.#renderMarkdownToLines(displayText);
838
- const maxLines = 100;
839
- for (let i = 0; i < Math.min(mdLines.length, maxLines); i++) {
840
- lines.push(mdLines[i]);
900
+ #appendChatMessage(message: AgentMessage): void {
901
+ switch (message.role) {
902
+ case "assistant":
903
+ this.#appendAssistantMessage(message);
904
+ break;
905
+ case "toolResult":
906
+ this.#appendToolResult(message);
907
+ break;
908
+ case "user":
909
+ case "developer": {
910
+ // A user prompt closes the poll-displacement window, same as the live path.
911
+ if (message.role === "user") this.#resolveWaitingPoll();
912
+ const textContent =
913
+ message.role !== "user"
914
+ ? ""
915
+ : typeof message.content === "string"
916
+ ? message.content
917
+ : message.content
918
+ .filter((block): block is { type: "text"; text: string } => block.type === "text")
919
+ .map(block => block.text)
920
+ .join("");
921
+ if (textContent) {
922
+ const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
923
+ this.#chatLog.addChild(new UserMessageComponent(textContent, isSynthetic));
924
+ }
925
+ break;
841
926
  }
842
- if (mdLines.length > maxLines) {
843
- lines.push(`${INDENT}${theme.fg("dim", `... ${mdLines.length - maxLines} more lines`)}`);
927
+ case "bashExecution": {
928
+ const component = new BashExecutionComponent(message.command, this.#ui, message.excludeFromContext);
929
+ if (message.output) component.appendOutput(message.output);
930
+ component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
931
+ this.#chatLog.addChild(component);
932
+ break;
844
933
  }
845
- } else {
846
- // Collapsed thinking: brief italic preview
847
- const thinkingLines = displayText.split("\n");
848
- const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
849
- for (let i = 0; i < Math.min(thinkingLines.length, maxLines); i++) {
850
- lines.push(`${INDENT}${theme.fg("thinkingText", sanitizeLine(thinkingLines[i]))}`);
934
+ case "pythonExecution": {
935
+ const component = new EvalExecutionComponent(message.code, this.#ui, message.excludeFromContext);
936
+ if (message.output) component.appendOutput(message.output);
937
+ component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
938
+ this.#chatLog.addChild(component);
939
+ break;
851
940
  }
852
- if (thinkingLines.length > maxLines) {
853
- lines.push(`${INDENT}${theme.fg("dim", `... ${thinkingLines.length - maxLines} more lines`)}`);
941
+ case "hookMessage":
942
+ case "custom":
943
+ this.#appendCustomMessage(message);
944
+ break;
945
+ case "compactionSummary": {
946
+ const component = new CompactionSummaryMessageComponent(message);
947
+ this.#trackExpandable(component);
948
+ this.#chatLog.addChild(component);
949
+ break;
854
950
  }
951
+ case "branchSummary": {
952
+ const component = new BranchSummaryMessageComponent(message);
953
+ this.#trackExpandable(component);
954
+ this.#chatLog.addChild(component);
955
+ break;
956
+ }
957
+ case "fileMention": {
958
+ const block = new TranscriptBlock();
959
+ for (const file of message.files) {
960
+ let suffix: string;
961
+ if (file.skippedReason === "tooLarge") {
962
+ const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
963
+ suffix = `(skipped: ${size})`;
964
+ } else {
965
+ suffix = file.image
966
+ ? "(image)"
967
+ : file.lineCount === undefined
968
+ ? "(unknown lines)"
969
+ : `(${file.lineCount} lines)`;
970
+ }
971
+ const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
972
+ "accent",
973
+ file.path,
974
+ )} ${theme.fg("dim", suffix)}`;
975
+ block.addChild(new Text(text, 0, 0));
976
+ }
977
+ if (block.children.length > 0) this.#chatLog.addChild(block);
978
+ break;
979
+ }
980
+ default:
981
+ message satisfies never;
855
982
  }
856
983
  }
857
984
 
858
- #renderTextLines(lines: string[], text: string, expanded: boolean, selected: boolean): void {
859
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
985
+ #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
986
+ const assistantComponent = new AssistantMessageComponent(message, this.#hideThinkingBlock?.() ?? false, () =>
987
+ this.#requestRender(),
988
+ );
989
+ assistantComponent.setUsageInfo(message.usage);
990
+ this.#chatLog.addChild(assistantComponent);
860
991
 
861
- lines.push("");
862
- lines.push(`${cursor} ${theme.fg("muted", "Response")}`);
992
+ const hasVisibleAssistantContent = message.content.some(
993
+ content =>
994
+ (content.type === "text" && content.text.trim().length > 0) ||
995
+ (content.type === "thinking" && content.thinking.trim().length > 0),
996
+ );
997
+ if (hasVisibleAssistantContent) {
998
+ // New visible turn content closes the current read run (mirrors rebuild).
999
+ this.#chatReadGroup?.seal();
1000
+ this.#chatReadGroup = null;
1001
+ }
863
1002
 
864
- if (expanded) {
865
- // Expanded: full markdown rendering
866
- const mdLines = this.#renderMarkdownToLines(text);
867
- for (const ml of mdLines) {
868
- lines.push(ml);
869
- }
870
- } else {
871
- // Collapsed: first few lines as plain text
872
- const textLines = text.split("\n");
873
- const maxLines = PREVIEW_LIMITS.COLLAPSED_LINES;
874
- const maxWidth = contentWidth();
875
- for (let i = 0; i < Math.min(textLines.length, maxLines); i++) {
876
- lines.push(`${INDENT}${sanitizeLine(textLines[i], maxWidth)}`);
1003
+ const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
1004
+ const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
1005
+ const errorMessage = hasErrorStop
1006
+ ? message.stopReason === "aborted"
1007
+ ? resolveAbortLabel(message.errorMessage)
1008
+ : message.errorMessage || "Error"
1009
+ : null;
1010
+
1011
+ for (const content of message.content) {
1012
+ if (content.type !== "toolCall") continue;
1013
+ this.#resolveWaitingPoll(content.name);
1014
+
1015
+ if (
1016
+ content.name === "read" &&
1017
+ readArgsHaveTarget(content.arguments) &&
1018
+ !readArgsTargetInternalUrl(content.arguments)
1019
+ ) {
1020
+ if (hasErrorStop && errorMessage) {
1021
+ const group = this.#ensureReadGroup();
1022
+ group.updateArgs(content.arguments, content.id);
1023
+ group.updateResult(
1024
+ { content: [{ type: "text", text: errorMessage }], isError: true },
1025
+ false,
1026
+ content.id,
1027
+ );
1028
+ } else {
1029
+ const normalizedArgs =
1030
+ content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
1031
+ ? (content.arguments as Record<string, unknown>)
1032
+ : {};
1033
+ this.#chatReadArgs.set(content.id, normalizedArgs);
1034
+ }
1035
+ continue;
877
1036
  }
878
- if (textLines.length > maxLines) {
879
- lines.push(`${INDENT}${theme.fg("dim", `... ${textLines.length - maxLines} more lines`)}`);
1037
+
1038
+ this.#chatReadGroup?.seal();
1039
+ this.#chatReadGroup = null;
1040
+ const component = new ToolExecutionComponent(
1041
+ content.name,
1042
+ content.arguments,
1043
+ {
1044
+ // Images can't be sliced through the scroll viewport; keep them off.
1045
+ showImages: false,
1046
+ editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
1047
+ editAllowFuzzy: settings.get("edit.fuzzyMatch"),
1048
+ liveRegion: this.#chatLog,
1049
+ },
1050
+ this.#getTool?.(content.name),
1051
+ this.#ui,
1052
+ this.#cwd,
1053
+ content.id,
1054
+ );
1055
+ this.#trackExpandable(component);
1056
+ this.#chatLog.addChild(component);
1057
+
1058
+ if (hasErrorStop && errorMessage) {
1059
+ component.updateResult(
1060
+ { content: [{ type: "text", text: errorMessage }], isError: true },
1061
+ false,
1062
+ content.id,
1063
+ );
1064
+ } else {
1065
+ this.#chatPendingTools.set(content.id, component);
880
1066
  }
881
1067
  }
882
1068
  }
883
1069
 
884
- #renderToolCallLines(
885
- lines: string[],
886
- call: { id: string; name: string; arguments: Record<string, unknown>; intent?: string },
887
- result: ToolResultMessage | undefined,
888
- expanded: boolean,
889
- selected: boolean,
890
- ): void {
891
- const cursor = selected ? theme.fg("accent", theme.nav.cursor) : " ";
892
- lines.push("");
893
-
894
- // Tool call header
895
- const intentStr = call.intent ? theme.fg("dim", ` ${sanitizeLine(call.intent, TRUNCATE_LENGTHS.SHORT)}`) : "";
896
- lines.push(`${cursor} ${theme.fg("accent", "▸")} ${theme.bold(theme.fg("muted", call.name))}${intentStr}`);
897
-
898
- // Key arguments
899
- const argSummary = this.#formatToolArgs(call.name, call.arguments);
900
- if (argSummary) {
901
- lines.push(`${INDENT}${theme.fg("dim", sanitizeLine(argSummary, contentWidth()))}`);
1070
+ #appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
1071
+ const pending = this.#chatPendingTools.get(message.toolCallId);
1072
+ const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent);
1073
+ if (isReadGroupResult) {
1074
+ let component = pending;
1075
+ if (!component) {
1076
+ const group = this.#ensureReadGroup();
1077
+ const args = this.#chatReadArgs.get(message.toolCallId);
1078
+ if (args) group.updateArgs(args, message.toolCallId);
1079
+ component = group;
1080
+ }
1081
+ component.updateResult(message, false, message.toolCallId);
1082
+ this.#chatPendingTools.delete(message.toolCallId);
1083
+ this.#chatReadArgs.delete(message.toolCallId);
1084
+ return;
902
1085
  }
903
-
904
- // Tool result
905
- if (result) {
906
- this.#renderToolResultLines(lines, result, expanded);
1086
+ if (!pending) return;
1087
+ pending.updateResult(message, false, message.toolCallId);
1088
+ this.#chatPendingTools.delete(message.toolCallId);
1089
+ if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
1090
+ this.#chatWaitingPoll = pending;
907
1091
  }
908
1092
  }
909
1093
 
910
- #renderToolResultLines(lines: string[], result: ToolResultMessage, expanded: boolean): void {
911
- const textParts = result.content
912
- .filter((p): p is { type: "text"; text: string } => p.type === "text")
913
- .map(p => p.text);
914
- const text = textParts.join("\n").trim();
915
-
916
- if (result.isError) {
917
- const errorLines = text.split("\n");
918
- const maxErrorLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.COLLAPSED_LINES;
919
- const maxWidth = contentWidth();
920
- lines.push(`${INDENT}${theme.fg("error", `✗ ${sanitizeLine(errorLines[0] || "Error", maxWidth)}`)}`);
921
- for (let i = 1; i < Math.min(errorLines.length, maxErrorLines); i++) {
922
- lines.push(`${INDENT} ${theme.fg("error", sanitizeLine(errorLines[i], maxWidth))}`);
923
- }
924
- if (errorLines.length > maxErrorLines) {
925
- lines.push(`${INDENT} ${theme.fg("dim", `... ${errorLines.length - maxErrorLines} more lines`)}`);
1094
+ #appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
1095
+ if (!message.display) return;
1096
+ if (message.customType === "async-result") {
1097
+ const details = (
1098
+ message as CustomMessage<{
1099
+ jobId?: string;
1100
+ type?: "bash" | "task";
1101
+ label?: string;
1102
+ durationMs?: number;
1103
+ jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
1104
+ }>
1105
+ ).details;
1106
+ const jobs =
1107
+ details?.jobs && details.jobs.length > 0
1108
+ ? details.jobs
1109
+ : [
1110
+ {
1111
+ jobId: details?.jobId,
1112
+ type: details?.type,
1113
+ label: details?.label,
1114
+ durationMs: details?.durationMs,
1115
+ },
1116
+ ];
1117
+ const block = new TranscriptBlock();
1118
+ for (const job of jobs) {
1119
+ const jobId = job.jobId ?? "unknown";
1120
+ const typeLabel = job.type ? `[${job.type}]` : "[job]";
1121
+ const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
1122
+ const line = [
1123
+ theme.fg("success", `${theme.status.done} Background job completed`),
1124
+ theme.fg("dim", typeLabel),
1125
+ theme.fg("accent", jobId),
1126
+ duration ? theme.fg("dim", `(${duration})`) : undefined,
1127
+ ]
1128
+ .filter(Boolean)
1129
+ .join(" ");
1130
+ block.addChild(new Text(line, 1, 0));
926
1131
  }
1132
+ this.#chatLog.addChild(block);
927
1133
  return;
928
1134
  }
929
-
930
- if (!text) {
931
- lines.push(`${INDENT}${theme.fg("dim", "✓ done")}`);
1135
+ if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
1136
+ const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details;
1137
+ const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
1138
+ this.#trackExpandable(component);
1139
+ this.#chatLog.addChild(component);
932
1140
  return;
933
1141
  }
934
-
935
- const resultLines = text.split("\n");
936
- const maxLines = expanded ? PREVIEW_LIMITS.EXPANDED_LINES : PREVIEW_LIMITS.OUTPUT_COLLAPSED;
937
-
938
- // Status line
939
- const statusPrefix = `${INDENT}${theme.fg("success", "✓")}`;
940
-
941
- if (resultLines.length === 1 && text.length < TRUNCATE_LENGTHS.LONG) {
942
- lines.push(`${statusPrefix} ${theme.fg("dim", sanitizeLine(text))}`);
1142
+ if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
1143
+ this.#chatLog.addChild(new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>));
943
1144
  return;
944
1145
  }
945
-
946
- lines.push(`${statusPrefix} ${theme.fg("dim", `${resultLines.length} lines`)}`);
947
- const displayLines = resultLines.slice(0, maxLines);
948
- for (const rl of displayLines) {
949
- lines.push(`${INDENT} ${theme.fg("dim", sanitizeLine(rl))}`);
950
- }
951
- if (resultLines.length > maxLines) {
952
- lines.push(`${INDENT} ${theme.fg("dim", `... ${resultLines.length - maxLines} more`)}`);
1146
+ if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
1147
+ const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
1148
+ this.#trackExpandable(component);
1149
+ this.#chatLog.addChild(component);
1150
+ return;
953
1151
  }
954
- }
955
-
956
- #formatToolArgs(toolName: string, args: Record<string, unknown>): string {
957
- switch (toolName) {
958
- case "read":
959
- case "write":
960
- case "edit":
961
- return args.path ? `path: ${args.path}` : "";
962
- case "search": {
963
- const searchPathsInput =
964
- typeof args.paths === "string" || Array.isArray(args.paths)
965
- ? args.paths
966
- : typeof args.path === "string"
967
- ? args.path
968
- : undefined;
969
- const searchPaths = toPathList(searchPathsInput);
970
- return [
971
- args.pattern ? `pattern: ${args.pattern}` : "",
972
- searchPaths.length > 0 ? `paths: ${searchPaths.join(", ")}` : "",
973
- ]
974
- .filter(Boolean)
975
- .join(", ");
976
- }
977
- case "find":
978
- return Array.isArray(args.paths) ? `paths: ${args.paths.join(", ")}` : "";
979
- case "bash": {
980
- const cmd = args.command;
981
- return typeof cmd === "string" ? replaceTabs(cmd) : "";
982
- }
983
- case "lsp":
984
- return [args.action, args.file, args.symbol].filter(Boolean).join(" ");
985
- case "ast_grep":
986
- case "ast_edit":
987
- return args.path ? `path: ${args.path}` : "";
988
- case "task": {
989
- const target = typeof args.agent === "string" ? args.agent : "";
990
- const id = typeof args.id === "string" && args.id ? ` ${args.id}` : "";
991
- return `${target}${id}`.trim();
992
- }
993
- default: {
994
- const parts: string[] = [];
995
- let total = 0;
996
- for (const key in args) {
997
- if (key.startsWith("_")) continue;
998
- const value = args[key];
999
- const v = typeof value === "string" ? value : JSON.stringify(value);
1000
- const entry = `${key}: ${replaceTabs(v ?? "")}`;
1001
- if (total + entry.length > MAX_TOOL_ARGS_CHARS) break;
1002
- parts.push(entry);
1003
- total += entry.length;
1004
- }
1005
- return parts.join(", ");
1006
- }
1152
+ if (
1153
+ message.customType === "irc:incoming" ||
1154
+ message.customType === "irc:autoreply" ||
1155
+ message.customType === "irc:relay"
1156
+ ) {
1157
+ const details = (
1158
+ message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
1159
+ ).details;
1160
+ const kind =
1161
+ message.customType === "irc:incoming"
1162
+ ? ("incoming" as const)
1163
+ : message.customType === "irc:autoreply"
1164
+ ? ("autoreply" as const)
1165
+ : ("relay" as const);
1166
+ const card = createIrcMessageCard(
1167
+ {
1168
+ kind,
1169
+ from: details?.from,
1170
+ to: details?.to,
1171
+ body: kind === "incoming" ? details?.message : details?.body,
1172
+ replyTo: details?.replyTo,
1173
+ timestamp: message.timestamp,
1174
+ },
1175
+ () => this.#chatExpanded,
1176
+ theme,
1177
+ );
1178
+ this.#chatLog.addChild(card);
1179
+ return;
1007
1180
  }
1181
+ const component = new CustomMessageComponent(
1182
+ message as CustomMessage<unknown>,
1183
+ this.#getMessageRenderer?.(message.customType),
1184
+ );
1185
+ this.#trackExpandable(component);
1186
+ this.#chatLog.addChild(component);
1008
1187
  }
1009
1188
 
1010
1189
  #loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
@@ -1024,31 +1203,80 @@ export class AgentHubOverlayComponent extends Container {
1024
1203
  return this.#loadTranscript(sessionFile);
1025
1204
  }
1026
1205
 
1206
+ this.#ingestTranscriptChunk(sessionFile, result.text, fromByte);
1207
+ return this.#transcriptCache?.entries ?? null;
1208
+ }
1209
+
1210
+ /** Parse a complete-line JSONL chunk into the transcript cache and advance bytesRead. Shared by the local file and remote paths. */
1211
+ #ingestTranscriptChunk(cacheKey: string, text: string, fromByte: number): void {
1027
1212
  if (!this.#transcriptCache) {
1028
- this.#transcriptCache = { path: sessionFile, bytesRead: 0, entries: [] };
1029
- }
1030
-
1031
- if (result.text.length > 0) {
1032
- const lastNewline = result.text.lastIndexOf("\n");
1033
- if (lastNewline >= 0) {
1034
- const completeChunk = result.text.slice(0, lastNewline + 1);
1035
- const newEntries = parseSessionEntries(completeChunk);
1036
- for (const entry of newEntries) {
1037
- if (entry.type === "message") {
1038
- this.#transcriptCache.entries.push(entry);
1039
- // Extract model from first assistant message
1040
- const msg = entry.message;
1041
- if (!this.#transcriptCache.model && msg.role === "assistant") {
1042
- this.#transcriptCache.model = msg.model;
1043
- }
1044
- } else if (entry.type === "model_change") {
1045
- this.#transcriptCache.model = entry.model;
1046
- }
1213
+ this.#transcriptCache = { path: cacheKey, bytesRead: 0, entries: [] };
1214
+ }
1215
+ if (text.length === 0) return;
1216
+ const lastNewline = text.lastIndexOf("\n");
1217
+ if (lastNewline < 0) return;
1218
+ const completeChunk = text.slice(0, lastNewline + 1);
1219
+ const newEntries = parseSessionEntries(completeChunk);
1220
+ for (const entry of newEntries) {
1221
+ if (entry.type === "message") {
1222
+ this.#transcriptCache.entries.push(entry);
1223
+ // Extract model from first assistant message
1224
+ const msg = entry.message;
1225
+ if (!this.#transcriptCache.model && msg.role === "assistant") {
1226
+ this.#transcriptCache.model = msg.model;
1047
1227
  }
1048
- this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
1228
+ } else if (entry.type === "model_change") {
1229
+ this.#transcriptCache.model = entry.model;
1049
1230
  }
1050
1231
  }
1051
- return this.#transcriptCache.entries;
1232
+ this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
1233
+ }
1234
+
1235
+ /** Kick an incremental transcript fetch from the collab host (single-flight). */
1236
+ #fetchRemoteTranscript(id: string): void {
1237
+ const remote = this.#remote;
1238
+ if (!remote || this.#remoteFetchInFlight) return;
1239
+ const cacheKey = `remote:${id}`;
1240
+ if (this.#transcriptCache && this.#transcriptCache.path !== cacheKey) {
1241
+ this.#transcriptCache = undefined;
1242
+ }
1243
+ const fromByte = this.#transcriptCache?.bytesRead ?? 0;
1244
+ this.#remoteFetchInFlight = true;
1245
+ const token = ++this.#remoteFetchToken;
1246
+ void remote
1247
+ .readTranscript(id, fromByte)
1248
+ .then(result => {
1249
+ if (token !== this.#remoteFetchToken) return;
1250
+ this.#remoteFetchInFlight = false;
1251
+ if (this.#chatAgentId !== id) return;
1252
+ if (!result) {
1253
+ if (!this.#transcriptCache || this.#transcriptCache.entries.length === 0) {
1254
+ if (!this.#remoteTranscriptUnavailable) {
1255
+ this.#remoteTranscriptUnavailable = true;
1256
+ this.#scheduleChatRefresh();
1257
+ }
1258
+ }
1259
+ return;
1260
+ }
1261
+ if (result.newSize < fromByte) {
1262
+ // Host transcript truncated/rotated — restart from 0.
1263
+ this.#transcriptCache = undefined;
1264
+ this.#fetchRemoteTranscript(id);
1265
+ return;
1266
+ }
1267
+ this.#remoteTranscriptUnavailable = false;
1268
+ const hadCache = this.#transcriptCache !== undefined;
1269
+ const before = this.#transcriptCache?.entries.length ?? 0;
1270
+ this.#ingestTranscriptChunk(cacheKey, result.text, fromByte);
1271
+ const after = this.#transcriptCache?.entries.length ?? 0;
1272
+ // Only refresh on new content (or first completed fetch) — an
1273
+ // unconditional rebuild would re-kick the fetch in a tight loop.
1274
+ if (after > before || !hadCache) this.#scheduleChatRefresh();
1275
+ })
1276
+ .catch((error: unknown) => {
1277
+ if (token === this.#remoteFetchToken) this.#remoteFetchInFlight = false;
1278
+ logger.warn("Agent hub: remote transcript fetch failed", { id, error: String(error) });
1279
+ });
1052
1280
  }
1053
1281
  }
1054
1282