@oh-my-pi/pi-coding-agent 17.0.1 → 17.0.3

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 (179) hide show
  1. package/CHANGELOG.md +162 -40
  2. package/dist/cli.js +4559 -4543
  3. package/dist/types/advisor/config.d.ts +14 -6
  4. package/dist/types/advisor/runtime.d.ts +20 -11
  5. package/dist/types/autolearn/controller.d.ts +1 -0
  6. package/dist/types/config/model-discovery.d.ts +17 -0
  7. package/dist/types/config/model-resolver.d.ts +6 -2
  8. package/dist/types/config/settings-schema.d.ts +39 -10
  9. package/dist/types/config/settings.d.ts +50 -0
  10. package/dist/types/cursor.d.ts +11 -0
  11. package/dist/types/discovery/helpers.d.ts +5 -2
  12. package/dist/types/eval/agent-bridge.d.ts +7 -19
  13. package/dist/types/exec/bash-executor.d.ts +2 -0
  14. package/dist/types/extensibility/extensions/managed-timers.d.ts +15 -0
  15. package/dist/types/extensibility/extensions/runner.d.ts +7 -0
  16. package/dist/types/extensibility/extensions/types.d.ts +18 -0
  17. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +55 -1
  18. package/dist/types/extensibility/shared-events.d.ts +6 -0
  19. package/dist/types/extensibility/utils.d.ts +2 -2
  20. package/dist/types/lsp/client.d.ts +2 -0
  21. package/dist/types/lsp/types.d.ts +3 -0
  22. package/dist/types/mcp/transports/stdio.d.ts +42 -1
  23. package/dist/types/modes/components/advisor-config.d.ts +8 -1
  24. package/dist/types/modes/components/agent-hub.d.ts +15 -0
  25. package/dist/types/modes/components/hook-editor.d.ts +7 -0
  26. package/dist/types/modes/components/model-hub.d.ts +5 -2
  27. package/dist/types/modes/components/session-selector.d.ts +2 -0
  28. package/dist/types/modes/controllers/command-controller.d.ts +8 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +3 -1
  30. package/dist/types/modes/print-mode.d.ts +1 -1
  31. package/dist/types/modes/warp-events.d.ts +24 -0
  32. package/dist/types/modes/warp-events.test.d.ts +1 -0
  33. package/dist/types/plan-mode/model-transition.d.ts +47 -0
  34. package/dist/types/plan-mode/model-transition.test.d.ts +1 -0
  35. package/dist/types/registry/agent-lifecycle.d.ts +26 -1
  36. package/dist/types/registry/persisted-agents.d.ts +3 -0
  37. package/dist/types/sdk.d.ts +27 -5
  38. package/dist/types/session/agent-session.d.ts +34 -10
  39. package/dist/types/session/session-entries.d.ts +6 -1
  40. package/dist/types/session/session-history-format.d.ts +10 -0
  41. package/dist/types/session/session-manager.d.ts +19 -0
  42. package/dist/types/slash-commands/helpers/active-oauth-account.d.ts +9 -0
  43. package/dist/types/task/executor.d.ts +26 -1
  44. package/dist/types/task/index.d.ts +1 -1
  45. package/dist/types/task/label.d.ts +1 -1
  46. package/dist/types/task/parallel.d.ts +14 -0
  47. package/dist/types/task/structured-subagent.d.ts +111 -0
  48. package/dist/types/task/types.d.ts +51 -0
  49. package/dist/types/telemetry-export.d.ts +34 -9
  50. package/dist/types/tools/approval.d.ts +8 -0
  51. package/dist/types/tools/bash.d.ts +2 -0
  52. package/dist/types/tools/essential-tools.d.ts +29 -0
  53. package/dist/types/tools/fetch.d.ts +4 -5
  54. package/dist/types/tools/hub/messaging.d.ts +5 -1
  55. package/dist/types/tools/image-gen.d.ts +2 -1
  56. package/dist/types/tools/index.d.ts +17 -1
  57. package/dist/types/tools/todo.d.ts +31 -0
  58. package/dist/types/tools/xdev.d.ts +11 -2
  59. package/dist/types/tui/tree-list.d.ts +7 -0
  60. package/dist/types/utils/markit.d.ts +11 -0
  61. package/dist/types/utils/title-generator.d.ts +2 -1
  62. package/dist/types/web/search/providers/kimi.d.ts +4 -1
  63. package/dist/types/web/search/types.d.ts +1 -1
  64. package/package.json +21 -16
  65. package/src/advisor/__tests__/advisor.test.ts +1304 -42
  66. package/src/advisor/__tests__/config.test.ts +58 -2
  67. package/src/advisor/config.ts +76 -24
  68. package/src/advisor/runtime.ts +445 -92
  69. package/src/autolearn/controller.ts +23 -28
  70. package/src/cli/file-processor.ts +1 -2
  71. package/src/cli.ts +5 -1
  72. package/src/config/model-discovery.ts +81 -21
  73. package/src/config/model-registry.ts +25 -6
  74. package/src/config/model-resolver.ts +14 -7
  75. package/src/config/settings-schema.ts +46 -9
  76. package/src/config/settings.ts +405 -25
  77. package/src/cursor.ts +20 -3
  78. package/src/debug/report-bundle.ts +40 -4
  79. package/src/discovery/helpers.ts +28 -5
  80. package/src/eval/__tests__/agent-bridge.test.ts +133 -47
  81. package/src/eval/__tests__/prelude-agent.test.ts +29 -0
  82. package/src/eval/agent-bridge.ts +104 -477
  83. package/src/eval/jl/prelude.jl +7 -6
  84. package/src/eval/js/shared/prelude.txt +5 -4
  85. package/src/eval/py/prelude.py +11 -39
  86. package/src/eval/rb/prelude.rb +5 -6
  87. package/src/exec/bash-executor.ts +14 -5
  88. package/src/extensibility/custom-tools/loader.ts +3 -3
  89. package/src/extensibility/custom-tools/wrapper.ts +2 -1
  90. package/src/extensibility/extensions/loader.ts +3 -3
  91. package/src/extensibility/extensions/managed-timers.ts +83 -0
  92. package/src/extensibility/extensions/runner.ts +26 -0
  93. package/src/extensibility/extensions/types.ts +18 -0
  94. package/src/extensibility/extensions/wrapper.ts +2 -1
  95. package/src/extensibility/hooks/loader.ts +3 -3
  96. package/src/extensibility/legacy-pi-coding-agent-shim.ts +149 -8
  97. package/src/extensibility/plugins/legacy-pi-compat.ts +225 -22
  98. package/src/extensibility/plugins/manager.ts +2 -2
  99. package/src/extensibility/shared-events.ts +6 -0
  100. package/src/extensibility/utils.ts +91 -25
  101. package/src/irc/bus.ts +22 -3
  102. package/src/launch/broker.ts +3 -2
  103. package/src/launch/client.ts +2 -2
  104. package/src/launch/presence.ts +2 -2
  105. package/src/lsp/client.ts +58 -1
  106. package/src/lsp/index.ts +62 -6
  107. package/src/lsp/types.ts +3 -0
  108. package/src/main.ts +11 -8
  109. package/src/mcp/manager.ts +9 -3
  110. package/src/mcp/oauth-flow.ts +20 -0
  111. package/src/mcp/transports/stdio.test.ts +269 -1
  112. package/src/mcp/transports/stdio.ts +255 -24
  113. package/src/modes/components/advisor-config.ts +65 -3
  114. package/src/modes/components/agent-hub.ts +1 -72
  115. package/src/modes/components/ask-dialog.ts +1 -1
  116. package/src/modes/components/bash-execution.ts +7 -3
  117. package/src/modes/components/eval-execution.ts +3 -1
  118. package/src/modes/components/hook-editor.ts +18 -3
  119. package/src/modes/components/model-hub.ts +138 -42
  120. package/src/modes/components/session-selector.ts +4 -0
  121. package/src/modes/components/status-line/component.test.ts +1 -0
  122. package/src/modes/components/status-line/segments.ts +21 -6
  123. package/src/modes/controllers/command-controller.ts +167 -47
  124. package/src/modes/controllers/event-controller.ts +5 -0
  125. package/src/modes/controllers/extension-ui-controller.ts +4 -22
  126. package/src/modes/controllers/input-controller.ts +12 -12
  127. package/src/modes/controllers/selector-controller.ts +191 -31
  128. package/src/modes/interactive-mode.ts +169 -62
  129. package/src/modes/print-mode.ts +3 -3
  130. package/src/modes/rpc/host-tools.ts +2 -1
  131. package/src/modes/rpc/rpc-mode.ts +19 -4
  132. package/src/modes/warp-events.test.ts +794 -0
  133. package/src/modes/warp-events.ts +232 -0
  134. package/src/plan-mode/model-transition.test.ts +60 -0
  135. package/src/plan-mode/model-transition.ts +51 -0
  136. package/src/prompts/system/system-prompt.md +1 -1
  137. package/src/prompts/tools/eval.md +5 -2
  138. package/src/prompts/tools/read.md +1 -1
  139. package/src/prompts/tools/task.md +12 -8
  140. package/src/prompts/tools/write.md +1 -1
  141. package/src/registry/agent-lifecycle.ts +133 -18
  142. package/src/registry/persisted-agents.ts +74 -0
  143. package/src/sdk.ts +343 -122
  144. package/src/session/agent-session.ts +1359 -368
  145. package/src/session/session-entries.ts +6 -1
  146. package/src/session/session-history-format.ts +20 -5
  147. package/src/session/session-manager.ts +57 -0
  148. package/src/session/streaming-output.ts +41 -1
  149. package/src/slash-commands/builtin-registry.ts +7 -0
  150. package/src/slash-commands/helpers/active-oauth-account.ts +16 -0
  151. package/src/system-prompt.ts +7 -2
  152. package/src/task/executor.ts +100 -22
  153. package/src/task/index.ts +258 -429
  154. package/src/task/label.ts +2 -0
  155. package/src/task/parallel.ts +43 -0
  156. package/src/task/persisted-revive.ts +19 -7
  157. package/src/task/structured-subagent.ts +642 -0
  158. package/src/task/types.ts +58 -0
  159. package/src/telemetry-export.ts +453 -97
  160. package/src/tools/__tests__/eval-description.test.ts +1 -1
  161. package/src/tools/approval.ts +11 -0
  162. package/src/tools/bash.ts +71 -38
  163. package/src/tools/essential-tools.ts +45 -0
  164. package/src/tools/fetch.ts +28 -105
  165. package/src/tools/gh.ts +169 -2
  166. package/src/tools/hub/messaging.ts +16 -3
  167. package/src/tools/image-gen.ts +69 -7
  168. package/src/tools/index.ts +50 -15
  169. package/src/tools/path-utils.ts +1 -0
  170. package/src/tools/read.ts +62 -29
  171. package/src/tools/todo.ts +126 -13
  172. package/src/tools/write.ts +22 -4
  173. package/src/tools/xdev.ts +14 -3
  174. package/src/tui/tree-list.ts +39 -0
  175. package/src/utils/markit.ts +12 -0
  176. package/src/utils/title-generator.ts +15 -4
  177. package/src/utils/zip.ts +16 -1
  178. package/src/web/search/providers/kimi.ts +18 -12
  179. package/src/web/search/types.ts +6 -1
