@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17

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 (143) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli.js +3531 -3812
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/model-discovery.d.ts +1 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/settings-schema.d.ts +12 -1
  11. package/dist/types/eval/agent-bridge.d.ts +19 -0
  12. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  13. package/dist/types/eval/js/shared/types.d.ts +1 -1
  14. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  15. package/dist/types/eval/py/executor.d.ts +1 -1
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  17. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  18. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  19. package/dist/types/hindsight/content.d.ts +2 -10
  20. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  21. package/dist/types/main.d.ts +2 -0
  22. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  25. package/dist/types/modes/components/session-selector.d.ts +25 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  30. package/dist/types/session/agent-session.d.ts +6 -6
  31. package/dist/types/session/provider-image-budget.d.ts +3 -0
  32. package/dist/types/session/session-context.d.ts +6 -5
  33. package/dist/types/task/isolation-runner.d.ts +128 -0
  34. package/dist/types/task/parallel.d.ts +4 -0
  35. package/dist/types/task/worktree.d.ts +14 -1
  36. package/dist/types/thinking.d.ts +23 -1
  37. package/dist/types/tiny/title-client.d.ts +2 -2
  38. package/dist/types/tools/eval-render.d.ts +3 -0
  39. package/dist/types/tools/eval.d.ts +11 -17
  40. package/dist/types/tools/todo.d.ts +26 -28
  41. package/dist/types/tui/output-block.d.ts +8 -0
  42. package/dist/types/utils/image-resize.d.ts +2 -0
  43. package/dist/types/utils/tools-manager.d.ts +2 -0
  44. package/dist/types/web/search/providers/exa.d.ts +2 -0
  45. package/package.json +12 -12
  46. package/scripts/build-binary.ts +18 -4
  47. package/src/auto-thinking/classifier.ts +7 -2
  48. package/src/cli/args.ts +4 -5
  49. package/src/cli/flag-tables.ts +3 -3
  50. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  51. package/src/cli/gallery-fixtures/shell.ts +15 -23
  52. package/src/cli/profile-alias.ts +38 -7
  53. package/src/cli/session-picker.ts +17 -3
  54. package/src/cli/usage-cli.ts +5 -1
  55. package/src/commands/launch.ts +3 -3
  56. package/src/config/model-discovery.ts +59 -8
  57. package/src/config/model-registry.ts +74 -3
  58. package/src/config/settings-schema.ts +13 -1
  59. package/src/discovery/omp-extension-roots.ts +1 -3
  60. package/src/edit/renderer.ts +34 -12
  61. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  62. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  63. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  64. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  65. package/src/eval/agent-bridge.ts +301 -71
  66. package/src/eval/jl/prelude.jl +32 -227
  67. package/src/eval/jl/runner.jl +38 -12
  68. package/src/eval/js/shared/helpers.ts +1 -114
  69. package/src/eval/js/shared/prelude.txt +13 -27
  70. package/src/eval/js/shared/runtime.ts +0 -6
  71. package/src/eval/js/shared/types.ts +1 -1
  72. package/src/eval/js/worker-protocol.ts +1 -1
  73. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  74. package/src/eval/py/executor.ts +1 -1
  75. package/src/eval/py/prelude.py +47 -105
  76. package/src/eval/py/runner.py +0 -6
  77. package/src/eval/rb/prelude.rb +21 -189
  78. package/src/eval/rb/runner.rb +116 -9
  79. package/src/export/html/tool-views.generated.js +29 -29
  80. package/src/extensibility/extensions/wrapper.ts +3 -2
  81. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  83. package/src/extensibility/plugins/loader.ts +71 -23
  84. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  85. package/src/extensibility/tool-event-input.ts +57 -0
  86. package/src/hindsight/content.ts +21 -12
  87. package/src/internal-urls/docs-index.generated.txt +1 -1
  88. package/src/internal-urls/local-protocol.ts +100 -53
  89. package/src/main.ts +15 -4
  90. package/src/mcp/tool-bridge.ts +27 -2
  91. package/src/mnemopi/state.ts +5 -2
  92. package/src/modes/acp/acp-event-mapper.ts +7 -2
  93. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  94. package/src/modes/components/chat-transcript-builder.ts +6 -0
  95. package/src/modes/components/custom-editor.test.ts +18 -1
  96. package/src/modes/components/custom-editor.ts +77 -45
  97. package/src/modes/components/hook-editor.ts +15 -2
  98. package/src/modes/components/plugin-settings.ts +7 -1
  99. package/src/modes/components/session-selector.ts +143 -29
  100. package/src/modes/components/settings-selector.ts +2 -2
  101. package/src/modes/components/status-line/component.ts +52 -8
  102. package/src/modes/components/status-line/segments.ts +5 -1
  103. package/src/modes/components/status-line/types.ts +1 -0
  104. package/src/modes/components/welcome.ts +12 -14
  105. package/src/modes/controllers/command-controller.ts +21 -5
  106. package/src/modes/controllers/input-controller.ts +115 -3
  107. package/src/modes/controllers/selector-controller.ts +19 -1
  108. package/src/modes/interactive-mode.ts +3 -3
  109. package/src/modes/rpc/rpc-mode.ts +6 -0
  110. package/src/modes/utils/copy-targets.ts +7 -2
  111. package/src/modes/utils/ui-helpers.ts +3 -3
  112. package/src/prompts/system/system-prompt.md +3 -3
  113. package/src/prompts/system/workflow-notice.md +3 -3
  114. package/src/prompts/tools/bash.md +16 -0
  115. package/src/prompts/tools/eval.md +19 -19
  116. package/src/prompts/tools/todo.md +1 -1
  117. package/src/sdk.ts +8 -10
  118. package/src/session/agent-session.ts +422 -97
  119. package/src/session/provider-image-budget.ts +86 -0
  120. package/src/session/session-context.ts +14 -7
  121. package/src/session/session-storage.ts +24 -2
  122. package/src/session/snapcompact-inline.ts +19 -3
  123. package/src/slash-commands/builtin-registry.ts +0 -22
  124. package/src/slash-commands/helpers/usage-report.ts +9 -1
  125. package/src/task/index.ts +61 -207
  126. package/src/task/isolation-runner.ts +354 -0
  127. package/src/task/parallel.ts +6 -1
  128. package/src/task/worktree.ts +46 -9
  129. package/src/thinking.ts +29 -2
  130. package/src/tiny/title-client.ts +75 -21
  131. package/src/tools/ask.ts +44 -38
  132. package/src/tools/bash.ts +9 -2
  133. package/src/tools/browser/tab-worker.ts +1 -1
  134. package/src/tools/eval-render.ts +34 -27
  135. package/src/tools/eval.ts +100 -103
  136. package/src/tools/index.ts +8 -1
  137. package/src/tools/read.ts +136 -60
  138. package/src/tools/todo.ts +60 -64
  139. package/src/tui/code-cell.ts +1 -1
  140. package/src/tui/output-block.ts +11 -0
  141. package/src/utils/image-resize.ts +30 -0
  142. package/src/utils/tools-manager.ts +67 -10
  143. package/src/web/search/providers/exa.ts +85 -1
