@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.18

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 (75) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +16646 -16608
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/usage-cli.d.ts +14 -0
  5. package/dist/types/commands/token.d.ts +9 -0
  6. package/dist/types/config/model-discovery.d.ts +1 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +7 -0
  9. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +34 -15
  10. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  11. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  12. package/dist/types/hindsight/content.d.ts +2 -10
  13. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  15. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +26 -10
  16. package/dist/types/modes/components/extensions/extension-list.d.ts +13 -0
  17. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  18. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  19. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  20. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  21. package/dist/types/session/agent-session.d.ts +6 -6
  22. package/dist/types/session/provider-image-budget.d.ts +3 -0
  23. package/dist/types/session/session-context.d.ts +6 -5
  24. package/dist/types/task/parallel.d.ts +4 -0
  25. package/dist/types/thinking.d.ts +8 -1
  26. package/dist/types/tiny/title-client.d.ts +2 -2
  27. package/dist/types/utils/tools-manager.d.ts +2 -0
  28. package/package.json +12 -12
  29. package/scripts/build-binary.ts +9 -16
  30. package/src/auto-thinking/classifier.ts +7 -2
  31. package/src/cli/profile-alias.ts +38 -7
  32. package/src/cli/usage-cli.ts +40 -5
  33. package/src/commands/token.ts +54 -0
  34. package/src/config/model-discovery.ts +59 -8
  35. package/src/config/model-registry.ts +74 -3
  36. package/src/discovery/omp-extension-roots.ts +1 -3
  37. package/src/extensibility/extensions/wrapper.ts +3 -2
  38. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  39. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +40 -0
  40. package/src/extensibility/plugins/legacy-pi-compat.ts +240 -90
  41. package/src/extensibility/plugins/loader.ts +71 -23
  42. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  43. package/src/extensibility/tool-event-input.ts +57 -0
  44. package/src/hindsight/content.ts +21 -12
  45. package/src/mcp/tool-bridge.ts +27 -2
  46. package/src/mnemopi/state.ts +5 -2
  47. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  48. package/src/modes/components/chat-transcript-builder.ts +6 -0
  49. package/src/modes/components/custom-editor.test.ts +18 -1
  50. package/src/modes/components/custom-editor.ts +77 -45
  51. package/src/modes/components/extensions/extension-dashboard.ts +221 -165
  52. package/src/modes/components/extensions/extension-list.ts +66 -33
  53. package/src/modes/components/hook-editor.ts +15 -2
  54. package/src/modes/components/settings-selector.ts +2 -2
  55. package/src/modes/components/status-line/component.ts +52 -8
  56. package/src/modes/components/status-line/segments.ts +5 -1
  57. package/src/modes/components/status-line/types.ts +1 -0
  58. package/src/modes/components/welcome.ts +12 -14
  59. package/src/modes/controllers/command-controller.ts +16 -5
  60. package/src/modes/controllers/input-controller.ts +115 -3
  61. package/src/modes/controllers/selector-controller.ts +23 -1
  62. package/src/modes/interactive-mode.ts +3 -3
  63. package/src/modes/utils/ui-helpers.ts +3 -3
  64. package/src/sdk.ts +8 -10
  65. package/src/session/agent-session.ts +193 -49
  66. package/src/session/provider-image-budget.ts +86 -0
  67. package/src/session/session-context.ts +14 -7
  68. package/src/session/session-storage.ts +24 -2
  69. package/src/session/snapcompact-inline.ts +19 -3
  70. package/src/slash-commands/builtin-registry.ts +0 -22
  71. package/src/slash-commands/helpers/usage-report.ts +9 -1
  72. package/src/task/parallel.ts +6 -1
  73. package/src/thinking.ts +9 -2
  74. package/src/tiny/title-client.ts +75 -21
  75. package/src/utils/tools-manager.ts +67 -10
@@ -11,6 +11,8 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
 
13
13
  import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
14
+ import { normalizePluginRuntimeConfig } from "../runtime-config";
15
+ import type { PluginRuntimeConfig } from "../types";
14
16
 
15
17
  import { cachePlugin } from "./cache";
16
18
  import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
@@ -38,6 +40,16 @@ import type {
38
40
  } from "./types";
39
41
  import { buildPluginId, parsePluginId } from "./types";
40
42
 