package/src/tools/read.ts CHANGED
@@ -36,7 +36,7 @@ import {
36
36
  import { normalizeToLF } from "../edit/normalize";
37
37
  import { isNotebookPath, readEditableNotebookText } from "../edit/notebook";
38
38
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
39
- import { InternalUrlRouter, resolveLocalUrlToFile } from "../internal-urls";
39
+ import { InternalUrlRouter, resolveLocalUrlToFile, resolveLocalUrlToPath } from "../internal-urls";
40
40
  import { type ResolvedArtifactFile, resolveArtifactFile } from "../internal-urls/artifact-protocol";
41
41
  import { parseInternalUrl } from "../internal-urls/parse";
42
42
  import type { InternalUrl } from "../internal-urls/types";
@@ -62,7 +62,7 @@ import {
62
62
  MAX_IMAGE_INPUT_BYTES,
63
63
  webpExclusionForModel,
64
64
  } from "../utils/image-loading";
65
- import { convertFileWithMarkit } from "../utils/markit";
65
+ import { CONVERTIBLE_EXTENSIONS, convertFileWithMarkit } from "../utils/markit";
66
66
  import { type ArchiveReader, formatArchiveEntryLines, openArchive, parseArchivePathCandidates } from "../utils/zip";
67
67
  import { buildDirectoryTree, type DirectoryTree } from "../workspace-tree";
68
68
  import {
@@ -78,7 +78,7 @@ import {
78
78
  } from "./conflict-detect";
79
79
  import {
80
80
  executeReadUrl,
81
- loadReadUrlCacheEntry,
81
+ fetchReadUrl,
82
82
  parseReadUrlTarget,
83
83
  type ReadUrlToolDetails,
84
84
  renderReadUrlCall,
@@ -151,9 +151,6 @@ function getSummaryParseCache(session: object): LRUCache<string, SummaryResult |
151
151
  return cache;
152
152
  }
153
153
 
154
- // Document types converted to markdown via markit.
155
- const CONVERTIBLE_EXTENSIONS = new Set([".pdf", ".doc", ".docx", ".ppt", ".pptx", ".xls", ".xlsx", ".rtf", ".epub"]);
156
-
157
154
  const MAX_SUMMARY_BYTES = 2 * 1024 * 1024;
158
155
  const MAX_SUMMARY_LINES = 20_000;
159
156
  const MAX_ARTIFACT_RAW_INLINE_BYTES = DEFAULT_MAX_BYTES;
@@ -920,6 +917,32 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
920
917
  });
