@oh-my-pi/pi-coding-agent 16.0.10 → 16.1.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 (135) hide show
  1. package/CHANGELOG.md +57 -0
  2. package/dist/cli.js +3344 -3371
  3. package/dist/types/advisor/index.d.ts +1 -0
  4. package/dist/types/advisor/transcript-recorder.d.ts +52 -0
  5. package/dist/types/commit/agentic/agent.d.ts +1 -1
  6. package/dist/types/config/settings-schema.d.ts +14 -8
  7. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/types.d.ts +7 -0
  9. package/dist/types/modes/components/__tests__/skill-message.test.d.ts +1 -0
  10. package/dist/types/modes/components/agent-hub.d.ts +6 -1
  11. package/dist/types/modes/components/agent-transcript-viewer.d.ts +39 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +8 -0
  13. package/dist/types/modes/components/cache-invalidation-marker.d.ts +34 -0
  14. package/dist/types/modes/components/chat-transcript-builder.d.ts +42 -0
  15. package/dist/types/modes/components/compaction-summary-message.d.ts +14 -1
  16. package/dist/types/modes/components/index.d.ts +0 -1
  17. package/dist/types/modes/components/message-frame.d.ts +6 -4
  18. package/dist/types/modes/controllers/command-controller.d.ts +3 -2
  19. package/dist/types/modes/interactive-mode.d.ts +4 -2
  20. package/dist/types/modes/theme/theme.d.ts +7 -1
  21. package/dist/types/modes/types.d.ts +9 -2
  22. package/dist/types/registry/agent-registry.d.ts +10 -3
  23. package/dist/types/sdk.d.ts +1 -1
  24. package/dist/types/session/agent-session.d.ts +20 -1
  25. package/dist/types/session/compact-modes.d.ts +60 -0
  26. package/dist/types/session/session-context.d.ts +7 -0
  27. package/dist/types/session/session-dump-format.d.ts +1 -0
  28. package/dist/types/session/streaming-output.d.ts +0 -2
  29. package/dist/types/session/tool-choice-queue.d.ts +14 -0
  30. package/dist/types/system-prompt.d.ts +3 -3
  31. package/dist/types/tools/__tests__/json-tree.test.d.ts +1 -0
  32. package/dist/types/tools/index.d.ts +4 -0
  33. package/dist/types/tools/resolve.d.ts +15 -5
  34. package/package.json +12 -12
  35. package/src/advisor/index.ts +1 -0
  36. package/src/advisor/transcript-recorder.ts +136 -0
  37. package/src/cli/stats-cli.ts +2 -11
  38. package/src/collab/host.ts +25 -13
  39. package/src/commit/agentic/agent.ts +2 -1
  40. package/src/commit/agentic/tools/git-file-diff.ts +2 -2
  41. package/src/commit/changelog/index.ts +1 -1
  42. package/src/commit/map-reduce/map-phase.ts +1 -1
  43. package/src/commit/map-reduce/utils.ts +1 -1
  44. package/src/config/settings-schema.ts +16 -9
  45. package/src/config/settings.ts +0 -6
  46. package/src/debug/log-viewer.ts +4 -4
  47. package/src/debug/raw-sse.ts +4 -4
  48. package/src/edit/file-snapshot-store.ts +1 -1
  49. package/src/edit/renderer.ts +9 -9
  50. package/src/eval/js/tool-bridge.ts +3 -2
  51. package/src/eval/py/prelude.py +3 -2
  52. package/src/export/html/tool-views.generated.js +28 -28
  53. package/src/extensibility/extensions/types.ts +7 -0
  54. package/src/hindsight/mental-models.ts +1 -1
  55. package/src/internal-urls/docs-index.generated.txt +1 -1
  56. package/src/internal-urls/history-protocol.ts +8 -3
  57. package/src/irc/bus.ts +8 -0
  58. package/src/lsp/index.ts +2 -2
  59. package/src/lsp/render.ts +7 -7
  60. package/src/main.ts +4 -1
  61. package/src/modes/acp/acp-agent.ts +63 -0
  62. package/src/modes/components/__tests__/skill-message.test.ts +92 -0
  63. package/src/modes/components/agent-dashboard.ts +1 -1
  64. package/src/modes/components/agent-hub.ts +97 -920
  65. package/src/modes/components/agent-transcript-viewer.ts +461 -0
  66. package/src/modes/components/assistant-message.ts +21 -0
  67. package/src/modes/components/cache-invalidation-marker.ts +84 -0
  68. package/src/modes/components/chat-transcript-builder.ts +476 -0
  69. package/src/modes/components/compaction-summary-message.ts +29 -1
  70. package/src/modes/components/custom-message.ts +4 -1
  71. package/src/modes/components/diff.ts +12 -35
  72. package/src/modes/components/dynamic-border.ts +1 -1
  73. package/src/modes/components/extensions/extension-dashboard.ts +1 -1
  74. package/src/modes/components/extensions/inspector-panel.ts +5 -5
  75. package/src/modes/components/hook-selector.ts +2 -2
  76. package/src/modes/components/index.ts +0 -1
  77. package/src/modes/components/message-frame.ts +10 -6
  78. package/src/modes/components/model-selector.ts +2 -2
  79. package/src/modes/components/overlay-box.ts +10 -9
  80. package/src/modes/components/skill-message.ts +39 -19
  81. package/src/modes/components/tiny-title-download-progress.ts +1 -1
  82. package/src/modes/components/welcome.ts +1 -1
  83. package/src/modes/controllers/command-controller.ts +12 -2
  84. package/src/modes/controllers/event-controller.ts +15 -1
  85. package/src/modes/controllers/input-controller.ts +8 -1
  86. package/src/modes/controllers/selector-controller.ts +11 -1
  87. package/src/modes/interactive-mode.ts +13 -3
  88. package/src/modes/theme/theme.ts +14 -0
  89. package/src/modes/types.ts +9 -2
  90. package/src/modes/utils/ui-helpers.ts +20 -2
  91. package/src/prompts/steering/user-interjection.md +3 -4
  92. package/src/prompts/tools/read.md +1 -1
  93. package/src/registry/agent-registry.ts +13 -4
  94. package/src/sdk.ts +9 -7
  95. package/src/session/agent-session.ts +182 -16
  96. package/src/session/compact-modes.ts +105 -0
  97. package/src/session/messages.ts +7 -9
  98. package/src/session/session-context.ts +54 -7
  99. package/src/session/session-dump-format.ts +4 -2
  100. package/src/session/session-history-format.ts +1 -1
  101. package/src/session/snapcompact-inline.ts +2 -2
  102. package/src/session/streaming-output.ts +5 -5
  103. package/src/session/tool-choice-queue.ts +59 -0
  104. package/src/slash-commands/builtin-registry.ts +16 -4
  105. package/src/system-prompt.ts +10 -9
  106. package/src/task/executor.ts +1 -1
  107. package/src/task/output-manager.ts +5 -0
  108. package/src/tools/__tests__/json-tree.test.ts +35 -0
  109. package/src/tools/approval.ts +1 -1
  110. package/src/tools/bash-interactive.ts +4 -4
  111. package/src/tools/bash.ts +0 -1
  112. package/src/tools/browser.ts +0 -1
  113. package/src/tools/eval.ts +1 -1
  114. package/src/tools/gh.ts +1 -1
  115. package/src/tools/index.ts +4 -0
  116. package/src/tools/irc.ts +1 -1
  117. package/src/tools/json-tree.ts +22 -5
  118. package/src/tools/read.ts +5 -6
  119. package/src/tools/resolve.ts +66 -41
  120. package/src/tui/output-block.ts +9 -9
  121. package/src/web/scrapers/firefox-addons.ts +1 -1
  122. package/src/web/scrapers/github.ts +1 -1
  123. package/src/web/scrapers/go-pkg.ts +2 -2
  124. package/src/web/scrapers/metacpan.ts +2 -2
  125. package/src/web/scrapers/nvd.ts +2 -2
  126. package/src/web/scrapers/ollama.ts +1 -1
  127. package/src/web/scrapers/opencorporates.ts +1 -1
  128. package/src/web/scrapers/pub-dev.ts +1 -1
  129. package/src/web/scrapers/repology.ts +1 -1
  130. package/src/web/scrapers/sourcegraph.ts +1 -1
  131. package/src/web/scrapers/terraform.ts +6 -6
  132. package/src/web/scrapers/wikidata.ts +2 -2
  133. package/src/workspace-tree.ts +1 -1
  134. package/dist/types/modes/components/branch-summary-message.d.ts +0 -13
  135. package/src/modes/components/branch-summary-message.ts +0 -46
