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

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 +44 -0
  2. package/dist/cli.js +5349 -5328
  3. package/dist/types/cli/args.d.ts +1 -0
  4. package/dist/types/cli-commands.d.ts +12 -0
  5. package/dist/types/commands/launch.d.ts +4 -0
  6. package/dist/types/config/api-key-resolver.d.ts +3 -0
  7. package/dist/types/config/model-registry.d.ts +1 -0
  8. package/dist/types/config/model-resolver.d.ts +18 -0
  9. package/dist/types/config/settings-schema.d.ts +29 -1
  10. package/dist/types/config/settings.d.ts +7 -0
  11. package/dist/types/edit/hashline/noop-loop-guard.d.ts +72 -0
  12. package/dist/types/eval/py/executor.d.ts +5 -0
  13. package/dist/types/eval/py/kernel.d.ts +6 -1
  14. package/dist/types/eval/py/runtime.d.ts +9 -0
  15. package/dist/types/exec/bash-executor.d.ts +2 -0
  16. package/dist/types/extensibility/extensions/runner.d.ts +3 -2
  17. package/dist/types/extensibility/extensions/types.d.ts +3 -0
  18. package/dist/types/memory-backend/index.d.ts +1 -0
  19. package/dist/types/memory-backend/runtime.d.ts +4 -0
  20. package/dist/types/memory-backend/types.d.ts +66 -1
  21. package/dist/types/modes/index.d.ts +3 -3
  22. package/dist/types/modes/interactive-mode.d.ts +7 -2
  23. package/dist/types/modes/oauth-manual-input.d.ts +7 -0
  24. package/dist/types/modes/rpc/rpc-client.d.ts +39 -2
  25. package/dist/types/modes/rpc/rpc-mode.d.ts +31 -2
  26. package/dist/types/modes/rpc/rpc-subagents.d.ts +24 -0
  27. package/dist/types/modes/rpc/rpc-types.d.ts +75 -1
  28. package/dist/types/modes/setup-wizard/index.d.ts +5 -1
  29. package/dist/types/modes/setup-wizard/lazy.d.ts +2 -0
  30. package/dist/types/modes/types.d.ts +2 -0
  31. package/dist/types/secrets/index.d.ts +1 -1
  32. package/dist/types/secrets/obfuscator.d.ts +8 -2
  33. package/dist/types/session/agent-session.d.ts +14 -2
  34. package/dist/types/session/streaming-output.d.ts +23 -0
  35. package/dist/types/slash-commands/acp-builtins.d.ts +16 -0
  36. package/dist/types/slash-commands/builtin-registry.d.ts +1 -0
  37. package/dist/types/slash-commands/types.d.ts +1 -1
  38. package/dist/types/system-prompt.d.ts +2 -0
  39. package/dist/types/task/executor.d.ts +1 -0
  40. package/dist/types/task/index.d.ts +2 -2
  41. package/dist/types/task/types.d.ts +8 -0
  42. package/dist/types/thinking.d.ts +4 -0
  43. package/dist/types/tiny/title-client.d.ts +11 -0
  44. package/dist/types/tiny/title-protocol.d.ts +1 -0
  45. package/dist/types/tools/index.d.ts +6 -0
  46. package/dist/types/utils/git.d.ts +15 -2
  47. package/dist/types/utils/title-generator.d.ts +3 -2
  48. package/package.json +10 -10
  49. package/src/auto-thinking/classifier.ts +1 -0
  50. package/src/cli/args.ts +3 -0
  51. package/src/cli-commands.ts +29 -0
  52. package/src/cli.ts +8 -9
  53. package/src/commands/launch.ts +4 -0
  54. package/src/commit/model-selection.ts +3 -2
  55. package/src/config/api-key-resolver.ts +8 -6
  56. package/src/config/model-registry.ts +97 -30
  57. package/src/config/model-resolver.ts +60 -0
  58. package/src/config/settings-schema.ts +43 -15
  59. package/src/config/settings.ts +61 -3
  60. package/src/edit/hashline/execute.ts +39 -2
  61. package/src/edit/hashline/noop-loop-guard.ts +99 -0
  62. package/src/eval/completion-bridge.ts +1 -0
  63. package/src/eval/py/executor.ts +29 -7
  64. package/src/eval/py/index.ts +6 -1
  65. package/src/eval/py/kernel.ts +31 -11
  66. package/src/eval/py/runtime.ts +37 -0
  67. package/src/exec/bash-executor.ts +82 -3
  68. package/src/extensibility/extensions/get-commands-handler.ts +2 -1
  69. package/src/extensibility/extensions/runner.ts +6 -1
  70. package/src/extensibility/extensions/types.ts +3 -0
  71. package/src/hindsight/bank.ts +17 -2
  72. package/src/internal-urls/docs-index.generated.ts +3 -3
  73. package/src/main.ts +18 -6
  74. package/src/memories/index.ts +2 -0
  75. package/src/memory-backend/index.ts +1 -0
  76. package/src/memory-backend/local-backend.ts +9 -0
  77. package/src/memory-backend/off-backend.ts +9 -0
  78. package/src/memory-backend/runtime.ts +66 -0
  79. package/src/memory-backend/types.ts +81 -1
  80. package/src/mnemopi/backend.ts +151 -4
  81. package/src/modes/acp/acp-agent.ts +119 -11
  82. package/src/modes/components/assistant-message.ts +19 -21
  83. package/src/modes/components/footer.ts +3 -1
  84. package/src/modes/components/status-line/component.ts +118 -34
  85. package/src/modes/controllers/command-controller.ts +1 -1
  86. package/src/modes/controllers/input-controller.ts +1 -0
  87. package/src/modes/controllers/mcp-command-controller.ts +38 -3
  88. package/src/modes/index.ts +3 -21
  89. package/src/modes/interactive-mode.ts +39 -9
  90. package/src/modes/oauth-manual-input.ts +30 -3
  91. package/src/modes/rpc/rpc-client.ts +154 -3
  92. package/src/modes/rpc/rpc-mode.ts +97 -12
  93. package/src/modes/rpc/rpc-subagents.ts +265 -0
  94. package/src/modes/rpc/rpc-types.ts +81 -1
  95. package/src/modes/setup-wizard/index.ts +12 -2
  96. package/src/modes/setup-wizard/lazy.ts +16 -0
  97. package/src/modes/types.ts +2 -0
  98. package/src/sdk.ts +8 -1
  99. package/src/secrets/index.ts +8 -1
  100. package/src/secrets/obfuscator.ts +39 -18
  101. package/src/session/agent-session.ts +179 -54
  102. package/src/session/streaming-output.ts +166 -10
  103. package/src/slash-commands/acp-builtins.ts +24 -0
  104. package/src/slash-commands/builtin-registry.ts +20 -0
  105. package/src/slash-commands/types.ts +1 -1
  106. package/src/system-prompt.ts +14 -0
  107. package/src/task/executor.ts +13 -12
  108. package/src/task/index.ts +9 -8
  109. package/src/task/render.ts +18 -3
  110. package/src/task/types.ts +9 -0
  111. package/src/thinking.ts +7 -0
  112. package/src/tiny/title-client.ts +34 -5
  113. package/src/tiny/title-protocol.ts +1 -1
  114. package/src/tiny/worker.ts +6 -4
  115. package/src/tools/bash.ts +46 -5
  116. package/src/tools/image-gen.ts +11 -4
  117. package/src/tools/index.ts +13 -1
  118. package/src/tools/inspect-image.ts +1 -0
  119. package/src/utils/commit-message-generator.ts +1 -0
  120. package/src/utils/git.ts +267 -13
  121. package/src/utils/title-generator.ts +24 -5
