@oh-my-pi/pi-coding-agent 15.0.0 → 15.0.2

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 (165) hide show
  1. package/CHANGELOG.md +79 -0
  2. package/examples/extensions/plan-mode.ts +0 -1
  3. package/package.json +10 -10
  4. package/scripts/build-binary.ts +5 -0
  5. package/src/autoresearch/helpers.ts +17 -0
  6. package/src/autoresearch/tools/log-experiment.ts +9 -17
  7. package/src/autoresearch/tools/run-experiment.ts +2 -17
  8. package/src/capability/skill.ts +7 -0
  9. package/src/cli/list-models.ts +1 -1
  10. package/src/cli/shell-cli.ts +3 -13
  11. package/src/cli/update-cli.ts +1 -1
  12. package/src/cli.ts +10 -29
  13. package/src/commands/commit.ts +10 -0
  14. package/src/commit/agentic/tools/propose-changelog.ts +8 -1
  15. package/src/commit/analysis/conventional.ts +8 -66
  16. package/src/commit/map-reduce/reduce-phase.ts +6 -65
  17. package/src/commit/pipeline.ts +2 -2
  18. package/src/commit/shared-llm.ts +89 -0
  19. package/src/config/config-file.ts +210 -0
  20. package/src/config/model-equivalence.ts +8 -11
  21. package/src/config/model-registry.ts +44 -3
  22. package/src/config/model-resolver.ts +1 -4
  23. package/src/config/settings-schema.ts +82 -1
  24. package/src/config/settings.ts +1 -1
  25. package/src/config.ts +3 -219
  26. package/src/discovery/claude-plugins.ts +19 -7
  27. package/src/edit/renderer.ts +7 -1
  28. package/src/eval/js/executor.ts +3 -0
  29. package/src/eval/js/shared/rewrite-imports.ts +2 -2
  30. package/src/eval/py/executor.ts +5 -0
  31. package/src/eval/py/runner.py +42 -11
  32. package/src/eval/py/runtime.ts +1 -0
  33. package/src/exa/factory.ts +2 -2
  34. package/src/exa/mcp-client.ts +74 -1
  35. package/src/exec/bash-executor.ts +5 -1
  36. package/src/export/html/template.generated.ts +1 -1
  37. package/src/export/html/template.js +0 -11
  38. package/src/extensibility/extensions/get-commands-handler.ts +77 -0
  39. package/src/extensibility/extensions/runner.ts +1 -1
  40. package/src/extensibility/extensions/types.ts +89 -223
  41. package/src/extensibility/hooks/types.ts +89 -314
  42. package/src/extensibility/plugins/legacy-pi-compat.ts +48 -31
  43. package/src/extensibility/shared-events.ts +343 -0
  44. package/src/extensibility/skills.ts +9 -0
  45. package/src/goals/index.ts +3 -0
  46. package/src/goals/runtime.ts +500 -0
  47. package/src/goals/state.ts +37 -0
  48. package/src/goals/tools/goal-tool.ts +237 -0
  49. package/src/hashline/anchors.ts +2 -2
  50. package/src/hashline/input.ts +2 -1
  51. package/src/hashline/parser.ts +27 -3
  52. package/src/hindsight/mental-models.ts +1 -1
  53. package/src/internal-urls/agent-protocol.ts +1 -20
  54. package/src/internal-urls/artifact-protocol.ts +1 -19
  55. package/src/internal-urls/docs-index.generated.ts +11 -12
  56. package/src/internal-urls/registry-helpers.ts +25 -0
  57. package/src/internal-urls/router.ts +8 -0
  58. package/src/internal-urls/types.ts +21 -0
  59. package/src/lsp/config.ts +15 -6
  60. package/src/lsp/defaults.json +6 -2
  61. package/src/main.ts +11 -2
  62. package/src/mcp/oauth-flow.ts +20 -0
  63. package/src/modes/acp/acp-agent.ts +327 -95
  64. package/src/modes/components/assistant-message.ts +14 -8
  65. package/src/modes/components/bash-execution.ts +24 -63
  66. package/src/modes/components/custom-message.ts +14 -40
  67. package/src/modes/components/eval-execution.ts +27 -57
  68. package/src/modes/components/execution-shared.ts +102 -0
  69. package/src/modes/components/hook-message.ts +17 -49
  70. package/src/modes/components/mcp-add-wizard.ts +26 -5
  71. package/src/modes/components/message-frame.ts +88 -0
  72. package/src/modes/components/model-selector.ts +1 -1
  73. package/src/modes/components/session-observer-overlay.ts +6 -2
  74. package/src/modes/components/session-selector.ts +1 -1
  75. package/src/modes/components/status-line/segments.ts +93 -8
  76. package/src/modes/components/status-line/types.ts +4 -0
  77. package/src/modes/components/status-line.ts +28 -10
  78. package/src/modes/components/tool-execution.ts +7 -8
  79. package/src/modes/controllers/command-controller-shared.ts +108 -0
  80. package/src/modes/controllers/command-controller.ts +13 -4
  81. package/src/modes/controllers/event-controller.ts +36 -7
  82. package/src/modes/controllers/extension-ui-controller.ts +3 -2
  83. package/src/modes/controllers/input-controller.ts +13 -0
  84. package/src/modes/controllers/mcp-command-controller.ts +56 -61
  85. package/src/modes/controllers/ssh-command-controller.ts +18 -57
  86. package/src/modes/interactive-mode.ts +624 -52
  87. package/src/modes/print-mode.ts +16 -86
  88. package/src/modes/rpc/host-uris.ts +235 -0
  89. package/src/modes/rpc/rpc-mode.ts +41 -88
  90. package/src/modes/rpc/rpc-types.ts +57 -0
  91. package/src/modes/runtime-init.ts +116 -0
  92. package/src/modes/theme/defaults/dark-poimandres.json +3 -0
  93. package/src/modes/theme/defaults/light-poimandres.json +3 -0
  94. package/src/modes/theme/theme.ts +24 -6
  95. package/src/modes/types.ts +14 -3
  96. package/src/modes/utils/context-usage.ts +13 -13
  97. package/src/modes/utils/ui-helpers.ts +10 -3
  98. package/src/plan-mode/approved-plan.ts +35 -1
  99. package/src/prompts/goals/goal-budget-limit.md +16 -0
  100. package/src/prompts/goals/goal-continuation.md +28 -0
  101. package/src/prompts/goals/goal-mode-active.md +23 -0
  102. package/src/prompts/system/plan-mode-active.md +5 -5
  103. package/src/prompts/system/plan-mode-tool-decision-reminder.md +1 -1
  104. package/src/prompts/tools/bash.md +6 -0
  105. package/src/prompts/tools/github.md +4 -4
  106. package/src/prompts/tools/goal.md +13 -0
  107. package/src/prompts/tools/hashline.md +101 -117
  108. package/src/prompts/tools/read.md +55 -36
  109. package/src/prompts/tools/resolve.md +6 -5
  110. package/src/sdk.ts +12 -5
  111. package/src/session/agent-session.ts +428 -106
  112. package/src/session/blob-store.ts +36 -3
  113. package/src/session/messages.ts +67 -2
  114. package/src/session/session-manager.ts +131 -12
  115. package/src/session/session-storage.ts +33 -15
  116. package/src/session/streaming-output.ts +309 -13
  117. package/src/slash-commands/builtin-registry.ts +18 -0
  118. package/src/ssh/ssh-executor.ts +5 -0
  119. package/src/system-prompt.ts +4 -2
  120. package/src/task/discovery.ts +5 -2
  121. package/src/task/executor.ts +19 -8
  122. package/src/task/index.ts +3 -0
  123. package/src/task/render.ts +21 -15
  124. package/src/task/types.ts +4 -0
  125. package/src/tools/ast-edit.ts +21 -120
  126. package/src/tools/ast-grep.ts +21 -119
  127. package/src/tools/bash-command-fixup.ts +47 -0
  128. package/src/tools/bash-interactive.ts +9 -1
  129. package/src/tools/bash.ts +66 -19
  130. package/src/tools/browser/attach.ts +3 -3
  131. package/src/tools/browser/launch.ts +81 -18
  132. package/src/tools/browser/registry.ts +1 -5
  133. package/src/tools/browser/render.ts +2 -2
  134. package/src/tools/browser/tab-supervisor.ts +51 -14
  135. package/src/tools/conflict-detect.ts +15 -4
  136. package/src/tools/eval.ts +12 -2
  137. package/src/tools/find.ts +20 -38
  138. package/src/tools/gh.ts +44 -10
  139. package/src/tools/index.ts +22 -11
  140. package/src/tools/inspect-image.ts +3 -10
  141. package/src/tools/job.ts +16 -7
  142. package/src/tools/output-meta.ts +202 -37
  143. package/src/tools/path-utils.ts +125 -2
  144. package/src/tools/read.ts +548 -237
  145. package/src/tools/render-utils.ts +92 -0
  146. package/src/tools/renderers.ts +2 -0
  147. package/src/tools/resolve.ts +72 -44
  148. package/src/tools/search.ts +120 -186
  149. package/src/tools/ssh.ts +3 -2
  150. package/src/tools/write.ts +64 -9
  151. package/src/utils/file-mentions.ts +1 -1
  152. package/src/utils/image-loading.ts +7 -3
  153. package/src/utils/image-resize.ts +32 -43
  154. package/src/vim/parser.ts +0 -17
  155. package/src/vim/render.ts +1 -1
  156. package/src/vim/types.ts +1 -1
  157. package/src/web/search/providers/anthropic.ts +5 -0
  158. package/src/web/search/providers/exa.ts +3 -0
  159. package/src/web/search/providers/gemini.ts +40 -95
  160. package/src/web/search/providers/jina.ts +5 -2
  161. package/src/web/search/providers/zai.ts +5 -2
  162. package/src/prompts/tools/exit-plan-mode.md +0 -6
  163. package/src/tools/exit-plan-mode.ts +0 -97
  164. package/src/utils/fuzzy.ts +0 -108
  165. package/src/utils/image-convert.ts +0 -27
