@oh-my-pi/pi-coding-agent 16.5.0 → 16.5.1

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 (121) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/cli.js +3336 -3318
  3. package/dist/types/advisor/advise-tool.d.ts +12 -1
  4. package/dist/types/advisor/runtime.d.ts +41 -1
  5. package/dist/types/cli/args.d.ts +2 -0
  6. package/dist/types/cli/update-cli.d.ts +4 -1
  7. package/dist/types/cli/usage-cli.d.ts +3 -0
  8. package/dist/types/cli/usage-error.d.ts +4 -0
  9. package/dist/types/config/api-key-resolver.d.ts +2 -2
  10. package/dist/types/config/model-registry.d.ts +3 -3
  11. package/dist/types/config/model-resolver.d.ts +8 -1
  12. package/dist/types/config/models-config.d.ts +1 -1
  13. package/dist/types/eval/__tests__/process-entry-import.test.d.ts +1 -0
  14. package/dist/types/eval/bridge-timeout.d.ts +9 -1
  15. package/dist/types/eval/js/context-manager.d.ts +5 -3
  16. package/dist/types/eval/js/process-entry.d.ts +6 -0
  17. package/dist/types/eval/js/worker-core.d.ts +15 -1
  18. package/dist/types/eval/py/spawn-options.d.ts +10 -0
  19. package/dist/types/eval/py/tool-bridge.d.ts +1 -0
  20. package/dist/types/extensibility/custom-tools/types.d.ts +3 -0
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -1
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/internal-urls/memory-protocol.d.ts +6 -7
  24. package/dist/types/main.d.ts +1 -0
  25. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  26. package/dist/types/modes/magic-keyword-boundary.d.ts +9 -0
  27. package/dist/types/modes/orchestrate.d.ts +1 -1
  28. package/dist/types/modes/rpc/host-tools.d.ts +2 -0
  29. package/dist/types/modes/rpc/rpc-mode.d.ts +26 -6
  30. package/dist/types/modes/ultrathink.d.ts +1 -1
  31. package/dist/types/modes/utils/transcript-render-helpers.d.ts +12 -0
  32. package/dist/types/modes/workflow.d.ts +1 -1
  33. package/dist/types/session/agent-session.d.ts +6 -0
  34. package/dist/types/session/exit-diagnostics.d.ts +11 -0
  35. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +11 -0
  36. package/dist/types/subprocess/worker-client.d.ts +6 -0
  37. package/dist/types/tools/bash-skill-urls.d.ts +1 -0
  38. package/dist/types/web/search/provider.d.ts +10 -3
  39. package/dist/types/web/search/providers/codex.d.ts +5 -4
  40. package/package.json +12 -12
  41. package/src/advisor/__tests__/advisor.test.ts +830 -42
  42. package/src/advisor/advise-tool.ts +17 -1
  43. package/src/advisor/runtime.ts +288 -67
  44. package/src/autolearn/controller.ts +15 -3
  45. package/src/cli/args.ts +12 -0
  46. package/src/cli/auth-broker-cli.ts +30 -11
  47. package/src/cli/auth-gateway-cli.ts +5 -1
  48. package/src/cli/dry-balance-cli.ts +14 -4
  49. package/src/cli/flag-tables.ts +21 -7
  50. package/src/cli/update-cli.ts +62 -11
  51. package/src/cli/usage-cli.ts +58 -5
  52. package/src/cli/usage-error.ts +7 -0
  53. package/src/cli.ts +23 -1
  54. package/src/commands/acp.ts +11 -2
  55. package/src/commands/launch.ts +12 -3
  56. package/src/commands/token.ts +3 -1
  57. package/src/config/api-key-resolver.ts +12 -3
  58. package/src/config/config-file.ts +30 -12
  59. package/src/config/model-registry.ts +7 -7
  60. package/src/config/model-resolver.ts +21 -7
  61. package/src/config/models-config.ts +1 -1
  62. package/src/eval/__tests__/agent-bridge.test.ts +19 -14
  63. package/src/eval/__tests__/bridge-timeout.test.ts +106 -0
  64. package/src/eval/__tests__/js-context-manager.test.ts +158 -1
  65. package/src/eval/__tests__/kernel-spawn.test.ts +12 -0
  66. package/src/eval/__tests__/process-entry-import.test.ts +27 -0
  67. package/src/eval/agent-bridge.ts +121 -116
  68. package/src/eval/bridge-timeout.ts +20 -2
  69. package/src/eval/executor-base.ts +85 -7
  70. package/src/eval/jl/kernel.ts +2 -1
  71. package/src/eval/js/context-manager.ts +109 -32
  72. package/src/eval/js/process-entry.ts +27 -0
  73. package/src/eval/js/shared/runtime.ts +1 -1
  74. package/src/eval/js/worker-core.ts +70 -9
  75. package/src/eval/js/worker-entry.ts +1 -1
  76. package/src/eval/py/kernel.ts +2 -1
  77. package/src/eval/py/spawn-options.ts +13 -0
  78. package/src/eval/py/tool-bridge.ts +13 -14
  79. package/src/eval/rb/kernel.ts +2 -1
  80. package/src/extensibility/custom-tools/types.ts +3 -0
  81. package/src/extensibility/extensions/runner.ts +3 -0
  82. package/src/extensibility/extensions/types.ts +3 -0
  83. package/src/extensibility/plugins/manager.ts +21 -0
  84. package/src/internal-urls/memory-protocol.ts +13 -9
  85. package/src/lsp/client.ts +7 -1
  86. package/src/main.ts +29 -0
  87. package/src/mcp/tool-bridge.ts +57 -6
  88. package/src/modes/components/chat-transcript-builder.ts +22 -1
  89. package/src/modes/components/status-line/component.ts +10 -1
  90. package/src/modes/components/transcript-container.ts +110 -7
  91. package/src/modes/controllers/command-controller.ts +12 -4
  92. package/src/modes/controllers/event-controller.ts +80 -15
  93. package/src/modes/controllers/selector-controller.ts +15 -3
  94. package/src/modes/magic-keyword-boundary.ts +23 -0
  95. package/src/modes/orchestrate.ts +6 -5
  96. package/src/modes/print-mode.ts +9 -0
  97. package/src/modes/rpc/host-tools.ts +15 -0
  98. package/src/modes/rpc/rpc-mode.ts +123 -48
  99. package/src/modes/ultrathink.ts +6 -5
  100. package/src/modes/utils/transcript-render-helpers.ts +54 -0
  101. package/src/modes/utils/ui-helpers.ts +27 -1
  102. package/src/modes/workflow.ts +6 -5
  103. package/src/prompts/advisor/system.md +1 -0
  104. package/src/sdk.ts +33 -3
  105. package/src/session/agent-session.ts +239 -17
  106. package/src/session/exit-diagnostics.ts +108 -0
  107. package/src/session/streaming-output.ts +40 -12
  108. package/src/slash-commands/helpers/active-oauth-account.ts +22 -2
  109. package/src/slash-commands/helpers/logout.ts +23 -3
  110. package/src/slash-commands/helpers/usage-report.ts +14 -2
  111. package/src/subprocess/worker-client.ts +9 -2
  112. package/src/task/executor.ts +8 -0
  113. package/src/task/render.test.ts +36 -0
  114. package/src/task/render.ts +55 -43
  115. package/src/tools/bash-skill-urls.ts +4 -1
  116. package/src/tools/bash.ts +1 -0
  117. package/src/tools/write.ts +82 -9
  118. package/src/tools/yield.ts +29 -1
  119. package/src/web/search/index.ts +39 -22
  120. package/src/web/search/provider.ts +33 -16
  121. package/src/web/search/providers/codex.ts +68 -21
