@oh-my-pi/pi-coding-agent 16.3.5 → 16.3.6

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 (48) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/dist/cli.js +3388 -3374
  3. package/dist/types/edit/file-snapshot-store.d.ts +4 -2
  4. package/dist/types/edit/renderer.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/types.d.ts +2 -0
  6. package/dist/types/extensibility/shared-events.d.ts +8 -1
  7. package/dist/types/modes/components/assistant-message.d.ts +15 -10
  8. package/dist/types/modes/components/bash-execution.d.ts +6 -0
  9. package/dist/types/modes/components/eval-execution.d.ts +6 -0
  10. package/dist/types/modes/components/tool-execution.d.ts +3 -13
  11. package/dist/types/modes/components/transcript-container.d.ts +26 -28
  12. package/dist/types/modes/utils/transcript-render-helpers.d.ts +15 -6
  13. package/dist/types/session/agent-session.d.ts +2 -0
  14. package/dist/types/tools/bash.d.ts +0 -2
  15. package/dist/types/tools/browser/tab-supervisor.d.ts +10 -0
  16. package/dist/types/tools/eval-render.d.ts +0 -2
  17. package/dist/types/tools/renderers.d.ts +0 -20
  18. package/dist/types/tools/ssh.d.ts +0 -2
  19. package/dist/types/tools/write.d.ts +1 -1
  20. package/package.json +12 -12
  21. package/src/edit/file-snapshot-store.ts +5 -2
  22. package/src/edit/renderer.ts +0 -5
  23. package/src/extensibility/custom-tools/types.ts +2 -0
  24. package/src/extensibility/shared-events.ts +9 -1
  25. package/src/modes/components/assistant-message.ts +134 -82
  26. package/src/modes/components/bash-execution.ts +9 -0
  27. package/src/modes/components/chat-transcript-builder.ts +8 -4
  28. package/src/modes/components/eval-execution.ts +9 -0
  29. package/src/modes/components/tool-execution.ts +4 -50
  30. package/src/modes/components/transcript-container.ts +82 -432
  31. package/src/modes/components/tree-selector.ts +9 -3
  32. package/src/modes/controllers/command-controller.ts +0 -3
  33. package/src/modes/controllers/event-controller.ts +74 -14
  34. package/src/modes/controllers/extension-ui-controller.ts +0 -1
  35. package/src/modes/controllers/input-controller.ts +4 -10
  36. package/src/modes/interactive-mode.ts +12 -8
  37. package/src/modes/utils/transcript-render-helpers.ts +40 -13
  38. package/src/modes/utils/ui-helpers.ts +12 -7
  39. package/src/sdk.ts +1 -0
  40. package/src/session/agent-session.ts +148 -1
  41. package/src/session/session-context.ts +7 -0
  42. package/src/tools/bash.ts +0 -4
  43. package/src/tools/browser/tab-supervisor.ts +47 -3
  44. package/src/tools/eval-render.ts +0 -20
  45. package/src/tools/read.ts +63 -20
  46. package/src/tools/renderers.ts +0 -20
  47. package/src/tools/ssh.ts +0 -16
  48. package/src/tools/write.ts +13 -6
@@ -48,6 +48,16 @@ export interface PendingRun {
48
48
  session: ToolSession;
49
49
  signal?: AbortSignal;
50
50
  toolCalls: Map<string, AbortController>;
51
+ /**
52
+ * Fires when `releaseTab` closes the tab out from under an in-flight run
53
+ * (sibling `browser close --all`, session-scoped reap, etc.). Composed
54
+ * into the cmux run's signal so `wait(...)`, cmux socket calls, and the
55
+ * facade proxies unwind promptly instead of blocking to the run's
56
+ * timeout. `pending.reject` still fires first so the awaiting caller
57
+ * sees the tab-close error immediately; `closeAc` propagates the
58
+ * cancellation into the still-running `runCmuxCode` body (issue #4499).
59
+ */
60
+ closeAc?: AbortController;
51
61
  }
52
62
 