921
918
  }
922
919
 
920
+ /**
921
+ * Recover the active approved plan when a model rewrites its `local://` URL
922
+ * as a same-basename path in the working-directory root.
923
+ *
924
+ * Only missing cwd-root paths qualify, so a real working-tree file always
925
+ * wins and unrelated paths cannot escape into the session artifact sandbox.
926
+ */
927
+ #approvedPlanAlias(missingAbsolutePath: string): string | undefined {
928
+ const planReferencePath = this.session.getPlanReferencePath?.();
929
+ if (!planReferencePath?.startsWith("local:")) return undefined;
930
+
931
+ const requestedPath = path.resolve(missingAbsolutePath);
932
+ if (path.dirname(requestedPath) !== path.resolve(this.session.cwd)) return undefined;
933
+
934
+ const localProtocolOptions = this.session.localProtocolOptions ?? {
935
+ getArtifactsDir: () => this.session.getArtifactsDir?.() ?? null,
936
+ getSessionId: () => this.session.getSessionId?.() ?? null,
937
+ };
938
+ try {
939
+ const approvedPlanPath = resolveLocalUrlToPath(planReferencePath, localProtocolOptions);
940
+ return path.basename(requestedPath) === path.basename(approvedPlanPath) ? approvedPlanPath : undefined;
941
+ } catch {
942
+ return undefined;
943
+ }
944
+ }
945
+
923
946
  async #tryReadDelimitedPaths(
924
947
  readPath: string,
925
948
  signal?: AbortSignal,
@@ -2141,15 +2164,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2141
2164
  const urlRaw = parsedUrlTarget.raw;
2142
2165
  const urlRanges = parsedUrlTarget.ranges;
2143
2166
  if (urlRanges !== undefined && urlRanges.length > 1) {
2144
- const cached = await loadReadUrlCacheEntry(
2145
- this.session,
2146
- { path: parsedUrlTarget.path, raw: urlRaw },
2147
- signal,
2148
- { ensureArtifact: true, preferCached: true },
2149
- );
2150
- return this.#buildInMemoryMultiRangeResult(cached.output, urlRanges, {
2151
- details: { ...cached.details },
2152
- sourceUrl: cached.details.finalUrl,
2167
+ const entry = await fetchReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal, {
2168
+ ensureArtifact: true,
2169
+ });
2170
+ return this.#buildInMemoryMultiRangeResult(entry.output, urlRanges, {
2171
+ details: { ...entry.details },
2172
+ sourceUrl: entry.details.finalUrl,
2153
2173
  entityLabel: "URL output",
2154
2174
  raw: urlRaw,
2155
2175
  immutable: true,
@@ -2158,18 +2178,12 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2158
2178
  const urlOffset = parsedUrlTarget.offset;
2159
2179
  const urlLimit = parsedUrlTarget.limit;