@@ -157,7 +157,7 @@ export function resolveLocalUrlToPath(
157
157
  }
158
158
 
159
159
  /**
160
- * On-disk roots the eval helpers (`read`/`write`/`append`) substitute for
160
+ * On-disk roots the eval helpers (`read`/`write`) substitute for
161
161
  * internal-URL schemes so e.g. `write("local://x.md")` lands where a later
162
162
  * `read local://x.md` resolves — instead of a literal `local:/` directory under
163
163
  * the cwd (a stdlib `pathlib.Path`/`path.resolve` collapses `local://` to
@@ -169,6 +169,96 @@ export function buildEvalUrlRoots(options: LocalProtocolOptions): Record<string,
169
169
  return { local: resolveLocalRoot(options) };
170
170
  }
171
171
 
172
+ const LOCAL_WRITE_NOTE = "Use write path local://<file> to persist large intermediate artifacts across turns.";
173
+
174
+ type ResolvedLocalTarget =
175
+ | { kind: "listing"; root: string }
176
+ | { kind: "directory"; path: string }
177
+ | { kind: "file"; path: string; size: number };
178
+
179
+ /**
180
+ * Resolve a local:// URL to its on-disk target with realpath + containment
181
+ * checks on the root, parent, and target so symlinks cannot escape the session
182
+ * local root. Does NOT read or decode file contents — callers decide how to
183
+ * consume the resolved path. Shared by {@link LocalProtocolHandler.resolve} and
184
+ * {@link resolveLocalUrlToFile}.
185
+ */
186
+ async function resolveLocalTarget(url: InternalUrl, opts: LocalProtocolOptions): Promise<ResolvedLocalTarget> {
187
+ const localRoot = path.resolve(resolveLocalRoot(opts));
188
+ await fs.mkdir(localRoot, { recursive: true });
189
+
190
+ let resolvedRoot: string;
191
+ try {
192
+ resolvedRoot = await fs.realpath(localRoot);
193
+ } catch (error) {
194
+ if (isEnoent(error)) {
195
+ throw new Error("Unable to initialize local:// root");
196
+ }
197
+ throw error;
198
+ }
199
+
200
+ const relativePath = extractRelativePath(url);
201
+ const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
202
+ ensureWithinRoot(targetPath, resolvedRoot);
203
+
204
+ if (targetPath === resolvedRoot) {
205
+ return { kind: "listing", root: resolvedRoot };
206
+ }
207
+
208
+ const parentDir = path.dirname(targetPath);
209
+ try {
210
+ const realParent = await fs.realpath(parentDir);
211
+ ensureWithinRoot(realParent, resolvedRoot);
212
+ } catch (error) {
213
+ if (!isEnoent(error)) throw error;
214
+ }
215
+
216
+ let realTargetPath: string;
217
+ try {
218
+ realTargetPath = await fs.realpath(targetPath);
219
+ } catch (error) {
220
+ if (isEnoent(error)) {
221
+ throw new Error(`Local file not found: ${url.href}`);
222
+ }
223
+ throw error;
224
+ }
225
+
226
+ ensureWithinRoot(realTargetPath, resolvedRoot);
227
+
228
+ const stat = await fs.stat(realTargetPath);
229
+ if (stat.isDirectory()) {
230
+ return { kind: "directory", path: realTargetPath };
231
+ }
232
+ if (!stat.isFile()) {
233
+ throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
234
+ }
235
+ return { kind: "file", path: realTargetPath, size: stat.size };
236
+ }
237
+
238
+ /**
239
+ * Resolve a local:// URL to a regular on-disk file, applying the same
240
+ * realpath + containment guarantees as {@link LocalProtocolHandler.resolve}
241
+ * but WITHOUT reading or UTF-8-decoding its contents. Returns null when there
242
+ * is no active session or when the URL targets the root listing or a directory;
243
+ * throws the handler's not-found and "escapes local root" errors for missing
244
+ * files and symlink escapes.
245
+ *
246
+ * Options are resolved via {@link LocalProtocolHandler.resolveOptions} so the
247
+ * caller-options → override → registry order matches router resolution exactly.
248
+ * The read tool uses this to detect and emit image files from their real path
249
+ * before the text-only resource contract would decode the binary into mojibake.
250
+ */
251
+ export async function resolveLocalUrlToFile(
252
+ input: string | InternalUrl,
253
+ context?: ResolveContext,
254
+ ): Promise<{ path: string; size: number } | null> {
255
+ const opts = LocalProtocolHandler.resolveOptions(context);
256
+ if (!opts) return null;
257
+ const url = typeof input === "string" ? parseLocalUrl(input) : input;
258
+ const resolved = await resolveLocalTarget(url, opts);
259
+ return resolved.kind === "file" ? { path: resolved.path, size: resolved.size } : null;
260
+ }
261
+
172
262
  /**
173
263
  * Protocol handler for local:// URLs.
174
264
  *
@@ -238,65 +328,22 @@ export class LocalProtocolHandler implements ProtocolHandler {
238
328
  throw new Error("No session - local:// unavailable");
239
329
  }
240
330
 
241
- const localRoot = path.resolve(resolveLocalRoot(opts));
242
- await fs.mkdir(localRoot, { recursive: true });
243
-
244
- let resolvedRoot: string;
245
- try {
246
- resolvedRoot = await fs.realpath(localRoot);
247
- } catch (error) {
248
- if (isEnoent(error)) {
249
- throw new Error("Unable to initialize local:// root");
250
- }
251
- throw error;
252
- }
253
-
254
- const relativePath = extractRelativePath(url);
255
- const targetPath = relativePath ? path.resolve(resolvedRoot, relativePath) : resolvedRoot;
256
- ensureWithinRoot(targetPath, resolvedRoot);
257
-
258
- if (targetPath === resolvedRoot) {
259
- return buildListing(url, resolvedRoot);
260
- }
261
-
262
- const parentDir = path.dirname(targetPath);
263
- try {
264
- const realParent = await fs.realpath(parentDir);
265
- ensureWithinRoot(realParent, resolvedRoot);
266
- } catch (error) {
267
- if (!isEnoent(error)) throw error;
268
- }
269
-
270
- let realTargetPath: string;
271
- try {
272
- realTargetPath = await fs.realpath(targetPath);
273
- } catch (error) {
274
- if (isEnoent(error)) {
275
- throw new Error(`Local file not found: ${url.href}`);
276
- }
277
- throw error;
278
- }
279
-
280
- ensureWithinRoot(realTargetPath, resolvedRoot);
281
-
282
- const stat = await fs.stat(realTargetPath);
283
- if (stat.isDirectory()) {
284
- return buildDirectoryResource(url.href, realTargetPath, [
285
- "Use write path local://<file> to persist large intermediate artifacts across turns.",
286
- ]);
331
+ const resolved = await resolveLocalTarget(url, opts);
332
+ if (resolved.kind === "listing") {
333
+ return buildListing(url, resolved.root);
287
334
  }
288
- if (!stat.isFile()) {
289
- throw new Error(`local:// URL must resolve to a file or directory: ${url.href}`);
335
+ if (resolved.kind === "directory") {
336
+ return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]);
290
337
  }
291
338
 
292
- const content = await Bun.file(realTargetPath).text();
339
+ const content = await Bun.file(resolved.path).text();
293
340
  return {
294
341
  url: url.href,
295
342
  content,
296
- contentType: getContentType(realTargetPath),
343
+ contentType: getContentType(resolved.path),
297
344
  size: Buffer.byteLength(content, "utf-8"),
298
- sourcePath: realTargetPath,
299
- notes: ["Use write path local://<file> to persist large intermediate artifacts across turns."],
345
+ sourcePath: resolved.path,
346
+ notes: [LOCAL_WRITE_NOTE],
300
347
  };
301
348
  }
302
349
 
package/src/main.ts CHANGED
@@ -945,6 +945,7 @@ async function buildSessionOptions(
945
945
  interface RunRootCommandDependencies {
946
946
  createAgentSession?: typeof createAgentSession;
947
947
  discoverAuthStorage?: typeof discoverAuthStorage;
948
+ selectSession?: typeof selectSession;
948
949
  runAcpMode?: RunAcpMode;
949
950
  settings?: Settings;
950
951
  forceSetupWizard?: boolean;
@@ -1131,7 +1132,8 @@ export async function runRootCommand(
1131
1132
  // (see issue #1668).
1132
1133
  if (typeof parsedArgs.resume === "string" && !sessionManager) {
1133
1134
  writeStartupNotice(parsedArgs, `${chalk.dim("Resume cancelled: session is in another project.")}\n`);
1134
- return;
1135
+ stopStartupWatchdog();
1136
+ process.exit(0);
1135
1137
  }
1136
1138
 
1137
1139
  // Handle --resume (no value): show session picker
@@ -1147,17 +1149,26 @@ export async function runRootCommand(
1147
1149
  preloadedAllSessions = await logger.time("SessionManager.listAll", SessionManager.listAll);
1148
1150
  if (preloadedAllSessions.length === 0) {
1149
1151
  writeStartupNotice(parsedArgs, `${chalk.dim("No sessions found")}\n`);
1150
- return;
1152
+ stopStartupWatchdog();
1153
+ process.exit(0);
1151
1154
  }
1152
1155
  }
1153
1156
  pauseStartupWatchdog();
1154
- const selected = await logger.time("selectSession", selectSession, folderSessions, {
1157
+ const selected = await logger.time("selectSession", deps.selectSession ?? selectSession, folderSessions, {
1155
1158
  allSessions: preloadedAllSessions,
1156
1159
  });
1157
1160
  resumeStartupWatchdog();
1158
1161
  if (!selected) {
1159
1162
  writeStartupNotice(parsedArgs, `${chalk.dim("No session selected")}\n`);
1160
- return;
1163
+ // Quit instead of returning: startup already armed long-lived handles
1164
+ // (theme watcher + SIGWINCH/macOS appearance listeners via initTheme,
1165
+ // settings save timer, model registry) that keep the event loop alive,
1166
+ // so a bare return hangs the process after the picker leaves the alt
1167
+ // screen. No session was built here, so there is nothing to flush. The
1168
+ // in-session `/resume` picker (selector-controller.ts) takes a different
1169
+ // onCancel that just closes the overlay — only this startup path exits.
1170
+ stopStartupWatchdog();
1171
+ process.exit(0);
1161
1172
  }
1162
1173
  // Resuming a session from another project: switch the process into that
1163
1174
  // project's directory and refresh cwd-derived caches before the session is
@@ -58,6 +58,31 @@ function normalizeToolArgs(value: unknown): MCPToolArgs {
58
58
  return value as MCPToolArgs;
59
59
  }
60
60
 
61
+ function isUnusedOptionalPlaceholder(value: unknown): boolean {
62
+ return (
63
+ value === undefined ||
64
+ value === "" ||
65
+ (typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0)
66
+ );
67
+ }
68
+
69
+ function omitUnusedOptionalArgs(args: MCPToolArgs, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
70
+ const properties = inputSchema.properties;
71
+ if (!properties) return args;
72
+
73
+ let cleaned: MCPToolArgs | undefined;
74
+ const required = new Set(inputSchema.required ?? []);
75
+ for (const [key, value] of Object.entries(args)) {
76
+ if (required.has(key) || !Object.hasOwn(properties, key) || !isUnusedOptionalPlaceholder(value)) {
77
+ continue;
78
+ }
79
+ cleaned ??= { ...args };
80
+ delete cleaned[key];
81
+ }
82
+
83
+ return cleaned ?? args;
84
+ }
85
+
61
86
  /** Details included in MCP tool results for rendering */