53
63
  interface TabSessionBase<TBrowser extends BrowserHandle = BrowserHandle> {
@@ -380,23 +390,47 @@ async function runInTabWithSnapshot(
380
390
  if (tab.pending.size > 0) throw new ToolError(`Tab ${JSON.stringify(name)} is busy`);
381
391
  const id = Snowflake.next();
382
392
  const { promise, resolve, reject } = Promise.withResolvers<RunResultOk>();
393
+ // `releaseTab` calls `pending.reject(closeError)` when the tab dies
394
+ // out from under an in-flight run (sibling `browser close --all`,
395
+ // session-scoped reap, etc.). Both backends below MUST end up awaiting
396
+ // this same `promise` so:
397
+ // 1. The caller sees `Tab ... was closed` immediately instead of
398
+ // blocking to the run's timeout, and
399
+ // 2. `reject(...)` always has an attached handler — a zero-consumer
400
+ // rejection would fire `unhandledRejection` and the CLI's
401
+ // top-level handler would tear the whole session down, killing
402
+ // every other tab and subagent sharing the process (issue #4499).
403
+ // The cmux branch also composes `closeAc.signal` into the run's abort
404
+ // signal so `wait(...)`, cmux socket calls, and the facade proxies
405
+ // unwind promptly when the tab is closed — otherwise a `wait(60_000)`
406
+ // with no in-flight socket request would keep `runCmuxCode` blocked
407
+ // until timeout even after the tab is gone.
408
+ const closeAc = new AbortController();
383
409
  const pending: PendingRun = {
384
410
  resolve,
385
411
  reject,
386
412
  session: opts.session ?? ({} as ToolSession),
387
413
  signal: opts.signal,
388
414
  toolCalls: new Map(),
415
+ closeAc,
389
416
  };
390
417
  tab.pending.set(id, pending);
391
418
  if (tab.backend === "cmux") {
419
+ const runSignal = opts.signal ? AbortSignal.any([opts.signal, closeAc.signal]) : closeAc.signal;
392
420
  try {
393
- return await runCmuxCode(tab.cmuxTab, {
421
+ // `runCmuxCode.then(resolve, reject)` publishes the run's real
422
+ // outcome to `promise`, but `releaseTab` may have already
423
+ // rejected it — `Promise.withResolvers` settles on the first
424
+ // call and later resolve/reject are no-ops, so the tab-close
425
+ // error still wins the race.
426
+ runCmuxCode(tab.cmuxTab, {
394
427
  code: opts.code,
395
428
  timeoutMs: opts.timeoutMs,
396
- signal: opts.signal,
429
+ signal: runSignal,
397
430
  session: pending.session,
398
431
  snapshot,
399
- });
432
+ }).then(resolve, reject);
433
+ return await promise;
400
434
  } finally {
401
435
  tab.pending.delete(id);
402
436
  }
@@ -460,6 +494,16 @@ export async function releaseTab(name: string, opts: ReleaseTabOptions = {}): Pr
460
494
  } catch {}
461
495
  }
462
496
  for (const ctrl of pending.toolCalls.values()) ctrl.abort(closeError);
497
+ // Propagate the closure into the cmux run's abort signal so
498
+ // `wait(...)`, in-flight cmux socket calls, and the facade proxies
499
+ // unwind promptly. Firing this BEFORE `pending.reject` means
500
+ // `runCmuxCode` finishes with `ToolAbortError` and its `.then(reject)`
501
+ // is a no-op — `promise` still settles with the tab-close error via
502
+ // the `reject` call below. Without it, a run that isn't currently
503
+ // making a socket request (e.g. `await wait(60_000)`) would keep
504
+ // `runCmuxCode` blocked until timeout even after `pending.reject`
505
+ // unblocked the caller (issue #4499 review feedback).
506
+ pending.closeAc?.abort(closeError);
463
507
  pending.reject(closeError);
464
508
  }
465
509
  tab.pending.clear();
@@ -773,24 +773,4 @@ export const evalToolRenderer = {
773
773
 
774
774
  mergeCallAndResult: true,
775
775
  inline: true,
776
- // Collapsed pending preview shows tail-window code cells; the result render
777
- // interleaves each cell's output under its code, re-laying-out every row
778
- // below the first cell. Expanded output is top-anchored enough for the
779
- // transcript to commit its settled prefix.
780
- provisionalPendingPreview: "collapsed",
781
- // Partial-result chrome is NOT byte-stable: `renderAgentProgressEvents`
782
- // inserts/removes each subagent's current-tool line as it starts/stops a
783
- // tool, and ticks status icon/stats/duration on already-rendered rows,
784
- // while `options.isPartial` holds for the whole eval() cell (agent
785
- // progress ticks never carry an `async` completed/failed state, so
786
- // `event-controller.ts` keeps `isPartial: true` throughout). If this
787
- // block were commit-stable during that churn, `deriveLiveCommitState`'s
788
- // stable-prefix ratchet would promote agent rows that keep mutating (a
789
- // "slow ticker", see transcript-container.ts) into native scrollback,
790
- // and the tui resync's "duplication, never loss" contract would
791
- // repeatedly re-show the frame tail under a heavy concurrent
792
- // `agent()`/`parallel()` fan-out — the overlapping/duplicated tree rows
793
- // seen when many subagents run at once. Once the cell settles
794
- // (`isPartial === false`) the block is commit-stable again.
795
- provisionalPartialResult: true,
796
776
  };