43
+ const RUNTIME_PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
44
+ const MAX_RUNTIME_PACKAGE_NAME_LENGTH = 214;
45
+
46
+ function assertRuntimePackageName(name: string): string {
47
+ if (name.length > MAX_RUNTIME_PACKAGE_NAME_LENGTH || !RUNTIME_PACKAGE_NAME_RE.test(name)) {
48
+ throw new Error(`Invalid marketplace plugin package name: ${JSON.stringify(name)}`);
49
+ }
50
+ return name;
51
+ }
52
+
41
53
  // ── Options ──────────────────────────────────────────────────────────────────
42
54
 
43
55
  export interface MarketplaceManagerOptions {
@@ -298,6 +310,9 @@ export class MarketplaceManager {
298
310
  }
299
311
  }
300
312
 
313
+ const packageName = await this.#resolvePluginPackageName(cachePath, name);
314
+ const previousPackageNames = await this.#resolveInstalledPackageNames(existing ?? [], name);
315
+
301
316
  // Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
302
317
  if (existing && existing.length > 0) {
303
318
  // Remove from scope-appropriate registry first, then cross-check refs before disk deletion.
@@ -337,6 +352,13 @@ export class MarketplaceManager {
337
352
  const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry);
338
353
  await writeInstalledPluginsRegistry(registryPath, newInstReg);
339
354
 
355
+ for (const previousPackageName of previousPackageNames) {
356
+ if (previousPackageName !== packageName) {
357
+ await this.#removeRuntimePlugin(scope, previousPackageName);
358
+ }
359
+ }
360
+ await this.#registerRuntimePlugin(scope, packageName, cachePath, version, wasDisabled ? false : undefined);
361
+
340
362
  this.#clearCache();
341
363
 
342
364
  logger.debug("Plugin installed", { pluginId, version, cachePath });
@@ -434,6 +456,7 @@ export class MarketplaceManager {
434
456
  const targetEntries = targetScope === "project" ? projectEntries! : userEntries!;
435
457
  const targetReg = targetScope === "project" ? projectReg : userReg;
436
458
  const registryPath = this.#registryPath(targetScope);
459
+ const packageNames = await this.#resolveInstalledPackageNames(targetEntries, parsed.name);
437
460
 
438
461
  const updatedReg = removeInstalledPlugin(targetReg, pluginId);
439
462
  await writeInstalledPluginsRegistry(registryPath, updatedReg);
@@ -453,6 +476,10 @@ export class MarketplaceManager {
453
476
  }
454
477
  }
455
478
 
479
+ for (const packageName of packageNames) {
480
+ await this.#removeRuntimePlugin(targetScope, packageName);
481
+ }
482
+
456
483
  this.#clearCache();
457
484
 
458
485
  logger.debug("Plugin uninstalled", { pluginId, scope: targetScope });
@@ -539,6 +566,12 @@ export class MarketplaceManager {
539
566
  };
540
567
  await writeInstalledPluginsRegistry(registryPath, updated);
541
568
 
569
+ const fallbackName = parsePluginId(pluginId)?.name ?? pluginId;
570
+ const packageNames = await this.#resolveInstalledPackageNames(entries, fallbackName);
571
+ for (const packageName of packageNames) {
572
+ await this.#setRuntimePluginEnabled(targetScope, packageName, enabled);
573
+ }
574
+
542
575
  this.#clearCache();
543
576
 
544
577
  logger.debug("Plugin enabled state changed", { pluginId, enabled, scope: targetScope });
@@ -701,6 +734,107 @@ export class MarketplaceManager {
701
734
 
702
735
  // ── Private helpers ───────────────────────────────────────────────────────
703
736
 
737
+ #runtimeRoot(scope: "user" | "project"): string {
738
+ return path.dirname(this.#registryPath(scope));
739
+ }
740
+
741
+ #nodeModulesPath(scope: "user" | "project"): string {
742
+ return path.join(this.#runtimeRoot(scope), "node_modules");
743
+ }
744
+
745
+ #runtimeLockPath(scope: "user" | "project"): string {
746
+ return path.join(this.#runtimeRoot(scope), "omp-plugins.lock.json");
747
+ }
748
+
749
+ async #loadRuntimeConfig(scope: "user" | "project"): Promise<PluginRuntimeConfig> {
750
+ try {
751
+ return normalizePluginRuntimeConfig(await Bun.file(this.#runtimeLockPath(scope)).json());
752
+ } catch (err) {
753
+ if (isEnoent(err)) return normalizePluginRuntimeConfig({});
754
+ logger.warn("Failed to load marketplace plugin runtime config", {
755
+ path: this.#runtimeLockPath(scope),
756
+ error: String(err),
757
+ });
758
+ return normalizePluginRuntimeConfig({});
759
+ }
760
+ }
761
+
762
+ async #writeRuntimeConfig(scope: "user" | "project", config: PluginRuntimeConfig): Promise<void> {
763
+ await Bun.write(this.#runtimeLockPath(scope), JSON.stringify(config, null, 2));
764
+ }
765
+
766
+ async #resolvePluginPackageName(installPath: string, fallbackName: string): Promise<string> {
767
+ try {
768
+ const pkg: { name?: unknown } = await Bun.file(path.join(installPath, "package.json")).json();
769
+ const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : fallbackName;
770
+ return assertRuntimePackageName(name);
771
+ } catch (err) {
772
+ if (isEnoent(err)) return assertRuntimePackageName(fallbackName);
773
+ throw err;
774
+ }
775
+ }
776
+
777
+ #runtimePackagePath(scope: "user" | "project", packageName: string): string {
778
+ const nodeModules = path.resolve(this.#nodeModulesPath(scope));
779
+ const linkPath = path.resolve(nodeModules, assertRuntimePackageName(packageName));
780
+ const relative = path.relative(nodeModules, linkPath);
781
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
782
+ throw new Error(`Marketplace plugin package path escapes node_modules: ${JSON.stringify(packageName)}`);
783
+ }
784
+ return linkPath;
785
+ }
786
+
787
+ async #resolveInstalledPackageNames(
788
+ entries: readonly InstalledPluginEntry[],
789
+ fallbackName: string,
790
+ ): Promise<Set<string>> {
791
+ const packageNames = new Set<string>();
792
+ for (const entry of entries) {
793
+ packageNames.add(await this.#resolvePluginPackageName(entry.installPath, fallbackName));
794
+ }
795
+ return packageNames;
796
+ }
797
+
798
+ async #registerRuntimePlugin(
799
+ scope: "user" | "project",
800
+ packageName: string,
801
+ cachePath: string,
802
+ version: string,
803
+ enabled: boolean | undefined,
804
+ ): Promise<void> {
805
+ const linkPath = this.#runtimePackagePath(scope, packageName);
806
+ await fs.mkdir(path.dirname(linkPath), { recursive: true });
807
+ await fs.rm(linkPath, { recursive: true, force: true });
808
+ await fs.symlink(cachePath, linkPath, process.platform === "win32" ? "junction" : "dir");
809
+
810
+ const config = await this.#loadRuntimeConfig(scope);
811
+ const previous = config.plugins[packageName];
812
+ config.plugins[packageName] = {
813
+ version,
814
+ enabledFeatures: previous?.enabledFeatures ?? null,
815
+ enabled: enabled ?? previous?.enabled ?? true,
816
+ };
817
+ await this.#writeRuntimeConfig(scope, config);
818
+ }
819
+
820
+ async #removeRuntimePlugin(scope: "user" | "project", packageName: string): Promise<void> {
821
+ await fs.rm(this.#runtimePackagePath(scope, packageName), { recursive: true, force: true });
822
+
823
+ const config = await this.#loadRuntimeConfig(scope);
824
+ delete config.plugins[packageName];
825
+ delete config.settings[packageName];
826
+ await this.#writeRuntimeConfig(scope, config);
827
+ }
828
+
829
+ async #setRuntimePluginEnabled(scope: "user" | "project", packageName: string, enabled: boolean): Promise<void> {
830
+ const config = await this.#loadRuntimeConfig(scope);
831
+ const previous = config.plugins[packageName];
832
+ if (!previous) return;
833
+
834
+ config.plugins[packageName] = { ...previous, enabled };
835
+ await this.#writeRuntimeConfig(scope, config);
836
+ }
837
+
704
838
  #registryPath(scope: "user" | "project"): string {
