@oh-my-pi/pi-coding-agent 17.1.7 → 17.2.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 (246) hide show
  1. package/CHANGELOG.md +127 -0
  2. package/dist/{CHANGELOG-5k4dq4g6.md → CHANGELOG-be1f2t8h.md} +127 -0
  3. package/dist/cli.js +9554 -12379
  4. package/dist/template-8vdv6xb4.js +1671 -0
  5. package/dist/template-f8wx9vfn.css +1355 -0
  6. package/dist/template-qat058wr.html +55 -0
  7. package/dist/tool-views.generated-jdfmzwmn.js +35 -0
  8. package/dist/types/advisor/advise-tool.d.ts +0 -7
  9. package/dist/types/advisor/runtime.d.ts +8 -11
  10. package/dist/types/advisor/transcript-recorder.d.ts +15 -0
  11. package/dist/types/async/job-manager.d.ts +11 -0
  12. package/dist/types/auto-thinking/classifier.d.ts +9 -2
  13. package/dist/types/cleanse/agent.d.ts +19 -0
  14. package/dist/types/cleanse/balance.d.ts +7 -0
  15. package/dist/types/cleanse/checkers.d.ts +13 -0
  16. package/dist/types/cleanse/index.d.ts +16 -0
  17. package/dist/types/cleanse/loop.d.ts +16 -0
  18. package/dist/types/cleanse/parsers.d.ts +13 -0
  19. package/dist/types/cleanse/progress.d.ts +14 -0
  20. package/dist/types/cleanse/types.d.ts +64 -0
  21. package/dist/types/cli/args.d.ts +2 -0
  22. package/dist/types/cli/session-picker.d.ts +14 -7
  23. package/dist/types/collab/guest.d.ts +21 -2
  24. package/dist/types/commands/cleanse.d.ts +23 -0
  25. package/dist/types/commands/launch.d.ts +6 -0
  26. package/dist/types/config/settings-schema.d.ts +63 -8
  27. package/dist/types/cursor-bridge-tools.d.ts +54 -0
  28. package/dist/types/cursor.d.ts +193 -8
  29. package/dist/types/edit/edit-clipboard.d.ts +19 -0
  30. package/dist/types/edit/hashline/diff.d.ts +9 -1
  31. package/dist/types/edit/index.d.ts +8 -1
  32. package/dist/types/edit/streaming.d.ts +8 -1
  33. package/dist/types/export/html/index.d.ts +2 -0
  34. package/dist/types/extensibility/extensions/runner.d.ts +19 -2
  35. package/dist/types/extensibility/extensions/types.d.ts +25 -1
  36. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  37. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +2 -0
  38. package/dist/types/internal-urls/local-protocol.d.ts +3 -2
  39. package/dist/types/launch/terminal-output-worker-client.d.ts +5 -0
  40. package/dist/types/launch/terminal-output-worker-protocol.d.ts +14 -0
  41. package/dist/types/launch/terminal-output-worker.d.ts +1 -0
  42. package/dist/types/lsp/client.d.ts +2 -0
  43. package/dist/types/main.d.ts +2 -0
  44. package/dist/types/mcp/manager.d.ts +33 -4
  45. package/dist/types/modes/components/codex-reset-fireworks.d.ts +51 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +12 -0
  47. package/dist/types/modes/components/session-selector.d.ts +6 -0
  48. package/dist/types/modes/components/status-line/component.d.ts +23 -0
  49. package/dist/types/modes/components/tool-execution.d.ts +16 -7
  50. package/dist/types/modes/components/usage-row.d.ts +2 -0
  51. package/dist/types/modes/controllers/mcp-command-controller.d.ts +26 -1
  52. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  53. package/dist/types/modes/interactive-mode.d.ts +8 -5
  54. package/dist/types/modes/rpc/rpc-client.d.ts +7 -0
  55. package/dist/types/modes/rpc/rpc-types.d.ts +16 -0
  56. package/dist/types/modes/theme/theme.d.ts +12 -1
  57. package/dist/types/modes/types.d.ts +2 -1
  58. package/dist/types/sdk.d.ts +1 -0
  59. package/dist/types/session/agent-session-events.d.ts +2 -0
  60. package/dist/types/session/agent-session-types.d.ts +36 -1
  61. package/dist/types/session/agent-session.d.ts +4 -2
  62. package/dist/types/session/async-job-delivery.d.ts +8 -0
  63. package/dist/types/session/claude-session-store.d.ts +13 -0
  64. package/dist/types/session/codex-session-store.d.ts +14 -0
  65. package/dist/types/session/credential-pin.d.ts +58 -0
  66. package/dist/types/session/foreign-session-import.d.ts +15 -0
  67. package/dist/types/session/foreign-session-jsonl.d.ts +9 -0
  68. package/dist/types/session/foreign-session-store.d.ts +23 -0
  69. package/dist/types/session/model-controls.d.ts +3 -3
  70. package/dist/types/session/session-advisors.d.ts +47 -2
  71. package/dist/types/session/session-entries.d.ts +19 -1
  72. package/dist/types/session/session-handoff.d.ts +4 -1
  73. package/dist/types/session/session-loader.d.ts +2 -0
  74. package/dist/types/session/session-maintenance.d.ts +1 -1
  75. package/dist/types/session/session-manager.d.ts +21 -0
  76. package/dist/types/session/session-tools.d.ts +25 -1
  77. package/dist/types/session/turn-recovery.d.ts +10 -4
  78. package/dist/types/system-prompt.d.ts +2 -1
  79. package/dist/types/task/executor.d.ts +11 -2
  80. package/dist/types/task/isolation-ownership.d.ts +34 -0
  81. package/dist/types/task/render.d.ts +7 -0
  82. package/dist/types/thinking.d.ts +18 -6
  83. package/dist/types/tools/acp-bridge.d.ts +28 -5
  84. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  85. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  86. package/dist/types/tools/grep.d.ts +17 -1
  87. package/dist/types/tools/hub/launch.d.ts +5 -1
  88. package/dist/types/tools/index.d.ts +4 -1
  89. package/dist/types/tools/path-utils.d.ts +19 -0
  90. package/dist/types/tools/read.d.ts +2 -0
  91. package/dist/types/tools/xdev.d.ts +2 -1
  92. package/dist/types/tui/output-block.d.ts +5 -5
  93. package/dist/types/utils/changelog.d.ts +19 -0
  94. package/dist/types/utils/git.d.ts +10 -0
  95. package/dist/types/utils/jj.d.ts +11 -4
  96. package/dist/types/web/search/types.d.ts +2 -2
  97. package/package.json +16 -12
  98. package/scripts/bundle-dist.ts +3 -1
  99. package/src/advisor/advise-tool.ts +0 -11
  100. package/src/advisor/runtime.ts +138 -24
  101. package/src/advisor/transcript-recorder.ts +56 -0
  102. package/src/async/job-manager.ts +30 -7
  103. package/src/auto-thinking/classifier.ts +48 -10
  104. package/src/cleanse/agent.ts +226 -0
  105. package/src/cleanse/balance.ts +79 -0
  106. package/src/cleanse/checkers.ts +996 -0
  107. package/src/cleanse/index.ts +190 -0
  108. package/src/cleanse/loop.ts +51 -0
  109. package/src/cleanse/parsers.ts +726 -0
  110. package/src/cleanse/progress.ts +50 -0
  111. package/src/cleanse/prompts/assignment.md +47 -0
  112. package/src/cleanse/types.ts +72 -0
  113. package/src/cli/args.ts +6 -0
  114. package/src/cli/flag-tables.ts +2 -0
  115. package/src/cli/gallery-fixtures/fs.ts +25 -0
  116. package/src/cli/session-picker.ts +34 -17
  117. package/src/cli/ttsr-cli.ts +19 -1
  118. package/src/cli/update-cli.ts +3 -0
  119. package/src/cli/usage-cli.ts +29 -4
  120. package/src/cli/worktree-cli.ts +28 -11
  121. package/src/cli-commands.ts +1 -0
  122. package/src/cli.ts +11 -1
  123. package/src/collab/guest.ts +37 -4
  124. package/src/collab/host.ts +1 -0
  125. package/src/commands/cleanse.ts +45 -0
  126. package/src/commands/launch.ts +6 -0
  127. package/src/config/model-registry.ts +103 -25
  128. package/src/config/settings-schema.ts +65 -6
  129. package/src/config/settings.ts +25 -0
  130. package/src/cursor-bridge-tools.ts +81 -0
  131. package/src/cursor.ts +463 -12
  132. package/src/discovery/claude-plugins.ts +144 -34
  133. package/src/edit/edit-clipboard.ts +23 -0
  134. package/src/edit/hashline/diff.ts +49 -10
  135. package/src/edit/hashline/execute.ts +38 -6
  136. package/src/edit/hashline/filesystem.ts +27 -3
  137. package/src/edit/index.ts +12 -2
  138. package/src/edit/renderer.ts +4 -4
  139. package/src/edit/streaming.ts +17 -2
  140. package/src/export/html/index.ts +17 -10
  141. package/src/export/html/template.js +1 -1
  142. package/src/extensibility/extensions/runner.ts +70 -0
  143. package/src/extensibility/extensions/types.ts +30 -0
  144. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  145. package/src/extensibility/legacy-pi-coding-agent-shim.ts +12 -13
  146. package/src/internal-urls/local-protocol.ts +3 -2
  147. package/src/internal-urls/mcp-protocol.ts +4 -1
  148. package/src/launch/broker.ts +14 -4
  149. package/src/launch/terminal-output-worker-client.ts +53 -0
  150. package/src/launch/terminal-output-worker-protocol.ts +11 -0
  151. package/src/launch/terminal-output-worker.ts +23 -0
  152. package/src/lsp/client.ts +18 -3
  153. package/src/lsp/index.ts +4 -0
  154. package/src/lsp/render.ts +1 -1
  155. package/src/main.ts +127 -56
  156. package/src/mcp/manager.ts +120 -18
  157. package/src/modes/acp/acp-agent.ts +27 -18
  158. package/src/modes/acp/acp-event-mapper.ts +38 -4
  159. package/src/modes/components/chat-transcript-builder.ts +30 -11
  160. package/src/modes/components/codex-reset-fireworks.ts +369 -0
  161. package/src/modes/components/read-tool-group.ts +159 -17
  162. package/src/modes/components/session-selector.ts +19 -3
  163. package/src/modes/components/settings-selector.ts +9 -1
  164. package/src/modes/components/status-line/component.ts +381 -54
  165. package/src/modes/components/tool-execution.ts +78 -31
  166. package/src/modes/components/usage-row.ts +10 -5
  167. package/src/modes/controllers/event-controller.ts +26 -4
  168. package/src/modes/controllers/mcp-command-controller.ts +68 -3
  169. package/src/modes/controllers/selector-controller.ts +94 -36
  170. package/src/modes/controllers/tan-command-controller.ts +14 -0
  171. package/src/modes/interactive-mode.ts +67 -64
  172. package/src/modes/prompt-action-autocomplete.ts +5 -3
  173. package/src/modes/rpc/rpc-client.ts +18 -0
  174. package/src/modes/rpc/rpc-mode.ts +16 -1
  175. package/src/modes/rpc/rpc-types.ts +11 -0
  176. package/src/modes/theme/theme.ts +151 -6
  177. package/src/modes/types.ts +2 -1
  178. package/src/modes/utils/ui-helpers.ts +38 -21
  179. package/src/prompts/goals/guided-goal-interview.md +41 -6
  180. package/src/prompts/system/auto-thinking-difficulty.md +4 -2
  181. package/src/prompts/system/plan-mode-active.md +2 -2
  182. package/src/prompts/system/system-prompt.md +3 -0
  183. package/src/prompts/system/vibe-mode-active.md +4 -1
  184. package/src/prompts/system/xdev-mount-notice.md +1 -1
  185. package/src/prompts/tools/bash.md +15 -17
  186. package/src/prompts/tools/browser.md +1 -0
  187. package/src/prompts/tools/checkpoint.md +1 -1
  188. package/src/prompts/tools/glob.md +7 -6
  189. package/src/prompts/tools/goal.md +1 -1
  190. package/src/prompts/tools/grep.md +7 -6
  191. package/src/sdk.ts +178 -15
  192. package/src/session/agent-session-events.ts +1 -0
  193. package/src/session/agent-session-types.ts +40 -1
  194. package/src/session/agent-session.ts +330 -179
  195. package/src/session/async-job-delivery.ts +8 -0
  196. package/src/session/claude-session-store.ts +426 -0
  197. package/src/session/codex-session-store.ts +673 -0
  198. package/src/session/credential-pin.ts +93 -0
  199. package/src/session/foreign-session-import.ts +52 -0
  200. package/src/session/foreign-session-jsonl.ts +29 -0
  201. package/src/session/foreign-session-store.ts +26 -0
  202. package/src/session/model-controls.ts +15 -7
  203. package/src/session/session-advisors.ts +142 -23
  204. package/src/session/session-entries.ts +21 -1
  205. package/src/session/session-handoff.ts +20 -3
  206. package/src/session/session-history-format.ts +31 -6
  207. package/src/session/session-loader.ts +25 -9
  208. package/src/session/session-maintenance.ts +59 -11
  209. package/src/session/session-manager.ts +61 -0
  210. package/src/session/session-tools.ts +107 -5
  211. package/src/session/settings-stream-fn.ts +1 -0
  212. package/src/session/turn-recovery.ts +97 -51
  213. package/src/slash-commands/builtin-registry.ts +136 -5
  214. package/src/system-prompt.ts +3 -2
  215. package/src/task/executor.ts +17 -4
  216. package/src/task/isolation-ownership.ts +106 -0
  217. package/src/task/render.ts +20 -4
  218. package/src/task/worktree.ts +8 -0
  219. package/src/thinking.ts +29 -10
  220. package/src/tools/acp-bridge.ts +52 -8
  221. package/src/tools/ask.ts +3 -3
  222. package/src/tools/ast-edit.ts +9 -2
  223. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  224. package/src/tools/browser/tab-worker.ts +12 -35
  225. package/src/tools/browser.ts +7 -2
  226. package/src/tools/checkpoint.ts +0 -13
  227. package/src/tools/gh-renderer.ts +3 -3
  228. package/src/tools/grep.ts +64 -8
  229. package/src/tools/hub/launch.ts +10 -15
  230. package/src/tools/index.ts +38 -5
  231. package/src/tools/path-utils.ts +88 -0
  232. package/src/tools/read.ts +3 -0
  233. package/src/tools/write.ts +44 -3
  234. package/src/tools/xdev.ts +39 -14
  235. package/src/tui/code-cell.ts +4 -4
  236. package/src/tui/output-block.ts +25 -8
  237. package/src/utils/changelog.ts +144 -4
  238. package/src/utils/git.ts +61 -14
  239. package/src/utils/jj.ts +19 -13
  240. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  241. package/src/web/search/providers/codex.ts +34 -0
  242. package/src/web/search/render.ts +2 -2
  243. package/src/web/search/types.ts +1 -1
  244. package/dist/types/goals/guided-setup.d.ts +0 -30
  245. package/src/goals/guided-setup.ts +0 -171
  246. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -1,7 +1,9 @@