package/src/tools/read.ts CHANGED
@@ -334,6 +334,24 @@ function contiguousLineNumbers(startLine: number, count: number): number[] {
334
334
  return lines;
335
335
  }
336
336
 
337
+ function lineNumbersFromSpans(spans: readonly { startLine: number; endLine: number }[]): number[] {
338
+ const lines: number[] = [];
339
+ for (const span of spans) {
340
+ for (let line = span.startLine; line <= span.endLine; line++) lines.push(line);
341
+ }
342
+ return lines;
343
+ }
344
+
345
+ function recordInMemorySeenLines(
346
+ session: ToolSession,
347
+ absolutePath: string | undefined,
348
+ fullText: string,
349
+ seenLines: readonly number[] | undefined,
350
+ ): void {
351
+ if (!absolutePath || !path.isAbsolute(absolutePath) || !seenLines || seenLines.length === 0) return;
352
+ getFileSnapshotStore(session).record(canonicalSnapshotKey(absolutePath), normalizeToLF(fullText), seenLines);
353
+ }
354
+
337
355
  function lineNumbersFromEntries(entries: readonly LineEntry[]): number[] {
338
356
  const lines: number[] = [];
339
357
  for (const entry of entries) {
@@ -1319,6 +1337,7 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1319
1337
  : undefined;
1320
1338
  let emittedHashlineHeader = false;
1321
1339
  let seenLines: number[] | undefined;
1340
+ let rawSeenLines: number[] | undefined;
1322
1341
  const formatText = (content: string, startNum: number): string => {
1323
1342
  const lineCount = countTextLines(content);
1324
1343
  details.displayContent = {
@@ -1382,10 +1401,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1382
1401
  } else if (truncation.truncated) {
1383
1402
  const outputLines = truncation.outputLines ?? countTextLines(truncation.content);
1384
1403
  const endLineDisplay = startLineDisplay + Math.max(0, outputLines - 1);
1385
- outputText =
1386
- options.raw === true
1387
- ? formatText(truncation.content, startLineDisplay)
1388
- : formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
1404
+ if (options.raw === true) {
1405
+ rawSeenLines = contiguousLineNumbers(startLineDisplay, outputLines);
1406
+ outputText = formatText(truncation.content, startLineDisplay);
1407
+ } else {
1408
+ outputText = formatLineEntries(buildLineEntries(endLineDisplay), startLineDisplay);
1409
+ }
1389
1410
  details.truncation = truncation;
1390
1411
  truncationInfo = {
1391
1412
  result: truncation,
@@ -1395,21 +1416,28 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1395
1416
  const remaining = allLines.length - (startLine + userLimitedLines);
1396
1417
  const nextOffset = startLine + userLimitedLines + 1;
1397
1418
 
1398
- outputText =
1399
- options.raw === true
1400
- ? formatText(selectedContent, startLineDisplay)
1401
- : formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1419
+ if (options.raw === true) {
1420
+ rawSeenLines = contiguousLineNumbers(startLineDisplay, userLimitedLines);
1421
+ outputText = formatText(selectedContent, startLineDisplay);
1422
+ } else {
1423
+ outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1424
+ }
1402
1425
  outputText += `\n\n[${remaining} more lines in ${options.entityLabel}. Use :${nextOffset} to continue]`;
1403
1426
  } else {
1404
- outputText =
1405
- options.raw === true
1406
- ? formatText(truncation.content, startLineDisplay)
1407
- : formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1427
+ if (options.raw === true) {
1428
+ rawSeenLines = contiguousLineNumbers(startLineDisplay, endLine - startLine);
1429
+ outputText = formatText(truncation.content, startLineDisplay);
1430
+ } else {
1431
+ outputText = formatLineEntries(buildLineEntries(endLine), startLineDisplay);
1432
+ }
1408
1433
  }
1409
1434
 
1410
1435
  if (hashContext?.tag && options.sourcePath && seenLines) {
1411
1436
  recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines);
1412
1437
  }
1438
+ if (options.raw === true && options.sourcePath && options.immutable !== true && rawSeenLines) {
1439
+ recordInMemorySeenLines(this.session, options.sourcePath, text, rawSeenLines);
1440
+ }
1413
1441
  resultBuilder.text(outputText);
1414
1442
  if (truncationInfo) {
1415
1443
  resultBuilder.truncation(truncationInfo.result, truncationInfo.options);
@@ -1503,6 +1531,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1503
1531
  if (hashContext?.tag && options.sourcePath && seenLines) {
1504
1532
  recordSeenLines(this.session, options.sourcePath, hashContext.tag, seenLines);
1505
1533
  }
1534
+ if (options.raw === true && options.sourcePath && options.immutable !== true && visibleSpans.length > 0) {
1535
+ recordInMemorySeenLines(this.session, options.sourcePath, text, lineNumbersFromSpans(visibleSpans));
1536
+ }
1506
1537
  resultBuilder.text(finalText);
1507
1538
  return resultBuilder.done();
1508
1539
  }
@@ -1615,14 +1646,16 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1615
1646
  }
1616
1647
  if (cloned) displayLines = cloned;
1617
1648
  }
1618
- const endLine = range.startLine + Math.max(0, displayLines.length - 1);
1619
- visibleSpans.push({ startLine: range.startLine, endLine });
1620
- for (let i = 0; i < displayLines.length; i++) {
1621
- displayLineByNumber.set(range.startLine + i, displayLines[i] ?? "");
1622
- }
1623
- if (!fullLines || rawSelector) {
1624
- const blockText = displayLines.join("\n");
1625
- blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1649
+ if (displayLines.length > 0) {
1650
+ const endLine = range.startLine + displayLines.length - 1;
1651
+ visibleSpans.push({ startLine: range.startLine, endLine });
1652
+ for (let i = 0; i < displayLines.length; i++) {
1653
+ displayLineByNumber.set(range.startLine + i, displayLines[i] ?? "");
1654
+ }
1655
+ if (!fullLines || rawSelector) {
1656
+ const blockText = displayLines.join("\n");
1657
+ blocks.push(formatTextWithMode(blockText, range.startLine, shouldAddHashLines, shouldAddLineNumbers));
1658
+ }
1626
1659
  }
1627
1660
  }
1628
1661
 
@@ -1662,6 +1695,9 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
1662
1695
  recordSeenLinesFromBody(this.session, absolutePath, tag, outputText, clippedLines);
1663
1696
  outputText = `${formatReadHashlineHeader(formatPathRelativeToCwd(absolutePath, this.session.cwd), tag)}\n${outputText}`;
1664
1697
  }
