@oh-my-pi/pi-coding-agent 15.10.11 → 15.11.0

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 (217) hide show
  1. package/CHANGELOG.md +103 -2
  2. package/dist/cli.js +5790 -5731
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/args.d.ts +1 -0
  5. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  6. package/dist/types/cli-commands.d.ts +12 -0
  7. package/dist/types/commands/launch.d.ts +4 -0
  8. package/dist/types/config/api-key-resolver.d.ts +3 -0
  9. package/dist/types/config/keybindings.d.ts +6 -1
  10. package/dist/types/config/model-registry.d.ts +1 -0
  11. package/dist/types/config/model-resolver.d.ts +18 -0
  12. package/dist/types/config/settings-schema.d.ts +85 -34
  13. package/dist/types/config/settings.d.ts +7 -0
  14. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  15. package/dist/types/eval/py/executor.d.ts +5 -0
  16. package/dist/types/eval/py/kernel.d.ts +6 -1
  17. package/dist/types/eval/py/runtime.d.ts +9 -0
  18. package/dist/types/exec/bash-executor.d.ts +2 -0
  19. package/dist/types/export/html/template.generated.d.ts +1 -1
  20. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  21. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  22. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  23. package/dist/types/extensibility/shared-events.d.ts +2 -2
  24. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  25. package/dist/types/internal-urls/index.d.ts +1 -0
  26. package/dist/types/internal-urls/types.d.ts +1 -1
  27. package/dist/types/irc/bus.d.ts +66 -0
  28. package/dist/types/memory-backend/index.d.ts +1 -0
  29. package/dist/types/memory-backend/runtime.d.ts +4 -0
  30. package/dist/types/memory-backend/types.d.ts +66 -1
  31. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  32. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  33. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  34. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  35. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  36. package/dist/types/modes/components/welcome.d.ts +3 -9
  37. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  38. package/dist/types/modes/index.d.ts +3 -3
  39. package/dist/types/modes/interactive-mode.d.ts +10 -4
  40. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  41. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  42. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  43. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  44. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  45. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  46. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  47. package/dist/types/modes/theme/theme.d.ts +2 -1
  48. package/dist/types/modes/types.d.ts +5 -2
  49. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  50. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  51. package/dist/types/registry/agent-registry.d.ts +16 -5
  52. package/dist/types/secrets/index.d.ts +1 -1
  53. package/dist/types/secrets/obfuscator.d.ts +8 -2
  54. package/dist/types/session/agent-session.d.ts +49 -32
  55. package/dist/types/session/messages.d.ts +2 -4
  56. package/dist/types/session/session-history-format.d.ts +12 -0
  57. package/dist/types/session/session-manager.d.ts +21 -3
  58. package/dist/types/session/streaming-output.d.ts +46 -0
  59. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  60. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  61. package/dist/types/slash-commands/types.d.ts +1 -1
  62. package/dist/types/system-prompt.d.ts +2 -0
  63. package/dist/types/task/executor.d.ts +12 -2
  64. package/dist/types/task/index.d.ts +13 -6
  65. package/dist/types/task/output-manager.d.ts +0 -7
  66. package/dist/types/task/repair-args.d.ts +8 -7
  67. package/dist/types/task/types.d.ts +63 -51
  68. package/dist/types/thinking.d.ts +4 -0
  69. package/dist/types/tiny/title-client.d.ts +11 -0
  70. package/dist/types/tiny/title-protocol.d.ts +1 -0
  71. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  72. package/dist/types/tools/find.d.ts +0 -11
  73. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  74. package/dist/types/tools/index.d.ts +7 -3
  75. package/dist/types/tools/irc.d.ts +76 -38
  76. package/dist/types/tools/job.d.ts +7 -1
  77. package/dist/types/utils/git.d.ts +15 -2
  78. package/dist/types/utils/title-generator.d.ts +3 -2
  79. package/examples/extensions/with-deps/package.json +1 -0
  80. package/package.json +11 -10
  81. package/scripts/bundle-dist.ts +28 -19
  82. package/src/async/index.ts +0 -1
  83. package/src/auto-thinking/classifier.ts +1 -0
  84. package/src/cli/args.ts +3 -0
  85. package/src/cli/gallery-cli.ts +1 -1
  86. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  87. package/src/cli/gallery-fixtures/types.ts +5 -0
  88. package/src/cli-commands.ts +29 -0
  89. package/src/cli.ts +28 -15
  90. package/src/commands/launch.ts +4 -0
  91. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  92. package/src/commit/model-selection.ts +3 -2
  93. package/src/config/api-key-resolver.ts +8 -6
  94. package/src/config/keybindings.ts +6 -1
  95. package/src/config/model-registry.ts +97 -30
  96. package/src/config/model-resolver.ts +60 -0
  97. package/src/config/settings-schema.ts +99 -55
  98. package/src/config/settings.ts +68 -3
  99. package/src/edit/hashline/execute.ts +39 -2
  100. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  101. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  102. package/src/eval/agent-bridge.ts +3 -16
  103. package/src/eval/completion-bridge.ts +1 -0
  104. package/src/eval/js/shared/prelude.txt +1 -1
  105. package/src/eval/py/executor.ts +29 -7
  106. package/src/eval/py/index.ts +6 -1
  107. package/src/eval/py/kernel.ts +31 -11
  108. package/src/eval/py/prelude.py +5 -6
  109. package/src/eval/py/runtime.ts +37 -0
  110. package/src/exec/bash-executor.ts +82 -3
  111. package/src/export/html/template.generated.ts +1 -1
  112. package/src/export/html/template.js +38 -13
  113. package/src/extensibility/custom-tools/types.ts +2 -2
  114. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  115. package/src/extensibility/extensions/runner.ts +6 -1
  116. package/src/extensibility/extensions/types.ts +3 -0
  117. package/src/extensibility/shared-events.ts +2 -2
  118. package/src/hindsight/bank.ts +17 -2
  119. package/src/internal-urls/docs-index.generated.ts +11 -11
  120. package/src/internal-urls/history-protocol.ts +113 -0
  121. package/src/internal-urls/index.ts +1 -0
  122. package/src/internal-urls/router.ts +3 -1
  123. package/src/internal-urls/types.ts +1 -1
  124. package/src/irc/bus.ts +292 -0
  125. package/src/main.ts +26 -66
  126. package/src/memories/index.ts +2 -0
  127. package/src/memory-backend/index.ts +1 -0
  128. package/src/memory-backend/local-backend.ts +9 -0
  129. package/src/memory-backend/off-backend.ts +9 -0
  130. package/src/memory-backend/runtime.ts +66 -0
  131. package/src/memory-backend/types.ts +81 -1
  132. package/src/mnemopi/backend.ts +151 -4
  133. package/src/modes/acp/acp-agent.ts +119 -11
  134. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  135. package/src/modes/components/assistant-message.ts +19 -21
  136. package/src/modes/components/compaction-summary-message.ts +68 -32
  137. package/src/modes/components/custom-editor.ts +10 -0
  138. package/src/modes/components/footer.ts +3 -1
  139. package/src/modes/components/status-line/component.ts +118 -34
  140. package/src/modes/components/tool-execution.ts +31 -1
  141. package/src/modes/components/ttsr-notification.ts +72 -30
  142. package/src/modes/components/welcome.ts +9 -33
  143. package/src/modes/controllers/command-controller.ts +1 -1
  144. package/src/modes/controllers/event-controller.ts +65 -0
  145. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  146. package/src/modes/controllers/input-controller.ts +19 -2
  147. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  148. package/src/modes/controllers/selector-controller.ts +21 -17
  149. package/src/modes/index.ts +3 -21
  150. package/src/modes/interactive-mode.ts +47 -22
  151. package/src/modes/oauth-manual-input.ts +30 -3
  152. package/src/modes/rpc/rpc-client.ts +154 -3
  153. package/src/modes/rpc/rpc-mode.ts +97 -12
  154. package/src/modes/rpc/rpc-subagents.ts +265 -0
  155. package/src/modes/rpc/rpc-types.ts +81 -1
  156. package/src/modes/setup-wizard/index.ts +12 -2
  157. package/src/modes/setup-wizard/lazy.ts +16 -0
  158. package/src/modes/theme/theme.ts +18 -5
  159. package/src/modes/types.ts +5 -5
  160. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  161. package/src/modes/utils/ui-helpers.ts +51 -49
  162. package/src/prompts/system/irc-incoming.md +3 -4
  163. package/src/prompts/system/orchestrate-notice.md +2 -2
  164. package/src/prompts/system/subagent-system-prompt.md +0 -5
  165. package/src/prompts/system/system-prompt.md +1 -0
  166. package/src/prompts/system/workflow-notice.md +2 -2
  167. package/src/prompts/tools/eval.md +3 -3
  168. package/src/prompts/tools/irc.md +29 -19
  169. package/src/prompts/tools/read.md +2 -2
  170. package/src/prompts/tools/task-summary.md +5 -16
  171. package/src/prompts/tools/task.md +38 -29
  172. package/src/registry/agent-lifecycle.ts +218 -0
  173. package/src/registry/agent-registry.ts +16 -5
  174. package/src/sdk.ts +37 -10
  175. package/src/secrets/index.ts +8 -1
  176. package/src/secrets/obfuscator.ts +39 -18
  177. package/src/session/agent-session.ts +422 -291
  178. package/src/session/messages.ts +11 -78
  179. package/src/session/session-history-format.ts +246 -0
  180. package/src/session/session-manager.ts +59 -5
  181. package/src/session/streaming-output.ts +226 -10
  182. package/src/slash-commands/acp-builtins.ts +24 -0
  183. package/src/slash-commands/builtin-registry.ts +20 -0
  184. package/src/slash-commands/types.ts +1 -1
  185. package/src/system-prompt.ts +14 -0
  186. package/src/task/executor.ts +851 -461
  187. package/src/task/index.ts +721 -796
  188. package/src/task/output-manager.ts +0 -11
  189. package/src/task/render.ts +148 -63
  190. package/src/task/repair-args.ts +21 -9
  191. package/src/task/types.ts +82 -66
  192. package/src/thinking.ts +7 -0
  193. package/src/tiny/title-client.ts +34 -5
  194. package/src/tiny/title-protocol.ts +1 -1
  195. package/src/tiny/worker.ts +6 -4
  196. package/src/tools/ask.ts +4 -2
  197. package/src/tools/bash.ts +61 -10
  198. package/src/tools/browser/tab-worker.ts +26 -7
  199. package/src/tools/browser.ts +28 -1
  200. package/src/tools/find.ts +2 -27
  201. package/src/tools/grouped-file-output.ts +1 -118
  202. package/src/tools/image-gen.ts +11 -4
  203. package/src/tools/index.ts +17 -13
  204. package/src/tools/inspect-image.ts +1 -0
  205. package/src/tools/irc.ts +596 -171
  206. package/src/tools/job.ts +41 -7
  207. package/src/tools/read.ts +57 -1
  208. package/src/tools/renderers.ts +2 -0
  209. package/src/tools/resolve.ts +4 -1
  210. package/src/utils/commit-message-generator.ts +1 -0
  211. package/src/utils/git.ts +267 -13
  212. package/src/utils/title-generator.ts +24 -5
  213. package/dist/types/async/support.d.ts +0 -2
  214. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  215. package/dist/types/task/simple-mode.d.ts +0 -8
  216. package/src/async/support.ts +0 -5
  217. package/src/task/simple-mode.ts +0 -27