62
87
  export interface MCPToolDetails {
63
88
  /** Server name */
@@ -261,7 +286,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
261
286
  signal?: AbortSignal,
262
287
  ): Promise<CustomToolResult<MCPToolDetails>> {
263
288
  throwIfAborted(signal);
264
- const args = normalizeToolArgs(params);
289
+ const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
265
290
  const provider = this.connection._source?.provider;
266
291
  const providerName = this.connection._source?.providerName;
267
292
 
@@ -360,7 +385,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
360
385
  signal?: AbortSignal,
361
386
  ): Promise<CustomToolResult<MCPToolDetails>> {
362
387
  throwIfAborted(signal);
363
- const args = normalizeToolArgs(params);
388
+ const args = omitUnusedOptionalArgs(normalizeToolArgs(params), this.tool.inputSchema);
364
389
  const provider = this.#fallbackProvider;
365
390
  const providerName = this.#fallbackProviderName;
366
391
 
@@ -9,6 +9,7 @@ import {
9
9
  composeRecallQuery,
10
10
  formatCurrentTime,
11
11
  prepareRetentionTranscript,
12
+ prepareUserRetentionTranscript,
12
13
  truncateRecallQuery,
13
14
  } from "../hindsight/content";
14
15
  import { extractMessages } from "../hindsight/transcript";
@@ -352,6 +353,7 @@ export class MnemopiSessionState {
352
353
  async retainMessages(messages: Array<{ role: string; content: string }>, sourceId: string): Promise<void> {
353
354
  const { transcript, messageCount } = prepareRetentionTranscript(messages, true);
354
355
  if (!transcript) return;
356
+ const { transcript: extractText } = prepareUserRetentionTranscript(messages);
355
357
  this.rememberInScope(transcript, {
356
358
  source: "coding-agent-transcript",
357
359
  importance: 0.65,
@@ -362,8 +364,9 @@ export class MnemopiSessionState {
362
364
  cwd: this.session.sessionManager.getCwd(),
363
365
  },
364
366
  scope: "bank",
365
- extract: true,
366
- extractEntities: true,
367
+ extract: extractText !== null,
368
+ extractEntities: extractText !== null,
369
+ extractText,
367
370
  veracity: "unknown",
368
371
  memoryType: "episode",
369
372
  });
@@ -468,8 +468,13 @@ function buildEvalStartText(args: unknown): string | undefined {
468
468
  if (typeof args !== "object" || args === null || Array.isArray(args)) {
469
469
  return undefined;
470
470
  }
471
- const cells = (args as EvalCellContainer).cells;
472
- if (!Array.isArray(cells) || cells.length === 0) {
471
+ const container = args as EvalCellContainer & EvalCellLike;
472
+ const cells = Array.isArray(container.cells)
473
+ ? container.cells
474
+ : typeof container.code === "string"
475
+ ? [container]
476
+ : [];
477
+ if (cells.length === 0) {
473
478
  return undefined;
474
479
  }
475
480
  const lines: string[] = [];
@@ -7,16 +7,11 @@
7
7
  * compositing into the live transcript's scrollback. It renders a parked
8
8
  * subagent / advisor / collab-guest transcript that has no live in-view session.
9
9
  *
10
- * The transcript is rebuilt from scratch on every refresh ({@link ChatTranscriptBuilder.rebuild})
11
- * rather than synced incrementally, so a growing file-backed transcript (the
12
- * advisor appends while you watch) can never duplicate or misorder rows. Scroll
13
- * is owned end-to-end by a single {@link ScrollView}; the viewer follows the tail
14
- * until the reader scrolls up.
15
- *
16
- * Local agents re-read the whole session file whenever its size or mtime changes
17
- * (covering SessionManager's in-place rewrites, not just appends). Collab guests
18
- * keep the incremental byte cursor the host's capped `readTranscript` requires
19
- * and rebuild components from the accumulated entries.
10
+ * Local transcripts tail append-only growth: unchanged file identity plus stable
11
+ * sentinels means only newly appended JSONL is parsed and rendered. Rewrites,
12
+ * truncation, rotation, or sentinel drift fall back to a full rebuild so changed
13
+ * historical entries cannot leave stale components behind. Collab guests use the
14
+ * same append path over the host's byte-capped transcript reads.
20
15
  */
21
16
  import * as fs from "node:fs";
22
17
  import type { AgentTool } from "@oh-my-pi/pi-agent-core";
@@ -64,6 +59,56 @@ export interface AgentTranscriptViewerDeps {
64
59
  /** How often to re-stat a file-backed transcript for growth (advisor/live tail). */
65
60
  const POLL_MS = 250;
66
61
 
62
+ const SENTINEL_BYTES = 4096;
63
+
64
+ interface LocalTranscriptSentinel {
65
+ offset: number;
66
+ bytes: Buffer;
67
+ }
68
+
69
+ interface LocalTranscriptState {
70
+ path: string;
71
+ dev: number;
72
+ ino: number;
73
+ size: number;
74
+ mtimeMs: number;
75
+ offset: number;
76
+ pending: string;
77
+ sentinels: LocalTranscriptSentinel[];
78
+ }
79
+
80
+ function readFileRangeSync(file: string, offset: number, length: number): Buffer {
81
+ if (length <= 0) return Buffer.alloc(0);
82
+ const fd = fs.openSync(file, "r");
83
+ try {
84
+ const buffer = Buffer.alloc(length);
85
+ const bytesRead = fs.readSync(fd, buffer, 0, length, offset);
86
+ return bytesRead === length ? buffer : buffer.subarray(0, bytesRead);
87
+ } finally {
88
+ fs.closeSync(fd);
89
+ }
90
+ }
91
+
92
+ function sentinelOffsets(size: number): number[] {
93
+ if (size <= 0) return [];
94
+ const length = Math.min(SENTINEL_BYTES, size);
95
+ return [...new Set([0, Math.max(0, Math.floor((size - length) / 2)), Math.max(0, size - length)])];
96
+ }
97
+
98
+ function sentinelsFromBuffer(buffer: Buffer): LocalTranscriptSentinel[] {
99
+ const size = buffer.byteLength;
100
+ const length = Math.min(SENTINEL_BYTES, size);
101
+ return sentinelOffsets(size).map(offset => ({
102
+ offset,
103
+ bytes: Buffer.from(buffer.subarray(offset, offset + length)),
104
+ }));
105
+ }
106
+
107
+ function sentinelsFromFile(file: string, size: number): LocalTranscriptSentinel[] {
108
+ const length = Math.min(SENTINEL_BYTES, size);
109
+ return sentinelOffsets(size).map(offset => ({ offset, bytes: readFileRangeSync(file, offset, length) }));
110
+ }
111
+
67
112
  function statusBadge(status: AgentStatus): string {
68
113
  switch (status) {
69
114
  case "running":
@@ -85,10 +130,9 @@ export class AgentTranscriptViewer implements Component {
85
130
  #notice: string | undefined;
86
131
  #expanded = false;
87
132
 
88
- // Local file transcript state: re-read when the file size or mtime changes.
89
- #lastSignature = "";
133
+ #localState: LocalTranscriptState | undefined;
134
+ #localUnavailable = "";
90
135
  // Remote transcript state (incremental; the host caps each read).
91
- #remoteEntries: SessionMessageEntry[] = [];
92
136
  #remoteBytes = 0;
93
137
  #remoteFetchInFlight = false;
94
138
  #remoteToken = 0;
@@ -145,7 +189,7 @@ export class AgentTranscriptViewer implements Component {
145
189
  // Transcript loading
146
190
  // ========================================================================
147
191
 
148
- /** Re-read the transcript and rebuild components when it changed. */
192
+ /** Refresh the transcript from a local file or remote host. */
149
193
  #refresh(): void {
150
194
  if (this.#disposed) return;
151
195
  if (this.deps.remote) {
@@ -154,39 +198,132 @@ export class AgentTranscriptViewer implements Component {
154
198
  }
155
199
  const sessionFile = this.deps.registry.get(this.deps.agentId)?.sessionFile;
156
200
  if (!sessionFile) {
157
- if (this.#lastSignature !== "none") {
158
- this.#lastSignature = "none";
159
- this.#rebuild([]);
160
- }
201
+ this.#clearLocal("none");
161
202
  return;
162
203
  }
163
- let signature: string;
204
+ let stat: fs.Stats;
164
205
  try {
165
- const stat = fs.statSync(sessionFile);
166
- // Include the path: a different file with the same size/mtime must not alias.
167
- signature = `${sessionFile}:${stat.size}:${stat.mtimeMs}`;
206
+ stat = fs.statSync(sessionFile);
168
207
  } catch {
169
- // File deleted/rotated while open (e.g. the owning session was dropped):
170
- // clear stale content once instead of freezing on it forever.
171
- if (this.#lastSignature !== "missing") {
172
- this.#lastSignature = "missing";
173
- this.#model = undefined;
174
- this.#rebuild([]);
175
- }
208
+ this.#clearLocal("missing");
176
209
  return;
177
210
  }
178
- if (signature === this.#lastSignature) return;
179
- let text: string;
211
+ const state = this.#localState;
212
+ if (state && this.#canAppendLocal(sessionFile, stat, state)) {
213
+ if (stat.size === state.size && stat.mtimeMs === state.mtimeMs) return;
214
+ if (stat.size > state.size) {
215
+ this.#appendLocal(sessionFile, stat, state);
216
+ return;
217
+ }
218
+ }
219
+ this.#loadLocalFull(sessionFile, stat);
220
+ }
221
+
222
+ #clearLocal(reason: string): void {
223
+ if (!this.#localState && this.#localUnavailable === reason) return;
224
+ this.#localState = undefined;
225
+ this.#localUnavailable = reason;
226
+ this.#model = undefined;
227
+ this.#rebuild([]);
228
+ }
229
+
230
+ #canAppendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): boolean {
231
+ if (state.path !== sessionFile || state.dev !== stat.dev || state.ino !== stat.ino || stat.size < state.size)
232
+ return false;
233
+ for (const sentinel of state.sentinels) {
234
+ let current: Buffer;
235
+ try {
236
+ current = readFileRangeSync(sessionFile, sentinel.offset, sentinel.bytes.byteLength);
237
+ } catch (err) {
238
+ // The file can be unlinked/rotated between statSync and this read.
239
+ // Treat as not-appendable so #refresh falls back to a guarded full load.
240
+ logger.debug("transcript viewer: sentinel read failed", { err: String(err) });
241
+ return false;
242
+ }
243
+ if (!current.equals(sentinel.bytes)) return false;
244
+ }
245
+ return true;
246
+ }
247
+
248
+ #loadLocalFull(sessionFile: string, stat: fs.Stats): void {
249
+ let data: Buffer;
180
250
  try {
181
- text = fs.readFileSync(sessionFile, "utf-8");
251
+ data = fs.readFileSync(sessionFile);
182
252
  } catch (err) {
183
- // Leave #lastSignature unchanged so a transient read error retries next poll.
253
+ // Leave #localState unchanged so a transient read error retries next poll.
184
254
  logger.debug("transcript viewer: read failed", { err: String(err) });
185
255
  return;
186
256
  }
187
- this.#lastSignature = signature;
257
+ // The file may have grown between the earlier `statSync` and this read.
258
+ // Anchor the tail cursor to what we actually consumed so the next poll's
259
+ // `#appendLocal` never re-renders bytes already in the rebuilt transcript;
260
+ // re-stat for mtime/identity so the post-read clock matches what's on disk.
261
+ let post: fs.Stats;
262
+ try {
263
+ post = fs.statSync(sessionFile);
264
+ } catch {
265
+ post = stat;
266
+ }
267
+ // A reader that opens the file mid-append sees a trailing partial line
268
+ // (no terminating newline). Carry those bytes as `pending` so the next
269
+ // poll's `#appendLocal` joins them with the completion bytes instead of
270
+ // parsing a headless line fragment and dropping the entry.
271
+ const text = data.toString("utf-8");
272
+ const lastNewline = text.lastIndexOf("\n");
273
+ const complete = lastNewline >= 0 ? text.slice(0, lastNewline + 1) : "";
274
+ const pending = lastNewline >= 0 ? text.slice(lastNewline + 1) : text;
275
+ this.#localUnavailable = "";
276
+ this.#localState = {
277
+ path: sessionFile,
278
+ dev: post.dev,
279
+ ino: post.ino,
280
+ size: data.byteLength,
281
+ mtimeMs: post.mtimeMs,
282
+ offset: data.byteLength,
283
+ pending,
284
+ sentinels: sentinelsFromBuffer(data),
285
+ };
188
286
  this.#model = undefined;
189
- this.#rebuild(this.#extractMessages(parseSessionEntries(text)));
287
+ this.#rebuild(this.#extractMessages(parseSessionEntries(complete)));
288
+ }
289
+
290
+ #appendLocal(sessionFile: string, stat: fs.Stats, state: LocalTranscriptState): void {
291
+ let chunk: string;
292
+ try {
293
+ chunk = readFileRangeSync(sessionFile, state.offset, stat.size - state.offset).toString("utf-8");
294
+ } catch (err) {
295
+ logger.debug("transcript viewer: tail read failed", { err: String(err) });
296
+ this.#loadLocalFull(sessionFile, stat);
297
+ return;
298
+ }
299
+ const combined = state.pending + chunk;
300
+ const lastNewline = combined.lastIndexOf("\n");
301
+ const complete = lastNewline >= 0 ? combined.slice(0, lastNewline + 1) : "";
302
+ const previousModel = this.#model;
303
+ const parsed = complete ? this.#extractMessages(parseSessionEntries(complete)) : [];
304
+ let sentinels: LocalTranscriptSentinel[];
305
+ try {
306
+ sentinels = sentinelsFromFile(sessionFile, stat.size);
307
+ } catch (err) {
308
+ // File unlinked/rotated mid-poll: fall back to a guarded full reload
309
+ // instead of letting the open escape the poll timer.
310
+ logger.debug("transcript viewer: sentinel recompute failed", { err: String(err) });
311
+ this.#loadLocalFull(sessionFile, stat);
312
+ return;
313
+ }
314
+ this.#localState = {
315
+ ...state,
316
+ size: stat.size,
317
+ mtimeMs: stat.mtimeMs,
318
+ offset: stat.size,
319
+ pending: lastNewline >= 0 ? combined.slice(lastNewline + 1) : combined,
320
+ sentinels,
321
+ };
322
+ if (parsed.length > 0) {
323
+ this.#append(parsed);
324
+ } else if (this.#model !== previousModel) {
325
+ this.deps.requestRender();
326
+ }
190
327
  }
191
328
 
192
329
  #fetchRemote(): void {
@@ -209,9 +346,13 @@ export class AgentTranscriptViewer implements Component {
209
346
  return;
210
347
  }
211
348
  if (result.newSize < fromByte) {
212
- // Host transcript rotated/truncated — restart from 0.
349
+ // Host transcript rotated/truncated — drop the stale rendered rows
350
+ // before restarting; otherwise the post-rotation fetch would stack
351
+ // new content under the pre-rotation history.
213
352
  this.#remoteBytes = 0;
214
- this.#remoteEntries = [];
353
+ this.#hasRemoteData = false;
354
+ this.#model = undefined;
355
+ this.#rebuild([]);
215
356
  this.#fetchRemote();
216
357
  return;
217
358
  }
@@ -222,10 +363,14 @@ export class AgentTranscriptViewer implements Component {
222
363
  if (lastNewline >= 0) {
223
364
  const completeChunk = result.text.slice(0, lastNewline + 1);
224
365
  this.#remoteBytes = fromByte + Buffer.byteLength(completeChunk, "utf-8");
366
+ const previousModel = this.#model;
225
367
  const parsed = this.#extractMessages(parseSessionEntries(completeChunk));
226
368
  if (parsed.length > 0) {
227
- this.#remoteEntries.push(...parsed);
228
- this.#rebuild(this.#remoteEntries);
369
+ this.#append(parsed);
370
+ return;
371
+ }
372
+ if (this.#model !== previousModel) {
373
+ this.deps.requestRender();
229
374
  return;
230
375
  }
231
376
  }
@@ -257,6 +402,11 @@ export class AgentTranscriptViewer implements Component {
257
402
  this.deps.requestRender();
258
403
  }
259
404
 
405
+ #append(entries: SessionMessageEntry[]): void {
406
+ this.#builder.append(entries);
407
+ this.deps.requestRender();
408
+ }
409
+
260
410
  // ========================================================================
261
411
  // Input
262
412
  // ========================================================================
@@ -455,8 +605,10 @@ export class AgentTranscriptViewer implements Component {
455
605
  }
456
606
 
457
607
  #placeholder(): string {
458
- if (this.deps.remote && this.#remoteUnavailable) return "Transcript lives on the host — not available.";
459
- if (this.deps.remote && !this.#hasRemoteData) return "Loading transcript from host";
608
+ if (this.deps.remote) {
609
+ if (this.#remoteUnavailable) return "Transcript lives on the host — not available.";
610
+ return this.#hasRemoteData ? "No messages yet." : "Loading transcript from host…";
611
+ }
460
612
  if (!this.deps.registry.get(this.deps.agentId)?.sessionFile) return "No session file available yet.";
461
613
  return "No messages yet.";
462
614
  }
@@ -103,6 +103,12 @@ export class ChatTranscriptBuilder {
103
103
  if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
104
104
  }
105
105
 
106
+ /** Append newly persisted entries without rebuilding already rendered rows. */
107
+ append(entries: SessionMessageEntry[]): void {
108
+ for (const entry of entries) this.#appendChatMessage(entry.message);
109
+ if (this.#readArgs.size === 0 && this.#pendingTools.size === 0) this.#flushPendingUsage();
110
+ }
111
+
106
112
  /** Toggle tool-output expansion across every expandable component. */
107
113
  setExpanded(expanded: boolean): void {
108
114
  this.#expanded = expanded;