2160
2180
  if (urlOffset !== undefined || urlLimit !== undefined) {
2161
- const cached = await loadReadUrlCacheEntry(
2162
- this.session,
2163
- { path: parsedUrlTarget.path, raw: urlRaw },
2164
- signal,
2165
- {
2166
- ensureArtifact: true,
2167
- preferCached: true,
2168
- },
2169
- );
2170
- return this.#buildInMemoryTextResult(cached.output, urlOffset, urlLimit, {
2171
- details: { ...cached.details },
2172
- sourceUrl: cached.details.finalUrl,
2181
+ const entry = await fetchReadUrl(this.session, { path: parsedUrlTarget.path, raw: urlRaw }, signal, {
2182
+ ensureArtifact: true,
2183
+ });
2184
+ return this.#buildInMemoryTextResult(entry.output, urlOffset, urlLimit, {
2185
+ details: { ...entry.details },
2186
+ sourceUrl: entry.details.finalUrl,
2173
2187
  entityLabel: "URL output",
2174
2188
  raw: urlRaw,
2175
2189
  immutable: true,
@@ -2292,7 +2306,8 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2292
2306
  isDirectory = stat.isDirectory();
2293
2307
  } catch (error) {
2294
2308
  if (isNotFoundError(error)) {
2295
- // Attempt unique suffix resolution before falling back to fuzzy suggestions
2309
+ // Attempt unique suffix resolution before falling back to the approved-plan
2310
+ // alias or fuzzy suggestions. Existing workspace files retain precedence.
2296
2311
  if (!isRemoteMountPath(absolutePath)) {
2297
2312
  const suffixMatch = await this.#findSuffixMatchCached(suffixCache, localReadPath, signal);
2298
2313
  if (suffixMatch) {
@@ -2303,12 +2318,30 @@ export class ReadTool implements AgentTool<typeof readSchema, ReadToolDetails> {
2303
2318
  isDirectory = retryStat.isDirectory();
2304
2319
  suffixResolution = { from: localReadPath, to: suffixMatch.displayPath };
2305
2320
  } catch {
2306
- // Suffix match candidate no longer stats — fall through to error path
2321
+ // Suffix match candidate no longer stats — continue through
2322
+ // approved-plan recovery and the original not-found error.
2307
2323
  }
2308
2324
  }
2309
2325
  }
2310
2326
 
2327
+ let recoveredApprovedPlan = false;
2311
2328
  if (!suffixResolution) {
2329
+ const approvedPlanPath = this.#approvedPlanAlias(absolutePath);
2330
+ if (approvedPlanPath) {
2331
+ try {
2332
+ const approvedPlanStat = await Bun.file(approvedPlanPath).stat();
2333
+ absolutePath = approvedPlanPath;
2334
+ fileSize = approvedPlanStat.size;
2335
+ isDirectory = approvedPlanStat.isDirectory();
2336
+ recoveredApprovedPlan = true;
2337
+ } catch {
2338
+ // The referenced plan disappeared after resolution; continue through
2339
+ // the ordinary delimited-path fallback and not-found error.
2340
+ }
2341
+ }
2342
+ }
2343
+
2344
+ if (!recoveredApprovedPlan && !suffixResolution) {
2312
2345
  const delimitedResult = await this.#tryReadDelimitedPaths(readPath, signal);
2313
2346
  if (delimitedResult) return delimitedResult;
2314
2347
  throw new ToolError(`Path '${localReadPath}' not found`);
package/src/tools/todo.ts CHANGED
@@ -12,7 +12,7 @@ import type { ToolSession } from "../sdk";
12
12
  import type { SessionEntry } from "../session/session-entries";
13
13
  import { framedBlock, renderStatusLine, renderTreeList } from "../tui";
14
14
  import { normalizePathLikeInput, resolveToCwd } from "./path-utils";
15
- import { formatErrorDetail, PREVIEW_LIMITS } from "./render-utils";
15
+ import { formatErrorDetail, formatMoreItems, PREVIEW_LIMITS, pluralize } from "./render-utils";
16
16
 
17
17
  // =============================================================================
18
18
  // Types
@@ -214,6 +214,80 @@ export function todoMatchesAnyDescription(content: string, descriptions: readonl
214
214
  return false;
215
215
  }
216
216
 
217
+ /**
218
+ * A todo the collapsed viewport treats as current work: the literal
219
+ * `in_progress` task or a pending task a live subagent is executing. Both
220
+ * collapsed views (transient tool result + sticky HUD) run this same policy so
221
+ * they can never disagree about what the agent is doing (#5873).
222
+ */
223
+ function isActiveTodo<T extends { status: TodoStatus }>(task: T, isMatched: (task: T) => boolean): boolean {
224
+ return task.status === "in_progress" || (task.status === "pending" && isMatched(task));
225
+ }
226
+
227
+ /** Result of {@link selectCollapsedTodos}: the rows to render plus an optional
228
+ * summary line (empty string ⇒ no summary row). */
229
+ export interface CollapsedTodoSelection<T> {
230
+ items: T[];
231
+ summary: string;
232
+ }
233
+
234
+ /**
235
+ * Walking-viewport selection for a phase's collapsed todo preview (#5873).
236
+ *
237
+ * Policy, applied to `tasks` in todo order:
238
+ * 1. While the phase has open work, completed/abandoned tasks are omitted. A
239
+ * phase with no open tasks left falls back to its closed tasks so the sticky
240
+ * HUD's closed-todo persistence still has something to render.
241
+ * 2. Every active task (in-progress, or pending matched to a live subagent) is
242
+ * placed at the head in stable todo order — never dropped for lying outside
243
+ * an ordinary window.
244
+ * 3. Remaining rows up to `cap` are filled with the pending tasks that follow
245
+ * the first active one, in todo order (falling back to leading pending tasks
246
+ * when no active task exists), so a freshly-promoted task leads the preview.
247
+ * 4. When active tasks alone exceed `cap`, only the first `cap` active tasks are
248
+ * shown and the summary counts the hidden *active* todos, never replacing
249
+ * them with unrelated pending rows.
250
+ *
251
+ * The summary otherwise counts the remaining tasks in the display base. Returns
252
+ * the whole base with an empty summary when it already fits.
253
+ */
254
+ export function selectCollapsedTodos<T extends { status: TodoStatus }>(
255
+ tasks: T[],
256
+ isMatched: (task: T) => boolean,
257
+ cap: number,
258
+ ): CollapsedTodoSelection<T> {
259
+ const open = tasks.filter(task => task.status === "pending" || task.status === "in_progress");
260
+ // No open work: fall back to the closed tasks so a settled phase still
261
+ // renders (HUD closed-todo persistence). Closed tasks are never active.
262
+ const base = open.length > 0 ? open : tasks;
263
+ if (base.length <= cap) return { items: base, summary: "" };
264
+
265
+ const active = base.filter(task => isActiveTodo(task, isMatched));
266
+ // Only when active work strictly exceeds the cap do we drop pending rows and
267
+ // count hidden *actives*. At exactly `cap` actives, fall through so the normal
268
+ // branch still surfaces any following pending work in the summary.
269
+ if (active.length > cap) {
270
+ const hiddenActive = active.length - cap;
271
+ return {
272
+ items: active.slice(0, cap),
273
+ summary: `… ${hiddenActive} more active ${pluralize("todo", hiddenActive)}`,
274
+ };
275
+ }
276
+
277
+ // Fill trailing rows with tasks following the first active one, so the
278
+ // promoted/current task leads and its successors follow in todo order.
279
+ const firstActiveIdx = active.length > 0 ? base.indexOf(active[0]) : 0;
280
+ const fill: T[] = [];
281
+ for (let i = firstActiveIdx; i < base.length && active.length + fill.length < cap; i++) {
282
+ const task = base[i];
283
+ if (isActiveTodo(task, isMatched)) continue;
284
+ fill.push(task);
285
+ }
286
+ const items = [...active, ...fill];
287
+ const hidden = base.length - items.length;
288
+ return { items, summary: hidden > 0 ? formatMoreItems(hidden, "todo") : "" };
289
+ }
290
+
217
291
  function resolveTaskOrError(
218
292
  phases: TodoPhase[],
219
293
  content: string | undefined,
@@ -755,6 +829,7 @@ function formatTodoLine(
755
829
  prefix: string,
756
830
  completionKeys: Set<string>,
757
831
  frame: number | undefined,
832
+ matched = false,
758
833
  ): string {
759
834
  const checkbox = uiTheme.checkbox;
760
835
  switch (item.status) {
@@ -771,7 +846,9 @@ function formatTodoLine(
771
846
  case "abandoned":
772
847
  return uiTheme.fg("error", `${prefix}${checkbox.unchecked} ${strikethroughText(item.content)}`);
773
848
  default:
774
- return uiTheme.fg("dim", `${prefix}${checkbox.unchecked} ${item.content}`);
849
+ // A pending todo lit by a live subagent match renders accent, matching
850
+ // the sticky HUD's convention (#5873).
851
+ return uiTheme.fg(matched ? "accent" : "dim", `${prefix}${checkbox.unchecked} ${item.content}`);
775
852
  }
776
853
  }
777
854
 
@@ -822,6 +899,21 @@ function formatPhaseSummary(phase: TodoPhase, oneBasedIndex: number, uiTheme: Th
822
899
  return `${name}${uiTheme.fg("dim", ` ${done}/${total}`)}`;
823
900
  }
824
901
 
902
+ /**
903
+ * Live subagent descriptions the transient tool result uses to detect
904
+ * pending todos being executed by an in-flight subagent, so its collapsed
905
+ * viewport surfaces the same active work the sticky HUD does (#5873). Wired
906
+ * once by interactive mode from its observer registry; returns `[]` outside an
907
+ * interactive session (tests, SDK, transcript rebuilds), where only literal
908
+ * `in_progress` counts as active.
909
+ */
910
+ let activeTodoDescriptionsProvider: () => readonly string[] = () => [];
911
+
912
+ /** Wire the live-subagent description source for {@link todoToolRenderer}. */
913
+ export function setActiveTodoDescriptionsProvider(provider: () => readonly string[]): void {
914
+ activeTodoDescriptionsProvider = provider;
915
+ }
916
+
825
917
  export const todoToolRenderer = {
826
918
  renderCall(args: TodoRenderArgs, options: RenderResultOptions, uiTheme: Theme): Component {
827
919
  // `args` is the raw partially-parsed JSON from the streaming tool-call
@@ -903,6 +995,12 @@ export const todoToolRenderer = {
903
995
  // a single task flip doesn't redraw every phase's full task list. The
904
996
  // manual expand toggle (and the no-signal fallback) still shows all.
905
997
  const touched = expanded || !multiPhase ? null : computeTouchedPhases(args, phases, completedTasks);
998
+ // A pending todo counts as active work when an in-flight subagent is
999
+ // executing it — the transient result surfaces the same active set the
1000
+ // sticky HUD does (#5873). Empty outside an interactive session.
1001
+ const activeDescs = expanded ? [] : activeTodoDescriptionsProvider();
1002
+ const isMatched = (task: TodoItem): boolean =>
1003
+ activeDescs.length > 0 && todoMatchesAnyDescription(task.content, activeDescs);
906
1004
  const bodyLines: string[] = [];
907
1005
  for (let p = 0; p < phases.length; p++) {
908
1006
  const phase = phases[p];
@@ -914,17 +1012,32 @@ export const todoToolRenderer = {
914
1012
  bodyLines.push(uiTheme.fg("accent", chalk.bold(formatPhaseDisplayName(phase.name, p + 1))));
915
1013
  }
916
1014
  const completionKeys = completionKeysByPhase.get(phase.name) ?? EMPTY_COMPLETION_KEYS;
917
- const treeLines = renderTreeList(
918
- {
919
- items: phase.tasks,
920
- expanded,
921
- maxCollapsed: PREVIEW_LIMITS.COLLAPSED_ITEMS,
922
- itemType: "todo",
923
- truncateFrom: "start",
924
- renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
925
- },
926
- uiTheme,
927
- );
1015
+ // Collapsed: walking viewport — completed/abandoned omitted, active
1016
+ // work (in-progress / subagent-matched) pulled to the head, then
1017
+ // following pending tasks (#5873). Expanded: every task in order.
1018
+ const treeLines = expanded
1019
+ ? renderTreeList(
1020
+ {
1021
+ items: phase.tasks,
1022
+ expanded,
1023
+ itemType: "todo",
1024
+ renderItem: todo => formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame),
1025
+ },
1026
+ uiTheme,
1027
+ )
1028
+ : (() => {
1029
+ const selection = selectCollapsedTodos(phase.tasks, isMatched, PREVIEW_LIMITS.COLLAPSED_ITEMS);
1030
+ return renderTreeList(
1031
+ {
1032
+ items: selection.items,
1033
+ itemType: "todo",
1034
+ trailingSummary: selection.summary,
1035
+ renderItem: todo =>
1036
+ formatTodoLine(todo, uiTheme, "", completionKeys, spinnerFrame, isMatched(todo)),
1037
+ },
1038
+ uiTheme,
1039
+ );
1040
+ })();
928
1041
  for (const line of treeLines) {
929
1042
  bodyLines.push(`${indent}${line}`);
930
1043
  }
@@ -36,7 +36,7 @@ import {
36
36
  writeArchive,
37
37
  } from "../utils/zip";
38
38
  import { routeWriteThroughBridge } from "./acp-bridge";
39
- import { truncateForPrompt } from "./approval";
39
+ import { resolveToolTier, truncateForPrompt } from "./approval";
40
40
  import { assertEditableFile } from "./auto-generated-guard";
41
41
  import {
42
42
  type ConflictEntry,
@@ -398,9 +398,27 @@ export class WriteTool implements AgentTool<typeof writeSchema, WriteToolDetails
398
398
  if (xdevTarget.name === REPORT_ISSUE_DEVICE_NAME) return "write";
399
399
  if (xdevTarget.name && isResolutionDeviceName(xdevTarget.name)) return "read";
400
400
  const inst = xdevTarget.name ? this.session.xdevRegistry?.get(xdevTarget.name) : undefined;
401
- const decision = typeof inst?.approval === "function" ? undefined : inst?.approval;
402
- const tier = typeof decision === "object" ? decision?.tier : decision;
403
- return tier ?? "exec";
401
+ if (!inst) return "exec";
402
+ // Decode the device JSON payload and evaluate the mounted tool's own
403
+ // approval (which may be argument-dependent, e.g. ast_edit is read-tier
404
+ // for internal-URL paths, debug is read-tier for inspection actions).
405
+ // Malformed JSON, non-object payloads, missing content, and approval
406
+ // functions that reject schema-invalid objects stay exec so the gate
407
+ // fails closed — the dispatch itself rejects invalid arguments too.
408
+ const rawContent = (args as Partial<WriteParams>).content;
409
+ if (typeof rawContent !== "string") return "exec";
410
+ let parsed: unknown;
411
+ try {
412
+ parsed = JSON.parse(rawContent);
413
+ } catch {
414
+ return "exec";
415
+ }
416
+ if (!isRecord(parsed)) return "exec";
417
+ try {
418
+ return resolveToolTier(inst, parsed);
419
+ } catch {
420
+ return "exec";
421
+ }
404
422
  }
405
423
  // Remote SSH writes open an outbound connection and run a remote shell —
406
424
  // gate them like the exec-tier `ssh` tool, ahead of the handler-write
package/src/tools/xdev.ts CHANGED
@@ -44,15 +44,26 @@ import { ToolError } from "./tool-errors";
44
44
  */
45
45
  export const XDEV_KEEP_TOP_LEVEL: Record<string, true> = { todo: true, ask: true, grep: true };
46
46
 
47
+ /**
48
+ * Tools that carry the `xd://` transport itself and therefore can never be
49
+ * mounted as devices: `read xd://` lists/documents devices and
50
+ * `write xd://<tool>` executes them. Demoting either leaves every mounted
51
+ * device unreachable (issue #5764), so they stay top-level regardless of a
52
+ * declared `loadMode`.
53
+ */
54
+ export const XDEV_TRANSPORT_TOOLS: Record<string, true> = { read: true, write: true };
55
+
47
56
  /**
48
57
  * Whether an enabled tool is presented under `xd://` (rather than top-level)
49
58
  * while the `xd://` transport is active. Discoverable tools mount unless they
50
- * are pinned top-level by {@link XDEV_KEEP_TOP_LEVEL}; essential tools never do.
51
- * The caller gates this on the transport being active (a session-owned
59
+ * are pinned top-level by {@link XDEV_KEEP_TOP_LEVEL} or carry the transport
60
+ * itself ({@link XDEV_TRANSPORT_TOOLS}); essential tools never do. The caller
61
+ * gates this on the transport being active (a session-owned
52
62
  * {@link XdevRegistry} existing).
53
63
  */
54
64
  export function isMountableUnderXdev(tool: { name: string; loadMode?: ToolLoadMode }): boolean {
55
- return tool.loadMode === "discoverable" && !(tool.name in XDEV_KEEP_TOP_LEVEL);
65
+ if (tool.name in XDEV_TRANSPORT_TOOLS || tool.name in XDEV_KEEP_TOP_LEVEL) return false;
66
+ return tool.loadMode === "discoverable";
56
67
  }
57
68
 
58
69
  /** Dispatch metadata carried on write-tool details for renderer delegation. */
@@ -18,6 +18,13 @@ export interface TreeListOptions<T> {
18
18
  maxCollapsedLines?: number;
19
19
  itemType?: string;
20
20
  truncateFrom?: "start" | "end";
21
+ /** Caller-supplied trailing summary line. When set (and not expanded),
22
+ * `renderTreeList` renders exactly the provided `items` (the caller has
23
+ * already applied its own selection/cap) and appends this text as the
24
+ * final `└` row, with the last item using `├`. Empty string renders the
25
+ * items with no summary. Bypasses the built-in truncation/`maxCollapsed`
26
+ * path. */
27
+ trailingSummary?: string;
21
28
  /** Called once per item with `isLast: false` during budget calculation;
22
29
  * line count MUST NOT vary based on `isLast`. */
23
30
  renderItem: (item: T, context: TreeContext) => string | string[];
@@ -36,6 +43,38 @@ export function renderTreeList<T>(options: TreeListOptions<T>, theme: Theme): st
36
43
  const maxItems = expanded ? items.length : Math.min(items.length, maxCollapsed);
37
44
  const linesBudget = !expanded && maxCollapsedLines !== undefined ? maxCollapsedLines : Infinity;
38
45
 
46
+ // Caller-driven collapse: render exactly the provided items (the caller
47
+ // already picked/capped them) plus an optional trailing summary row. The
48
+ // walking-viewport todo policy uses this so item selection lives in the
49
+ // todo domain, not here.
50
+ if (!expanded && options.trailingSummary !== undefined) {
51
+ const summary = options.trailingSummary;
52
+ const lines: string[] = [];
53
+ for (let i = 0; i < items.length; i++) {
54
+ const rendered = renderItem(items[i], {
55
+ index: i,
56
+ isLast: false,
57
+ depth: 0,
58
+ theme,
59
+ prefix: "",
60
+ continuePrefix: "",
61
+ });
62
+ const itemLines = Array.isArray(rendered) ? rendered : rendered ? [rendered] : [];
63
+ if (itemLines.length === 0) continue;
64
+ const isLast = summary === "" && i === items.length - 1;
65
+ const prefix = `${theme.fg("dim", getTreeBranch(isLast, theme))} `;
66
+ const continuePrefix = `${theme.fg("dim", getTreeContinuePrefix(isLast, theme))}`;
67
+ lines.push(`${prefix}${replaceTabs(itemLines[0]!)}`);
68
+ for (let j = 1; j < itemLines.length; j++) {
69
+ lines.push(`${continuePrefix}${replaceTabs(itemLines[j]!)}`);
70
+ }
71
+ }
72
+ if (summary !== "") {
73
+ lines.push(`${theme.fg("dim", theme.tree.last)} ${theme.fg("muted", summary)}`);
74
+ }
75
+ return lines;
76
+ }
77
+
39
78
  const candidateIndices: number[] = [];
40
79
  if (truncateFrom === "start") {
41
80
  const startCandidateIdx = Math.max(0, items.length - maxItems);
@@ -10,6 +10,18 @@ import {
10
10
  } from "./markit-cache";
11
11
  import { loadEmbeddedMupdfWasm } from "./mupdf-wasm-embed";
12
12
 
13
+ /**
14
+ * File extensions markit can actually convert to markdown — one per registered
15
+ * converter in `src/markit/registry.ts` (pdf, docx, pptx, xlsx, epub). This is
16
+ * the single source of truth shared by the read, fetch, and CLI file tools so
17
+ * the advertised set never drifts from the converters that back it. Legacy
18
+ * binary formats (`.doc`, `.ppt`, `.xls`, `.rtf`) are intentionally absent:
19
+ * markit has no converter for them, so routing them here only produced an
20
+ * `Unsupported format` error instead of letting them fall through to the
21
+ * binary-file handling.
22
+ */
23
+ export const CONVERTIBLE_EXTENSIONS: ReadonlySet<string> = new Set([".pdf", ".docx", ".pptx", ".xlsx", ".epub"]);
24
+
13
25
  export interface MarkitConversionResult {
14
26
  content: string;
15
27
  ok: boolean;
@@ -68,6 +68,7 @@ function getTitleModel(registry: ModelRegistry, settings: Settings, currentModel
68
68
  * resolver instead of a pre-evaluated value ensures the metadata's account_uuid
69
69
  * reflects the credential actually selected for this request.
70
70
  * @param customSystemPrompt Optional title-specific system prompt override
71
+ * @param signal Session-lifecycle cancellation for background title requests
71
72
  */
72
73
  export async function generateSessionTitle(
73
74
  firstMessage: string,
@@ -77,6 +78,7 @@ export async function generateSessionTitle(
77
78
  currentModel?: Model<Api>,
78
79
  metadataResolver?: (provider: string) => Record<string, unknown> | undefined,
79
80
  customSystemPrompt?: string,
81
+ signal?: AbortSignal,
80
82
  ): Promise<string | null> {
81
83
  // Defer titling for greetings / acknowledgements / empty input. The default
82
84
  // tiny title model can't reliably decline trivial input, so this happens
@@ -97,7 +99,7 @@ export async function generateSessionTitle(
97
99
  sessionId,
98
100
  currentModel,
99
101
  metadataResolver,
100
- undefined,
102
+ signal,
101
103
  titleSystemPrompt,
102
104
  );
103
105
  }
@@ -117,9 +119,18 @@ export async function generateSessionTitle(
117
119
  return null;
118
120
  }
119
121
  try {
120
- const localTitle = titleSystemPrompt
121
- ? await tinyTitleClient.generate(tinyModel, firstMessage, { systemPrompt: titleSystemPrompt })
122
- : await tinyTitleClient.generate(tinyModel, firstMessage);
122
+ let localTitle: string | null;
123
+ if (signal) {
124
+ localTitle = await tinyTitleClient.generate(
125
+ tinyModel,
126
+ firstMessage,
127
+ titleSystemPrompt ? { signal, systemPrompt: titleSystemPrompt } : { signal },
128
+ );
129
+ } else if (titleSystemPrompt) {
130
+ localTitle = await tinyTitleClient.generate(tinyModel, firstMessage, { systemPrompt: titleSystemPrompt });
131
+ } else {
132
+ localTitle = await tinyTitleClient.generate(tinyModel, firstMessage);
133
+ }
123
134
  if (!localTitle) {
124
135
  logger.warn("title-generator: local tiny model produced no title; skipping (no online fallback)", {
125
136
  sessionId,
package/src/utils/zip.ts CHANGED
@@ -233,12 +233,27 @@ function ensureParentDirectories(map: Map<string, ArchiveIndexEntry>): void {
233
233
  }
234
234
  }
235
235
 
236
+ /**
237
+ * Extensions that are ZIP containers under a different name — JVM (`.jar`,
238
+ * `.war`, `.ear`) and Android (`.apk`) packages are all ZIP archives. Treated
239
+ * as `zip` for member read/list and whole-archive rewrite.
240
+ */
241
+ const ZIP_ALIAS_EXTENSIONS = ["jar", "war", "ear", "apk"] as const;
242
+
243
+ /**
244
+ * Regex alternation of every recognized archive extension, longest first so
245
+ * `.tar.gz` wins over `.tar`. Shared with `parseArchivePathCandidates` as its
246
+ * split pattern so extension recognition and path splitting never drift.
247
+ */
248
+ const ARCHIVE_EXTENSION_ALTERNATION = ["tar\\.gz", "tgz", "zip", "tar", ...ZIP_ALIAS_EXTENSIONS].join("|");
249
+
236
250
  /** Infer an archive format from a filesystem path's extension. */
237
251
  export function archiveFormatFromPath(filePath: string): ArchiveFormat | undefined {
238
252
  const normalized = filePath.toLowerCase();
239
253
  if (normalized.endsWith(".tar.gz") || normalized.endsWith(".tgz")) return "tar.gz";
240
254
  if (normalized.endsWith(".tar")) return "tar";
241
255
  if (normalized.endsWith(".zip")) return "zip";
256
+ if (ZIP_ALIAS_EXTENSIONS.some(ext => normalized.endsWith(`.${ext}`))) return "zip";
242
257
  return undefined;
243
258
  }
244
259
 
@@ -635,7 +650,7 @@ async function readZipEntries(source: ByteSource): Promise<ArchiveIndexEntry[]>
635
650
  */
636
651
  export function parseArchivePathCandidates(filePath: string): ArchivePathCandidate[] {
637
652
  const normalized = filePath.replace(/\\/g, "/");
638
- const pattern = /\.(?:tar\.gz|tgz|zip|tar)(?=(?::|$))/gi;
653
+ const pattern = new RegExp(`\\.(?:${ARCHIVE_EXTENSION_ALTERNATION})(?=(?::|$))`, "gi");
639
654
  const seen = new Set<string>();
640
655
  const candidates: ArchivePathCandidate[] = [];
641
656
 
@@ -1,7 +1,10 @@
1
1
  /**
2
2
  * Kimi Web Search Provider
3
3
  *
4
- * Uses Moonshot Kimi Code search API to retrieve web results.
4
+ * Uses the Kimi Code search API to retrieve web results. This is the Kimi Code
5
+ * membership service, distinct from the Moonshot Open Platform — it requires a
6
+ * Kimi Code Console credential (`omp /login kimi-code` or an explicit
7
+ * `MOONSHOT_SEARCH_API_KEY` / `KIMI_SEARCH_API_KEY`), not `MOONSHOT_API_KEY`.
5
8
  * Endpoint: POST https://api.kimi.com/coding/v1/search
6
9
  */
7
10
  import { type ApiKey, type AuthStorage, type FetchImpl, withAuth } from "@oh-my-pi/pi-ai";
@@ -58,11 +61,17 @@ function resolveBaseUrl(): string {
58
61
  }
59
62
 
60
63
  /**
61
- * Resolve the Kimi search credential. Highest precedence is the static env key;
62
- * otherwise an AuthStorage-backed resolver for whichever stored provider id
63
- * holds a key (`moonshot` first, then `kimi-code`), so a stale token triggers
64
- * the central force-refresh / sibling-rotate retry. Returns `undefined` when
65
- * neither is configured.
64
+ * Resolve the Kimi Code search credential. Highest precedence is the explicit
65
+ * search-key env override; otherwise an AuthStorage-backed resolver for a
66
+ * stored `kimi-code` credential (from `omp /login kimi-code`), so a stale token
67
+ * triggers the central force-refresh / sibling-rotate retry. Returns
68
+ * `undefined` when neither is configured.
69
+ *
70
+ * The endpoint (`https://api.kimi.com/coding/v1/search`) is the Kimi Code
71
+ * membership service, which has a different credential system from the Moonshot
72
+ * Open Platform (`https://api.moonshot.ai`). A stored `moonshot` credential
73
+ * (or `MOONSHOT_API_KEY`) is NOT accepted here — it 401s against Kimi Code
74
+ * (issue #5762).
66
75
  */
67
76
  async function resolveKey(
68
77
  authStorage: AuthStorage,
@@ -72,10 +81,8 @@ async function resolveKey(
72
81
  const envKey = asTrimmed($env.MOONSHOT_SEARCH_API_KEY) ?? asTrimmed($env.KIMI_SEARCH_API_KEY);
73
82
  if (envKey) return envKey;
74
83
 
75
- for (const provider of ["moonshot", "kimi-code"] as const) {
76
- const stored = await authStorage.getApiKey(provider, sessionId, { signal });
77
- if (stored) return authStorage.resolver(provider, { sessionId });
78
- }
84
+ const stored = await authStorage.getApiKey("kimi-code", sessionId, { signal });
85
+ if (stored) return authStorage.resolver("kimi-code", { sessionId });
79
86
  return undefined;
80
87
  }
81
88
 
@@ -127,7 +134,7 @@ export async function searchKimi(params: KimiSearchParams): Promise<SearchRespon
127
134
  const keyOrResolver = await resolveKey(params.authStorage, params.sessionId, params.signal);
128
135
  if (!keyOrResolver) {
129
136
  throw new Error(
130
- "Kimi search credentials not found. Set MOONSHOT_SEARCH_API_KEY, KIMI_SEARCH_API_KEY, MOONSHOT_API_KEY, or login with 'omp /login moonshot'.",
137
+ "Kimi search credentials not found. Kimi web search uses the Kimi Code service (api.kimi.com); set MOONSHOT_SEARCH_API_KEY / KIMI_SEARCH_API_KEY to a Kimi Code Console key, or login with 'omp /login kimi-code'. A Moonshot Open Platform key (MOONSHOT_API_KEY) is not accepted here.",
131
138
  );
132
139
  }
133
140
 
@@ -176,7 +183,6 @@ export class KimiProvider extends SearchProvider {
176
183
  return (
177
184
  !!asTrimmed($env.MOONSHOT_SEARCH_API_KEY) ||
178
185
  !!asTrimmed($env.KIMI_SEARCH_API_KEY) ||
179
- authStorage.hasAuth("moonshot") ||
180
186
  authStorage.hasAuth("kimi-code")
181
187
  );
182
188
  }
@@ -39,7 +39,12 @@ export const SEARCH_PROVIDER_OPTIONS = [
39
39
  { value: "tavily", label: "Tavily", description: "Requires TAVILY_API_KEY" },
40
40
  { value: "firecrawl", label: "Firecrawl", description: "Requires FIRECRAWL_API_KEY" },
41
41
  { value: "brave", label: "Brave", description: "Requires BRAVE_API_KEY" },
42
- { value: "kimi", label: "Kimi", description: "Requires MOONSHOT_SEARCH_API_KEY or MOONSHOT_API_KEY" },
42
+ {
43
+ value: "kimi",
44
+ label: "Kimi",
45
+ description:
46
+ "Kimi Code search (requires a Kimi Code Console key via KIMI_SEARCH_API_KEY/MOONSHOT_SEARCH_API_KEY or /login kimi-code; not MOONSHOT_API_KEY)",
47
+ },
43
48
  { value: "parallel", label: "Parallel", description: "Requires PARALLEL_API_KEY" },
44
49
  { value: "synthetic", label: "Synthetic", description: "Requires SYNTHETIC_API_KEY" },
45
50
  { value: "searxng", label: "SearXNG", description: "Requires SEARXNG_ENDPOINT or searxng.endpoint" },