@@ -11,6 +11,16 @@ export const DEFAULT_MAX_LINES = 3000;
11
11
  export const DEFAULT_MAX_BYTES = 50 * 1024; // 50KB
12
12
  export const DEFAULT_MAX_COLUMN = 512; // Max chars per grep match line
13
13
 
14
+ /**
15
+ * Default artifact-on-disk cap for {@link OutputSink}.
16
+ *
17
+ * `0` means unbounded: by default, `artifact://<id>` references preserve the
18
+ * complete raw stream instead of a capped head/tail sample.
19
+ */
20
+ export const ARTIFACT_DEFAULT_MAX_BYTES = 0;
21
+ /** Default head budget; the remainder becomes the rolling tail window. */
22
+ export const ARTIFACT_DEFAULT_HEAD_BYTES = 3 * 1024 * 1024; // 3 MiB
23
+
14
24
  const NL = "\n";
15
25
  const ELLIPSIS = "…";
16
26
 
@@ -58,6 +68,20 @@ export interface OutputSinkOptions {
58
68
  onChunk?: (chunk: string) => void;
59
69
  /** Minimum ms between onChunk calls. 0 = every chunk (default). */
60
70
  chunkThrottleMs?: number;
71
+ /**
72
+ * Optional cap on bytes written to the artifact-on-disk file. When the cap
73
+ * is hit, the head window is preserved verbatim and subsequent output feeds
74
+ * a rolling tail window; on close, the sink writes a single
75
+ * `[ARTIFACT TRUNCATED: …]` notice between them. Default
76
+ * {@link ARTIFACT_DEFAULT_MAX_BYTES} (unbounded).
77
+ */
78
+ artifactMaxBytes?: number;
79
+ /**
80
+ * Bytes reserved for the head window of the capped artifact file. The
81
+ * tail window receives `artifactMaxBytes - artifactHeadBytes`. Default
82
+ * {@link ARTIFACT_DEFAULT_HEAD_BYTES}; clamped to `[0, artifactMaxBytes]`.
83
+ */
84
+ artifactHeadBytes?: number;
61
85
  }