@@ -6,7 +6,7 @@ import { getMnemopiSessionState, type MnemopiScopedMemoryHit, type MnemopiSessio
6
6
  import { AgentRegistry } from "../registry/agent-registry";
7
7
  import { buildDirectoryResource } from "./filesystem-resource";
8
8
  import { validateRelativePath } from "./skill-protocol";
9
- import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
9
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
10
10
 
11
11
  const DEFAULT_MEMORY_FILE = "memory_summary.md";
12
12
  const MEMORY_NAMESPACE = "root";
@@ -28,6 +28,11 @@ export function memoryRootsFromRegistry(): string[] {
28
28
  return roots;
29
29
  }
30
30
 
31
+ function memoryRootsForContext(context?: ResolveContext): string[] {
32
+ if (context?.cwd) return [getMemoryRoot(getAgentDir(), context.cwd)];
33
+ return memoryRootsFromRegistry();
34
+ }
35
+
31
36
  function ensureWithinRoot(targetPath: string, rootPath: string): void {
32
37
  if (targetPath !== rootPath && !targetPath.startsWith(`${rootPath}${path.sep}`)) {
33
38
  throw new Error("memory:// URL escapes memory root");
@@ -196,16 +201,15 @@ function renderMnemopiMemory(url: InternalUrl, hit: MnemopiScopedMemoryHit): Int
196
201
 
197
202
  /**
198
203
  * Protocol handler for memory:// URLs.
199
- *
200
- * Walks every active session's memory root. Worktree-based subagents have
201
- * their own root; first one containing the file wins. Parent and subagent
202
- * sharing a cwd see the same file regardless of order.
204
+ * Resolves file-backed roots against the calling session cwd when provided.
205
+ * Contextless callers fall back to the live-session registry for legacy
206
+ * cross-session lookups.
203
207
  */
204
208
  export class MemoryProtocolHandler implements ProtocolHandler {
205
209
  readonly scheme = "memory";
206
210
  readonly immutable = true;
207
211
 
208
- async resolve(url: InternalUrl): Promise<InternalResource> {
212
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
209
213
  const namespace = url.rawHost || url.hostname;
210
214
  if (!namespace) {
211
215
  throw new Error("memory:// URL requires a namespace: memory://root or memory://<memory-id>");
@@ -230,7 +234,7 @@ export class MemoryProtocolHandler implements ProtocolHandler {
230
234
  );
231
235
  }
232
236
 
233
- const roots = memoryRootsFromRegistry();
237
+ const roots = memoryRootsForContext(context);
234
238
  if (roots.length === 0) {
235
239
  throw new Error(
236
240
  "Memory artifacts are not available for this project yet. Run a session with memories enabled first.",
@@ -259,9 +263,9 @@ export class MemoryProtocolHandler implements ProtocolHandler {
259
263
  throw new Error(`Memory file not found: ${url.href}`);
260
264
  }
261
265
 
262
- async complete(): Promise<UrlCompletion[]> {
266
+ async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
263
267
  const completions: UrlCompletion[] = [];
264
- if (memoryRootsFromRegistry().length > 0) {
268
+ if (memoryRootsForContext(context).length > 0) {
265
269
  completions.push({ value: MEMORY_NAMESPACE, description: "Project memory summary" });
266
270
  }
267
271
  if (mnemopiSessionStatesFromRegistry().length > 0) {
package/src/lsp/client.ts CHANGED
@@ -750,8 +750,14 @@ export async function getOrCreateClient(
750
750
 
751
751
  client.serverCapabilities = initResult.capabilities as LspClient["serverCapabilities"];
752
752
 
753
- // Send initialized notification
753
+ // Finish the initialize handshake before publishing the client as ready.
754
754
  await sendNotification(client, "initialized", {}, signal);
755
+ await sendNotification(
756
+ client,
757
+ "workspace/didChangeConfiguration",
758
+ { settings: config.settings ?? {} },
759
+ signal,
760
+ );
755
761
 
756
762
  client.status = "ready";
757
763
  // Publish only after init succeeds: pre-init clients are reachable
package/src/main.ts CHANGED
@@ -636,6 +636,27 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
636
636
  return undefined;
637
637
  }
638
638
 
639
+ const SESSION_ID_ARG_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
640
+
641
+ export function normalizeContinueSessionArgs(parsed: Args, rawArgs?: readonly string[]): void {
642
+ if (!parsed.continue || parsed.resume || parsed.fork) return;
643
+
644
+ let message: string | undefined;
645
+ if (parsed.unrecognizedFlags.length === 0 && parsed.messages.length === 1) {
646
+ message = parsed.messages[0]?.trim();
647
+ } else if (rawArgs) {
648
+ const continueIndex = rawArgs.findIndex(arg => arg === "--continue" || arg === "-c");
649
+ message = rawArgs[continueIndex + 1]?.trim();
650
+ }
651
+ if (!message || !SESSION_ID_ARG_RE.test(message)) return;
652
+
653
+ const messageIndex = parsed.messages.indexOf(message);
654
+ if (messageIndex === -1) return;
655
+ parsed.resume = message;
656
+ parsed.continue = false;
657
+ parsed.messages.splice(messageIndex, 1);
658
+ }
659
+
639
660
  /** Resolves CLI session flags into an existing, forked, in-memory, or cancelled session manager. */
640
661
  export async function createSessionManager(
641
662
  parsed: Args,
@@ -665,6 +686,8 @@ export async function createSessionManager(
665
686
  if (parsed.noSession) {
666
687
  return SessionManager.inMemory();
667
688
  }
689
+ normalizeContinueSessionArgs(parsed);
690
+
668
691
  if (typeof parsed.resume === "string") {
669
692
  const sessionArg = parsed.resume;
670
693
  if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
@@ -1188,6 +1211,11 @@ export async function runRootCommand(
1188
1211
  );
1189
1212
  }
1190
1213
 
1214
+ // Resolve an explicit `--continue <id>` before extension flags are loaded.
1215
+ // Reading the token immediately after `--continue` distinguishes the session
1216
+ // id from UUID-shaped values owned by later extension flags.
1217
+ normalizeContinueSessionArgs(parsedArgs, rawArgs);
1218
+
1191
1219
  // Create session manager based on CLI flags. SessionResolutionError signals a
1192
1220
  // user-facing failure (unknown --resume/--fork id, non-interactive fork
1193
1221
  // prompt, --fork with --no-session): print + exit cleanly instead of letting
@@ -1386,6 +1414,7 @@ export async function runRootCommand(
1386
1414
  },
1387
1415
  };
1388
1416
  const initialArgs = applyExtensionFlags(extensionFlagSink, rawArgs) ?? parsedArgs;
1417
+ normalizeContinueSessionArgs(initialArgs, rawArgs);
1389
1418
  // Fail fast on stale/typo flags (e.g. `omp --list-models`) now that we
1390
1419
  // know the real extension flag set. Without this check the unrecognized
1391
1420
  // token gets silently consumed and any following positional leaks as the
@@ -15,8 +15,10 @@ import type {
15
15
  CustomToolResult,
16
16
  RenderResultOptions,
17
17
  } from "../extensibility/custom-tools/types";
18
+ import { resolveLocalUrlToFile } from "../internal-urls/local-protocol";
18
19
  import type { Theme } from "../modes/theme/theme";
19
20
  import type { OutputMeta } from "../tools/output-meta";
21
+ import { normalizeLocalScheme } from "../tools/path-utils";
20
22
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
21
23
  import { callTool } from "./client";
22
24
  import { renderMCPCall, renderMCPResult } from "./render";
@@ -104,13 +106,62 @@ function stripHarnessIntent(args: MCPToolArgs, inputSchema: MCPToolDefinition["i
104
106
  return rest;
105
107
  }
106
108
 
109
+ async function resolveOutboundLocalUrlArgs(
110
+ value: unknown,
111
+ context: CustomToolContext,
112
+ seen: WeakSet<object> = new WeakSet(),
113
+ ): Promise<unknown> {
114
+ if (typeof value === "string") {
115
+ const normalized = normalizeLocalScheme(value);
116
+ if (!normalized.startsWith("local://")) return value;
117
+ const localFile = await resolveLocalUrlToFile(normalized, {
118
+ cwd: context.sessionManager?.getCwd?.(),
119
+ settings: context.settings,
120
+ localProtocolOptions: context.localProtocolOptions,
121
+ });
122
+ return localFile?.path ?? value;
123
+ }
124
+ if (typeof value !== "object" || value === null) return value;
125
+ if (seen.has(value)) return value;
126
+ seen.add(value);
127
+
128
+ if (Array.isArray(value)) {
129
+ let resolved: unknown[] | undefined;
130
+ for (let index = 0; index < value.length; index++) {
131
+ const item = value[index];
132
+ const next = await resolveOutboundLocalUrlArgs(item, context, seen);
133
+ if (next === item && !resolved) continue;
134
+ resolved ??= value.slice();
135
+ resolved[index] = next;
136
+ }
137
+ return resolved ?? value;
138
+ }
139
+
140
+ const input = value as Record<string, unknown>;
141
+ let resolved: Record<string, unknown> | undefined;
142
+ for (const key in input) {
143
+ const item = input[key];
144
+ const next = await resolveOutboundLocalUrlArgs(item, context, seen);
145
+ if (next === item && !resolved) continue;
146
+ resolved ??= { ...input };
147
+ resolved[key] = next;
148
+ }
149
+ return resolved ?? value;
150
+ }
151
+
107
152
  /**
108
153
  * Normalize raw tool params into the outbound `tools/call` arguments: strip
109
- * the harness intent field, then drop optional empty placeholders the server
110
- * declares but doesn't require.
154
+ * the harness intent field, drop optional empty placeholders the server
155
+ * declares but doesn't require, then translate session-local files to paths
156
+ * external MCP servers can read.
111
157
  */
112
- function prepareOutboundArgs(params: unknown, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
113
- return omitUnusedOptionalArgs(stripHarnessIntent(normalizeToolArgs(params), inputSchema), inputSchema);
158
+ async function prepareOutboundArgs(
159
+ params: unknown,
160
+ inputSchema: MCPToolDefinition["inputSchema"],
161
+ context: CustomToolContext,
162
+ ): Promise<MCPToolArgs> {
163
+ const args = omitUnusedOptionalArgs(stripHarnessIntent(normalizeToolArgs(params), inputSchema), inputSchema);
164
+ return (await resolveOutboundLocalUrlArgs(args, context)) as MCPToolArgs;
114
165
  }
115
166
 
116
167
  /** Details included in MCP tool results for rendering */
@@ -316,7 +367,7 @@ export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
316
367
  signal?: AbortSignal,
317
368
  ): Promise<CustomToolResult<MCPToolDetails>> {
318
369
  throwIfAborted(signal);
319
- const args = prepareOutboundArgs(params, this.tool.inputSchema);
370
+ const args = await prepareOutboundArgs(params, this.tool.inputSchema, _ctx);
320
371
  const provider = this.connection._source?.provider;
321
372
  const providerName = this.connection._source?.providerName;
322
373
 
@@ -415,7 +466,7 @@ export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
415
466
  signal?: AbortSignal,
416
467
  ): Promise<CustomToolResult<MCPToolDetails>> {
417
468
  throwIfAborted(signal);
418
- const args = prepareOutboundArgs(params, this.tool.inputSchema);
469
+ const args = await prepareOutboundArgs(params, this.tool.inputSchema, _ctx);
419
470
  const provider = this.#fallbackProvider;
420
471
  const providerName = this.#fallbackProviderName;
421
472
 
@@ -35,6 +35,7 @@ import {
35
35
  buildIrcMessageCard,
36
36
  normalizeToolArgs,
37
37
  resolveAssistantErrorPresentation,
38
+ splitAssistantMessageToolTimeline,
38
39
  } from "../utils/transcript-render-helpers";
39
40
  import { createAdvisorMessageCard } from "./advisor-message";
40
41
  import { AssistantMessageComponent } from "./assistant-message";
@@ -273,8 +274,9 @@ export class ChatTranscriptBuilder {
273
274
  #appendAssistantMessage(message: Extract<AgentMessage, { role: "assistant" }>): void {
274
275
  const hideThinkingBlock = this.deps.hideThinkingBlock?.() ?? false;
275
276
  const proseOnlyThinking = this.deps.proseOnlyThinking ? this.deps.proseOnlyThinking() : true;
277
+ const timeline = splitAssistantMessageToolTimeline(message);
276
278
  const assistantComponent = new AssistantMessageComponent(
277
- message,
279
+ timeline.beforeTools,
278
280
  hideThinkingBlock,
279
281
  () => this.deps.requestRender(),
280
282
  this.deps.getMessageRenderer ? undefined : [], // placeholder for thinkingRenderers
@@ -301,11 +303,24 @@ export class ChatTranscriptBuilder {
301
303
  const errorPresentation = resolveAssistantErrorPresentation(message);
302
304
  const hasErrorStop = errorPresentation.kind === "full";
303
305
  const errorMessage = hasErrorStop ? errorPresentation.text : null;
306
+ const appendAssistantSegment = (segment: Extract<AgentMessage, { role: "assistant" }> | undefined) => {
307
+ if (!segment || !assistantHasVisibleContent(segment)) return;
308
+ const component = new AssistantMessageComponent(
309
+ segment,
310
+ hideThinkingBlock,
311
+ () => this.deps.requestRender(),
312
+ this.deps.getMessageRenderer ? undefined : [],
313
+ undefined,
314
+ proseOnlyThinking,
315
+ );
316
+ this.container.addChild(component);
317
+ };
304
318
 
305
319
  for (const content of message.content) {
306
320
  if (content.type !== "toolCall") continue;
307
321
  this.#resolveWaitingPoll(content.name);
308
322
 
323
+ const afterToolSegment = timeline.afterToolCalls.get(content.id);
309
324
  if (
310
325
  content.name === "read" &&
311
326
  readArgsHaveTarget(content.arguments) &&
@@ -319,10 +334,15 @@ export class ChatTranscriptBuilder {
319
334
  false,
320
335
  content.id,
321
336
  );
337
+ } else if (afterToolSegment) {
338
+ const group = this.#ensureReadGroup();
339
+ group.updateArgs(content.arguments, content.id);
340
+ this.#pendingTools.set(content.id, group);
322
341
  } else {
323
342
  const normalizedArgs = normalizeToolArgs(content.arguments);
324
343
  this.#readArgs.set(content.id, normalizedArgs);
325
344
  }
345
+ appendAssistantSegment(afterToolSegment);
326
346
  continue;
327
347
  }
328
348
 
@@ -355,6 +375,7 @@ export class ChatTranscriptBuilder {
355
375
  } else {
356
376
  this.#pendingTools.set(content.id, component);
357
377
  }
378
+ appendAssistantSegment(afterToolSegment);
358
379
  }
359
380
 
360
381
  this.#pendingUsage =
@@ -780,7 +780,16 @@ export class StatusLineComponent implements Component {
780
780
  const activeProvider = session.state.model?.provider ?? session.model?.provider ?? "";
781
781
  if (!activeProvider) return "";
782
782
  const identity = session.modelRegistry?.authStorage?.getOAuthAccountIdentity(activeProvider, session.sessionId);
783
- return [activeProvider, identity?.accountId ?? "", identity?.email ?? "", identity?.projectId ?? ""].join("\0");
783
+ // orgId is part of the key: rotating between two same-email Anthropic
784
+ // subscriptions must invalidate the cached usage immediately instead of
785
+ // showing the previous org's quota for the rest of the cache TTL.
786
+ return [
787
+ activeProvider,
788
+ identity?.accountId ?? "",
789
+ identity?.email ?? "",
790
+ identity?.projectId ?? "",
791
+ identity?.orgId ?? "",
792
+ ].join("\0");
784
793
  }
785
794
 
786
795
  /**
@@ -3,6 +3,7 @@ import {
3
3
  Container,
4
4
  type NativeScrollbackCommittedRows,
5
5
  type NativeScrollbackLiveRegion,
6
+ type NativeScrollbackReplay,
6
7
  type RenderStablePrefix,
7
8
  type ViewportTailProvider,
8
9
  } from "@oh-my-pi/pi-tui";
@@ -119,6 +120,8 @@ interface BlockSegment {
119
120
  sep: number;
120
121
  /** Whether the block reported finalized when this segment was rendered. */
121
122
  finalized: boolean;
123
+ /** Safe to drop after commit: produced while finalized, without post-finalize version tracking. */
124
+ compactable: boolean;
122
125
  /** Block version observed when this segment was rendered (see {@link FinalizableBlock}). */
123
126
  version: number | undefined;
124
127
  }
@@ -154,7 +157,12 @@ const EMPTY_TAIL: readonly string[] = [];
154
157
  */
155
158
  export class TranscriptContainer
156
159
  extends Container
157
- implements NativeScrollbackLiveRegion, NativeScrollbackCommittedRows, RenderStablePrefix, ViewportTailProvider
160
+ implements
161
+ NativeScrollbackLiveRegion,
162
+ NativeScrollbackCommittedRows,
163
+ NativeScrollbackReplay,
164
+ RenderStablePrefix,
165
+ ViewportTailProvider
158
166
  {
159
167
  // Bumped to retire every block segment at once (theme change / clear); a
160
168
  // segment is only reused when its stored generation matches.
@@ -172,6 +180,14 @@ export class TranscriptContainer
172
180
  // Finalized blocks wholly before this boundary are immutable on-screen history;
173
181
  // their previous contribution can be replayed without calling render().
174
182
  #committedRows = 0;
183
+ // Leading children whose rows were handed to native scrollback and dropped
184
+ // from the local frame. Children remain owned by the session and can be
185
+ // re-rendered when the TUI prepares a destructive full replay.
186
+ #compactedChildStart = 0;
187
+ // Suppresses re-compaction for the rehydrating render. The TUI feeds its old
188
+ // committed-row count immediately before render, so resetting that count
189
+ // alone cannot distinguish a replay from an ordinary update.
190
+ #replayPending = false;
175
191
  // Stable-prefix floor accumulated across renders since the last
176
192
  // getRenderStablePrefixRows() read (see RenderStablePrefix: reading
177
193
  // consumes the report and re-bases the baseline). Out-of-band renders
@@ -187,12 +203,24 @@ export class TranscriptContainer
187
203
  override clear(): void {
188
204
  this.#generation++;
189
205
  super.clear();
206
+ this.#compactedChildStart = 0;
207
+ this.#committedRows = 0;
208
+ this.#replayPending = false;
190
209
  }
191
210
 
192
211
  setNativeScrollbackCommittedRows(rows: number): void {
193
212
  this.#committedRows = Number.isFinite(rows) ? Math.max(0, Math.trunc(rows)) : 0;
194
213
  }
195
214
 
215
+ prepareNativeScrollbackReplay(): void {
216
+ if (this.#compactedChildStart === 0) return;
217
+ this.#compactedChildStart = 0;
218
+ this.#replayPending = true;
219
+ this.#generation++;
220
+ this.#lines.length = 0;
221
+ this.#stableRowsFloor = 0;
222
+ }
223
+
196
224
  getRenderStablePrefixRows(): number {
197
225
  const value = Math.min(this.#stableRowsFloor, this.#lines.length);
198
226
  this.#stableRowsFloor = this.#lines.length;
@@ -213,8 +241,14 @@ export class TranscriptContainer
213
241
  * committed rows and is safely removable.
214
242
  */
215
243
  isBlockUncommitted(component: Component): boolean {
244
+ const index = this.children.indexOf(component);
245
+ // Compacted prefix is already committed native history and must not be
246
+ // retracted. Compacted slots may be sparse holes after a later re-render
247
+ // (render only fills from #compactedChildStart), so the loop below must
248
+ // skip undefined entries.
249
+ if (index >= 0 && index < this.#compactedChildStart) return false;
216
250
  for (const segment of this.#segments) {
217
- if (segment.component !== component) continue;
251
+ if (segment === undefined || segment.component !== component) continue;
218
252
  return segment.rowCount === 0 || segment.startRow >= this.#committedRows;
219
253
  }
220
254
  return true;
@@ -268,7 +302,7 @@ export class TranscriptContainer
268
302
  if (maxRows <= 0) return EMPTY_TAIL;
269
303
  const collected: (readonly string[])[] = [];
270
304
  let total = 0;
271
- for (let i = this.children.length - 1; i >= 0 && total < maxRows; i--) {
305
+ for (let i = this.children.length - 1; i >= this.#compactedChildStart && total < maxRows; i--) {
272
306
  const contribution = stripPlainBlankEdges(this.children[i]!.render(width));
273
307
  if (contribution.length === 0) continue;
274
308
  // One blank separator sits between this block and the (already
@@ -292,6 +326,7 @@ export class TranscriptContainer
292
326
  this.#nativeScrollbackLiveRegionStart = undefined;
293
327
 
294
328
  const count = this.children.length;
329
+ if (this.#compactedChildStart > count) this.#compactedChildStart = count;
295
330
 
296
331
  // Seal displaceable snapshots whose rows are already on the tape (per the
297
332
  // previous frame's segments — the geometry the committed count was
@@ -301,8 +336,9 @@ export class TranscriptContainer
301
336
  // frame, and every frame so a block that BECAME displaceable after its
302
337
  // pending-preview rows committed (late result on a scrolled-off call) is
303
338
  // caught too.
304
- for (let i = 0; i < count && i < this.#segments.length; i++) {
305
- const previous = this.#segments[i]!;
339
+ for (let i = this.#compactedChildStart; i < count && i < this.#segments.length; i++) {
340
+ const previous = this.#segments[i];
341
+ if (previous === undefined) continue;
306
342
  if (previous.startRow >= this.#committedRows) break;
307
343
  if (previous.rowCount === 0 || previous.component !== this.children[i]) continue;
308
344
  sealCommittedSnapshot(previous.component);
@@ -316,7 +352,7 @@ export class TranscriptContainer
316
352
  // reaches.
317
353
  let liveStartIndex = -1;
318
354
  let hasLiveBlock = false;
319
- for (let i = 0; i < count; i++) {
355
+ for (let i = this.#compactedChildStart; i < count; i++) {
320
356
  if (!isBlockFinalized(this.children[i]!)) {
321
357
  liveStartIndex = i;
322
358
  hasLiveBlock = true;
@@ -348,7 +384,7 @@ export class TranscriptContainer
348
384
  // Frame row cursor: rows emitted (reused or pushed) so far.
349
385
  let row = 0;
350
386
  let stableRows = 0;
351
- for (let i = 0; i < count; i++) {
387
+ for (let i = this.#compactedChildStart; i < count; i++) {
352
388
  const child = this.children[i]!;
353
389
 
354
390
  // This child's contribution: its current render with plain-blank
@@ -389,6 +425,7 @@ export class TranscriptContainer
389
425
  previous.width === width &&
390
426
  previous.generation === this.#generation);
391
427
  const contribution = reusable ? previous.contribution : stripPlainBlankEdges(raw);
428
+ const compactable = finalized && version === undefined && previous?.finalized !== false;
392
429
 
393
430
  // Empty (or stripped-to-nothing) children contribute nothing and never
394
431
  // affect spacing. An empty still-live child still gates the commit
@@ -413,6 +450,7 @@ export class TranscriptContainer
413
450
  rowCount: 0,
414
451
  sep: 0,
415
452
  finalized,
453
+ compactable,
416
454
  version,
417
455
  };
418
456
  continue;
@@ -464,6 +502,7 @@ export class TranscriptContainer
464
502
  rowCount,
465
503
  sep,
466
504
  finalized,
505
+ compactable,
467
506
  version,
468
507
  };
469
508
  row += rowCount;
@@ -473,8 +512,72 @@ export class TranscriptContainer
473
512
  if (lines.length !== row) lines.length = row;
474
513
  this.#segments = segments;
475
514
  this.#stableRowsFloor = Math.min(stableFloorBefore, stableRows, row);
515
+ if (this.#replayPending) {
516
+ this.#replayPending = false;
517
+ } else {
518
+ this.#compactCommittedPrefix();
519
+ }
476
520
  return lines;
477
521
  }
522
+
523
+ #compactCommittedPrefix(): void {
524
+ if (this.#committedRows <= 0 || this.#compactedChildStart >= this.children.length) return;
525
+ const lines = this.#lines;
526
+ const segments = this.#segments;
527
+ let dropRows = 0;
528
+ let dropUntil = this.#compactedChildStart;
529
+ for (let i = this.#compactedChildStart; i < segments.length; i++) {
530
+ const segment = segments[i];
531
+ if (segment === undefined || !segment.compactable) break;
532
+ const segmentEnd = segment.startRow + segment.rowCount;
533
+ if (segmentEnd > this.#committedRows) break;
534
+ dropRows = segmentEnd;
535
+ dropUntil = i + 1;
536
+ }
537
+ const retained = segments[dropUntil];
538
+ if (retained !== undefined && retained.sep > 0) {
539
+ const committedSeparatorRows = this.#committedRows - retained.startRow;
540
+ if (committedSeparatorRows <= 0) {
541
+ dropRows = 0;
542
+ dropUntil = this.#compactedChildStart;
543
+ } else {
544
+ const trim = Math.min(retained.sep, committedSeparatorRows);
545
+ dropRows += trim;
546
+ retained.sep -= trim;
547
+ retained.rowCount -= trim;
548
+ }
549
+ }
550
+ if (dropRows === 0) return;
551
+
552
+ lines.splice(0, dropRows);
553
+ for (let i = this.#compactedChildStart; i < dropUntil; i++) {
554
+ const segment = segments[i];
555
+ if (segment === undefined) continue;
556
+ segments[i] = {
557
+ component: segment.component,
558
+ rawRef: EMPTY_TAIL,
559
+ contribution: EMPTY_TAIL,
560
+ width: segment.width,
561
+ generation: segment.generation,
562
+ startRow: 0,
563
+ rowCount: 0,
564
+ sep: 0,
565
+ finalized: true,
566
+ compactable: false,
567
+ version: segment.version,
568
+ };
569
+ }
570
+ for (let i = dropUntil; i < segments.length; i++) {
571
+ const segment = segments[i];
572
+ if (segment !== undefined) segment.startRow -= dropRows;
573
+ }
574
+ this.#compactedChildStart = dropUntil;
575
+ this.#committedRows = Math.max(0, this.#committedRows - dropRows);
576
+ this.#stableRowsFloor = 0;
577
+ if (this.#nativeScrollbackLiveRegionStart !== undefined) {
578
+ this.#nativeScrollbackLiveRegionStart = Math.max(0, this.#nativeScrollbackLiveRegionStart - dropRows);
579
+ }
580
+ }
478
581
  }
479
582
 
480
583
  /**
@@ -1388,14 +1388,22 @@ function formatWindowSuffix(label: string, windowLabel: string, uiTheme: typeof
1388
1388
  return uiTheme.fg("dim", `(${windowLabel})`);
1389
1389
  }
1390
1390
 
1391
+ /** ` (org)` suffix when the report is org-attributed — two subscriptions can share one email. */
1392
+ function orgSuffix(report: UsageReport): string {
1393
+ const orgName = report.metadata?.orgName;
1394
+ const orgId = report.metadata?.orgId;
1395
+ const org = typeof orgName === "string" && orgName ? orgName : typeof orgId === "string" ? orgId : undefined;
1396
+ return org ? ` (${org})` : "";
1397
+ }
1398
+
1391
1399
  function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: number): string {
1392
1400
  const email = report.metadata?.email;
1393
- if (typeof email === "string" && email) return email;
1401
+ if (typeof email === "string" && email) return `${email}${orgSuffix(report)}`;
1394
1402
  const accountId =
1395
1403
  typeof report.metadata?.accountId === "string" && report.metadata.accountId
1396
1404
  ? report.metadata.accountId
1397
1405
  : limit.scope.accountId || undefined;
1398
- if (accountId) return accountId;
1406
+ if (accountId) return `${accountId}${orgSuffix(report)}`;
1399
1407
  const projectId =
1400
1408
  typeof report.metadata?.projectId === "string" && report.metadata.projectId
1401
1409
  ? report.metadata.projectId
@@ -1406,9 +1414,9 @@ function formatAccountLabel(limit: UsageLimit, report: UsageReport, index: numbe
1406
1414
 
1407
1415
  function formatUnlimitedReportLabel(report: UsageReport, index: number): string {
1408
1416
  const email = report.metadata?.email;
1409
- if (typeof email === "string" && email) return email;
1417
+ if (typeof email === "string" && email) return `${email}${orgSuffix(report)}`;
1410
1418
  const accountId = report.metadata?.accountId;
1411
- if (typeof accountId === "string" && accountId) return accountId;
1419
+ if (typeof accountId === "string" && accountId) return `${accountId}${orgSuffix(report)}`;
1412
1420
  const projectId = report.metadata?.projectId;
1413
1421
  if (typeof projectId === "string" && projectId) return projectId;
1414
1422
  return `account ${index + 1}`;