705
839
  if (scope === "project") {
706
840
  if (!this.#opts.projectInstalledRegistryPath) {
@@ -0,0 +1,57 @@
1
+ const HASHLINE_FILE_PREFIX = "¶";
2
+ const HASHLINE_FILE_TAG_RE = /#[0-9a-fA-F]{4}$/u;
3
+
4
+ function stringField(input: Record<string, unknown>, key: string): string | undefined {
5
+ const value = input[key];
6
+ return typeof value === "string" && value.length > 0 ? value : undefined;
7
+ }
8
+
9
+ function normalizeHashlineHeaderPath(body: string): string | undefined {
10
+ const trimmed = body.trim();
11
+ if (trimmed.length === 0) return undefined;
12
+ const hashStart = HASHLINE_FILE_TAG_RE.exec(trimmed)?.index;
13
+ const rawPath = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
14
+ if (rawPath.length < 2) return rawPath.length > 0 ? rawPath : undefined;
15
+ const first = rawPath[0];
16
+ const last = rawPath[rawPath.length - 1];
17
+ if ((first === '"' || first === "'") && first === last) return rawPath.slice(1, -1);
18
+ return rawPath;
19
+ }
20
+
21
+ function extractHashlinePaths(input: string): string[] {
22
+ const paths: string[] = [];
23
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
24
+ for (const rawLine of stripped.split("\n")) {
25
+ const line = rawLine.replace(/\r$/, "").trimStart();
26
+ if (!line.startsWith(HASHLINE_FILE_PREFIX)) continue;
27
+ let prefixEnd = 0;
28
+ while (prefixEnd < line.length && line[prefixEnd] === HASHLINE_FILE_PREFIX) prefixEnd++;
29
+ const path = normalizeHashlineHeaderPath(line.slice(prefixEnd));
30
+ if (path) paths.push(path);
31
+ }
32
+ return paths;
33
+ }
34
+
35
+ /** Adds derived compatibility fields to tool event input without changing tool execution parameters. */
36
+ export function normalizeToolEventInput(toolName: string, input: Record<string, unknown>): Record<string, unknown> {
37
+ if (toolName !== "edit" || stringField(input, "path")) return input;
38
+
39
+ // Hashline edit mode: the only authoritative target list is the parsed
40
+ // `¶PATH#TAG` headers inside `input`/`_input`. Trusting a passthrough
41
+ // `_path` here would let a model-supplied field override the real edit
42
+ // target and bypass extension gates that allowlist by path.
43
+ const rawInput = stringField(input, "input") ?? stringField(input, "_input");
44
+ if (rawInput !== undefined) {
45
+ const hashlinePaths = extractHashlinePaths(rawInput);
46
+ if (hashlinePaths.length === 0) return input;
47
+ if (hashlinePaths.length === 1) return { ...input, path: hashlinePaths[0], paths: hashlinePaths };
48
+ return { ...input, paths: hashlinePaths };
49
+ }
50
+
51
+ // Replace/patch modes: `path` is the real parameter; some hosts forward
52
+ // it as `_path` after schema normalization, so propagate it for gates.
53
+ const directPath = stringField(input, "_path");
54
+ if (directPath) return { ...input, path: directPath };
55
+
56
+ return input;
57
+ }
@@ -189,6 +189,22 @@ export interface RetentionTranscript {
189
189
  * Messages are tag-stripped before framing to break the recall→retain loop.
190
190
  * Returns `{ transcript: null }` when nothing meaningful survives.
191
191
  */
192
+ function formatRetentionMessages(messages: HindsightMessage[]): RetentionTranscript {
193
+ const parts: string[] = [];
194
+ for (const msg of messages) {
195
+ const content = stripMemoryTags(msg.content).trim();
196
+ if (!hasSubstantiveContent(content)) continue;
197
+ parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
198
+ }
199
+
200
+ if (parts.length === 0) return { transcript: null, messageCount: 0 };
201
+
202
+ const transcript = parts.join("\n\n");
203
+ if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
204
+
205
+ return { transcript, messageCount: parts.length };
206
+ }
207
+
192
208
  export function prepareRetentionTranscript(
193
209
  messages: HindsightMessage[],
194
210
  retainFullWindow = false,
@@ -210,17 +226,10 @@ export function prepareRetentionTranscript(
210
226
  targetMessages = messages.slice(lastUserIdx);
211
227
  }
212
228
 
213
- const parts: string[] = [];
214
- for (const msg of targetMessages) {
215
- const content = stripMemoryTags(msg.content).trim();
216
- if (!hasSubstantiveContent(content)) continue;
217
- parts.push(`[role: ${msg.role}]\n${content}\n[${msg.role}:end]`);
218
- }
219
-
220
- if (parts.length === 0) return { transcript: null, messageCount: 0 };
221
-
222
- const transcript = parts.join("\n\n");
223
- if (transcript.trim().length < 10) return { transcript: null, messageCount: 0 };
229
+ return formatRetentionMessages(targetMessages);
230
+ }
224
231
 
225
- return { transcript, messageCount: parts.length };
232
+ /** Format only user-authored messages for memory fact/entity extraction. */
233
+ export function prepareUserRetentionTranscript(messages: HindsightMessage[]): RetentionTranscript {
234
+ return formatRetentionMessages(messages.filter(message => message.role === "user"));
226
235
  }
@@ -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
  });
@@ -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
  }