62
86
 
63
87
  export interface TruncationResult {
@@ -546,6 +570,66 @@ export function truncateMiddle(content: string, options: TruncationOptions = {})
546
570
  };
547
571
  }
548
572
 
573
+ // =============================================================================
574
+ // Inline byte cap — final defense at the tool-result boundary
575
+ // =============================================================================
576
+
577
+ /** Options for {@link enforceInlineByteCap}. */
578
+ export interface InlineByteCapOptions {
579
+ /** Inline byte budget. Defaults to {@link DEFAULT_MAX_BYTES}. */
580
+ maxBytes?: number;
581
+ /** What the text is, for the elision marker (e.g. "bash output"). */
582
+ label: string;
583
+ /**
584
+ * Persist the full text as a session artifact. When an artifact id is
585
+ * returned, a `[raw output: artifact://<id>]` footer is appended so the
586
+ * elided bytes stay recoverable.
587
+ */
588
+ saveArtifact?: (full: string) => string | undefined | Promise<string | undefined>;
589
+ }
590
+
591
+ /** Drop the partial last line of a head window (keep it if there is no newline at all). */
592
+ function trimHeadToLineBoundary(text: string): string {
593
+ const idx = text.lastIndexOf(NL);
594
+ return idx > 0 ? text.substring(0, idx) : text;
595
+ }
596
+
597
+ /** Drop the partial first line of a tail window (keep it if there is no newline at all). */
598
+ function trimTailToLineBoundary(text: string): string {
599
+ const idx = text.indexOf(NL);
600
+ if (idx < 0 || idx === text.length - 1) return text;
601
+ return text.substring(idx + 1);
602
+ }
603
+
604
+ /**
605
+ * Final-defense inline size guard for tool results.
606
+ *
607
+ * No-op when `text` fits within `maxBytes` (the common path). Otherwise keeps
608
+ * ~60% of the budget from the head and ~25% from the tail — cut on line
609
+ * boundaries, never splitting a multi-byte UTF-8 sequence — with an elision
610
+ * marker between. The remaining ~15% is slack for the marker and the optional
611
+ * `[raw output: artifact://<id>]` footer, so the result stays under `maxBytes`.
612
+ */
613
+ export async function enforceInlineByteCap(text: string, options: InlineByteCapOptions): Promise<string> {
614
+ const maxBytes = options.maxBytes ?? DEFAULT_MAX_BYTES;
615
+ if (maxBytes <= 0) return text;
616
+ const totalBytes = Buffer.byteLength(text, "utf-8");
617
+ if (totalBytes <= maxBytes) return text;
618
+
619
+ const head = trimHeadToLineBoundary(truncateHeadBytes(text, Math.floor(maxBytes * 0.6)).text);
620
+ const tail = trimTailToLineBoundary(truncateTailBytes(text, Math.floor(maxBytes * 0.25)).text);
621
+ const elidedBytes = Math.max(0, totalBytes - Buffer.byteLength(head, "utf-8") - Buffer.byteLength(tail, "utf-8"));
622
+ const marker = `[… elided ${elidedBytes} bytes of ${options.label} …]`;
623
+ let composed = `${head}\n${marker}\n${tail}`;
624
+
625
+ const artifactId = await options.saveArtifact?.(text);
626
+ if (artifactId) {
627
+ const sep = composed.endsWith(NL) ? "" : NL;
628
+ composed += `${sep}[raw output: artifact://${artifactId}]`;
629
+ }
630
+ return composed;
631
+ }
632
+
549
633
  // =============================================================================