1
+ import * as fs from "node:fs/promises";
1
2
  import * as path from "node:path";
2
3
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
3
4
  import type { Message, UserMessage } from "@oh-my-pi/pi-ai";
4
5
  import { logger } from "@oh-my-pi/pi-utils";
6
+ import { visitEntriesFromFileStream } from "../session/session-loader";
5
7
  import { SessionManager } from "../session/session-manager";
6
8
 
7
9
  /**
@@ -31,6 +33,60 @@ export function isAdvisorTranscriptName(name: string): boolean {
31
33
  );
32
34
  }
33
35
 
36
+ /**
37
+ * Sum the advisor spend already persisted next to a primary session transcript,
38
+ * keyed by advisor slug.
39
+ *
40
+ * The ledger a session keeps in memory only covers the current process, so a
41
+ * resumed session would report zero until the next advisor turn. The recorded
42
+ * transcripts are the durable copy of exactly the same finalized messages, so
43
+ * they are read back through the shared loader - no lock, no writer, and no
44
+ * second parser to keep in step with the session format.
45
+ *
46
+ * Only the session's own advisors count: subagent advisors write to
47
+ * `<session>/<SubId>/__advisor.jsonl`, and their spend belongs to the subagent,
48
+ * not to this roster. Hence the scan stays at the top level of the directory.
49
+ */
50
+ export async function loadAdvisorTranscriptCosts(sessionFile: string | undefined): Promise<Map<string, number>> {
51
+ const costs = new Map<string, number>();
52
+ if (!sessionFile?.endsWith(JSONL_SUFFIX)) return costs;
53
+ const directory = sessionFile.slice(0, -JSONL_SUFFIX.length);
54
+ const dirents = await fs.readdir(directory, { withFileTypes: true }).catch(() => []);
55
+ for (const dirent of dirents) {
56
+ if (!dirent.isFile() || !isAdvisorTranscriptName(dirent.name)) continue;
57
+ const slug =
58
+ dirent.name === ADVISOR_TRANSCRIPT_FILENAME
59
+ ? ""
60
+ : dirent.name.slice(`${ADVISOR_TRANSCRIPT_STEM}.`.length, -JSONL_SUFFIX.length);
61
+ let total = 0;
62
+ let validHeader: boolean | undefined;
63
+ try {
64
+ await visitEntriesFromFileStream(path.join(directory, dirent.name), entry => {
65
+ const isObject = typeof entry === "object" && entry !== null;
66
+ if (validHeader === undefined) {
67
+ validHeader = isObject && entry.type === "session" && typeof entry.id === "string";
68
+ return;
69
+ }
70
+ // A syntactically valid but non-object entry (e.g. a bare `null`
71
+ // line) must cost only itself, not crash entry.type access and
72
+ // discard everything accumulated for this transcript.
73
+ if (!validHeader || !isObject || entry.type !== "message") return;
74
+ const message = entry.message;
75
+ if (!message || typeof message !== "object" || message.role !== "assistant") return;
76
+ // One malformed usage block must cost that entry only, not the
77
+ // whole transcript's total.
78
+ const total_ = message.usage?.cost?.total;
79
+ if (typeof total_ === "number" && Number.isFinite(total_)) total += total_;
80
+ });
81
+ } catch (err) {
82
+ logger.debug("advisor transcript cost read failed", { file: dirent.name, err: String(err) });
83
+ continue;
84
+ }
85
+ if (total > 0) costs.set(slug, total);
86
+ }
87
+ return costs;
88
+ }
89
+
34
90
  /**
35
91
  * Append-only persister for an advisor agent's transcript.
36
92
  *
@@ -417,6 +417,26 @@ export class AsyncJobManager {
417
417
  }
418
418
  }
419
419
 
420
+ /**
421
+ * Immediately evict completed and failed jobs matching the filter instead of
422
+ * waiting for retention expiry, dropping every queued delivery so a prior
423
+ * session's result can never be injected into a later transcript. Returns the
424
+ * number of jobs evicted.
425
+ *
426
+ * A delivery whose sink call is already in flight (or drained onto a caller's
427
+ * yield queue) is guarded by the owner's delivery generation, not the per-id
428
+ * suppression marker — that marker is cleared when the id is reused.
429
+ */
430
+ evictCompletedJobs(filter?: AsyncJobFilter): number {
431
+ let evicted = 0;
432
+ for (const job of this.#filterJobs(this.#jobs.values(), filter)) {
433
+ if (job.status !== "completed" && job.status !== "failed") continue;
434
+ this.acknowledgeDeliveries([job.id]);
435
+ if (this.#evictJob(job.id)) evicted += 1;
436
+ }
437
+ return evicted;
438
+ }
439
+
420
440
  async waitForAll(): Promise<void> {
421
441
  await Promise.all(Array.from(this.#jobs.values()).map(job => job.promise));
422
442
  }
@@ -579,12 +599,18 @@ export class AsyncJobManager {
579
599
  return candidate;
580
600
  }
581
601
 
602
+ #evictJob(jobId: string): boolean {
603
+ clearTimeout(this.#evictionTimers.get(jobId));
604
+ this.#evictionTimers.delete(jobId);
605
+ this.#suppressedDeliveries.delete(jobId);
606
+ this.#watchedJobs.delete(jobId);
607
+ return this.#jobs.delete(jobId);
608
+ }
609
+
582
610
  #scheduleEviction(jobId: string): void {
583
611
  if (this.#disposed) return;
584
612
  if (this.#retentionMs <= 0) {
585
- this.#jobs.delete(jobId);
586
- this.#suppressedDeliveries.delete(jobId);
587
- this.#watchedJobs.delete(jobId);
613
+ this.#evictJob(jobId);
588
614
  return;
589
615
  }
590
616
  const existing = this.#evictionTimers.get(jobId);
@@ -592,10 +618,7 @@ export class AsyncJobManager {
592
618
  clearTimeout(existing);
593
619
  }
594
620
  const timer = setTimeout(() => {
595
- this.#evictionTimers.delete(jobId);
596
- this.#jobs.delete(jobId);
597
- this.#suppressedDeliveries.delete(jobId);
598
- this.#watchedJobs.delete(jobId);
621
+ this.#evictJob(jobId);
599
622
  }, this.#retentionMs);
600
623
  timer.unref();
601
624
  this.#evictionTimers.set(jobId, timer);
@@ -5,7 +5,8 @@
5
5
  * {@link Effort}, clamped into the active model's supported range (never below
6
6
  * {@link Effort.Low}). Two backends, selected by `providers.autoThinkingModel`:
7
7
  *
8
- * - `online` (default): a smol model classifies into `low|medium|high|xhigh`.
8
+ * - `online` (default): a smol model classifies into `low|medium|high|xhigh`,
9
+ * plus `max` when the target model exposes that tier.
9
10
  * - a local key: an on-device memory model classifies into the coarser
10
11
  * `trivial|moderate|hard` scheme (3-class is more reliable than 4-way ordinal
11
12
  * on sub-2B models), mapped to `low|high|xhigh`.
@@ -14,6 +15,7 @@
14
15
  * the caller falls back to a concrete level and continues the turn.
15
16
  */
16
17
  import { type AssistantMessage, completeSimple, Effort, type Model } from "@oh-my-pi/pi-ai";
18
+ import { getSupportedEfforts } from "@oh-my-pi/pi-catalog/model-thinking";
17
19
  import { prompt } from "@oh-my-pi/pi-utils";
18
20
 
19
21
  import type { ModelRegistry } from "../config/model-registry";
@@ -30,7 +32,31 @@ import {
30
32
  } from "../tiny/models";
31
33
  import { tinyModelClient } from "../tiny/title-client";
32
34
 
33
- const DIFFICULTY_SYSTEM_PROMPT = prompt.render(difficultySystemPrompt);
35
+ /**
36
+ * Rendered classifier prompts, keyed by whether `max` is offered as a label.
37
+ * Two variants only, so both are memoized on first use.
38
+ */
39
+ const DIFFICULTY_SYSTEM_PROMPTS: Partial<Record<"max" | "xhigh", string>> = {};
40
+
41
+ /**
42
+ * Highest effort this turn's classification may resolve to: the configured
43
+ * ceiling, further limited by what the target model actually exposes. The
44
+ * default keeps `auto` one tier below the top, so only an explicit
45
+ * `ultrathink` reaches {@link Effort.Max}.
46
+ */
47
+ function autoEffortCeiling(deps: ClassifyDifficultyDeps): Effort {
48
+ if (deps.settings.get("providers.autoThinkingMaxEffort") !== Effort.Max) return Effort.XHigh;
49
+ return getSupportedEfforts(deps.model).includes(Effort.Max) ? Effort.Max : Effort.XHigh;
50
+ }
51
+
52
+ function difficultySystemPromptFor(ceiling: Effort): string {
53
+ const key = ceiling === Effort.Max ? "max" : "xhigh";
54
+ const cached = DIFFICULTY_SYSTEM_PROMPTS[key];
55
+ if (cached !== undefined) return cached;
56
+ const rendered = prompt.render(difficultySystemPrompt, { allowMax: key === "max" });
57
+ DIFFICULTY_SYSTEM_PROMPTS[key] = rendered;
58
+ return rendered;
59
+ }
34
60
 
35
61
  /** Local classifiers occasionally need more room for chat-template boilerplate. */
36
62
  const LOCAL_ANSWER_MAX_TOKENS = 16;
@@ -64,14 +90,18 @@ export async function classifyDifficulty(
64
90
  ): Promise<Effort | undefined> {
65
91
  const backend = deps.settings.get("providers.autoThinkingModel");
66
92
  const input = preprocessTinyMessage(promptText);
67
- const effort =
68
- backend === ONLINE_AUTO_THINKING_MODEL_KEY
69
- ? await classifyOnline(input, deps)
70
- : await classifyLocal(input, backend, deps);
71
- return clampAutoThinkingEffort(deps.model, effort);
93
+ const online = backend === ONLINE_AUTO_THINKING_MODEL_KEY;
94
+ // The 3-bucket local classifier cannot select `max`, so its ceiling stays at
95
+ // XHigh whatever the setting says — otherwise a sparse ladder would snap its
96
+ // `hard` bucket up to a tier it never chose.
97
+ const ceiling = online ? autoEffortCeiling(deps) : Effort.XHigh;
98
+ const effort = online ? await classifyOnline(input, deps, ceiling) : await classifyLocal(input, backend, deps);
99
+ // The ceiling goes into the clamp itself: capping the request alone is not
100
+ // enough, because a sparse ladder snaps an excluded request back up.
101
+ return clampAutoThinkingEffort(deps.model, effort, ceiling);
72
102
  }
73
103
 
74
- async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Promise<Effort> {
104
+ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps, ceiling: Effort): Promise<Effort> {
75
105
  const resolved = resolveRoleSelection(["tiny", "smol"], deps.settings, deps.registry.getAvailable());
76
106
  const model = resolved?.model;
77
107
  if (!model) {
@@ -88,7 +118,7 @@ async function classifyOnline(input: string, deps: ClassifyDifficultyDeps): Prom
88
118
  const response = await completeSimple(
89
119
  model,
90
120
  {
91
- systemPrompt: [DIFFICULTY_SYSTEM_PROMPT],
121
+ systemPrompt: [difficultySystemPromptFor(ceiling)],
92
122
  messages: [{ role: "user", content: input, timestamp: Date.now() }],
93
123
  },
94
124
  {
@@ -134,7 +164,13 @@ async function classifyLocal(input: string, modelKey: string, deps: ClassifyDiff
134
164
  return effort;
135
165
  }
136
166
 
137
- /** Map the online 4-way level keyword to an {@link Effort}; earliest match wins. */
167
+ /**
168
+ * Map an online level keyword to an {@link Effort}; earliest match wins.
169
+ *
170
+ * `max` is only offered to the classifier when the target model exposes that
171
+ * tier, but it is always parsed: an unsupported `max` is snapped back down by
172
+ * {@link clampAutoThinkingEffort} rather than failing the turn.
173
+ */
138
174
  export function parseDifficultyLevel(text: string): Effort | undefined {
139
175
  const lower = text.toLowerCase();
140
176
  const candidates: Array<[number, Effort]> = [];
@@ -142,6 +178,8 @@ export function parseDifficultyLevel(text: string): Effort | undefined {
142
178
  // inside "xhigh" (no word boundary between `x` and `h`), so the two never collide.
143
179
  const xhigh = lower.search(/x[\s_-]?high/);
144
180
  if (xhigh >= 0) candidates.push([xhigh, Effort.XHigh]);
181
+ const max = lower.search(/\bmax\b/);
182
+ if (max >= 0) candidates.push([max, Effort.Max]);
145
183
  const high = lower.search(/\bhigh\b/);
146
184
  if (high >= 0) candidates.push([high, Effort.High]);
147
185
  const medium = lower.search(/\bmed(?:ium)?\b/);
@@ -0,0 +1,226 @@
1
+ import { getProjectDir, prompt } from "@oh-my-pi/pi-utils";
2
+ import { ModelRegistry } from "../config/model-registry";
3
+ import { formatModelString, resolveCliModel } from "../config/model-resolver";
4
+ import { Settings } from "../config/settings";
5
+ import { MAIN_AGENT_ID } from "../registry/agent-registry";
6
+ import { discoverAuthStorage } from "../sdk";
7
+ import { SessionManager } from "../session/session-manager";
8
+ import { mapWithConcurrencyLimitAllSettled } from "../task/parallel";
9
+ import { runStructuredSubagent } from "../task/structured-subagent";
10
+ import type { ToolSession } from "../tools";
11
+ import { EventBus } from "../utils/event-bus";
12
+ import assignmentPrompt from "./prompts/assignment.md" with { type: "text" };
13
+ import type {
14
+ CleanseAgentOutcome,
15
+ CleanseAssignment,
16
+ CleanseCheckResult,
17
+ CleanseDiagnostic,
18
+ CleanseDiagnosticReport,
19
+ CleanseLoopResult,
20
+ } from "./types";
21
+
22
+ const MAX_DIAGNOSTIC_MESSAGE = 4_000;
23
+
24
+ /** Hooks used by the standalone command to render subagent lifecycle progress. */
25
+ export interface CleanseAgentHooks {
26
+ onStart?(name: string, assignment: CleanseAssignment): void;
27
+ onFinish?(outcome: CleanseAgentOutcome, assignment: CleanseAssignment): void;
28
+ }
29
+
30
+ /** Persisted parent session that dispatches file-disjoint cleanse workers. */
31
+ export interface CleanseAgentRuntime {
32
+ readonly model: string;
33
+ readonly sessionFile: string;
34
+ dispatch(
35
+ assignments: CleanseAssignment[],
36
+ wave: number,
37
+ report: CleanseDiagnosticReport,
38
+ signal?: AbortSignal,
39
+ ): Promise<CleanseAgentOutcome[]>;
40
+ close(result?: CleanseLoopResult): Promise<void>;
41
+ }
42
+
43
+ /** Resolve the requested model and create a fresh persisted cleanse session. */
44
+ export async function createCleanseAgentRuntime(options: {
45
+ cwd?: string;
46
+ model: string;
47
+ hooks?: CleanseAgentHooks;
48
+ }): Promise<CleanseAgentRuntime> {
49
+ const cwd = options.cwd ?? getProjectDir();
50
+ const [settings, authStorage] = await Promise.all([Settings.init({ cwd }), discoverAuthStorage()]);
51
+ const modelRegistry = new ModelRegistry(authStorage);
52
+ await modelRegistry.refresh();
53
+ const resolved = resolveCliModel({ cliModel: options.model, modelRegistry, settings });
54
+ if (resolved.error || !resolved.model) {
55
+ throw new Error(resolved.error ?? `Model "${options.model}" not found`);
56
+ }
57
+ const modelSelector = resolved.selector ?? formatModelString(resolved.model);
58
+ const modelDisplay = formatModelString(resolved.model);
59
+ const sessionManager = SessionManager.create(cwd);
60
+ await sessionManager.setSessionName("Cleanse", "auto");
61
+ sessionManager.appendCustomEntry("cleanse", {
62
+ status: "running",
63
+ model: modelDisplay,
64
+ selector: options.model,
65
+ });
66
+ await sessionManager.ensureOnDisk();
67
+ const sessionFile = sessionManager.getSessionFile();
68
+ if (!sessionFile) throw new Error("Cleanse session could not be persisted");
69
+ const eventBus = new EventBus();
70
+ const toolSession: ToolSession = {
71
+ cwd,
72
+ hasUI: false,
73
+ suppressSpawnAdvisory: true,
74
+ enableLsp: true,
75
+ enableIrc: true,
76
+ enableMCP: false,
77
+ eventBus,
78
+ getSessionFile: () => sessionFile,
79
+ getSessionId: () => sessionManager.getSessionId(),
80
+ getArtifactsDir: () => sessionManager.getArtifactsDir(),
81
+ getArtifactManager: () => sessionManager.getArtifactManager(),
82
+ getAgentId: () => MAIN_AGENT_ID,
83
+ getSessionSpawns: () => "sonic",
84
+ getModelString: () => modelSelector,
85
+ getActiveModelString: () => modelSelector,
86
+ getActiveModel: () => resolved.model,
87
+ sessionManager,
88
+ settings,
89
+ authStorage,
90
+ modelRegistry,
91
+ };
92
+ let closed = false;
93
+
94
+ return {
95
+ model: modelDisplay,
96
+ sessionFile,
97
+ async dispatch(
98
+ assignments: CleanseAssignment[],
99
+ wave: number,
100
+ report: CleanseDiagnosticReport,
101
+ signal?: AbortSignal,
102
+ ): Promise<CleanseAgentOutcome[]> {
103
+ sessionManager.appendCustomEntry("cleanse_wave", {
104
+ wave,
105
+ assignments: assignments.map(assignment => ({
106
+ weight: assignment.weight,
107
+ files: assignment.groups.map(group => group.file ?? "<project>"),
108
+ })),
109
+ });
110
+ const settled = await mapWithConcurrencyLimitAllSettled(
111
+ assignments,
112
+ assignments.length,
113
+ async (assignment, index, workerSignal) => {
114
+ const name = `CleanseW${wave}A${index + 1}`;
115
+ options.hooks?.onStart?.(name, assignment);
116
+ const result = await runStructuredSubagent({
117
+ session: toolSession,
118
+ invocationKind: "task",
119
+ assignment: renderAssignment(assignment, assignments, wave, index + 1, report.checks),
120
+ agent: "sonic",
121
+ model: modelSelector,
122
+ identity: { label: name },
123
+ index,
124
+ enableLsp: true,
125
+ enableIrc: true,
126
+ signal: workerSignal,
127
+ });
128
+ const outcome: CleanseAgentOutcome = {
129
+ name,
130
+ success: result.result.exitCode === 0 && !result.result.error && result.result.aborted !== true,
131
+ output: result.result.output,
132
+ error: result.result.error ?? (result.result.stderr || undefined),
133
+ resolvedModel: result.result.resolvedModel,
134
+ };
135
+ options.hooks?.onFinish?.(outcome, assignment);
136
+ return outcome;
137
+ },
138
+ signal,
139
+ );
140
+ const outcomes: CleanseAgentOutcome[] = [];
141
+ for (let index = 0; index < settled.results.length; index += 1) {
142
+ const result = settled.results[index];
143
+ if (!result) {
144
+ outcomes.push({ name: `CleanseW${wave}A${index + 1}`, success: false, output: "", error: "Cancelled" });
145
+ } else if (result.status === "fulfilled") {
146
+ outcomes.push(result.value);
147
+ } else {
148
+ const error = result.reason instanceof Error ? result.reason.message : String(result.reason);
149
+ const outcome = { name: `CleanseW${wave}A${index + 1}`, success: false, output: "", error };
150
+ options.hooks?.onFinish?.(outcome, assignments[index]);
151
+ outcomes.push(outcome);
152
+ }
153
+ }
154
+ return outcomes;
155
+ },
156
+ async close(result?: CleanseLoopResult): Promise<void> {
157
+ if (closed) return;
158
+ closed = true;
159
+ sessionManager.appendCustomEntry("cleanse", {
160
+ status: result?.status ?? "interrupted",
161
+ waves: result?.waves ?? 0,
162
+ remaining: result?.report.diagnostics.length,
163
+ });
164
+ await sessionManager.close();
165
+ },
166
+ };
167
+ }
168
+
169
+ function renderAssignment(
170
+ assignment: CleanseAssignment,
171
+ allAssignments: readonly CleanseAssignment[],
172
+ wave: number,
173
+ worker: number,
174
+ checks: readonly CleanseCheckResult[],
175
+ ): string {
176
+ const hasProjectIssues = assignment.groups.some(group => group.file === undefined);
177
+ const files = assignment.groups.flatMap(group => (group.file ? [group.file] : []));
178
+ const writeScope = [
179
+ ...(files.length > 0 ? files.map(file => `- ${file}`) : ["- No file is named by the project-level diagnostic."]),
180
+ ...(hasProjectIssues ? ["- Minimal additional files strictly required by project-level diagnostics."] : []),
181
+ ].join("\n");
182
+ return prompt.render(assignmentPrompt, {
183
+ wave,
184
+ worker,
185
+ write_scope: writeScope,
186
+ diagnostics: formatDiagnostics(assignment.groups.flatMap(group => group.diagnostics)),
187
+ checker_commands: formatCheckerCommands(checks),
188
+ peer_assignments: formatPeerAssignments(assignment, allAssignments),
189
+ });
190
+ }
191
+
192
+ function formatDiagnostics(diagnostics: readonly CleanseDiagnostic[]): string {
193
+ return diagnostics
194
+ .map(diagnostic => {
195
+ const location = diagnostic.file
196
+ ? `${diagnostic.file}${diagnostic.line ? `:${diagnostic.line}${diagnostic.column ? `:${diagnostic.column}` : ""}` : ""}`
197
+ : "<project>";
198
+ const code = diagnostic.code ? ` ${diagnostic.code}` : "";
199
+ const message = diagnostic.message.slice(0, MAX_DIAGNOSTIC_MESSAGE);
200
+ const suggestion = diagnostic.suggestion ? `\n Suggested fix: ${diagnostic.suggestion}` : "";
201
+ return `- [${diagnostic.severity}] ${location} — ${diagnostic.checker}${code}: ${message}${suggestion}`;
202
+ })
203
+ .join("\n");
204
+ }
205
+
206
+ function formatCheckerCommands(checks: readonly CleanseCheckResult[]): string {
207
+ const seen = new Set<string>();
208
+ const lines: string[] = [];
209
+ for (const check of checks) {
210
+ const key = `${check.cwd}\u0000${check.command}`;
211
+ if (seen.has(key)) continue;
212
+ seen.add(key);
213
+ lines.push(`- [${check.cwd}] ${check.command}`);
214
+ }
215
+ return lines.join("\n") || "- No command metadata available.";
216
+ }
217
+
218
+ function formatPeerAssignments(current: CleanseAssignment, assignments: readonly CleanseAssignment[]): string {
219
+ const lines: string[] = [];
220
+ for (const assignment of assignments) {
221
+ if (assignment.index === current.index) continue;
222
+ const files = assignment.groups.map(group => group.file ?? "<project-level>").join(", ");
223
+ lines.push(`- Worker ${assignment.index + 1}: ${files}`);
224
+ }
225
+ return lines.join("\n") || "- None.";
226
+ }
@@ -0,0 +1,79 @@
1
+ import type { CleanseAssignment, CleanseDiagnostic, CleanseFileIssues, CleanseSeverity } from "./types";
2
+
3
+ const SEVERITY_WEIGHT: Record<CleanseSeverity, number> = {
4
+ error: 5,
5
+ warning: 3,
6
+ info: 1,
7
+ };
8
+
9
+ /** Estimate repair burden from severity and available location/fix evidence. */
10
+ export function diagnosticWeight(diagnostic: CleanseDiagnostic): number {
11
+ let weight = SEVERITY_WEIGHT[diagnostic.severity];
12
+ if (diagnostic.line === undefined) weight += 2;
13
+ if (!diagnostic.code) weight += 1;
14
+ if (!diagnostic.suggestion) weight += 1;
15
+ return weight;
16
+ }
17
+
18
+ /** Group diagnostics by file while keeping project-level failures together. */
19
+ export function groupDiagnosticsByFile(diagnostics: readonly CleanseDiagnostic[]): CleanseFileIssues[] {
20
+ const groups = new Map<string, CleanseDiagnostic[]>();
21
+ for (const diagnostic of diagnostics) {
22
+ const key = diagnostic.file ?? "";
23
+ const existing = groups.get(key);
24
+ if (existing) existing.push(diagnostic);
25
+ else groups.set(key, [diagnostic]);
26
+ }
27
+ return [...groups.entries()]
28
+ .map(([file, entries]) => ({
29
+ file: file || undefined,
30
+ diagnostics: entries.sort(compareDiagnostics),
31
+ weight: entries.reduce((sum, diagnostic) => sum + diagnosticWeight(diagnostic), 0),
32
+ }))
33
+ .sort((left, right) => right.weight - left.weight || (left.file ?? "").localeCompare(right.file ?? ""));
34
+ }
35
+
36
+ /** Balance whole-file workloads with longest-processing-time bin packing. */
37
+ export function balanceDiagnostics(diagnostics: readonly CleanseDiagnostic[], maxAgents: number): CleanseAssignment[] {
38
+ if (!Number.isInteger(maxAgents) || maxAgents <= 0) {
39
+ throw new Error("maxAgents must be a positive integer");
40
+ }
41
+ const groups = groupDiagnosticsByFile(diagnostics);
42
+ if (groups.length === 0) return [];
43
+ const count = Math.min(maxAgents, groups.length);
44
+ const assignments: CleanseAssignment[] = Array.from({ length: count }, (_, index) => ({
45
+ index,
46
+ groups: [],
47
+ weight: 0,
48
+ }));
49
+ for (const group of groups) {
50
+ let lightest = assignments[0];
51
+ for (let index = 1; index < assignments.length; index += 1) {
52
+ const candidate = assignments[index];
53
+ if (
54
+ candidate.weight < lightest.weight ||
55
+ (candidate.weight === lightest.weight && candidate.groups.length < lightest.groups.length) ||
56
+ (candidate.weight === lightest.weight &&
57
+ candidate.groups.length === lightest.groups.length &&
58
+ candidate.index < lightest.index)
59
+ ) {
60
+ lightest = candidate;
61
+ }
62
+ }
63
+ lightest.groups.push(group);
64
+ lightest.weight += group.weight;
65
+ }
66
+ for (const assignment of assignments) {
67
+ assignment.groups.sort((left, right) => (left.file ?? "").localeCompare(right.file ?? ""));
68
+ }
69
+ return assignments;
70
+ }
71
+
72
+ function compareDiagnostics(left: CleanseDiagnostic, right: CleanseDiagnostic): number {
73
+ return (
74
+ (left.line ?? 0) - (right.line ?? 0) ||
75
+ (left.column ?? 0) - (right.column ?? 0) ||
76
+ left.checker.localeCompare(right.checker) ||
77
+ left.message.localeCompare(right.message)
78
+ );
79
+ }