@@ -12,6 +12,7 @@ export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
12
12
  export const DEFAULT_MAX_COLUMN = 1024; // Max chars per grep match line
13
13
 
14
14
  const NL = "\n";
15
+ const ELLIPSIS = "…";
15
16
 
16
17
  // =============================================================================
17
18
  // Interfaces
@@ -24,6 +25,14 @@ export interface OutputSummary {
24
25
  totalBytes: number;
25
26
  outputLines: number;
26
27
  outputBytes: number;
28
+ /** Bytes elided from the middle when head-retain mode is active. */
29
+ elidedBytes?: number;
30
+ /** Lines elided from the middle when head-retain mode is active. */
31
+ elidedLines?: number;
32
+ /** Bytes dropped by the per-line column cap (sum across all lines). */
33
+ columnDroppedBytes?: number;
34
+ /** Number of distinct lines that hit the per-line column cap. */
35
+ columnTruncatedLines?: number;
27
36
  /** Artifact ID for internal URL access (artifact://<id>) when truncated */
28
37
  artifactId?: string;
29
38
  }
@@ -31,7 +40,21 @@ export interface OutputSummary {
31
40
  export interface OutputSinkOptions {
32
41
  artifactPath?: string;
33
42
  artifactId?: string;
43
+ /** Tail buffer budget (bytes). Default DEFAULT_MAX_BYTES. */
34
44
  spillThreshold?: number;
45
+ /**
46
+ * When > 0, the sink keeps the first `headBytes` of output in addition to
47
+ * the rolling tail window. Output between the two windows is elided
48
+ * (middle elision). Default 0 = tail-only behavior.
49
+ */
50
+ headBytes?: number;
51
+ /**
52
+ * Per-line byte cap. When > 0, lines wider than `maxColumns` bytes are
53
+ * truncated with an ellipsis at write time; remaining bytes up to the next
54
+ * `\n` are dropped. Cap state persists across chunks so split-mid-line
55
+ * writes still respect the budget. Default 0 = no per-line cap.
56
+ */
57
+ maxColumns?: number;
35
58
  onChunk?: (chunk: string) => void;
36
59
  /** Minimum ms between onChunk calls. 0 = every chunk (default). */
37
60
  chunkThrottleMs?: number;
@@ -40,11 +63,15 @@ export interface OutputSinkOptions {
40
63
  export interface TruncationResult {
41
64
  content: string;
42
65
  truncated?: boolean;
43
- truncatedBy?: "lines" | "bytes";
66
+ truncatedBy?: "lines" | "bytes" | "middle";
44
67
  totalLines: number;
45
68
  totalBytes: number;
46
69
  outputLines?: number;
47
70
  outputBytes?: number;
71
+ /** Bytes elided from the middle (truncateMiddle only). */
72
+ elidedBytes?: number;
73
+ /** Lines elided from the middle (truncateMiddle only). */
74
+ elidedLines?: number;
48
75
  lastLinePartial?: boolean;
49
76
  firstLineExceedsLimit?: boolean;
50
77
  }
@@ -54,6 +81,16 @@ export interface TruncationOptions {
54
81
  maxLines?: number;
55
82
  /** Maximum number of bytes (default: 50KB) */
56
83
  maxBytes?: number;
84
+ /**
85
+ * For `truncateMiddle`: bytes reserved for the head window. The tail
86
+ * window receives `maxBytes - maxHeadBytes`. Default `floor(maxBytes/2)`.
87
+ */
88
+ maxHeadBytes?: number;
89
+ /**
90
+ * For `truncateMiddle`: lines reserved for the head window. The tail
91
+ * window receives `maxLines - maxHeadLines`. Default `floor(maxLines/2)`.
92
+ */
93
+ maxHeadLines?: number;
57
94
  }
58
95
 
59
96
  /** Result from byte-level truncation helpers. */
@@ -425,6 +462,90 @@ export function truncateTail(content: string, options: TruncationOptions = {}):
425
462
  };
426
463
  }
427
464
 
465
+ // =============================================================================
466
+ // Middle elision (keep head + tail, drop middle)
467
+ // =============================================================================
468
+
469
+ /**
470
+ * Format the inline marker substituted for the elided middle region.
471
+ * Returned without surrounding newlines so callers can position it freely.
472
+ */
473
+ export function formatMiddleElisionMarker(elidedLines: number, elidedBytes: number): string {
474
+ const linesPart = `${elidedLines.toLocaleString()} line${elidedLines === 1 ? "" : "s"}`;
475
+ return `[… ${linesPart} elided (${formatBytes(elidedBytes)}) …]`;
476
+ }
477
+
478
+ /**
479
+ * Truncate content keeping a head window and a tail window, eliding the middle.
480
+ *
481
+ * The combined output is `<head>\n<marker>\n<tail>` when truncation is needed.
482
+ * `maxHeadBytes` defaults to `floor(maxBytes / 2)`; the tail receives the
483
+ * remainder. Falls back to `truncateTail` / `truncateHead` if either side's
484
+ * budget is empty or the content already fits.
485
+ */
486
+ export function truncateMiddle(content: string, options: TruncationOptions = {}): TruncationResult {
487
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
488
+ const maxLines = options.maxLines ?? DEFAULT_MAX_LINES;
489
+ const headBytes = options.maxHeadBytes ?? Math.floor(maxBytes / 2);
490
+ const tailBytes = Math.max(0, maxBytes - headBytes);
491
+ const headLines = options.maxHeadLines ?? Math.max(1, Math.floor(maxLines / 2));
492
+ const tailLines = Math.max(0, maxLines - headLines);
493
+
494
+ const totalBytes = Buffer.byteLength(content, "utf-8");
495
+ const totalLines = countNewlines(content) + 1;
496
+
497
+ if (totalBytes <= maxBytes && totalLines <= maxLines) {
498
+ return noTruncResult(content, totalLines, totalBytes);
499
+ }
500
+
501
+ // Degenerate budgets → fall back to one-sided truncation.
502
+ if (headBytes <= 0 || headLines <= 0) {
503
+ return truncateTail(content, { maxBytes: tailBytes || maxBytes, maxLines: tailLines || maxLines });
504
+ }
505
+ if (tailBytes <= 0 || tailLines <= 0) {
506
+ return truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
507
+ }
508
+
509
+ const head = truncateHead(content, { maxBytes: headBytes, maxLines: headLines });
510
+ const tail = truncateTail(content, { maxBytes: tailBytes, maxLines: tailLines });
511
+
512
+ const headLinesKept = head.outputLines ?? 0;
513
+ const tailLinesKept = tail.outputLines ?? 0;
514
+ const headBytesKept = head.outputBytes ?? Buffer.byteLength(head.content, "utf-8");
515
+ const tailBytesKept = tail.outputBytes ?? Buffer.byteLength(tail.content, "utf-8");
516
+
517
+ // Head unusable (first line exceeds budget) → tail-only.
518
+ if (headLinesKept === 0 || head.firstLineExceedsLimit) return tail;
519
+ // Tail unusable → head-only.
520
+ if (tailLinesKept === 0) return head;
521
+ // Windows overlap → no meaningful elision; return content untruncated.
522
+ if (headLinesKept + tailLinesKept >= totalLines) {
523
+ return noTruncResult(content, totalLines, totalBytes);
524
+ }
525
+
526
+ const elidedLines = totalLines - headLinesKept - tailLinesKept;
527
+ // `totalBytes - headBytesKept - tailBytesKept` includes newline separators
528
+ // between the kept windows and the elided region; close enough for a notice.
529
+ const elidedBytes = Math.max(0, totalBytes - headBytesKept - tailBytesKept);
530
+ const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
531
+ const composed = `${head.content}\n${marker}\n${tail.content}`;
532
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
533
+
534
+ return {
535
+ content: composed,
536
+ truncated: true,
537
+ truncatedBy: "middle",
538
+ totalLines,
539
+ totalBytes,
540
+ outputLines: headLinesKept + tailLinesKept + 1,
541
+ outputBytes: headBytesKept + tailBytesKept + markerBytes + 2,
542
+ elidedLines,
543
+ elidedBytes,
544
+ lastLinePartial: tail.lastLinePartial,
545
+ firstLineExceedsLimit: false,
546
+ };
547
+ }
548
+
428
549
  // =============================================================================
429
550
  // TailBuffer — ring-style tail buffer with lazy joining
430
551
  // =============================================================================
@@ -520,12 +641,21 @@ export class TailBuffer {
520
641
  export class OutputSink {
521
642
  #buffer = "";
522
643
  #bufferBytes = 0;
644
+ #head = "";
645
+ #headBytes = 0;
646
+ #headLines = 0; // newline count inside #head
523
647
  #totalLines = 0; // newline count
524
648
  #totalBytes = 0;
525
649
  #sawData = false;
526
650
  #truncated = false;
527
651
  #lastChunkTime = 0;
528
652
 
653
+ // Per-line column cap streaming state (persists across `push` calls so a
654
+ // long line split across chunks still trips the same trigger).
655
+ #currentLineBytes = 0;
656
+ #columnEllipsisAdded = false;
657
+ #columnDroppedBytes = 0;
658
+ #columnTruncatedLines = 0;
529
659
  #file?: {
530
660
  path: string;
531
661
  artifactId?: string;
@@ -539,20 +669,26 @@ export class OutputSink {
539
669
  readonly #artifactPath?: string;
540
670
  readonly #artifactId?: string;
541
671
  readonly #spillThreshold: number;
672
+ readonly #headLimit: number;
542
673
  readonly #onChunk?: (chunk: string) => void;
543
674
  readonly #chunkThrottleMs: number;
675
+ readonly #maxColumns: number;
544
676
 
545
677
  constructor(options?: OutputSinkOptions) {
546
678
  const {
547
679
  artifactPath,
548
680
  artifactId,
549
681
  spillThreshold = DEFAULT_MAX_BYTES,
682
+ headBytes = 0,
683
+ maxColumns = 0,
550
684
  onChunk,
551
685
  chunkThrottleMs = 0,
552
686
  } = options ?? {};
553
687
  this.#artifactPath = artifactPath;
554
688
  this.#artifactId = artifactId;
555
689
  this.#spillThreshold = spillThreshold;
690
+ this.#headLimit = Math.max(0, headBytes);
691
+ this.#maxColumns = Math.max(0, maxColumns);
556
692
  this.#onChunk = onChunk;
557
693
  this.#chunkThrottleMs = chunkThrottleMs;
558
694
  }
@@ -565,6 +701,8 @@ export class OutputSink {
565
701
  chunk = sanitizeWithOptionalSixelPassthrough(chunk, sanitizeText);
566
702
 
567
703
  // Throttled onChunk: only call the callback when enough time has passed.
704
+ // Live preview gets the raw (pre-cap) chunk so the TUI never lags behind
705
+ // what reached the sink — the column cap is for the persisted LLM view.
568
706
  if (this.#onChunk) {
569
707
  const now = Date.now();
570
708
  if (now - this.#lastChunkTime >= this.#chunkThrottleMs) {
@@ -573,22 +711,124 @@ export class OutputSink {
573
711
  }
574
712
  }
575
713
 
576
- const dataBytes = Buffer.byteLength(chunk, "utf-8");
577
- this.#totalBytes += dataBytes;
714
+ const rawBytes = Buffer.byteLength(chunk, "utf-8");
715
+ this.#totalBytes += rawBytes;
578
716
 
579
717
  if (chunk.length > 0) {
580
718
  this.#sawData = true;
581
719
  this.#totalLines += countNewlines(chunk);
582
720
  }
583
721
 
584
- const threshold = this.#spillThreshold;
585
- const willOverflow = this.#bufferBytes + dataBytes > threshold;
586
-
587
- // Write to artifact file if configured and past the threshold
588
- if (this.#artifactPath && (this.#file != null || willOverflow)) {
722
+ // Per-line column cap. State persists across chunks so a mid-line split
723
+ // still respects the budget. Operates on the sanitized chunk; the cap is
724
+ // applied before head/tail accounting but after artifact mirroring decides.
725
+ const capped = this.#maxColumns > 0 ? this.#applyColumnCap(chunk) : chunk;
726
+ const cappedBytes = capped === chunk ? rawBytes : Buffer.byteLength(capped, "utf-8");
727
+ const cappedThisChunk = cappedBytes < rawBytes;
728
+ if (cappedThisChunk) this.#truncated = true;
729
+
730
+ // Mirror RAW chunk to the artifact file so the on-disk record is the full
731
+ // uncapped stream. Mirror triggers on: in-memory overflow OR this chunk's
732
+ // column cap dropped bytes (otherwise we'd lose data) OR file already open.
733
+ if (this.#artifactPath && (this.#file != null || cappedThisChunk || this.#willOverflow(cappedBytes))) {
589
734
  this.#writeToFile(chunk);
590
735
  }
591
736
 
737
+ if (cappedBytes === 0) return;
738
+
739
+ // Head retention: drain the (capped) chunk into #head until the budget is
740
+ // exhausted, then forward any leftover to the tail buffer.
741
+ let tailChunk = capped;
742
+ let tailBytes = cappedBytes;
743
+ if (this.#headLimit > 0 && this.#headBytes < this.#headLimit) {
744
+ const room = this.#headLimit - this.#headBytes;
745
+ if (cappedBytes <= room) {
746
+ this.#head += capped;
747
+ this.#headBytes += cappedBytes;
748
+ this.#headLines += countNewlines(capped);
749
+ return;
750
+ }
751
+ // Split: head takes a UTF-8-safe prefix; remainder flows to tail.
752
+ const headSlice = truncateHeadBytes(capped, room);
753
+ if (headSlice.bytes > 0) {
754
+ this.#head += headSlice.text;
755
+ this.#headBytes += headSlice.bytes;
756
+ this.#headLines += countNewlines(headSlice.text);
757
+ tailChunk = capped.substring(headSlice.text.length);
758
+ tailBytes = cappedBytes - headSlice.bytes;
759
+ }
760
+ }
761
+
762
+ this.#pushTail(tailChunk, tailBytes);
763
+ }
764
+
765
+ /**
766
+ * Apply the per-line byte cap to `chunk`, dropping bytes that would push the
767
+ * current line beyond `#maxColumns`. Emits a single `…` once a line trips the
768
+ * cap; subsequent bytes are skipped until the next `\n`. State persists
769
+ * across calls so a long line split across chunks still produces one marker.
770
+ */
771
+ #applyColumnCap(chunk: string): string {
772
+ if (chunk.length === 0) return chunk;
773
+ const max = this.#maxColumns;
774
+ const parts: string[] = [];
775
+ let cursor = 0;
776
+ while (cursor < chunk.length) {
777
+ const nlIdx = chunk.indexOf(NL, cursor);
778
+ const segEnd = nlIdx === -1 ? chunk.length : nlIdx;
779
+ if (segEnd > cursor) {
780
+ const segment = chunk.substring(cursor, segEnd);
781
+ if (this.#columnEllipsisAdded) {
782
+ // Past the cap; drop until newline.
783
+ this.#columnDroppedBytes += Buffer.byteLength(segment, "utf-8");
784
+ } else {
785
+ const segBytes = Buffer.byteLength(segment, "utf-8");
786
+ const remaining = max - this.#currentLineBytes;
787
+ if (segBytes <= remaining) {
788
+ parts.push(segment);
789
+ this.#currentLineBytes += segBytes;
790
+ } else {
791
+ // First overflow on this line: keep what fits, append ellipsis,
792
+ // arm the skip-until-newline flag.
793
+ const ellipsisBytes = 3; // "…" in UTF-8
794
+ const headRoom = Math.max(0, remaining - ellipsisBytes);
795
+ let kept = "";
796
+ let keptBytes = 0;
797
+ if (headRoom > 0) {
798
+ const sliced = truncateHeadBytes(segment, headRoom);
799
+ kept = sliced.text;
800
+ keptBytes = sliced.bytes;
801
+ parts.push(kept);
802
+ }
803
+ parts.push(ELLIPSIS);
804
+ this.#columnDroppedBytes += segBytes - keptBytes;
805
+ this.#columnTruncatedLines++;
806
+ this.#currentLineBytes += keptBytes + ellipsisBytes;
807
+ this.#columnEllipsisAdded = true;
808
+ }
809
+ }
810
+ }
811
+ if (nlIdx === -1) break;
812
+ parts.push(NL);
813
+ this.#currentLineBytes = 0;
814
+ this.#columnEllipsisAdded = false;
815
+ cursor = nlIdx + 1;
816
+ }
817
+ return parts.join("");
818
+ }
819
+
820
+ #willOverflow(dataBytes: number): boolean {
821
+ // Triggers file mirroring as soon as the next chunk would push us over
822
+ // the tail budget (head retention does not change spill-to-artifact).
823
+ return this.#bufferBytes + dataBytes > this.#spillThreshold;
824
+ }
825
+
826
+ #pushTail(chunk: string, dataBytes: number): void {
827
+ if (dataBytes === 0) return;
828
+
829
+ const threshold = this.#spillThreshold;
830
+ const willOverflow = this.#bufferBytes + dataBytes > threshold;
831
+
592
832
  if (!willOverflow) {
593
833
  this.#buffer += chunk;
594
834
  this.#bufferBytes += dataBytes;
@@ -612,8 +852,6 @@ export class OutputSink {
612
852
  this.#buffer = text;
613
853
  this.#bufferBytes = bytes;
614
854
  }
615
-
616
- if (this.#file) this.#truncated = true;
617
855
  }
618
856
 
619
857
  /**
@@ -685,26 +923,84 @@ export class OutputSink {
685
923
  * streaming counters (totalLines/totalBytes reflect the raw chunks that
686
924
  * already reached the sink). Used when an upstream minimizer rewrites the
687
925
  * captured output after the raw bytes have already been streamed.
926
+ *
927
+ * Clears any retained head window — the minimized text is authoritative.
688
928
  */
689
929
  replace(text: string): void {
690
930
  this.#buffer = text;
691
931
  this.#bufferBytes = Buffer.byteLength(text, "utf-8");
932
+ this.#head = "";
933
+ this.#headBytes = 0;
934
+ this.#headLines = 0;
935
+ this.#currentLineBytes = 0;
936
+ this.#columnEllipsisAdded = false;
937
+ this.#columnDroppedBytes = 0;
938
+ this.#columnTruncatedLines = 0;
692
939
  }
693
940
 
694
941
  async dump(notice?: string): Promise<OutputSummary> {
695
942
  const noticeLine = notice ? `[${notice}]\n` : "";
696
- const outputLines = this.#buffer.length > 0 ? countNewlines(this.#buffer) + 1 : 0;
697
943
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
698
944
 
699
945
  if (this.#file) await this.#file.sink.end();
700
946
 
947
+ // Compose the visible output. With head retention, splice head + marker
948
+ // + tail when content was elided. Otherwise return the rolling buffer.
949
+ const headBytes = this.#headBytes;
950
+ const tailBuf = this.#buffer;
951
+ const tailBytes = this.#bufferBytes;
952
+ const headLines = this.#headLines + (headBytes > 0 && !this.#head.endsWith("\n") ? 1 : 0);
953
+ const tailLines = tailBuf.length > 0 ? countNewlines(tailBuf) + 1 : 0;
954
+
955
+ // Bytes that survived the column cap. Middle elision operates on these,
956
+ // so column-dropped bytes don't inflate the "elided from middle" count.
957
+ const effectiveTotalBytes = Math.max(0, this.#totalBytes - this.#columnDroppedBytes);
958
+
959
+ let body: string;
960
+ let outputBytes: number;
961
+ let outputLines: number;
962
+ let elidedBytes: number | undefined;
963
+ let elidedLines: number | undefined;
964
+
965
+ if (headBytes > 0 && effectiveTotalBytes > headBytes + tailBytes) {
966
+ // Middle was elided. Emit head + marker + tail.
967
+ elidedBytes = Math.max(0, effectiveTotalBytes - headBytes - tailBytes);
968
+ elidedLines = Math.max(0, totalLines - headLines - tailLines);
969
+ const marker = formatMiddleElisionMarker(elidedLines, elidedBytes);
970
+ const markerBytes = Buffer.byteLength(marker, "utf-8");
971
+ const headSep = this.#head.endsWith("\n") ? "" : "\n";
972
+ const tailSep = tailBuf.startsWith("\n") ? "" : "\n";
973
+ body = `${this.#head}${headSep}${marker}${tailSep}${tailBuf}`;
974
+ outputBytes =
975
+ headBytes +
976
+ markerBytes +
977
+ tailBytes +
978
+ Buffer.byteLength(headSep, "utf-8") +
979
+ Buffer.byteLength(tailSep, "utf-8");
980
+ outputLines = headLines + 1 + tailLines;
981
+ this.#truncated = true;
982
+ } else if (headBytes > 0) {
983
+ // Head + tail combine into the full buffered output (no overlap or elision).
984
+ body = `${this.#head}${tailBuf}`;
985
+ outputBytes = headBytes + tailBytes;
986
+ outputLines = body.length > 0 ? countNewlines(body) + 1 : 0;
987
+ } else {
988
+ body = tailBuf;
989
+ outputBytes = tailBytes;
990
+ outputLines = tailLines;
991
+ }
992
+
701
993
  return {
702
- output: `${noticeLine}${this.#buffer}`,
994
+ output: `${noticeLine}${body}`,
703
995
  truncated: this.#truncated,
704
996
  totalLines,
705
997
  totalBytes: this.#totalBytes,
706
998
  outputLines,
707
- outputBytes: this.#bufferBytes,
999
+ outputBytes,
1000
+ elidedBytes,
1001
+ elidedLines,
1002
+ columnDroppedBytes: this.#columnDroppedBytes > 0 ? this.#columnDroppedBytes : undefined,
1003
+ columnTruncatedLines: this.#columnTruncatedLines > 0 ? this.#columnTruncatedLines : undefined,
708
1004
  artifactId: this.#file?.artifactId,
709
1005
  };
710
1006
  }
@@ -76,6 +76,24 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
76
76
  runtime.ctx.editor.setText("");
77
77
  },
78
78
  },
79
+ {
80
+ name: "goal",
81
+ description: "Toggle goal mode (persistent autonomous objective for this session)",
82
+ subcommands: [
83
+ { name: "set", description: "Set or replace the goal", usage: "<objective>" },
84
+ { name: "show", description: "Show current goal details" },
85
+ { name: "pause", description: "Pause the current goal" },
86
+ { name: "resume", description: "Resume a paused goal" },
87
+ { name: "drop", description: "Drop the current goal" },
88
+ { name: "budget", description: "Adjust the token budget", usage: "<N|off>" },
89
+ ],
90
+ inlineHint: "[objective]",
91
+ allowArgs: true,
92
+ handleTui: async (command, runtime) => {
93
+ await runtime.ctx.handleGoalModeCommand(command.args || undefined);
94
+ runtime.ctx.editor.setText("");
95
+ },
96
+ },
79
97
  {
80
98
  name: "loop",
81
99
  description:
@@ -1,5 +1,7 @@
1
1
  import { logger, ptree } from "@oh-my-pi/pi-utils";
2
+ import { Settings } from "../config/settings";
2
3
  import { OutputSink } from "../session/streaming-output";
4
+ import { resolveOutputMaxColumns, resolveOutputSinkHeadBytes } from "../tools/output-meta";
3
5
  import { buildRemoteCommand, ensureConnection, ensureHostInfo, type SSHConnectionTarget } from "./connection-manager";
4
6
  import { hasSshfs, mountRemote } from "./sshfs-mount";
5
7
 
@@ -83,10 +85,13 @@ export async function executeSSH(
83
85
  stderr: "full",
84
86
  });
85
87
 
88
+ const settings = await Settings.init();
86
89
  const sink = new OutputSink({
87
90
  onChunk: options?.onChunk,
88
91
  artifactPath: options?.artifactPath,
89
92
  artifactId: options?.artifactId,
93
+ headBytes: resolveOutputSinkHeadBytes(settings),
94
+ maxColumns: resolveOutputMaxColumns(settings),
90
95
  });
91
96
 
92
97
  const streams = [child.stdout.pipeTo(sink.createInput())];
@@ -530,9 +530,11 @@ export async function buildSystemPrompt(options: BuildSystemPromptOptions = {}):
530
530
  description: tools?.get(name)?.description ?? "",
531
531
  }));
532
532
 
533
- // Filter skills to only include those with read tool.
533
+ // Filter skills for the rendered system prompt:
534
+ // - require the `read` tool so the model can actually fetch skill content;
535
+ // - drop skills with frontmatter `hide: true` (still loadable via skill:// and /skill:<name>).
534
536
  const hasRead = tools?.has("read");
535
- const filteredSkills = hasRead ? skills : [];
537
+ const filteredSkills = hasRead ? skills.filter(skill => skill.hide !== true) : [];
536
538
 
537
539
  const effectiveSystemPromptCustomization = dedupePromptSource(systemPromptCustomization, [
538
540
  resolvedCustomPrompt,
@@ -15,6 +15,7 @@ import * as fs from "node:fs/promises";
15
15
  import * as os from "node:os";
16
16
  import * as path from "node:path";
17
17
  import { logger } from "@oh-my-pi/pi-utils";
18
+ import { isProviderEnabled } from "../capability";
18
19
  import { findAllNearestProjectConfigDirs, getConfigDirs } from "../config";
19
20
  import { listClaudePluginRoots } from "../discovery/helpers";
20
21
  import { loadBundledAgents, parseAgent } from "./agents";
@@ -87,8 +88,10 @@ export async function discoverAgents(cwd: string, home: string = os.homedir()):
87
88
  if (user) orderedDirs.push({ dir: user.path, source: "user" });
88
89
  }
89
90
 
90
- // Load agents from Claude Code marketplace plugins
91
- const { roots: pluginRoots } = await listClaudePluginRoots(home, resolvedCwd);
91
+ // Load agents from Claude Code marketplace plugins (respects disabledProviders)
92
+ const { roots: pluginRoots } = isProviderEnabled("claude-plugins")
93
+ ? await listClaudePluginRoots(home, resolvedCwd)
94
+ : { roots: [] };
92
95
  const sortedPluginRoots = [...pluginRoots].sort((a, b) => {
93
96
  if (a.scope === b.scope) return 0;
94
97
  return a.scope === "project" ? -1 : 1;
@@ -16,6 +16,7 @@ import { Settings } from "../config/settings";
16
16
  import { SETTINGS_SCHEMA, type SettingPath } from "../config/settings-schema";
17
17
  import type { CustomTool } from "../extensibility/custom-tools/types";
18
18
  import { runExtensionCompact, runExtensionSetModel } from "../extensibility/extensions/compact-handler";
19
+ import { getSessionSlashCommands } from "../extensibility/extensions/get-commands-handler";
19
20
  import type { Skill } from "../extensibility/skills";
20
21
  import type { HindsightSessionState } from "../hindsight/state";
21
22
  import type { LocalProtocolOptions } from "../internal-urls";
@@ -379,21 +380,29 @@ function firstNumberField(record: Record<string, unknown>, keys: string[]): numb
379
380
  }
380
381
 
381
382
  /**
382
- * Normalize usage objects from different event formats.
383
+ * Tokens for progress display: input + output + cacheWrite per turn.
384
+ *
385
+ * Deliberately excludes cacheRead. With prompt caching, cacheRead in each turn
386
+ * equals the full cached context (potentially hundreds of KB), so summing it
387
+ * across all turns produces a cumulative total that is N×context_size — far
388
+ * larger than the context window and misleading as a "work done" metric.
389
+ * cacheWrite is kept because each byte is written once, not repeated per turn.
390
+ * The cost segment handles billing; dedicated cache_read/cache_write segments
391
+ * handle cache-specific monitoring.
383
392
  */
384
393
  function getUsageTokens(usage: unknown): number {
385
394
  if (!usage || typeof usage !== "object") return 0;
386
395
  const record = usage as Record<string, unknown>;
387
396
 
388
- const totalTokens = firstNumberField(record, ["totalTokens", "total_tokens"]);
389
- if (totalTokens !== undefined && totalTokens > 0) return totalTokens;
390
-
391
397
  const input = firstNumberField(record, ["input", "input_tokens", "inputTokens"]) ?? 0;
392
398
  const output = firstNumberField(record, ["output", "output_tokens", "outputTokens"]) ?? 0;
393
- const cacheRead = firstNumberField(record, ["cacheRead", "cache_read", "cacheReadTokens"]) ?? 0;
394
399
  const cacheWrite = firstNumberField(record, ["cacheWrite", "cache_write", "cacheWriteTokens"]) ?? 0;
395
-
396
- return input + output + cacheRead + cacheWrite;
400
+ const computed = input + output + cacheWrite;
401
+ if (computed > 0) return computed;
402
+ // Fallback for providers that only surface a pre-summed total without individual
403
+ // field breakdown. This total includes cacheRead, but returning it is still better
404
+ // than silently showing 0 for those providers.
405
+ return firstNumberField(record, ["totalTokens", "total_tokens"]) ?? 0;
397
406
  }
398
407
 
399
408
  /**
@@ -497,6 +506,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
497
506
  recentOutput: [],
498
507
  toolCount: 0,
499
508
  tokens: 0,
509
+ cost: 0,
500
510
  durationMs: 0,
501
511
  modelOverride,
502
512
  };
@@ -892,6 +902,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
892
902
  accumulatedUsage.cost.cacheRead += getNumberField(costRecord, "cacheRead") ?? 0;
893
903
  accumulatedUsage.cost.cacheWrite += getNumberField(costRecord, "cacheWrite") ?? 0;
894
904
  accumulatedUsage.cost.total += getNumberField(costRecord, "total") ?? 0;
905
+ progress.cost = accumulatedUsage.cost.total;
895
906
  }
896
907
  }
897
908
  // Accumulate tokens for progress display
@@ -1109,7 +1120,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1109
1120
  getAllTools: () => session.getAllToolNames(),
1110
1121
  setActiveTools: (toolNames: string[]) =>
1111
1122
  session.setActiveToolsByName(toolNames.filter(name => !parentOwnedToolNames.has(name))),
1112
- getCommands: () => [],
1123
+ getCommands: () => getSessionSlashCommands(session),
1113
1124
  setModel: model => runExtensionSetModel(session, model),
1114
1125
  getThinkingLevel: () => session.thinkingLevel,
1115
1126
  setThinkingLevel: level => session.setThinkingLevel(level),
package/src/task/index.ts CHANGED
@@ -306,6 +306,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
306
306
  recentOutput: [],
307
307
  toolCount: 0,
308
308
  tokens: 0,
309
+ cost: 0,
309
310
  durationMs: 0,
310
311
  });
311
312
  }
@@ -390,6 +391,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
390
391
  : "failed";
391
392
  progress.durationMs = singleResult?.durationMs ?? Math.max(0, Date.now() - startedAt);
392
393
  progress.tokens = singleResult?.tokens ?? 0;
394
+ progress.cost = singleResult?.usage?.cost.total ?? 0;
393
395
  progress.extractedToolData = singleResult?.extractedToolData;
394
396
  }
395
397
  completedJobs += 1;
@@ -831,6 +833,7 @@ export class TaskTool implements AgentTool<TSchema, TaskToolDetails, Theme> {
831
833
  recentOutput: [],
832
834
  toolCount: 0,
833
835
  tokens: 0,
836
+ cost: 0,
834
837
  durationMs: 0,
835
838
  modelOverride,
836
839
  description: taskItem.description,