550
634
  // TailBuffer — ring-style tail buffer with lazy joining
551
635
  // =============================================================================
@@ -676,6 +760,21 @@ export class OutputSink {
676
760
  readonly #chunkThrottleMs: number;
677
761
  readonly #maxColumns: number;
678
762
 
763
+ // Optional artifact-on-disk cap. When `#artifactMaxBytes > 0` the file sink
764
+ // owns a head budget + a rolling tail buffer; once the head is closed,
765
+ // subsequent chunks are diverted into `#artifactTailRing` (bounded by
766
+ // `#artifactTailBudget`). On `dump()` the tail is flushed back to the sink
767
+ // behind a `[ARTIFACT TRUNCATED: …]` notice. The default cap is disabled so
768
+ // advertised `artifact://<id>` captures are lossless.
769
+ readonly #artifactMaxBytes: number;
770
+ readonly #artifactHeadBudget: number;
771
+ readonly #artifactTailBudget: number;
772
+ #artifactHeadBytesWritten = 0;
773
+ #artifactHeadClosed = false;
774
+ #artifactTailRing = "";
775
+ #artifactTailRingBytes = 0;
776
+ #artifactTailIncomingBytes = 0;
777
+
679
778
  constructor(options?: OutputSinkOptions) {
680
779
  const {
681
780
  artifactPath,
@@ -685,6 +784,8 @@ export class OutputSink {
685
784
  maxColumns = 0,
686
785
  onChunk,
687
786
  chunkThrottleMs = 0,
787
+ artifactMaxBytes = ARTIFACT_DEFAULT_MAX_BYTES,
788
+ artifactHeadBytes = ARTIFACT_DEFAULT_HEAD_BYTES,
688
789
  } = options ?? {};
689
790
  this.#artifactPath = artifactPath;
690
791
  this.#artifactId = artifactId;
@@ -693,6 +794,9 @@ export class OutputSink {
693
794
  this.#maxColumns = Math.max(0, maxColumns);
694
795
  this.#onChunk = onChunk;
695
796
  this.#chunkThrottleMs = chunkThrottleMs;
797
+ this.#artifactMaxBytes = Math.max(0, artifactMaxBytes);
798
+ this.#artifactHeadBudget = Math.max(0, Math.min(artifactHeadBytes, this.#artifactMaxBytes));
799
+ this.#artifactTailBudget = Math.max(0, this.#artifactMaxBytes - this.#artifactHeadBudget);
696
800
  }
697
801
 
698
802
  /**
@@ -865,14 +969,18 @@ export class OutputSink {
865
969
  /**
866
970
  * Write a chunk to the artifact file. Handles the async file sink creation
867
971
  * by queuing writes until the sink is ready, then draining synchronously.
972
+ * Once the sink is up, every byte flows through {@link #emitToSink} which
973
+ * owns the head + tail cap so artifacts cannot grow beyond
974
+ * `#artifactMaxBytes` on disk.
868
975
  */
869
976
  #writeToFile(chunk: string): void {
870
977
  if (this.#fileReady && this.#file) {
871
- // Fast path: file sink exists, write synchronously
872
- this.#file.sink.write(chunk);
978
+ this.#emitToSink(chunk);
873
979
  return;
874
980
  }
875
- // File sink not yet created — queue this chunk and kick off creation
981
+ // File sink not yet created — queue this chunk and kick off creation.
982
+ // The queue is bounded only by how many chunks arrive before the open
983
+ // resolves (typically <2). The cap is enforced on drain.
876
984
  if (!this.#pendingFileWrites) {
877
985
  this.#pendingFileWrites = [chunk];
878
986
  void this.#createFileSink();
@@ -881,31 +989,99 @@ export class OutputSink {
881
989
  }
882
990
  }
883
991
 
992
+ /**
993
+ * Cap-aware sink writer. Bytes flow into the head window verbatim until the
994
+ * budget is exhausted; subsequent bytes are diverted into a rolling tail
995
+ * ring, evicted from the front so total RAM stays bounded by
996
+ * `#artifactTailBudget`. `dump()` replays the ring behind a single notice
997
+ * line before closing the sink.
998
+ *
999
+ * When the cap is disabled (`#artifactMaxBytes === 0`) this collapses to a
1000
+ * straight pass-through, preserving the historical "stream everything"
1001
+ * contract.
1002
+ */
1003
+ #emitToSink(chunk: string): void {
1004
+ if (!this.#file || chunk.length === 0) return;
1005
+ if (this.#artifactMaxBytes === 0) {
1006
+ this.#file.sink.write(chunk);
1007
+ return;
1008
+ }
1009
+ const chunkBytes = Buffer.byteLength(chunk, "utf-8");
1010
+ const room = this.#artifactHeadClosed ? 0 : this.#artifactHeadBudget - this.#artifactHeadBytesWritten;
1011
+ if (room >= chunkBytes) {
1012
+ this.#file.sink.write(chunk);
1013
+ this.#artifactHeadBytesWritten += chunkBytes;
1014
+ return;
1015
+ }
1016
+ let overflow = chunk;
1017
+ if (room > 0) {
1018
+ const headSlice = truncateHeadBytes(chunk, room);
1019
+ if (headSlice.bytes > 0) {
1020
+ this.#file.sink.write(headSlice.text);
1021
+ this.#artifactHeadBytesWritten += headSlice.bytes;
1022
+ }
1023
+ // Even when UTF-8 boundary safety leaves a few bytes of nominal room,
1024
+ // this chunk has already overflowed the head window. Close it now so a
1025
+ // later small ASCII chunk cannot be written before this overflow tail.
1026
+ this.#artifactHeadClosed = true;
1027
+ overflow = chunk.substring(headSlice.text.length);
1028
+ }
1029
+ if (overflow.length === 0 || this.#artifactTailBudget === 0) {
1030
+ // No tail budget: count the dropped bytes so the notice reflects them.
1031
+ if (overflow.length > 0) {
1032
+ this.#artifactTailIncomingBytes += Buffer.byteLength(overflow, "utf-8");
1033
+ }
1034
+ return;
1035
+ }
1036
+ this.#pushArtifactTail(overflow);
1037
+ }
1038
+
1039
+ #pushArtifactTail(chunk: string): void {
1040
+ const chunkBytes = Buffer.byteLength(chunk, "utf-8");
1041
+ this.#artifactTailIncomingBytes += chunkBytes;
1042
+ const budget = this.#artifactTailBudget;
1043
+ if (chunkBytes >= budget) {
1044
+ // Chunk alone dominates — keep only its tail slice.
1045
+ const { text, bytes } = truncateTailBytes(chunk, budget);
1046
+ this.#artifactTailRing = text;
1047
+ this.#artifactTailRingBytes = bytes;
1048
+ return;
1049
+ }
1050
+ this.#artifactTailRing += chunk;
1051
+ this.#artifactTailRingBytes += chunkBytes;
1052
+ if (this.#artifactTailRingBytes > budget) {
1053
+ const { text, bytes } = truncateTailBytes(this.#artifactTailRing, budget);
1054
+ this.#artifactTailRing = text;
1055
+ this.#artifactTailRingBytes = bytes;
1056
+ }
1057
+ }
1058
+
884
1059
  async #createFileSink(): Promise<void> {
885
1060
  if (!this.#artifactPath || this.#fileReady) return;
886
1061
  try {
887
1062
  const sink = Bun.file(this.#artifactPath).writer();
888
1063
  this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
1064
+ this.#fileReady = true;
889
1065
 
890
1066
  // Head-retained bytes precede the rolling tail buffer in the capture.
1067
+ // Route through #emitToSink so they count against the artifact head
1068
+ // budget — a direct sink.write would let them escape the cap.
891
1069
  if (this.#head.length > 0) {
892
- sink.write(this.#head);
1070
+ this.#emitToSink(this.#head);
893
1071
  }
894
1072
 
895
1073
  // Flush existing buffer to file BEFORE it gets trimmed further.
896
1074
  if (this.#buffer.length > 0) {
897
- sink.write(this.#buffer);
1075
+ this.#emitToSink(this.#buffer);
898
1076
  }
899
1077
 
900
- // Drain any chunks that arrived while the sink was being created
1078
+ // Drain any chunks that arrived while the sink was being created.
901
1079
  if (this.#pendingFileWrites) {
902
1080
  for (const pending of this.#pendingFileWrites) {
903
- sink.write(pending);
1081
+ this.#emitToSink(pending);
904
1082
  }
905
1083
  this.#pendingFileWrites = undefined;
906
1084
  }
907
-
908
- this.#fileReady = true;
909
1085
  } catch {
910
1086
  try {
911
1087
  await this.#file?.sink?.end();
@@ -914,6 +1090,7 @@ export class OutputSink {
914
1090
  }
915
1091
  this.#file = undefined;
916
1092
  this.#pendingFileWrites = undefined;
1093
+ this.#fileReady = false;
917
1094
  }
918
1095
  }
919
1096
 
@@ -961,6 +1138,42 @@ export class OutputSink {
961
1138
  this.#pendingChunk = "";
962
1139
  }
963
1140
 
1141
+ /**
1142
+ * Replay the rolling tail ring back into the artifact sink. When bytes
1143
+ * were actually dropped from the middle (the head budget was exhausted
1144
+ * *and* the tail ring evicted), a single `[ARTIFACT TRUNCATED: …]`
1145
+ * notice is injected between head and tail so a reader of
1146
+ * `artifact://<id>` understands the gap. When the total stream simply
1147
+ * spilled past the head budget but still fits below `artifactMaxBytes`,
1148
+ * `droppedBytes` is zero — head + tail together are the verbatim stream
1149
+ * and the notice is suppressed so we don't corrupt the artifact with a
1150
+ * misleading "0 B elided" marker (PR #2083 review by codex).
1151
+ *
1152
+ * No-op when the cap was never hit at all (head budget never exhausted,
1153
+ * tail ring empty).
1154
+ */
1155
+ #flushArtifactTailIfCapped(): void {
1156
+ if (!this.#file) return;
1157
+ if (this.#artifactMaxBytes === 0) return;
1158
+ const tailBytes = this.#artifactTailRingBytes;
1159
+ const droppedBytes = Math.max(0, this.#artifactTailIncomingBytes - tailBytes);
1160
+ if (tailBytes === 0 && droppedBytes === 0) return;
1161
+
1162
+ if (droppedBytes > 0) {
1163
+ const headWritten = this.#artifactHeadBytesWritten;
1164
+ const totalCapped = headWritten + this.#artifactTailIncomingBytes;
1165
+ const headSep = headWritten > 0 ? "\n" : "";
1166
+ const tailSep = tailBytes > 0 && !this.#artifactTailRing.startsWith("\n") ? "\n" : "";
1167
+ const notice =
1168
+ `${headSep}[ARTIFACT TRUNCATED: kept first ${formatBytes(headWritten)} + last ${formatBytes(tailBytes)} ` +
1169
+ `of ${formatBytes(totalCapped)}; ${formatBytes(droppedBytes)} elided from the middle]${tailSep}`;
1170
+ this.#file.sink.write(notice);
1171
+ }
1172
+ if (tailBytes > 0) {
1173
+ this.#file.sink.write(this.#artifactTailRing);
1174
+ }
1175
+ }
1176
+
964
1177
  async dump(notice?: string): Promise<OutputSummary> {
965
1178
  const noticeLine = notice ? `[${notice}]\n` : "";
966
1179
 
@@ -973,7 +1186,10 @@ export class OutputSink {
973
1186
  }
974
1187
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
975
1188
 
976
- if (this.#file) await this.#file.sink.end();
1189
+ if (this.#file) {
1190
+ this.#flushArtifactTailIfCapped();
1191
+ await this.#file.sink.end();
1192
+ }
977
1193
 
978
1194
  // Compose the visible output. With head retention, splice head + marker
979
1195
  // + tail when content was elided. Otherwise return the rolling buffer.
@@ -5,6 +5,30 @@ import type { AcpBuiltinSlashCommandResult, SlashCommandRuntime } from "./types"
5
5
 
6
6
  export type { AcpBuiltinSlashCommandResult } from "./types";
7
7
 
8
+ /**
9
+ * All names (primary + aliases) that are reserved by ACP builtins. Used to
10
+ * filter out extension commands that would shadow a builtin or its alias at
11
+ * dispatch time (e.g. `models` is an alias for `/model`, so an extension
12
+ * registering `models` would appear in the palette but execute the builtin).
13
+ */
14
+ export const ACP_BUILTIN_RESERVED_NAMES: ReadonlySet<string> = new Set(
15
+ BUILTIN_SLASH_COMMANDS_INTERNAL.filter(c => c.handle !== undefined).flatMap(c => [c.name, ...(c.aliases ?? [])]),
16
+ );
17
+
18
+ /**
19
+ * Whether an extension command named `name` would be captured by ACP builtin
20
+ * dispatch before reaching the extension handler. Beyond exact name/alias
21
+ * collisions, `parseSlashCommand` treats `:` as a name/args separator, so a
22
+ * colon-namespaced name whose prefix is a handled builtin (e.g. `model:foo`)
23
+ * executes the `/model` builtin with `foo` as args. Such names must not be
24
+ * advertised to ACP clients.
25
+ */
26
+ export function isAcpBuiltinShadowedName(name: string): boolean {
27
+ if (ACP_BUILTIN_RESERVED_NAMES.has(name)) return true;
28
+ const colon = name.indexOf(":");
29
+ return colon !== -1 && ACP_BUILTIN_RESERVED_NAMES.has(name.slice(0, colon));
30
+ }
31
+
8
32
  /**
9
33
  * Commands advertised to ACP clients. Entries without a text-mode `handle`
10
34
  * (e.g. `/quit`, `/login`, dashboards) are filtered out so the client doesn't
@@ -82,6 +82,23 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
82
82
  runtime.ctx.editor.setText("");
83
83
  },
84
84
  },
85
+ {
86
+ name: "setup",
87
+ aliases: ["providers"],
88
+ description: "Open provider setup",
89
+ allowArgs: true,
90
+ subcommands: [{ name: "providers", description: "Configure sign-in and web search providers" }],
91
+ handleTui: async (command, runtime) => {
92
+ const args = command.args.trim().toLowerCase();
93
+ const opensProviders = args === "" || args === "providers";
94
+ if (opensProviders) {
95
+ await runtime.ctx.showProviderSetup();
96
+ } else {
97
+ runtime.ctx.showWarning(`Usage: /${command.name} [providers]`);
98
+ }
99
+ runtime.ctx.editor.setText("");
100
+ },
101
+ },
85
102
  {
86
103
  name: "plan",
87
104
  description: "Toggle plan mode (agent plans before executing)",
@@ -1697,10 +1714,13 @@ for (const command of BUILTIN_SLASH_COMMAND_REGISTRY) {
1697
1714
  }
1698
1715
  }
1699
1716
 
1717
+ export const BUILTIN_SLASH_COMMAND_RESERVED_NAMES: ReadonlySet<string> = new Set(BUILTIN_SLASH_COMMAND_LOOKUP.keys());
1718
+
1700
1719
  /** Builtin command metadata used for slash-command autocomplete and help text. */
1701
1720
  export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BUILTIN_SLASH_COMMAND_REGISTRY.map(
1702
1721
  command => ({
1703
1722
  name: command.name,
1723
+ aliases: command.aliases,
1704
1724
  description: command.description,
1705
1725
  subcommands: command.subcommands,
1706
1726
  inlineHint: command.inlineHint,
@@ -14,6 +14,7 @@ export interface SubcommandDef {
14
14
  /** Declarative builtin slash command metadata used by autocomplete and help UI. */
15
15
  export interface BuiltinSlashCommand {
16
16
  name: string;
17
+ aliases?: string[];
17
18
  description: string;
18
19
  /** Subcommands for dropdown completion (e.g. /mcp add, /mcp list). */
19
20
  subcommands?: SubcommandDef[];
@@ -82,7 +83,6 @@ export interface TuiSlashCommandRuntime {
82
83
 
83
84
  /** Unified slash-command spec consumed by both TUI and ACP dispatchers. */
84
85
  export interface SlashCommandSpec extends BuiltinSlashCommand {
85
- aliases?: string[];
86
86
  /** When false, the dispatcher refuses to handle invocations that include arguments. */
87
87
  allowArgs?: boolean;
88
88
  /**
@@ -8,6 +8,7 @@ import { $env, getGpuCachePath, getProjectDir, hasFsCode, isEnoent, logger, prom
8
8
  import { $ } from "bun";
9
9
  import { contextFileCapability } from "./capability/context-file";
10
10
  import { systemPromptCapability } from "./capability/system-prompt";
11
+ import { findConfigFile } from "./config";
11
12
  import type { SkillsSettings } from "./config/settings";
12
13
  import { type ContextFile, loadCapability, type SystemPrompt as SystemPromptFile } from "./discovery";
13
14
  import { expandAtImports } from "./discovery/at-imports";
@@ -208,6 +209,19 @@ async function getEnvironmentInfo(): Promise<Array<{ label: string; value: strin
208
209
  return entries.filter((e): e is { label: string; value: string } => !!e.value);
209
210
  }
210
211
 
212
+ /** Discover TITLE_SYSTEM.md file for automatic session-title prompt overrides */
213
+ export function discoverTitleSystemPromptFile(cwd?: string): string | undefined {
214
+ const projectPath = findConfigFile("TITLE_SYSTEM.md", { user: false, cwd });
215
+ if (projectPath) {
216
+ return projectPath;
217
+ }
218
+ const globalPath = findConfigFile("TITLE_SYSTEM.md", { user: true, cwd });
219
+ if (globalPath) {
220
+ return globalPath;
221
+ }
222
+ return undefined;
223
+ }
224
+
211
225
  /** Resolve input as file path or literal string */
212
226
  export async function resolvePromptInput(input: string | undefined, description: string): Promise<string | undefined> {
213
227
  if (!input) {