1698
+ } else if (rawSelector && visibleSpans.length > 0) {
1699
+ const rawSeenLines = lineNumbersFromSpans(visibleSpans);
1700
+ if (rawSeenLines.length > 0) await recordFileSnapshot(this.session, absolutePath, rawSeenLines);
1665
1701
  }
1666
1702
  if (notices.length > 0) {
1667
1703
  outputText = outputText ? `${outputText}\n${notices.join("\n")}` : notices.join("\n");
@@ -2669,6 +2705,13 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2669
2705
  if (hashContext?.tag) {
2670
2706
  recordSeenLinesFromBody(this.session, absolutePath, hashContext.tag, outputText, clippedLines);
2671
2707
  }
2708
+ if (rawSelector && !firstLineExceedsLimit && collectedLines.length > 0) {
2709
+ await recordFileSnapshot(
2710
+ this.session,
2711
+ absolutePath,
2712
+ contiguousLineNumbers(startLineDisplay, collectedLines.length),
2713
+ );
2714
+ }
2672
2715
 
2673
2716
  if (capturedDisplayContent) {
2674
2717
  details.displayContent = capturedDisplayContent;
@@ -43,26 +43,6 @@ export type ToolRenderer = {
43
43
  mergeCallAndResult?: boolean;
44
44
  /** Render without background box, inline in the response flow */
45
45
  inline?: boolean;
46
- /**
47
- * Whether pending-call rows are provisional: useful on screen while a tool is
48
- * streaming, but not durable transcript history. `true` means every pending
49
- * shape is provisional. `"collapsed"` means only the collapsed pending shape
50
- * is provisional; expanded rendering is top-anchored/append-shaped enough to
51
- * let the transcript commit its settled prefix. Absent = the pending preview
52
- * streams rows the result render preserves.
53
- */
54
- provisionalPendingPreview?: boolean | "collapsed";
55
- /**
56
- * Whether the partial-result render is provisional: chrome rows (header
57
- * glyph, frame state) that change between `options.isPartial === true` and
58
- * the final result render. When `true`, the block is treated as
59
- * commit-unstable while a partial result is in flight, so the
60
- * stable-prefix ratchet in `deriveLiveCommitState` cannot promote the
61
- * partial chrome to native scrollback only to have the final render strand
62
- * it above the settled frame. Absent = the partial render is byte-stable
63
- * with the final render and may commit like any settled stream.
64
- */
65
- provisionalPartialResult?: boolean;
66
46
  /**
67
47
  * Whether the renderer's pending-call path visibly consumes
68
48
  * `options.spinnerFrame`. Used to avoid scheduling repaint ticks for live
package/src/tools/ssh.ts CHANGED
@@ -386,22 +386,6 @@ export const sshToolRenderer = {
386
386
  });
387
387
  },
388
388
  mergeCallAndResult: true,
389
- // Pending call preview can re-anchor wholesale when the final result inserts
390
- // the `Output` section, so no pending SSH rows may commit to native
391
- // scrollback — even when expanded. The expanded pending shape was previously
392
- // allowed to commit, which left two visible shapes in native scrollback once
393
- // the result settled: a stale `⏳ SSH: [host]` header above the final frame,
394
- // and the pending `╰──╯` footer reused in-place as the new `├── Output ──┤`
395
- // separator with a fresh footer pushed below it.
396
- provisionalPendingPreview: true,
397
- // Partial-result chrome (pending icon and frame state) differs from the
398
- // final SSH glyph/state, so the block stays commit-unstable while
399
- // `options.isPartial` holds. Without this, a long-running SSH command's
400
- // stable pending header would be promoted by the stable-prefix ratchet and
401
- // committed to native scrollback, then the final render's SSH glyph would
402
- // land below and strand a duplicate pending header above the final frame
403
- // ([#3177](https://github.com/can1357/oh-my-pi/issues/3177)).
404
- provisionalPartialResult: true,
405
389
  // Streamed args can initially render the SSH placeholder (`⏳ SSH: […]` /
406
390
  // `$ …`), then the first partial result inserts the `Output` section and
407
391
  // re-anchors the frame. Force a full repaint at that seam so placeholder rows
@@ -974,7 +974,7 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
974
974
  interface WriteRenderArgs {
975
975
  path?: string;
976
976
  file_path?: string;
977
- content?: string;
977
+ content?: unknown;
978
978
  }
979
979
 
980
980
  const WRITE_PREVIEW_LINES = 6;
@@ -990,8 +990,14 @@ function formatLineCountSuffix(lineCount: number, uiTheme: Theme): string {
990
990
  return uiTheme.fg("dim", ` · ${lineCount} line${lineCount === 1 ? "" : "s"}`);
991
991
  }
992
992
 
993
- function normalizeDisplayText(text: string): string {
994
- return text.replace(/\r/g, "");
993
+ function normalizeDisplayText(text: unknown): string {
994
+ let displayText = "";
995
+ if (typeof text === "string") {
996
+ displayText = text;
997
+ } else if (text !== undefined && text !== null) {
998
+ displayText = String(text);
999
+ }
1000
+ return displayText.replace(/\r/g, "");
995
1001
  }
996
1002
 
997
1003
  /**
@@ -1098,11 +1104,12 @@ export const writeToolRenderer = {
1098
1104
  },
1099
1105
  uiTheme,
1100
1106
  );
1107
+ const content = normalizeDisplayText(args.content);
1101
1108
  const streamingCache = createRenderedStringCache();
1102
1109
  return framedBlock(uiTheme, width => {
1103
- const body = args.content
1110
+ const body = content
1104
1111
  ? formatStreamingContent(
1105
- args.content,
1112
+ content,
1106
1113
  Boolean(options?.expanded),
1107
1114
  lang,
1108
1115
  uiTheme,
@@ -1130,7 +1137,7 @@ export const writeToolRenderer = {
1130
1137
  ): Component {
1131
1138
  const rawPath = args?.file_path || args?.path || "";
1132
1139
  const filePath = shortenPath(rawPath);
1133
- const fileContent = args?.content || "";
1140
+ const fileContent = normalizeDisplayText(args?.content);
1134
1141
  const lang = getLanguageFromPath(rawPath);
1135
1142
  const langIcon = uiTheme.fg("muted", uiTheme.getLangIcon(lang));
1136
1143
  // The header shows the cwd-relative path but links to the absolute path the