@@ -15,63 +15,26 @@
15
15
  */
16
16
  import * as fs from "node:fs";
17
17
  import * as path from "node:path";
18
- import type { AgentMessage, AgentTool } from "@oh-my-pi/pi-agent-core";
19
- import type { Usage } from "@oh-my-pi/pi-ai";
20
- import { Container, Editor, Ellipsis, matchesKey, ScrollView, Text, type TUI } from "@oh-my-pi/pi-tui";
21
- import { formatAge, formatBytes, formatDuration, formatNumber, getProjectDir, logger } from "@oh-my-pi/pi-utils";
22
- import type { AdvisorMessageDetails } from "../../advisor";
23
- import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
18
+ import type { AgentTool } from "@oh-my-pi/pi-agent-core";
19
+ import { Container, Ellipsis, matchesKey, type OverlayHandle, type TUI } from "@oh-my-pi/pi-tui";
20
+ import { formatAge, getProjectDir, logger } from "@oh-my-pi/pi-utils";
21
+ import { ADVISOR_TRANSCRIPT_FILENAME } from "../../advisor";
24
22
  import type { KeyId } from "../../config/keybindings";
25
- import { settings } from "../../config/settings";
26
23
  import type { MessageRenderer } from "../../extensibility/extensions/types";
27
24
  import { IrcBus } from "../../irc/bus";
28
25
  import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
29
26
  import { type AgentRef, AgentRegistry, type AgentStatus, MAIN_AGENT_ID } from "../../registry/agent-registry";
30
- import type { AgentSession } from "../../session/agent-session";
31
- import {
32
- BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE,
33
- type CustomMessage,
34
- isSilentAbort,
35
- LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE,
36
- resolveAbortLabel,
37
- SKILL_PROMPT_MESSAGE_TYPE,
38
- type SkillPromptDetails,
39
- USER_INTERRUPT_LABEL,
40
- } from "../../session/messages";
41
- import type { SessionMessageEntry } from "../../session/session-entries";
42
- import { parseSessionEntries } from "../../session/session-loader";
43
- import { createIrcMessageCard } from "../../tools/irc";
27
+ import { USER_INTERRUPT_LABEL } from "../../session/messages";
44
28
  import { replaceTabs, TRUNCATE_LENGTHS, truncateToWidth } from "../../tools/render-utils";
45
- import { canonicalizeMessage } from "../../utils/thinking-display";
46
29
  import type { ObservableSession, SessionObserverRegistry } from "../session-observer-registry";
47
- import { getEditorTheme, theme } from "../theme/theme";
30
+ import { theme } from "../theme/theme";
48
31
  import { matchesSelectDown, matchesSelectUp } from "../utils/keybinding-matchers";
49
- import { createAdvisorMessageCard } from "./advisor-message";
50
- import { AssistantMessageComponent } from "./assistant-message";
51
- import { createBackgroundTanDispatchBlock } from "./background-tan-message";
52
- import { BashExecutionComponent } from "./bash-execution";
53
- import { BranchSummaryMessageComponent } from "./branch-summary-message";
54
- import { CollabPromptMessageComponent } from "./collab-prompt-message";
55
- import { CompactionSummaryMessageComponent, createHandoffSummaryMessageComponent } from "./compaction-summary-message";
56
- import { CustomMessageComponent } from "./custom-message";
32
+ import { AgentTranscriptViewer } from "./agent-transcript-viewer";
57
33
  import { DynamicBorder } from "./dynamic-border";
58
- import { EvalExecutionComponent } from "./eval-execution";
59
- import { type LateDiagnosticsFile, LateDiagnosticsMessageComponent } from "./late-diagnostics-message";
60
- import { ReadToolGroupComponent, readArgsHaveTarget, readArgsTargetInternalUrl } from "./read-tool-group";
61
- import { SkillMessageComponent } from "./skill-message";
62
- import { formatContextUsage } from "./status-line/context-thresholds";
63
- import { ToolExecutionComponent } from "./tool-execution";
64
- import { TranscriptBlock, TranscriptContainer } from "./transcript-container";
65
- import { createUsageRowBlock } from "./usage-row";
66
- import { UserMessageComponent } from "./user-message";
67
34
 
68
- /** Lines per page for PageUp/PageDown */
69
- const PAGE_SIZE = 15;
70
35
  /** Refresh cadence for the relative-time column */
71
36
  const AGE_TICK_MS = 5_000;
72
- /** Debounce for live-session transcript refreshes */
73
- const CHAT_REFRESH_DEBOUNCE_MS = 80;
74
- /** Double-tap window for the left-left "go to parent" gesture (matches the editor's). */
37
+ /** Double-tap window for the table's left-left "close hub" gesture. */
75
38
  const LEFT_TAP_WINDOW_MS = 500;
76
39
 
77
40
  /** Compute the max content width for the current terminal, accounting for chrome. */
@@ -120,8 +83,33 @@ function registerPersistedSubagentsFromDir(registry: AgentRegistry, dir: string,
120
83
  }