@@ -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 {
@@ -676,6 +700,21 @@ export class OutputSink {
676
700
  readonly #chunkThrottleMs: number;
677
701
  readonly #maxColumns: number;
678
702
 
703
+ // Optional artifact-on-disk cap. When `#artifactMaxBytes > 0` the file sink
704
+ // owns a head budget + a rolling tail buffer; once the head is closed,
705
+ // subsequent chunks are diverted into `#artifactTailRing` (bounded by
706
+ // `#artifactTailBudget`). On `dump()` the tail is flushed back to the sink
707
+ // behind a `[ARTIFACT TRUNCATED: …]` notice. The default cap is disabled so
708
+ // advertised `artifact://<id>` captures are lossless.
709
+ readonly #artifactMaxBytes: number;
710
+ readonly #artifactHeadBudget: number;
711
+ readonly #artifactTailBudget: number;
712
+ #artifactHeadBytesWritten = 0;
713
+ #artifactHeadClosed = false;
714
+ #artifactTailRing = "";
715
+ #artifactTailRingBytes = 0;
716
+ #artifactTailIncomingBytes = 0;
717
+
679
718
  constructor(options?: OutputSinkOptions) {
680
719
  const {
681
720
  artifactPath,
@@ -685,6 +724,8 @@ export class OutputSink {
685
724
  maxColumns = 0,
686
725
  onChunk,
687
726
  chunkThrottleMs = 0,
727
+ artifactMaxBytes = ARTIFACT_DEFAULT_MAX_BYTES,
728
+ artifactHeadBytes = ARTIFACT_DEFAULT_HEAD_BYTES,
688
729
  } = options ?? {};
689
730
  this.#artifactPath = artifactPath;
690
731
  this.#artifactId = artifactId;
@@ -693,6 +734,9 @@ export class OutputSink {
693
734
  this.#maxColumns = Math.max(0, maxColumns);
694
735
  this.#onChunk = onChunk;
695
736
  this.#chunkThrottleMs = chunkThrottleMs;
737
+ this.#artifactMaxBytes = Math.max(0, artifactMaxBytes);
738
+ this.#artifactHeadBudget = Math.max(0, Math.min(artifactHeadBytes, this.#artifactMaxBytes));
739
+ this.#artifactTailBudget = Math.max(0, this.#artifactMaxBytes - this.#artifactHeadBudget);
696
740
  }
697
741
 
698
742
  /**
@@ -865,14 +909,18 @@ export class OutputSink {
865
909
  /**
866
910
  * Write a chunk to the artifact file. Handles the async file sink creation
867
911
  * by queuing writes until the sink is ready, then draining synchronously.
912
+ * Once the sink is up, every byte flows through {@link #emitToSink} which
913
+ * owns the head + tail cap so artifacts cannot grow beyond
914
+ * `#artifactMaxBytes` on disk.
868
915
  */
869
916
  #writeToFile(chunk: string): void {
870
917
  if (this.#fileReady && this.#file) {
871
- // Fast path: file sink exists, write synchronously
872
- this.#file.sink.write(chunk);
918
+ this.#emitToSink(chunk);
873
919
  return;
874
920
  }
875
- // File sink not yet created — queue this chunk and kick off creation
921
+ // File sink not yet created — queue this chunk and kick off creation.
922
+ // The queue is bounded only by how many chunks arrive before the open
923
+ // resolves (typically <2). The cap is enforced on drain.
876
924
  if (!this.#pendingFileWrites) {
877
925
  this.#pendingFileWrites = [chunk];
878
926
  void this.#createFileSink();
@@ -881,31 +929,99 @@ export class OutputSink {
881
929
  }
882
930
  }
883
931
 
932
+ /**
933
+ * Cap-aware sink writer. Bytes flow into the head window verbatim until the
934
+ * budget is exhausted; subsequent bytes are diverted into a rolling tail
935
+ * ring, evicted from the front so total RAM stays bounded by
936
+ * `#artifactTailBudget`. `dump()` replays the ring behind a single notice
937
+ * line before closing the sink.
938
+ *
939
+ * When the cap is disabled (`#artifactMaxBytes === 0`) this collapses to a
940
+ * straight pass-through, preserving the historical "stream everything"
941
+ * contract.
942
+ */
943
+ #emitToSink(chunk: string): void {
944
+ if (!this.#file || chunk.length === 0) return;
945
+ if (this.#artifactMaxBytes === 0) {
946
+ this.#file.sink.write(chunk);
947
+ return;
948
+ }
949
+ const chunkBytes = Buffer.byteLength(chunk, "utf-8");
950
+ const room = this.#artifactHeadClosed ? 0 : this.#artifactHeadBudget - this.#artifactHeadBytesWritten;
951
+ if (room >= chunkBytes) {
952
+ this.#file.sink.write(chunk);
953
+ this.#artifactHeadBytesWritten += chunkBytes;
954
+ return;
955
+ }
956
+ let overflow = chunk;
957
+ if (room > 0) {
958
+ const headSlice = truncateHeadBytes(chunk, room);
959
+ if (headSlice.bytes > 0) {
960
+ this.#file.sink.write(headSlice.text);
961
+ this.#artifactHeadBytesWritten += headSlice.bytes;
962
+ }
963
+ // Even when UTF-8 boundary safety leaves a few bytes of nominal room,
964
+ // this chunk has already overflowed the head window. Close it now so a
965
+ // later small ASCII chunk cannot be written before this overflow tail.
966
+ this.#artifactHeadClosed = true;
967
+ overflow = chunk.substring(headSlice.text.length);
968
+ }
969
+ if (overflow.length === 0 || this.#artifactTailBudget === 0) {
970
+ // No tail budget: count the dropped bytes so the notice reflects them.
971
+ if (overflow.length > 0) {
972
+ this.#artifactTailIncomingBytes += Buffer.byteLength(overflow, "utf-8");
973
+ }
974
+ return;
975
+ }
976
+ this.#pushArtifactTail(overflow);
977
+ }
978
+
979
+ #pushArtifactTail(chunk: string): void {
980
+ const chunkBytes = Buffer.byteLength(chunk, "utf-8");
981
+ this.#artifactTailIncomingBytes += chunkBytes;
982
+ const budget = this.#artifactTailBudget;
983
+ if (chunkBytes >= budget) {
984
+ // Chunk alone dominates — keep only its tail slice.
985
+ const { text, bytes } = truncateTailBytes(chunk, budget);
986
+ this.#artifactTailRing = text;
987
+ this.#artifactTailRingBytes = bytes;
988
+ return;
989
+ }
990
+ this.#artifactTailRing += chunk;
991
+ this.#artifactTailRingBytes += chunkBytes;
992
+ if (this.#artifactTailRingBytes > budget) {
993
+ const { text, bytes } = truncateTailBytes(this.#artifactTailRing, budget);
994
+ this.#artifactTailRing = text;
995
+ this.#artifactTailRingBytes = bytes;
996
+ }
997
+ }
998
+
884
999
  async #createFileSink(): Promise<void> {
885
1000
  if (!this.#artifactPath || this.#fileReady) return;
886
1001
  try {
887
1002
  const sink = Bun.file(this.#artifactPath).writer();
888
1003
  this.#file = { path: this.#artifactPath, artifactId: this.#artifactId, sink };
1004
+ this.#fileReady = true;
889
1005
 
890
1006
  // Head-retained bytes precede the rolling tail buffer in the capture.
1007
+ // Route through #emitToSink so they count against the artifact head
1008
+ // budget — a direct sink.write would let them escape the cap.
891
1009
  if (this.#head.length > 0) {
892
- sink.write(this.#head);
1010
+ this.#emitToSink(this.#head);
893
1011
  }
894
1012
 
895
1013
  // Flush existing buffer to file BEFORE it gets trimmed further.
896
1014
  if (this.#buffer.length > 0) {
897
- sink.write(this.#buffer);
1015
+ this.#emitToSink(this.#buffer);
898
1016
  }
899
1017
 
900
- // Drain any chunks that arrived while the sink was being created
1018
+ // Drain any chunks that arrived while the sink was being created.
901
1019
  if (this.#pendingFileWrites) {
902
1020
  for (const pending of this.#pendingFileWrites) {
903
- sink.write(pending);
1021
+ this.#emitToSink(pending);
904
1022
  }
905
1023
  this.#pendingFileWrites = undefined;
906
1024
  }
907
-
908
- this.#fileReady = true;
909
1025
  } catch {
910
1026
  try {
911
1027
  await this.#file?.sink?.end();
@@ -914,6 +1030,7 @@ export class OutputSink {
914
1030
  }
915
1031
  this.#file = undefined;
916
1032
  this.#pendingFileWrites = undefined;
1033
+ this.#fileReady = false;
917
1034
  }
918
1035
  }
919
1036
 
@@ -961,6 +1078,42 @@ export class OutputSink {
961
1078
  this.#pendingChunk = "";
962
1079
  }
963
1080
 
1081
+ /**
1082
+ * Replay the rolling tail ring back into the artifact sink. When bytes
1083
+ * were actually dropped from the middle (the head budget was exhausted
1084
+ * *and* the tail ring evicted), a single `[ARTIFACT TRUNCATED: …]`
1085
+ * notice is injected between head and tail so a reader of
1086
+ * `artifact://<id>` understands the gap. When the total stream simply
1087
+ * spilled past the head budget but still fits below `artifactMaxBytes`,
1088
+ * `droppedBytes` is zero — head + tail together are the verbatim stream
1089
+ * and the notice is suppressed so we don't corrupt the artifact with a
1090
+ * misleading "0 B elided" marker (PR #2083 review by codex).
1091
+ *
1092
+ * No-op when the cap was never hit at all (head budget never exhausted,
1093
+ * tail ring empty).
1094
+ */
1095
+ #flushArtifactTailIfCapped(): void {
1096
+ if (!this.#file) return;
1097
+ if (this.#artifactMaxBytes === 0) return;
1098
+ const tailBytes = this.#artifactTailRingBytes;
1099
+ const droppedBytes = Math.max(0, this.#artifactTailIncomingBytes - tailBytes);
1100
+ if (tailBytes === 0 && droppedBytes === 0) return;
1101
+
1102
+ if (droppedBytes > 0) {
1103
+ const headWritten = this.#artifactHeadBytesWritten;
1104
+ const totalCapped = headWritten + this.#artifactTailIncomingBytes;
1105
+ const headSep = headWritten > 0 ? "\n" : "";
1106
+ const tailSep = tailBytes > 0 && !this.#artifactTailRing.startsWith("\n") ? "\n" : "";
1107
+ const notice =
1108
+ `${headSep}[ARTIFACT TRUNCATED: kept first ${formatBytes(headWritten)} + last ${formatBytes(tailBytes)} ` +
1109
+ `of ${formatBytes(totalCapped)}; ${formatBytes(droppedBytes)} elided from the middle]${tailSep}`;
1110
+ this.#file.sink.write(notice);
1111
+ }
1112
+ if (tailBytes > 0) {
1113
+ this.#file.sink.write(this.#artifactTailRing);
1114
+ }
1115
+ }
1116
+
964
1117
  async dump(notice?: string): Promise<OutputSummary> {
965
1118
  const noticeLine = notice ? `[${notice}]\n` : "";
966
1119
 
@@ -973,7 +1126,10 @@ export class OutputSink {
973
1126
  }
974
1127
  const totalLines = this.#sawData ? this.#totalLines + 1 : 0;
975
1128
 
976
- if (this.#file) await this.#file.sink.end();
1129
+ if (this.#file) {
1130
+ this.#flushArtifactTailIfCapped();
1131
+ await this.#file.sink.end();
1132
+ }
977
1133
 
978
1134
  // Compose the visible output. With head retention, splice head + marker
979
1135
  // + 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) {
@@ -162,6 +162,7 @@ export interface ExecutorOptions {
162
162
  description?: string;
163
163
  index: number;
164
164
  id: string;
165
+ parentToolCallId?: string;
165
166
  modelOverride?: string | string[];
166
167
  /**
167
168
  * Active model selector of the parent session, used as an auth-aware fallback
@@ -840,6 +841,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
840
841
  agent: agent.name,
841
842
  agentSource: agent.source,
842
843
  task,
844
+ parentToolCallId: options.parentToolCallId,
843
845
  assignment,
844
846
  progress: { ...progress },
845
847
  sessionFile: subtaskSessionFile,
@@ -922,20 +924,16 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
922
924
  progress.recentOutput = [];
923
925
  };
924
926
 
927
+ const emitSubagentEvent = (event: AgentSessionEvent) => {
928
+ if (!options.eventBus) return;
929
+ options.eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, {
930
+ id,
931
+ event,
932
+ });
933
+ };
934
+
925
935
  const processEvent = (event: AgentEvent) => {
926
936
  if (resolved) return;
927
-
928
- if (options.eventBus) {
929
- options.eventBus.emit(TASK_SUBAGENT_EVENT_CHANNEL, {
930
- index,
931
- agent: agent.name,
932
- agentSource: agent.source,
933
- task,
934
- assignment,
935
- event,
936
- });
937
- }
938
-
939
937
  const now = Date.now();
940
938
  let flushProgress = false;
941
939
 
@@ -1354,6 +1352,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1354
1352
  options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
1355
1353
  id,
1356
1354
  agent: agent.name,
1355
+ parentToolCallId: options.parentToolCallId,
1357
1356
  agentSource: agent.source,
1358
1357
  description: options.description,
1359
1358
  status: "started",
@@ -1452,6 +1451,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1452
1451
 
1453
1452
  const MAX_YIELD_RETRIES = 3;
1454
1453
  unsubscribe = session.subscribe(event => {
1454
+ emitSubagentEvent(event);
1455
1455
  if (event.type === "auto_retry_start") {
1456
1456
  progress.retryState = {
1457
1457
  attempt: event.attempt,
@@ -1704,6 +1704,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1704
1704
  options.eventBus.emit(TASK_SUBAGENT_LIFECYCLE_CHANNEL, {
1705
1705
  id,
1706
1706
  agent: agent.name,
1707
+ parentToolCallId: options.parentToolCallId,
1707
1708
  agentSource: agent.source,
1708
1709
  description: options.description,
1709
1710
  status: progress.status as "completed" | "failed" | "aborted",
package/src/task/index.ts CHANGED
@@ -119,6 +119,7 @@ export type {
119
119
  AgentDefinition,
120
120
  AgentProgress,
121
121
  SingleResult,
122
+ SubagentEventPayload,
122
123
  SubagentLifecyclePayload,
123
124
  SubagentProgressPayload,
124
125
  TaskParams,
@@ -412,7 +413,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
412
413
  }
413
414
 
414
415
  async execute(
415
- _toolCallId: string,
416
+ toolCallId: string,
416
417
  rawParams: unknown,
417
418
  signal?: AbortSignal,
418
419
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
@@ -427,7 +428,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
427
428
  const asyncEnabled = this.session.settings.get("async.enabled");
428
429
  const selectedAgent = this.#discoveredAgents.find(agent => agent.name === params.agent);
429
430
  if (!asyncEnabled || selectedAgent?.blocking === true) {
430
- return this.#executeSync(_toolCallId, params, signal, onUpdate);
431
+ return this.#executeSync(toolCallId, params, signal, onUpdate);
431
432
  }
432
433
 
433
434
  const manager = this.session.asyncJobManager;
@@ -437,12 +438,12 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
437
438
  // to the sync path keeps the tool usable; only background/job-poll
438
439
  // semantics are lost.
439
440
  logger.warn("task: async.enabled but no AsyncJobManager registered; falling back to sync execution");
440
- return this.#executeSync(_toolCallId, params, signal, onUpdate);
441
+ return this.#executeSync(toolCallId, params, signal, onUpdate);
441
442
  }
442
443
 
443
444
  const taskItems = params.tasks ?? [];
444
445
  if (taskItems.length === 0) {
445
- return this.#executeSync(_toolCallId, params, signal, onUpdate);
446
+ return this.#executeSync(toolCallId, params, signal, onUpdate);
446
447
  }
447
448
 
448
449
  const taskIdProblem = validateTaskIds(taskItems);
@@ -552,9 +553,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
552
553
  buildAsyncDetails("running", startedJobs[0]?.jobId ?? label) as unknown as Record<string, unknown>,
553
554
  );
554
555
  try {
555
- const result = await this.#executeSync(_toolCallId, singleParams, runSignal, undefined, [
556
- uniqueId,
557
- ]);
556
+ const result = await this.#executeSync(toolCallId, singleParams, runSignal, undefined, [uniqueId]);
558
557
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
559
558
  const singleResult = result.details?.results[0];
560
559
  // A missing per-task result means #executeSync failed at the
@@ -707,7 +706,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
707
706
  }
708
707
 
709
708
  async #executeSync(
710
- _toolCallId: string,
709
+ toolCallId: string,
711
710
  params: TaskParams,
712
711
  signal?: AbortSignal,
713
712
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
@@ -1035,6 +1034,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1035
1034
  planReference,
1036
1035
  description: task.description,
1037
1036
  index,
1037
+ parentToolCallId: toolCallId,
1038
1038
  id: task.id,
1039
1039
  taskDepth,
1040
1040
  modelOverride,
@@ -1097,6 +1097,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1097
1097
  planReference,
1098
1098
  description: task.description,
1099
1099
  index,
1100
+ parentToolCallId: toolCallId,
1100
1101
  id: task.id,
1101
1102
  taskDepth,
1102
1103
  modelOverride,
@@ -1072,6 +1072,20 @@ function renderAgentResult(
1072
1072
  return lines;
1073
1073
  }
1074
1074
 
1075
+ /**
1076
+ * Order live progress entries so finished agents render first and unfinished
1077
+ * (pending/running) ones stay pinned at the bottom as tasks complete. Stable
1078
+ * within each group, so agents keep their dispatch order.
1079
+ */
1080
+ function orderProgressForDisplay(progress: readonly AgentProgress[]): AgentProgress[] {
1081
+ const finished: AgentProgress[] = [];
1082
+ const unfinished: AgentProgress[] = [];
1083
+ for (const p of progress) {
1084
+ (p.status === "pending" || p.status === "running" ? unfinished : finished).push(p);
1085
+ }
1086
+ return finished.concat(unfinished);
1087
+ }
1088
+
1075
1089
  /**
1076
1090
  * Render the tool result.
1077
1091
  */
@@ -1140,7 +1154,7 @@ export function renderResult(
1140
1154
  const shouldRenderProgress =
1141
1155
  Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
1142
1156
  if (shouldRenderProgress && details.progress) {
1143
- details.progress.forEach(progress => {
1157
+ orderProgressForDisplay(details.progress).forEach(progress => {
1144
1158
  lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame));
1145
1159
  });
1146
1160
  } else if (details.results && details.results.length > 0) {
@@ -1269,8 +1283,9 @@ function renderNestedTaskTree(
1269
1283
  }
1270
1284
  const inflight = details.progress;
1271
1285
  if (inflight && inflight.length > 0) {
1272
- inflight.forEach((prog, index) => {
1273
- const { prefix, continuePrefix } = nestedMarkers(index === inflight.length - 1, theme);
1286
+ const ordered = orderProgressForDisplay(inflight);
1287
+ ordered.forEach((prog, index) => {
1288
+ const { prefix, continuePrefix } = nestedMarkers(index === ordered.length - 1, theme);
1274
1289
  lines.push(...renderAgentProgress(prog, prefix, continuePrefix, expanded, theme, spinnerFrame));
1275
1290
  });
1276
1291
  }
package/src/task/types.ts CHANGED
@@ -2,6 +2,7 @@ import type { ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Usage } from "@oh-my-pi/pi-ai";
3
3
  import { $env } from "@oh-my-pi/pi-utils";
4
4
  import * as z from "zod/v4";
5
+ import type { AgentSessionEvent } from "../session/agent-session";
5
6
  import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
6
7
  import type { NestedRepoPatch } from "./worktree";
7
8
 
@@ -41,11 +42,18 @@ export interface SubagentProgressPayload {
41
42
  agent: string;
42
43
  agentSource: AgentSource;
43
44
  task: string;
45
+ parentToolCallId?: string;
44
46
  assignment?: string;
45
47
  progress: AgentProgress;
46
48
  sessionFile?: string;
47
49
  }
48
50
 
51
+ /** Payload emitted on TASK_SUBAGENT_EVENT_CHANNEL */
52
+ export interface SubagentEventPayload {
53
+ id: string;
54
+ event: AgentSessionEvent;
55
+ }
56
+
49
57
  /** Payload emitted on TASK_SUBAGENT_LIFECYCLE_CHANNEL */
50
58
  export interface SubagentLifecyclePayload {
51
59
  id: string;
@@ -54,6 +62,7 @@ export interface SubagentLifecyclePayload {
54
62
  description?: string;
55
63
  status: "started" | "completed" | "failed" | "aborted";
56
64
  sessionFile?: string;
65
+ parentToolCallId?: string;
57
66
  index: number;
58
67
  }
59
68
 
package/src/thinking.ts CHANGED
@@ -71,6 +71,13 @@ export function toReasoningEffort(level: ThinkingLevel | undefined): Effort | un
71
71
  return level;
72
72
  }
73
73
 
74
+ /**
75
+ * True when a selector explicitly requests provider-side reasoning disablement.
76
+ */
77
+ export function shouldDisableReasoning(level: ThinkingLevel | undefined): boolean {
78
+ return level === ThinkingLevel.Off;
79
+ }
80
+
74
81
  /**
75
82
  * Resolves a selector against the current model while preserving explicit "off".
76
83
  */
@@ -39,6 +39,17 @@ export interface TinyTitleDownloadOptions {
39
39
  onProgress?: (event: TinyTitleProgressEvent) => void;
40
40
  }
41
41
 
42
+ /**
43
+ * Per-request controls for {@link TinyTitleClient.generate}.
44
+ *
45
+ * Carries the optional abort signal and title-system-prompt override used by
46
+ * callers that customize automatic session-title generation.
47
+ */
48
+ export interface TinyTitleGenerateOptions {
49
+ signal?: AbortSignal;
50
+ systemPrompt?: string;
51
+ }
52
+
42
53
  // Cold-starting the worker subprocess from a compiled binary (decompress + module
43
54
  // graph load) is slow on contended CI runners — the macos-15-intel release smoke
44
55
  // blew past 5s while arm64/linux/win passed. The probe only needs to prove the
@@ -46,6 +57,14 @@ export interface TinyTitleDownloadOptions {
46
57
  // generous bound removes the flake without weakening the check.
47
58
  const SMOKE_TEST_TIMEOUT_MS = 30_000;
48
59
 
60
+ function normalizeTinyTitleGenerateOptions(
61
+ options: AbortSignal | TinyTitleGenerateOptions | undefined,
62
+ ): TinyTitleGenerateOptions {
63
+ if (!options) return {};
64
+ if ("aborted" in options && "addEventListener" in options) return { signal: options };
65
+ return options;
66
+ }
67
+
49
68
  /**
50
69
  * Hidden subcommand on the main CLI that boots the tiny-model worker in the
51
70
  * spawned subprocess. Kept in sync with the dispatch in `cli.ts`.
@@ -295,9 +314,16 @@ export class TinyTitleClient {
295
314
  return () => this.#progressListeners.delete(listener);
296
315
  }
297
316
 
298
- async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null> {
317
+ async generate(modelKey: string, message: string, signal?: AbortSignal): Promise<string | null>;
318
+ async generate(modelKey: string, message: string, options?: TinyTitleGenerateOptions): Promise<string | null>;
319
+ async generate(
320
+ modelKey: string,
321
+ message: string,
322
+ optionsOrSignal?: AbortSignal | TinyTitleGenerateOptions,
323
+ ): Promise<string | null> {
324
+ const options = normalizeTinyTitleGenerateOptions(optionsOrSignal);
299
325
  if (!isTinyTitleLocalModelKey(modelKey)) return null;
300
- if (signal?.aborted) return null;
326
+ if (options.signal?.aborted) return null;
301
327
 
302
328
  try {
303
329
  const worker = this.#ensureWorker();
@@ -310,12 +336,15 @@ export class TinyTitleClient {
310
336
  this.#pending.delete(id);
311
337
  pending.resolve(null);
312
338
  };
313
- signal?.addEventListener("abort", abort, { once: true });
339
+ options.signal?.addEventListener("abort", abort, { once: true });
314
340
  try {
315
- worker.send({ type: "generate", id, modelKey, message });
341
+ const request: TinyTitleWorkerInbound = options.systemPrompt
342
+ ? { type: "generate", id, modelKey, message, systemPrompt: options.systemPrompt }
343
+ : { type: "generate", id, modelKey, message };
344
+ worker.send(request);
316
345
  return await promise;
317
346
  } finally {
318
- signal?.removeEventListener("abort", abort);
347
+ options.signal?.removeEventListener("abort", abort);
319
348
  this.#pending.delete(id);
320
349
  }
321
350
  } catch (error) {