121
84
  for (const entry of entries) {
122
85
  if (!entry.isFile() || !entry.name.endsWith(".jsonl") || entry.name.includes(".bak")) continue;
123
- const id = entry.name.slice(0, -6);
124
86
  const sessionFile = path.join(dir, entry.name);
87
+ // The advisor transcript is observability-only: register it as a non-peer
88
+ // `advisor` kind under its owning session so the Hub can show its read-only
89
+ // transcript, but it never joins agent-facing rosters and is not revivable.
90
+ if (entry.name === ADVISOR_TRANSCRIPT_FILENAME) {
91
+ const owner = parentId ?? MAIN_AGENT_ID;
92
+ const advisorId = `${owner}/advisor`;
93
+ const existing = registry.get(advisorId);
94
+ // Never clobber a non-advisor ref that happens to share this id (a freak
95
+ // user task literally named `<owner>/advisor`): leave it, skip the advisor.
96
+ if (existing && existing.kind !== "advisor") continue;
97
+ if (existing?.sessionFile !== sessionFile) {
98
+ // The id is reused across `/new`; refresh it to the current session's file.
99
+ if (existing) registry.unregister(advisorId);
100
+ registry.register({
101
+ id: advisorId,
102
+ displayName: "advisor",
103
+ kind: "advisor",
104
+ parentId: owner,
105
+ session: null,
106
+ sessionFile,
107
+ status: "parked",
108
+ });
109
+ }
110
+ continue;
111
+ }
112
+ const id = entry.name.slice(0, -6);
125
113
  if (!registry.get(id)) {
126
114
  registry.register({
127
115
  id,
@@ -190,29 +178,17 @@ export class AgentHubOverlayComponent extends Container {
190
178
  #unsubscribers: Array<() => void> = [];
191
179
  #ageTimer: NodeJS.Timeout | undefined;
192
180
  #remote: AgentHubRemote | undefined;
193
- #remoteFetchInFlight = false;
194
- /** Invalidates stale in-flight fetch callbacks after openChat resets the cache. */
195
- #remoteFetchToken = 0;
196
- #remoteTranscriptUnavailable = false;
197
181
 
198
182
  // Table state
199
- #view: "table" | "chat" = "table";
200
183
  #rows: AgentRef[] = [];
201
184
  #selectedRow = 0;
202
185
  #notice: string | undefined;
203
186
  /** Captured row order from the first refresh; keeps the hub stable while open. */
204
187
  #rowOrder: Map<string, number> | undefined;
188
+ /** Double-tap window state for the table's left-left "close hub" gesture. */
189
+ #lastLeftTap = 0;
205
190
 
206
- // Chat state
207
- #chatAgentId: string | undefined;
208
- #editor: Editor;
209
- #sessionUnsubscribe: (() => void) | undefined;
210
- #attachedSession: AgentSession | undefined;
211
- #chatRefreshTimer: NodeJS.Timeout | undefined;
212
- #transcriptCache: { path: string; bytesRead: number; entries: SessionMessageEntry[]; model?: string } | undefined;
213
-
214
- // Chat transcript: the same component renderers as the main session
215
- // transcript, assembled incrementally from the persisted JSONL entries.
191
+ // Transcript-viewer launch deps (passed through to AgentTranscriptViewer).
216
192
  #ui: TUI;
217
193
  #getTool: ((name: string) => AgentTool | undefined) | undefined;
218
194
  #getMessageRenderer: ((customType: string) => MessageRenderer | undefined) | undefined;
@@ -220,25 +196,10 @@ export class AgentHubOverlayComponent extends Container {
220
196
  #hideThinkingBlock: (() => boolean) | undefined;
221
197
  #expandKeys: KeyId[];
222
198
  #focusAgent: ((id: string) => Promise<void>) | undefined;
223
- #chatLog = new TranscriptContainer();
224
- #chatEntriesRef: SessionMessageEntry[] | undefined;
225
- #chatBuiltCount = 0;
226
- #chatPendingTools = new Map<string, ToolExecutionComponent | ReadToolGroupComponent>();
227
- #chatReadArgs = new Map<string, Record<string, unknown>>();
228
- #chatReadGroup: ReadToolGroupComponent | null = null;
229
- #pendingUsage: Usage | undefined;
230
- #chatWaitingPoll: ToolExecutionComponent | null = null;
231
- #chatExpandables: Array<{ setExpanded(expanded: boolean): void }> = [];
232
- #chatExpanded = false;
233
- #chatPlaceholder: string | undefined;
234
199
 
235
- // Viewport state
236
- #scrollOffset = 0;
237
- #lastMaxScroll = 0;
238
- #viewportHeight = 20;
239
- #wasAtBottom = true;
240
- #viewerHeaderLines: string[] = [];
241
- #lastLeftTap = 0;
200
+ // Fullscreen transcript overlay opened by openChat(), if any.
201
+ #transcriptOverlay: OverlayHandle | undefined;
202
+ #transcriptViewer: AgentTranscriptViewer | undefined;
242
203
 
243
204
  constructor(deps: AgentHubDeps) {
244
205
  super();
@@ -265,10 +226,6 @@ export class AgentHubOverlayComponent extends Container {
265
226
  this.#expandKeys = deps.expandKeys ?? ["ctrl+o"];
266
227
  this.#focusAgent = deps.focusAgent;
267
228
 
268
- this.#editor = new Editor(getEditorTheme());
269
- this.#editor.setMaxHeight(4);
270
- this.#editor.onSubmit = text => this.#submitChatMessage(text);
271
-
272
229
  this.#unsubscribers.push(this.#registry.onChange(() => this.#onDataChange()));
273
230
  this.#unsubscribers.push(this.#observers.onChange(() => this.#onDataChange()));
274
231
  this.#ageTimer = setInterval(() => this.#requestRender(), AGE_TICK_MS);
@@ -294,17 +251,11 @@ export class AgentHubOverlayComponent extends Container {
294
251
  clearInterval(this.#ageTimer);
295
252
  this.#ageTimer = undefined;
296
253
  }
297
- if (this.#chatRefreshTimer) {
298
- clearTimeout(this.#chatRefreshTimer);
299
- this.#chatRefreshTimer = undefined;
300
- }
301
- this.#detachLiveSession();
302
- this.#resetChatLog();
254
+ this.#closeTranscriptOverlay();
303
255
  }
304
256
 
305
257
  override render(width: number): readonly string[] {
306
- const lines = this.#view === "table" ? this.#renderTable(width) : this.#renderChat(width);
307
- return lines.map(line => clampHubLine(line, width));
258
+ return this.#renderTable(width).map(line => clampHubLine(line, width));
308
259
  }
309
260
 
310
261
  handleInput(keyData: string): void {
@@ -315,30 +266,53 @@ export class AgentHubOverlayComponent extends Container {
315
266
  return;
316
267
  }
317
268
  }
318
- if (this.#view === "table") {
319
- this.#handleTableInput(keyData);
320
- } else {
321
- this.#handleChatInput(keyData);
322
- }
269
+ this.#handleTableInput(keyData);
323
270
  }
324
271
 
325
- /** Open the chat view for an agent id (public for table Enter and tests). */
272
+ /**
273
+ * Open the fullscreen transcript viewer for an agent id (public for table Enter
274
+ * and tests). Mounts {@link AgentTranscriptViewer} as a `fullscreen` overlay so it
275
+ * owns the alternate screen; the hub table stays mounted underneath and is
276
+ * restored when the viewer closes. No-op without a real TUI (render-only test stub).
277
+ */
326
278
  openChat(id: string): void {
327
279
  if (!this.#registry.get(id)) return;
328
- this.#view = "chat";
329
- this.#chatAgentId = id;
280
+ if (typeof this.#ui.showOverlay !== "function") return;
281
+ this.#closeTranscriptOverlay();
330
282
  this.#notice = undefined;
331
- this.#transcriptCache = undefined;
332
- this.#remoteTranscriptUnavailable = false;
333
- this.#remoteFetchInFlight = false;
334
- this.#remoteFetchToken++;
335
- this.#resetChatLog();
336
- this.#scrollOffset = 0;
337
- this.#wasAtBottom = true;
338
- this.#lastLeftTap = 0;
339
- this.#editor.setText("");
340
- this.#attachLiveSession();
341
- this.#rebuildChatContent();
283
+ const viewer = new AgentTranscriptViewer({
284
+ agentId: id,
285
+ registry: this.#registry,
286
+ remote: this.#remote,
287
+ observers: this.#observers,
288
+ lifecycle: this.#remote ? undefined : this.#lifecycle,
289
+ ui: this.#ui,
290
+ getTool: this.#getTool,
291
+ getMessageRenderer: this.#getMessageRenderer,
292
+ cwd: this.#cwd,
293
+ hideThinkingBlock: this.#hideThinkingBlock,
294
+ expandKeys: this.#expandKeys,
295
+ hubKeys: this.#hubKeys,
296
+ requestRender: this.#requestRender,
297
+ onClose: () => this.#closeTranscriptOverlay(),
298
+ onHubClose: () => {
299
+ this.#closeTranscriptOverlay();
300
+ this.#onDone();
301
+ },
302
+ });
303
+ this.#transcriptViewer = viewer;
304
+ this.#transcriptOverlay = this.#ui.showOverlay(viewer, { width: "100%", margin: 0, fullscreen: true });
305
+ this.#ui.setFocus(viewer);
306
+ this.#requestRender();
307
+ }
308
+
309
+ /** Close and dispose the transcript overlay, restoring focus to the hub table. */
310
+ #closeTranscriptOverlay(): void {
311
+ this.#transcriptOverlay?.hide();
312
+ this.#transcriptOverlay = undefined;
313
+ this.#transcriptViewer?.dispose();
314
+ this.#transcriptViewer = undefined;
315
+ if (typeof this.#ui.setFocus === "function") this.#ui.setFocus(this);
342
316
  this.#requestRender();
343
317
  }
344
318
 
@@ -348,12 +322,6 @@ export class AgentHubOverlayComponent extends Container {
348
322
 
349
323
  #onDataChange(): void {
350
324
  this.#refreshRows();
351
- if (this.#view === "chat") {
352
- // A revive/park swaps the live session out from under the chat view.
353
- this.#attachLiveSession();
354
- this.#scheduleChatRefresh();
355
- return;
356
- }
357
325
  this.#requestRender();
358
326
  }
359
327
 
@@ -389,39 +357,6 @@ export class AgentHubOverlayComponent extends Container {
389
357
  this.#selectedRow = keptIndex >= 0 ? keptIndex : Math.min(this.#selectedRow, Math.max(0, this.#rows.length - 1));
390
358
  }
391
359
 
392
- /** Subscribe to the chat agent's live session (if any) for transcript refreshes. Idempotent per session. */
393
- #attachLiveSession(): void {
394
- // Remote refs carry no live session handle; refreshes come from observer onChange.
395
- if (this.#remote) return;
396
- const session = this.#chatAgentId ? (this.#registry.get(this.#chatAgentId)?.session ?? undefined) : undefined;
397
- if (session === this.#attachedSession) return;
398
- this.#detachLiveSession();
399
- if (!session) return;
400
- this.#attachedSession = session;
401
- this.#sessionUnsubscribe = session.subscribe(event => {
402
- if (event.type === "message_end" || event.type === "tool_execution_end" || event.type === "agent_end") {
403
- this.#scheduleChatRefresh();
404
- }
405
- });
406
- }
407
-
408
- #detachLiveSession(): void {
409
- this.#sessionUnsubscribe?.();
410
- this.#sessionUnsubscribe = undefined;
411
- this.#attachedSession = undefined;
412
- }
413
-
414
- #scheduleChatRefresh(): void {
415
- if (this.#chatRefreshTimer) return;
416
- this.#chatRefreshTimer = setTimeout(() => {
417
- this.#chatRefreshTimer = undefined;
418
- if (this.#view !== "chat") return;
419
- this.#rebuildChatContent();
420
- this.#requestRender();
421
- }, CHAT_REFRESH_DEBOUNCE_MS);
422
- this.#chatRefreshTimer.unref?.();
423
- }
424
-
425
360
  #observableFor(id: string): ObservableSession | undefined {
426
361
  return this.#observers.getSessions().find(s => s.id === id);
427
362
  }
@@ -553,7 +488,9 @@ export class AgentHubOverlayComponent extends Container {
553
488
  #activateAgent(ref: AgentRef): void {
554
489
  this.#notice = undefined;
555
490
  const focusAgent = this.#focusAgent;
556
- if (this.#remote || !focusAgent) {
491
+ // Advisor refs are read-only transcripts with no live/ revivable session;
492
+ // open the in-hub chat view (file-backed) instead of trying to focus one.
493
+ if (ref.kind === "advisor" || this.#remote || !focusAgent) {
557
494
  this.openChat(ref.id);
558
495
  return;
559
496
  }
@@ -571,6 +508,11 @@ export class AgentHubOverlayComponent extends Container {
571
508
  #reviveSelected(): void {
572
509
  const ref = this.#rows[this.#selectedRow];
573
510
  if (!ref) return;
511
+ if (ref.kind === "advisor") {
512
+ this.#notice = `"${ref.id}" is a read-only advisor transcript — nothing to revive.`;
513
+ this.#requestRender();
514
+ return;
515
+ }
574
516
  if (ref.status !== "parked") {
575
517
  this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`;
576
518
  this.#requestRender();
@@ -595,6 +537,11 @@ export class AgentHubOverlayComponent extends Container {
595
537
  #killSelected(): void {
596
538
  const ref = this.#rows[this.#selectedRow];
597
539
  if (!ref) return;
540
+ if (ref.kind === "advisor") {
541
+ this.#notice = `"${ref.id}" is a read-only advisor transcript — cannot be killed.`;
542
+ this.#requestRender();
543
+ return;
544
+ }
598
545
  this.#notice = undefined;
599
546
  if (this.#remote) {
600
547
  this.#remote.kill(ref.id);
@@ -616,774 +563,4 @@ export class AgentHubOverlayComponent extends Container {
616
563
  this.#requestRender();
617
564
  })();
618
565
  }
619
-
620
- // ========================================================================
621
- // Chat view
622
- // ========================================================================
623
-
624
- #renderChat(width: number): string[] {
625
- const termHeight = process.stdout.rows || 40;
626
- const innerWidth = Math.max(20, width - 2);
627
- const editorLines = this.#editor.render(innerWidth);
628
- const noticeLine = this.#notice
629
- ? ` ${theme.fg("error", sanitizeLine(this.#notice, Math.max(10, width - 2)))}`
630
- : undefined;
631
- const footerLines = this.#buildChatFooterLines();
632
-
633
- // Header: border + headerLines + border; footer: notice? + editor + footer + border
634
- const headerChrome = this.#viewerHeaderLines.length + 2;
635
- const footerChrome = editorLines.length + footerLines.length + (noticeLine ? 1 : 0) + 1;
636
- this.#viewportHeight = Math.max(5, termHeight - headerChrome - footerChrome);
637
-
638
- const contentLines: readonly string[] = this.#chatPlaceholder
639
- ? [theme.fg("dim", this.#chatPlaceholder)]
640
- : this.#chatLog.render(innerWidth);
641
-
642
- const maxScroll = Math.max(0, contentLines.length - this.#viewportHeight);
643
- this.#lastMaxScroll = maxScroll;
644
- if (this.#wasAtBottom) this.#scrollOffset = maxScroll;
645
- this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, maxScroll));
646
-
647
- const lines: string[] = [];
648
- lines.push(...new DynamicBorder().render(width));
649
- for (const headerLine of this.#viewerHeaderLines) {
650
- lines.push(` ${headerLine}`);
651
- }
652
- lines.push(...new DynamicBorder().render(width));
653
-
654
- const scrollView = new ScrollView(
655
- contentLines.slice(this.#scrollOffset, this.#scrollOffset + this.#viewportHeight),
656
- {
657
- height: this.#viewportHeight,
658
- scrollbar: "auto",
659
- totalRows: contentLines.length,
660
- theme: { track: t => theme.fg("dim", t), thumb: t => theme.fg("accent", t) },
661
- },
662
- );
663
- scrollView.setScrollOffset(this.#scrollOffset);
664
- for (const row of scrollView.render(Math.max(1, width - 1))) lines.push(` ${row}`);
665
-
666
- if (noticeLine) lines.push(noticeLine);
667
- for (const editorLine of editorLines) lines.push(` ${editorLine}`);
668
- lines.push(...footerLines);
669
- lines.push(...new DynamicBorder().render(width));
670
- return lines;
671
- }
672
-
673
- #buildChatFooterLines(): string[] {
674
- const lines: string[] = [];
675
- const observed = this.#chatAgentId ? this.#observableFor(this.#chatAgentId) : undefined;
676
- const statsLine = this.#buildStatsLine(observed);
677
- if (statsLine) lines.push(` ${statsLine}`);
678
- lines.push(
679
- ` ${theme.fg("dim", `Enter:send Esc:back ←←:parent ${this.#expandKeys[0] ?? "ctrl+o"}:expand empty input: j/k:scroll g/G:top/bottom`)}`,
680
- );
681
- return lines;
682
- }
683
-
684
- #buildStatsLine(observed: ObservableSession | undefined): string {
685
- const progress = observed?.progress;
686
- if (!progress) return "";
687
- const stats: string[] = [];
688
- // Current per-turn context — match the status line's `<pct>%/<window>` gauge (e.g. `5.1%/1M`).
689
- if (progress.contextTokens && progress.contextTokens > 0) {
690
- const ctx =
691
- progress.contextWindow && progress.contextWindow > 0
692
- ? formatContextUsage((progress.contextTokens / progress.contextWindow) * 100, progress.contextWindow)
693
- : `${formatNumber(progress.contextTokens)}`;
694
- stats.push(ctx);
695
- }
696
- if (progress.durationMs > 0) {
697
- stats.push(formatDuration(progress.durationMs));
698
- }
699
- const parts: string[] = [];
700
- if (stats.length > 0 || progress.toolCount > 0) {
701
- const toolCountStat =
702
- progress.toolCount > 0 ? `${formatNumber(progress.toolCount)} ${theme.icon.extensionTool}` : undefined;
703
- const statSegments = [toolCountStat, ...stats].filter((segment): segment is string => Boolean(segment));
704
- parts.push(theme.fg("dim", statSegments.join(theme.sep.dot)));
705
- }
706
- if (progress.cost > 0) {
707
- parts.push(theme.fg("statusLineCost", `$${progress.cost.toFixed(2)}`));
708
- }
709
- return parts.join(theme.sep.dot);
710
- }
711
-
712
- /** Rebuild the chat header and sync transcript components from new entries */
713
- #rebuildChatContent(): void {
714
- const id = this.#chatAgentId;
715
- const ref = id ? this.#registry.get(id) : undefined;
716
-
717
- // Load transcript first so model info is available for the header
718
- let messageEntries: SessionMessageEntry[] | null = null;
719
- if (this.#remote) {
720
- if (id) this.#fetchRemoteTranscript(id);
721
- messageEntries = this.#transcriptCache?.entries ?? [];
722
- } else if (ref?.sessionFile) {
723
- messageEntries = this.#loadTranscript(ref.sessionFile);
724
- }
725
-
726
- this.#viewerHeaderLines = [];
727
- this.#viewerHeaderLines.push(theme.fg("accent", `Agent Hub > ${id ?? "?"}`));
728
- if (ref) {
729
- const observed = this.#observableFor(ref.id);
730
- const model = observed?.progress?.resolvedModel ?? this.#transcriptCache?.model;
731
- const kindTag = theme.fg("dim", ` ${ref.parentId ? `${ref.kind} · of ${ref.parentId}` : ref.kind}`);
732
- const modelLabel = model ? theme.fg("muted", `${theme.sep.dot}${model}`) : "";
733
- this.#viewerHeaderLines.push(`${theme.bold(ref.id)} ${statusBadge(ref.status)}${kindTag}${modelLabel}`);
734
- }
735
-
736
- if (!ref) {
737
- this.#chatPlaceholder = "Agent no longer registered.";
738
- } else if (!this.#remote && !ref.sessionFile) {
739
- this.#chatPlaceholder = "No session file available yet.";
740
- } else if (!messageEntries) {
741
- this.#chatPlaceholder = "Unable to read session file.";
742
- } else if (messageEntries.length === 0) {
743
- if (this.#remote && this.#remoteTranscriptUnavailable) {
744
- this.#chatPlaceholder = "Transcript lives on the host — not available.";
745
- } else if (this.#remote && !this.#transcriptCache) {
746
- this.#chatPlaceholder = "Loading transcript from host…";
747
- } else {
748
- this.#chatPlaceholder = "No messages yet.";
749
- }
750
- } else {
751
- this.#chatPlaceholder = undefined;
752
- this.#syncChatComponents(messageEntries);
753
- }
754
- }
755
-
756
- #handleChatInput(keyData: string): void {
757
- const editorEmpty = this.#editor.getText().trim() === "";
758
-
759
- if (matchesKey(keyData, "escape")) {
760
- if (!editorEmpty) {
761
- this.#editor.setText("");
762
- this.#requestRender();
763
- return;
764
- }
765
- this.#closeChat();
766
- return;
767
- }
768
-
769
- // Tool output expansion mirrors the main transcript's app.tools.expand toggle.
770
- for (const key of this.#expandKeys) {
771
- if (matchesKey(keyData, key)) {
772
- this.#chatExpanded = !this.#chatExpanded;
773
- for (const component of this.#chatExpandables) component.setExpanded(this.#chatExpanded);
774
- this.#requestRender();
775
- return;
776
- }
777
- }
778
-
779
- // Double-tap left on an empty editor hops to the parent session —
780
- // the inverse of the main editor's double-left opening the hub.
781
- if (editorEmpty && matchesKey(keyData, "left")) {
782
- const now = Date.now();
783
- if (now - this.#lastLeftTap < LEFT_TAP_WINDOW_MS) {
784
- this.#lastLeftTap = 0;
785
- this.#openParent();
786
- } else {
787
- this.#lastLeftTap = now;
788
- }
789
- return;
790
- }
791
-
792
- // Scrolling works while the input is empty; once the user starts
793
- // typing, the editor owns every key.
794
- if (editorEmpty && this.#handleViewerNavigation(keyData)) {
795
- return;
796
- }
797
-
798
- this.#editor.handleInput(keyData);
799
- this.#requestRender();
800
- }
801
-
802
- /** Open the chat for the agent's parent, or close the hub when the parent is the main session. */
803
- #openParent(): void {
804
- const ref = this.#chatAgentId ? this.#registry.get(this.#chatAgentId) : undefined;
805
- const parentId = ref?.parentId;
806
- if (parentId && parentId !== MAIN_AGENT_ID && this.#registry.get(parentId)) {
807
- this.openChat(parentId);
808
- return;
809
- }
810
- this.#onDone();
811
- }
812
-
813
- #closeChat(): void {
814
- this.#view = "table";
815
- this.#chatAgentId = undefined;
816
- this.#notice = undefined;
817
- this.#detachLiveSession();
818
- this.#resetChatLog();
819
- this.#refreshRows();
820
- this.#requestRender();
821
- }
822
-
823
- #submitChatMessage(text: string): void {
824
- const id = this.#chatAgentId;
825
- const trimmed = text.trim();
826
- if (!id || !trimmed) return;
827
- this.#editor.setText("");
828
- this.#notice = undefined;
829
- if (this.#remote) {
830
- this.#remote.chat(id, trimmed);
831
- this.#scheduleChatRefresh();
832
- this.#requestRender();
833
- return;
834
- }
835
- void (async () => {
836
- try {
837
- // Revives a parked agent; returns the live session for running/idle.
838
- const session = await this.#lifecycle().ensureLive(id);
839
- this.#attachLiveSession();
840
- // Steers a mid-turn agent; sends a normal prompt to an idle one.
841
- await session.prompt(trimmed, { streamingBehavior: "steer" });
842
- } catch (error) {
843
- this.#notice = error instanceof Error ? error.message : String(error);
844
- }
845
- this.#scheduleChatRefresh();
846
- this.#requestRender();
847
- })();
848
- this.#requestRender();
849
- }
850
-
851
- /** Viewport scrolling for the chat transcript. Returns true when handled. */
852
- #handleViewerNavigation(keyData: string): boolean {
853
- const maxScroll = this.#lastMaxScroll;
854
- const scrollBy = (delta: number) => {
855
- this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset + delta, maxScroll));
856
- this.#wasAtBottom = this.#scrollOffset >= maxScroll;
857
- this.#requestRender();
858
- };
859
- if (keyData === "j" || matchesSelectDown(keyData)) {
860
- scrollBy(1);
861
- return true;
862
- }
863
- if (keyData === "k" || matchesSelectUp(keyData)) {
864
- scrollBy(-1);
865
- return true;
866
- }
867
- if (matchesKey(keyData, "pageDown")) {
868
- scrollBy(PAGE_SIZE);
869
- return true;
870
- }
871
- if (matchesKey(keyData, "pageUp")) {
872
- scrollBy(-PAGE_SIZE);
873
- return true;
874
- }
875
- if (keyData === "G") {
876
- this.#scrollOffset = maxScroll;
877
- this.#wasAtBottom = true;
878
- this.#requestRender();
879
- return true;
880
- }
881
- if (keyData === "g") {
882
- this.#scrollOffset = 0;
883
- this.#wasAtBottom = maxScroll === 0;
884
- this.#requestRender();
885
- return true;
886
- }
887
- return false;
888
- }
889
-
890
- // ========================================================================
891
- // Transcript assembly — the same components as the main session transcript
892
- // (mirrors UiHelpers.renderSessionContext / addMessageToChat).
893
- // ========================================================================
894
-
895
- /** Tear down transcript components (sealing pending spinners) and reset build state. */
896
- #resetChatLog(): void {
897
- for (const pending of this.#chatPendingTools.values()) pending.seal();
898
- this.#chatPendingTools.clear();
899
- this.#chatReadArgs.clear();
900
- this.#chatReadGroup = null;
901
- this.#pendingUsage = undefined;
902
- this.#chatWaitingPoll = null;
903
- this.#chatExpandables = [];
904
- this.#chatLog.dispose();
905
- this.#chatLog.clear();
906
- this.#chatEntriesRef = undefined;
907
- this.#chatBuiltCount = 0;
908
- this.#chatPlaceholder = undefined;
909
- }
910
-
911
- /** Append components for entries not yet materialized. Rebuilds from scratch when the cache was replaced (agent switch, file rotation). */
912
- #syncChatComponents(entries: SessionMessageEntry[]): void {
913
- if (this.#chatEntriesRef !== entries) {
914
- this.#resetChatLog();
915
- this.#chatEntriesRef = entries;
916
- }
917
- for (let i = this.#chatBuiltCount; i < entries.length; i++) {
918
- this.#appendChatMessage(entries[i].message);
919
- }
920
- this.#chatBuiltCount = entries.length;
921
- // Flush the trailing turn's usage row only once its tools are materialized.
922
- // A read (or any tool) whose toolResult lands in a later debounced sync stays
923
- // pending in #chatReadArgs / #chatPendingTools; flushing now would emit the
924
- // row above it. The sync that drains the maps flushes it below the tools.
925
- if (this.#chatReadArgs.size === 0 && this.#chatPendingTools.size === 0) {
926
- this.#flushPendingUsage();
927
- }
928
- }
929
-
930
- #trackExpandable(component: { setExpanded(expanded: boolean): void }): void {
931
- component.setExpanded(this.#chatExpanded);
932
- this.#chatExpandables.push(component);
933
- }
934
-
935
- /** A `job` poll showing all-running is displaced by the next `job` call (mirrors the rebuild path). */
936
- #resolveWaitingPoll(nextToolName?: string): void {
937
- const previous = this.#chatWaitingPoll;
938
- if (!previous) return;
939
- this.#chatWaitingPoll = null;
940
- if (nextToolName === "job" && previous.isDisplaceableBlock()) {
941
- this.#chatLog.removeChild(previous);
942
- }
943
- previous.seal();
944
- }
945
-
946
- #ensureReadGroup(): ReadToolGroupComponent {
947
- if (!this.#chatReadGroup) {
948
- this.#chatReadGroup = new ReadToolGroupComponent({
949
- showContentPreview: settings.get("read.toolResultPreview"),
950
- });
951
- this.#trackExpandable(this.#chatReadGroup);
952
- this.#chatLog.addChild(this.#chatReadGroup);
953
- }
954
- return this.#chatReadGroup;
955
- }
956
-
957
- // The per-turn token-usage row must land below the turn's tool blocks, but
958
- // normal `read` calls only materialize their group in #appendToolResult. Defer
959
- // the row: stash it on the assistant message and flush once the turn's tools
960
- // are placed — before the next non-toolResult message and at the end of each
961
- // sync pass — sealing the read run so the row sits under it.
962
- #flushPendingUsage(): void {
963
- if (!this.#pendingUsage) return;
964
- this.#chatReadGroup?.seal();
965
- this.#chatReadGroup = null;
966
- this.#chatLog.addChild(createUsageRowBlock(this.#pendingUsage));
967
- this.#pendingUsage = undefined;
968
- }
969
-
970
- #appendChatMessage(message: AgentMessage): void {
971
- if (message.role !== "toolResult") this.#flushPendingUsage();
972
- switch (message.role) {
973
- case "assistant":
974
- this.#appendAssistantMessage(message);
975
- break;
976
- case "toolResult":
977
- this.#appendToolResult(message);
978
- break;
979
- case "user":
980
- case "developer": {
981
- // A user prompt closes the poll-displacement window, same as the live path.
982
- if (message.role === "user") this.#resolveWaitingPoll();
983
- const textContent =
984
- message.role !== "user"
985
- ? ""
986
- : typeof message.content === "string"
987
- ? message.content
988
- : message.content
989
- .filter((block): block is { type: "text"; text: string } => block.type === "text")
990
- .map(block => block.text)
991
- .join("");
992
- if (textContent) {
993
- const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
994
- this.#chatLog.addChild(new UserMessageComponent(textContent, isSynthetic));
995
- }
996
- break;
997
- }
998
- case "bashExecution": {
999
- const component = new BashExecutionComponent(message.command, this.#ui, message.excludeFromContext);
1000
- if (message.output) component.appendOutput(message.output);
1001
- component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
1002
- this.#chatLog.addChild(component);
1003
- break;
1004
- }
1005
- case "pythonExecution": {
1006
- const component = new EvalExecutionComponent(message.code, this.#ui, message.excludeFromContext);
1007
- if (message.output) component.appendOutput(message.output);
1008
- component.setComplete(message.exitCode, message.cancelled, { truncation: message.meta?.truncation });
1009
- this.#chatLog.addChild(component);
1010
- break;
1011
- }
1012
- case "hookMessage":
1013
- case "custom":
1014
- this.#appendCustomMessage(message);
1015
- break;
1016
- case "compactionSummary": {
1017
- const component = new CompactionSummaryMessageComponent(message);
1018
- this.#trackExpandable(component);
1019
- this.#chatLog.addChild(component);
1020
- break;
1021
- }
1022
- case "branchSummary": {
1023
- const component = new BranchSummaryMessageComponent(message);
1024
- this.#trackExpandable(component);
1025
- this.#chatLog.addChild(component);
1026
- break;
1027
- }
1028
- case "fileMention": {
1029
- const block = new TranscriptBlock();
1030
- for (const file of message.files) {
1031
- let suffix: string;
1032
- if (file.skippedReason === "tooLarge") {
1033
- const size = typeof file.byteSize === "number" ? formatBytes(file.byteSize) : "unknown size";
1034
- suffix = `(skipped: ${size})`;
1035
- } else {
1036
- suffix = file.image
1037
- ? "(image)"
1038
- : file.lineCount === undefined
1039
- ? "(unknown lines)"
1040
- : `(${file.lineCount} lines)`;
1041
- }
1042
- const text = `${theme.fg("dim", `${theme.tree.last} `)}${theme.fg("muted", "Read")} ${theme.fg(
1043
- "accent",
1044
- file.path,
1045
- )} ${theme.fg("dim", suffix)}`;
1046
- block.addChild(new Text(text, 0, 0));
1047
- }
1048
- if (block.children.length > 0) this.#chatLog.addChild(block);
1049
- break;
1050
- }
1051
- default:
1052
- message satisfies never;
1053
- }
1054
- }
1055
-
1056
- #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
1057
- const assistantComponent = new AssistantMessageComponent(message, this.#hideThinkingBlock?.() ?? false, () =>
1058
- this.#requestRender(),
1059
- );
1060
- this.#chatLog.addChild(assistantComponent);
1061
-
1062
- const hasVisibleAssistantContent = message.content.some(
1063
- content =>
1064
- (content.type === "text" && canonicalizeMessage(content.text)) ||
1065
- (content.type === "thinking" && canonicalizeMessage(content.thinking)),
1066
- );
1067
- if (hasVisibleAssistantContent) {
1068
- // New visible turn content closes the current read run (mirrors rebuild).
1069
- this.#chatReadGroup?.seal();
1070
- this.#chatReadGroup = null;
1071
- }
1072
-
1073
- const isAbortedSilently = message.stopReason === "aborted" && isSilentAbort(message.errorMessage);
1074
- const hasErrorStop = !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
1075
- const errorMessage = hasErrorStop
1076
- ? message.stopReason === "aborted"
1077
- ? resolveAbortLabel(message.errorMessage)
1078
- : message.errorMessage || "Error"
1079
- : null;
1080
-
1081
- for (const content of message.content) {
1082
- if (content.type !== "toolCall") continue;
1083
- this.#resolveWaitingPoll(content.name);
1084
-
1085
- if (
1086
- content.name === "read" &&
1087
- readArgsHaveTarget(content.arguments) &&
1088
- !readArgsTargetInternalUrl(content.arguments)
1089
- ) {
1090
- if (hasErrorStop && errorMessage) {
1091
- const group = this.#ensureReadGroup();
1092
- group.updateArgs(content.arguments, content.id);
1093
- group.updateResult(
1094
- { content: [{ type: "text", text: errorMessage }], isError: true },
1095
- false,
1096
- content.id,
1097
- );
1098
- } else {
1099
- const normalizedArgs =
1100
- content.arguments && typeof content.arguments === "object" && !Array.isArray(content.arguments)
1101
- ? (content.arguments as Record<string, unknown>)
1102
- : {};
1103
- this.#chatReadArgs.set(content.id, normalizedArgs);
1104
- }
1105
- continue;
1106
- }
1107
-
1108
- this.#chatReadGroup?.seal();
1109
- this.#chatReadGroup = null;
1110
- const component = new ToolExecutionComponent(
1111
- content.name,
1112
- content.arguments,
1113
- {
1114
- // Images can't be sliced through the scroll viewport; keep them off.
1115
- showImages: false,
1116
- editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
1117
- editAllowFuzzy: settings.get("edit.fuzzyMatch"),
1118
- liveRegion: this.#chatLog,
1119
- },
1120
- this.#getTool?.(content.name),
1121
- this.#ui,
1122
- this.#cwd,
1123
- content.id,
1124
- );
1125
- this.#trackExpandable(component);
1126
- this.#chatLog.addChild(component);
1127
-
1128
- if (hasErrorStop && errorMessage) {
1129
- component.updateResult(
1130
- { content: [{ type: "text", text: errorMessage }], isError: true },
1131
- false,
1132
- content.id,
1133
- );
1134
- } else {
1135
- this.#chatPendingTools.set(content.id, component);
1136
- }
1137
- }
1138
-
1139
- this.#pendingUsage = settings.get("display.showTokenUsage") ? message.usage : undefined;
1140
- }
1141
-
1142
- #appendToolResult(message: Extract<AgentMessage, { role: "toolResult" }>): void {
1143
- const pending = this.#chatPendingTools.get(message.toolCallId);
1144
- const isReadGroupResult = message.toolName === "read" && (!pending || pending instanceof ReadToolGroupComponent);
1145
- if (isReadGroupResult) {
1146
- let component = pending;
1147
- if (!component) {
1148
- const group = this.#ensureReadGroup();
1149
- const args = this.#chatReadArgs.get(message.toolCallId);
1150
- if (args) group.updateArgs(args, message.toolCallId);
1151
- component = group;
1152
- }
1153
- component.updateResult(message, false, message.toolCallId);
1154
- this.#chatPendingTools.delete(message.toolCallId);
1155
- this.#chatReadArgs.delete(message.toolCallId);
1156
- return;
1157
- }
1158
- if (!pending) return;
1159
- pending.updateResult(message, false, message.toolCallId);
1160
- this.#chatPendingTools.delete(message.toolCallId);
1161
- if (message.toolName === "job" && pending instanceof ToolExecutionComponent && pending.isDisplaceableBlock()) {
1162
- this.#chatWaitingPoll = pending;
1163
- }
1164
- }
1165
-
1166
- #appendCustomMessage(message: Extract<AgentMessage, { role: "custom" | "hookMessage" }>): void {
1167
- if (!message.display) return;
1168
- if (message.customType === "async-result") {
1169
- const details = (
1170
- message as CustomMessage<{
1171
- jobId?: string;
1172
- type?: "bash" | "task";
1173
- label?: string;
1174
- durationMs?: number;
1175
- jobs?: Array<{ jobId?: string; type?: "bash" | "task"; label?: string; durationMs?: number }>;
1176
- }>
1177
- ).details;
1178
- const jobs =
1179
- details?.jobs && details.jobs.length > 0
1180
- ? details.jobs
1181
- : [
1182
- {
1183
- jobId: details?.jobId,
1184
- type: details?.type,
1185
- label: details?.label,
1186
- durationMs: details?.durationMs,
1187
- },
1188
- ];
1189
- const block = new TranscriptBlock();
1190
- for (const job of jobs) {
1191
- const jobId = job.jobId ?? "unknown";
1192
- const typeLabel = job.type ? `[${job.type}]` : "[job]";
1193
- const duration = typeof job.durationMs === "number" ? formatDuration(job.durationMs) : undefined;
1194
- const line = [
1195
- theme.fg("success", `${theme.status.done} Background job completed`),
1196
- theme.fg("dim", typeLabel),
1197
- theme.fg("accent", jobId),
1198
- duration ? theme.fg("dim", `(${duration})`) : undefined,
1199
- ]
1200
- .filter(Boolean)
1201
- .join(" ");
1202
- block.addChild(new Text(line, 1, 0));
1203
- }
1204
- this.#chatLog.addChild(block);
1205
- return;
1206
- }
1207
- if (message.customType === LSP_LATE_DIAGNOSTIC_MESSAGE_TYPE) {
1208
- const details = (message as CustomMessage<{ files?: LateDiagnosticsFile[] }>).details;
1209
- const component = new LateDiagnosticsMessageComponent(details?.files ?? []);
1210
- this.#trackExpandable(component);
1211
- this.#chatLog.addChild(component);
1212
- return;
1213
- }
1214
- if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
1215
- this.#chatLog.addChild(new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>));
1216
- return;
1217
- }
1218
- if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
1219
- const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
1220
- this.#trackExpandable(component);
1221
- this.#chatLog.addChild(component);
1222
- return;
1223
- }
1224
- if (
1225
- message.customType === "irc:incoming" ||
1226
- message.customType === "irc:autoreply" ||
1227
- message.customType === "irc:relay"
1228
- ) {
1229
- const details = (
1230
- message as CustomMessage<{ from?: string; to?: string; message?: string; body?: string; replyTo?: string }>
1231
- ).details;
1232
- const kind =
1233
- message.customType === "irc:incoming"
1234
- ? ("incoming" as const)
1235
- : message.customType === "irc:autoreply"
1236
- ? ("autoreply" as const)
1237
- : ("relay" as const);
1238
- const card = createIrcMessageCard(
1239
- {
1240
- kind,
1241
- from: details?.from,
1242
- to: details?.to,
1243
- body: kind === "incoming" ? details?.message : details?.body,
1244
- replyTo: details?.replyTo,
1245
- timestamp: message.timestamp,
1246
- },
1247
- () => this.#chatExpanded,
1248
- theme,
1249
- );
1250
- this.#chatLog.addChild(card);
1251
- return;
1252
- }
1253
- if (message.customType === "advisor") {
1254
- const details = (message as CustomMessage<AdvisorMessageDetails>).details;
1255
- this.#chatLog.addChild(createAdvisorMessageCard(details, () => this.#chatExpanded, theme));
1256
- return;
1257
- }
1258
- if (message.customType === BACKGROUND_TAN_DISPATCH_MESSAGE_TYPE) {
1259
- this.#chatLog.addChild(createBackgroundTanDispatchBlock(message as CustomMessage<unknown>));
1260
- return;
1261
- }
1262
- const handoffComponent = createHandoffSummaryMessageComponent(
1263
- message as CustomMessage<unknown>,
1264
- this.#chatExpanded,
1265
- );
1266
- if (handoffComponent) {
1267
- this.#trackExpandable(handoffComponent);
1268
- this.#chatLog.addChild(handoffComponent);
1269
- return;
1270
- }
1271
- const component = new CustomMessageComponent(
1272
- message as CustomMessage<unknown>,
1273
- this.#getMessageRenderer?.(message.customType),
1274
- );
1275
- this.#trackExpandable(component);
1276
- this.#chatLog.addChild(component);
1277
- }
1278
-
1279
- #loadTranscript(sessionFile: string): SessionMessageEntry[] | null {
1280
- if (this.#transcriptCache && this.#transcriptCache.path !== sessionFile) {
1281
- this.#transcriptCache = undefined;
1282
- }
1283
-
1284
- const fromByte = this.#transcriptCache?.bytesRead ?? 0;
1285
- const result = readFileIncremental(sessionFile, fromByte);
1286
- if (!result) {
1287
- logger.debug("Agent hub: failed to read session file", { path: sessionFile });
1288
- return this.#transcriptCache?.entries ?? null;
1289
- }
1290
-
1291
- if (result.newSize < fromByte) {
1292
- this.#transcriptCache = undefined;
1293
- return this.#loadTranscript(sessionFile);
1294
- }
1295
-
1296
- this.#ingestTranscriptChunk(sessionFile, result.text, fromByte);
1297
- return this.#transcriptCache?.entries ?? null;
1298
- }
1299
-
1300
- /** Parse a complete-line JSONL chunk into the transcript cache and advance bytesRead. Shared by the local file and remote paths. */
1301
- #ingestTranscriptChunk(cacheKey: string, text: string, fromByte: number): void {
1302
- if (!this.#transcriptCache) {
1303
- this.#transcriptCache = { path: cacheKey, bytesRead: 0, entries: [] };
1304
- }
1305
- if (text.length === 0) return;
1306
- const lastNewline = text.lastIndexOf("\n");
1307
- if (lastNewline < 0) return;
1308
- const completeChunk = text.slice(0, lastNewline + 1);
1309
- const newEntries = parseSessionEntries(completeChunk);
1310
- for (const entry of newEntries) {
1311
- if (entry.type === "message") {
1312
- this.#transcriptCache.entries.push(entry);
1313
- // Extract model from first assistant message
1314
- const msg = entry.message;
1315
- if (!this.#transcriptCache.model && msg.role === "assistant") {
1316
- this.#transcriptCache.model = msg.model;
1317
- }
1318
- } else if (entry.type === "model_change") {
1319
- this.#transcriptCache.model = entry.model;
1320
- }
1321
- }
1322
- this.#transcriptCache.bytesRead = fromByte + Buffer.byteLength(completeChunk, "utf-8");
1323
- }
1324
-
1325
- /** Kick an incremental transcript fetch from the collab host (single-flight). */
1326
- #fetchRemoteTranscript(id: string): void {
1327
- const remote = this.#remote;
1328
- if (!remote || this.#remoteFetchInFlight) return;
1329
- const cacheKey = `remote:${id}`;
1330
- if (this.#transcriptCache && this.#transcriptCache.path !== cacheKey) {
1331
- this.#transcriptCache = undefined;
1332
- }
1333
- const fromByte = this.#transcriptCache?.bytesRead ?? 0;
1334
- this.#remoteFetchInFlight = true;
1335
- const token = ++this.#remoteFetchToken;
1336
- void remote
1337
- .readTranscript(id, fromByte)
1338
- .then(result => {
1339
- if (token !== this.#remoteFetchToken) return;
1340
- this.#remoteFetchInFlight = false;
1341
- if (this.#chatAgentId !== id) return;
1342
- if (!result) {
1343
- if (!this.#transcriptCache || this.#transcriptCache.entries.length === 0) {
1344
- if (!this.#remoteTranscriptUnavailable) {
1345
- this.#remoteTranscriptUnavailable = true;
1346
- this.#scheduleChatRefresh();
1347
- }
1348
- }
1349
- return;
1350
- }
1351
- if (result.newSize < fromByte) {
1352
- // Host transcript truncated/rotated — restart from 0.
1353
- this.#transcriptCache = undefined;
1354
- this.#fetchRemoteTranscript(id);
1355
- return;
1356
- }
1357
- this.#remoteTranscriptUnavailable = false;
1358
- const hadCache = this.#transcriptCache !== undefined;
1359
- const before = this.#transcriptCache?.entries.length ?? 0;
1360
- this.#ingestTranscriptChunk(cacheKey, result.text, fromByte);
1361
- const after = this.#transcriptCache?.entries.length ?? 0;
1362
- // Only refresh on new content (or first completed fetch) — an
1363
- // unconditional rebuild would re-kick the fetch in a tight loop.
1364
- if (after > before || !hadCache) this.#scheduleChatRefresh();
1365
- })
1366
- .catch((error: unknown) => {
1367
- if (token === this.#remoteFetchToken) this.#remoteFetchInFlight = false;
1368
- logger.warn("Agent hub: remote transcript fetch failed", { id, error: String(error) });
1369
- });
1370
- }
1371
- }
1372
-
1373
- // Sync helper for the render path
1374
- function readFileIncremental(filePath: string, fromByte: number): { text: string; newSize: number } | null {
1375
- try {
1376
- const stat = fs.statSync(filePath);
1377
- if (stat.size <= fromByte) return { text: "", newSize: stat.size };
1378
- const buf = Buffer.alloc(stat.size - fromByte);
1379
- const fd = fs.openSync(filePath, "r");
1380
- try {
1381
- fs.readSync(fd, buf, 0, buf.length, fromByte);
1382
- } finally {
1383
- fs.closeSync(fd);
1384
- }
1385
- return { text: buf.toString("utf-8"), newSize: stat.size };
1386
- } catch {
1387
- return null;
1388
- }
1389
566
  }