@oh-my-pi/pi-coding-agent 17.1.6 → 17.1.8

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 (130) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/{CHANGELOG-x9zt79k8.md → CHANGELOG-vt8ene9g.md} +58 -0
  3. package/dist/cli.js +4309 -7287
  4. package/dist/template-dys3vk5b.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 +0 -9
  10. package/dist/types/async/job-manager.d.ts +11 -0
  11. package/dist/types/cleanse/agent.d.ts +19 -0
  12. package/dist/types/cleanse/balance.d.ts +7 -0
  13. package/dist/types/cleanse/checkers.d.ts +13 -0
  14. package/dist/types/cleanse/index.d.ts +16 -0
  15. package/dist/types/cleanse/loop.d.ts +16 -0
  16. package/dist/types/cleanse/parsers.d.ts +13 -0
  17. package/dist/types/cleanse/progress.d.ts +14 -0
  18. package/dist/types/cleanse/types.d.ts +64 -0
  19. package/dist/types/commands/cleanse.d.ts +23 -0
  20. package/dist/types/config/settings-schema.d.ts +16 -1
  21. package/dist/types/cursor.d.ts +2 -1
  22. package/dist/types/export/html/index.d.ts +2 -0
  23. package/dist/types/extensibility/extensions/runner.d.ts +4 -0
  24. package/dist/types/extensibility/legacy-pi-ai-shim.d.ts +27 -1
  25. package/dist/types/extensibility/shared-events.d.ts +18 -1
  26. package/dist/types/modes/components/custom-editor.d.ts +5 -0
  27. package/dist/types/modes/interactive-mode.d.ts +4 -3
  28. package/dist/types/session/agent-session-types.d.ts +5 -5
  29. package/dist/types/session/agent-session.d.ts +26 -1
  30. package/dist/types/session/async-job-delivery.d.ts +8 -0
  31. package/dist/types/session/model-controls.d.ts +4 -4
  32. package/dist/types/session/session-tools.d.ts +44 -6
  33. package/dist/types/session/streaming-output.d.ts +7 -2
  34. package/dist/types/session/turn-recovery.d.ts +1 -1
  35. package/dist/types/task/isolation-ownership.d.ts +34 -0
  36. package/dist/types/tools/browser/cmux/cmux-tab.d.ts +1 -2
  37. package/dist/types/tools/browser/tab-worker.d.ts +1 -4
  38. package/dist/types/tools/index.d.ts +8 -3
  39. package/dist/types/tools/output-meta.d.ts +5 -0
  40. package/dist/types/tools/read.d.ts +9 -1
  41. package/dist/types/tools/xdev.d.ts +53 -67
  42. package/dist/types/tui/output-block.d.ts +5 -5
  43. package/dist/types/utils/cpuprofile.d.ts +51 -0
  44. package/dist/types/utils/inspect-image-mode.d.ts +29 -0
  45. package/dist/types/utils/profile-tree.d.ts +47 -0
  46. package/dist/types/utils/sample-profile.d.ts +67 -0
  47. package/package.json +16 -12
  48. package/scripts/bundle-dist.ts +3 -1
  49. package/src/advisor/advise-tool.ts +0 -11
  50. package/src/advisor/runtime.ts +0 -12
  51. package/src/async/job-manager.ts +30 -7
  52. package/src/cleanse/agent.ts +226 -0
  53. package/src/cleanse/balance.ts +79 -0
  54. package/src/cleanse/checkers.ts +996 -0
  55. package/src/cleanse/index.ts +190 -0
  56. package/src/cleanse/loop.ts +51 -0
  57. package/src/cleanse/parsers.ts +726 -0
  58. package/src/cleanse/progress.ts +50 -0
  59. package/src/cleanse/prompts/assignment.md +47 -0
  60. package/src/cleanse/types.ts +72 -0
  61. package/src/cli/update-cli.ts +3 -0
  62. package/src/cli/usage-cli.ts +29 -4
  63. package/src/cli/worktree-cli.ts +28 -11
  64. package/src/cli-commands.ts +1 -0
  65. package/src/cli.ts +2 -1
  66. package/src/commands/cleanse.ts +45 -0
  67. package/src/config/settings-schema.ts +15 -1
  68. package/src/config/settings.ts +35 -0
  69. package/src/cursor.ts +4 -3
  70. package/src/discovery/builtin-rules/index.ts +2 -0
  71. package/src/discovery/builtin-rules/ts-no-local-is-record.md +48 -0
  72. package/src/discovery/claude-plugins.ts +144 -34
  73. package/src/export/html/index.ts +17 -10
  74. package/src/extensibility/extensions/runner.ts +26 -0
  75. package/src/extensibility/extensions/wrapper.ts +74 -42
  76. package/src/extensibility/hooks/tool-wrapper.ts +11 -4
  77. package/src/extensibility/legacy-pi-ai-shim.ts +46 -1
  78. package/src/extensibility/shared-events.ts +18 -1
  79. package/src/launch/broker.ts +14 -4
  80. package/src/modes/acp/acp-agent.ts +12 -9
  81. package/src/modes/acp/acp-event-mapper.ts +38 -4
  82. package/src/modes/components/custom-editor.ts +39 -16
  83. package/src/modes/components/settings-selector.ts +9 -1
  84. package/src/modes/components/tips.txt +2 -1
  85. package/src/modes/components/tool-execution.ts +8 -7
  86. package/src/modes/controllers/extension-ui-controller.ts +4 -4
  87. package/src/modes/controllers/selector-controller.ts +7 -2
  88. package/src/modes/interactive-mode.ts +36 -47
  89. package/src/modes/prompt-action-autocomplete.ts +5 -3
  90. package/src/prompts/goals/guided-goal-interview.md +41 -6
  91. package/src/prompts/system/vibe-mode-active.md +4 -1
  92. package/src/prompts/tools/browser.md +1 -0
  93. package/src/prompts/tools/goal.md +1 -1
  94. package/src/sdk.ts +47 -50
  95. package/src/session/agent-session-types.ts +5 -5
  96. package/src/session/agent-session.ts +151 -11
  97. package/src/session/async-job-delivery.ts +8 -0
  98. package/src/session/model-controls.ts +9 -9
  99. package/src/session/session-advisors.ts +2 -7
  100. package/src/session/session-history-format.ts +31 -6
  101. package/src/session/session-listing.ts +66 -4
  102. package/src/session/session-tools.ts +158 -52
  103. package/src/session/streaming-output.ts +18 -6
  104. package/src/session/turn-recovery.ts +4 -4
  105. package/src/slash-commands/builtin-registry.ts +74 -2
  106. package/src/task/isolation-ownership.ts +106 -0
  107. package/src/task/worktree.ts +8 -0
  108. package/src/tools/ask.ts +3 -3
  109. package/src/tools/ast-edit.ts +9 -2
  110. package/src/tools/bash.ts +16 -9
  111. package/src/tools/browser/cmux/cmux-tab.ts +9 -14
  112. package/src/tools/browser/tab-worker.ts +12 -35
  113. package/src/tools/browser.ts +2 -2
  114. package/src/tools/gh-renderer.ts +3 -3
  115. package/src/tools/index.ts +46 -17
  116. package/src/tools/output-meta.ts +20 -0
  117. package/src/tools/read.ts +87 -13
  118. package/src/tools/write.ts +51 -7
  119. package/src/tools/xdev.ts +198 -210
  120. package/src/tui/code-cell.ts +4 -4
  121. package/src/tui/output-block.ts +25 -8
  122. package/src/utils/cpuprofile.ts +235 -0
  123. package/src/utils/inspect-image-mode.ts +39 -0
  124. package/src/utils/profile-tree.ts +111 -0
  125. package/src/utils/sample-profile.ts +437 -0
  126. package/src/utils/shell-snapshot-fn-env.sh +5 -2
  127. package/src/web/search/render.ts +2 -2
  128. package/dist/types/goals/guided-setup.d.ts +0 -30
  129. package/src/goals/guided-setup.ts +0 -171
  130. package/src/prompts/goals/guided-goal-system.md +0 -33
@@ -0,0 +1,50 @@
1
+ const BAR_WIDTH = 16;
2
+
3
+ /** Minimal output contract used by the interactive cleanse progress reporter. */
4
+ export interface CleanseProgressOutput {
5
+ isTTY?: boolean;
6
+ write(text: string): boolean;
7
+ }
8
+
9
+ /** Renders completed repair workers on one transient terminal line. */
10
+ export interface CleanseProgressReporter {
11
+ readonly interactive: boolean;
12
+ start(total: number): void;
13
+ complete(): void;
14
+ finish(): void;
15
+ }
16
+
17
+ /** Create the TTY-only worker completion reporter used by `omp cleanse`. */
18
+ export function createCleanseProgressReporter(output: CleanseProgressOutput = process.stdout): CleanseProgressReporter {
19
+ const interactive = output.isTTY === true;
20
+ let total = 0;
21
+ let completed = 0;
22
+ let rendered = false;
23
+
24
+ const render = (): void => {
25
+ if (!interactive || total === 0) return;
26
+ const ratio = Math.min(completed / total, 1);
27
+ const filled = Math.round(ratio * BAR_WIDTH);
28
+ const bar = `${"█".repeat(filled)}${"░".repeat(BAR_WIDTH - filled)}`;
29
+ output.write(`\rRepairing [${bar}] ${completed}/${total}\x1b[K`);
30
+ rendered = true;
31
+ };
32
+
33
+ return {
34
+ interactive,
35
+ start(nextTotal) {
36
+ total = Math.max(nextTotal, 0);
37
+ completed = 0;
38
+ render();
39
+ },
40
+ complete() {
41
+ completed = Math.min(completed + 1, total);
42
+ render();
43
+ },
44
+ finish() {
45
+ if (!rendered) return;
46
+ output.write("\n");
47
+ rendered = false;
48
+ },
49
+ };
50
+ }
@@ -0,0 +1,47 @@
1
+ <critical>
2
+ - You MUST fix every assigned diagnostic at its root cause.
3
+ - You MUST stay inside the write scope below.
4
+ - You NEVER suppress valid diagnostics to make checks pass.
5
+ - You NEVER delegate or spawn another agent.
6
+ - You NEVER run project-wide checks or formatters; the orchestrator reruns them.
7
+ </critical>
8
+
9
+ # Assignment
10
+
11
+ Repair wave {{wave}}, worker {{worker}}.
12
+
13
+ ## Write scope
14
+
15
+ {{write_scope}}
16
+
17
+ Read related code freely. Project-level diagnostics MAY require the smallest necessary edit outside named files; use `hub` before touching a peer-owned file.
18
+
19
+ ## Diagnostics
20
+
21
+ {{diagnostics}}
22
+
23
+ ## Checker commands
24
+
25
+ {{checker_commands}}
26
+
27
+ ## Concurrent peer ownership
28
+
29
+ {{peer_assignments}}
30
+
31
+ <workflow>
32
+ 1. Inspect every assigned location and its local context.
33
+ 2. Fix causes, not emitted text or checker configuration.
34
+ 3. Preserve behavior unless a diagnostic proves it wrong.
35
+ 4. Run only targeted checks for assigned files when available.
36
+ 5. Re-read changed sections and resolve every assigned item.
37
+ </workflow>
38
+
39
+ <completeness>
40
+ - Done means every assigned diagnostic has a concrete fix.
41
+ - Blocked items MUST name the missing prerequisite in the final result.
42
+ - Final output MUST list changed files and unresolved items.
43
+ </completeness>
44
+
45
+ <critical>
46
+ You MUST leave peer-owned files untouched and complete the full assignment.
47
+ </critical>
@@ -0,0 +1,72 @@
1
+ /** Severity normalized across checker-specific output formats. */
2
+ export type CleanseSeverity = "error" | "warning" | "info";
3
+
4
+ /** One actionable problem reported by a project checker. */
5
+ export interface CleanseDiagnostic {
6
+ checker: string;
7
+ file?: string;
8
+ line?: number;
9
+ column?: number;
10
+ endLine?: number;
11
+ endColumn?: number;
12
+ code?: string;
13
+ severity: CleanseSeverity;
14
+ message: string;
15
+ suggestion?: string;
16
+ }
17
+
18
+ /** Result metadata for one checker invocation. */
19
+ export interface CleanseCheckResult {
20
+ id: string;
21
+ label: string;
22
+ language: string;
23
+ cwd: string;
24
+ command: string;
25
+ exitCode: number | null;
26
+ diagnostics: CleanseDiagnostic[];
27
+ }
28
+
29
+ /** Checker discovery result omitted because its required executable was unavailable. */
30
+ export interface SkippedCleanseCheck {
31
+ label: string;
32
+ language: string;
33
+ reason: string;
34
+ }
35
+
36
+ /** Aggregate diagnostics from every checker selected for a project. */
37
+ export interface CleanseDiagnosticReport {
38
+ checks: CleanseCheckResult[];
39
+ diagnostics: CleanseDiagnostic[];
40
+ skipped: SkippedCleanseCheck[];
41
+ }
42
+
43
+ /** All diagnostics attached to one file, which scheduling never splits across agents. */
44
+ export interface CleanseFileIssues {
45
+ file?: string;
46
+ diagnostics: CleanseDiagnostic[];
47
+ weight: number;
48
+ }
49
+
50
+ /** One weighted, file-disjoint workload handed to a subagent. */
51
+ export interface CleanseAssignment {
52
+ index: number;
53
+ groups: CleanseFileIssues[];
54
+ weight: number;
55
+ }
56
+
57
+ /** Settled result from one cleanse subagent. */
58
+ export interface CleanseAgentOutcome {
59
+ name: string;
60
+ success: boolean;
61
+ output: string;
62
+ error?: string;
63
+ resolvedModel?: string;
64
+ }
65
+
66
+ /** Final state after one bounded repair wave and verification pass. */
67
+ export interface CleanseLoopResult {
68
+ status: "clean" | "stalled" | "cancelled";
69
+ waves: number;
70
+ report: CleanseDiagnosticReport;
71
+ outcomes: CleanseAgentOutcome[];
72
+ }
@@ -231,6 +231,9 @@ export async function downloadVerifiedBinary(options: VerifiedBinaryDownloadOpti
231
231
  await fs.promises.chmod(options.targetPath, 0o755);
232
232
  } catch (err) {
233
233
  await unlinkIfExists(options.targetPath);
234
+ if (isTimeoutError(err)) {
235
+ throw new Error("Timed out downloading release binary after 15 minutes", { cause: err });
236
+ }
234
237
  throw err;
235
238
  }
236
239
  }
@@ -560,9 +560,34 @@ function formatReloginDeadline(
560
560
  * automatically (refresh failure, upstream invalidation). Rows the user
561
561
  * replaced or deleted deliberately are lifecycle noise, not lost capacity.
562
562
  */
563
- function isActionableDisable(summary: DisabledCredentialSummary): boolean {
563
+ function isActionableDisable(summary: DisabledCredentialSummary, activeAccounts: UsageAccountIdentity[] = []): boolean {
564
564
  if (summary.type !== "oauth") return false;
565
- return !/^(replaced by|deleted by user)/i.test(summary.cause);
565
+ if (/^(replaced by|deleted by user)/i.test(summary.cause)) return false;
566
+
567
+ // Do not display tombstone if there is an active account for the same provider
568
+ // matching the same identity (email, accountId, or org).
569
+ const summaryEmail = summary.email?.toLowerCase();
570
+ const summaryAccountId = summary.accountId?.toLowerCase();
571
+ const summaryOrgId = summary.orgId?.toLowerCase();
572
+
573
+ const matchesActive = activeAccounts.some(account => {
574
+ if (account.provider !== summary.provider) return false;
575
+
576
+ const accountEmail = account.email?.toLowerCase();
577
+ const accountAccountId = account.accountId?.toLowerCase();
578
+ const accountOrgId = account.orgId?.toLowerCase();
579
+
580
+ // If email or accountId match, it's the same identity
581
+ if (summaryEmail && accountEmail && summaryEmail === accountEmail) return true;
582
+ if (summaryAccountId && accountAccountId && summaryAccountId === accountAccountId) return true;
583
+
584
+ // Fallback: if orgId matches and neither email nor accountId contradicts
585
+ if (summaryOrgId && accountOrgId && summaryOrgId === accountOrgId) return true;
586
+
587
+ return false;
588
+ });
589
+
590
+ return !matchesActive;
566
591
  }
567
592
 
568
593
  /** Human-sized disable cause: the upstream `error_description` when embedded, else the first clause. */
@@ -610,7 +635,7 @@ export function formatUsageBreakdown(
610
635
  }
611
636
  const disabledByProvider = new Map<string, DisabledCredentialSummary[]>();
612
637
  for (const summary of disabled) {
613
- if (!isActionableDisable(summary)) continue;
638
+ if (!isActionableDisable(summary, accounts)) continue;
614
639
  const list = disabledByProvider.get(summary.provider) ?? [];
615
640
  list.push(summary);
616
641
  disabledByProvider.set(summary.provider, list);
@@ -1018,7 +1043,7 @@ export async function runUsageCommand(cmd: UsageCommandArgs): Promise<void> {
1018
1043
  const stats = computeProviderWindowStats(filteredReports.filter(peer => peer.provider === report.provider));
1019
1044
  if (stats.length > 0) capacity[report.provider] = stats;
1020
1045
  }
1021
- let disabledForJson = disabled.filter(isActionableDisable);
1046
+ let disabledForJson = disabled.filter(summary => isActionableDisable(summary, accounts));
1022
1047
  if (redaction) {
1023
1048
  disabledForJson = disabledForJson.map(summary => ({
1024
1049
  ...summary,
@@ -8,8 +8,10 @@
8
8
  * `<parent-repo>/.git/worktrees/<name>/`.
9
9
  * - **Task-isolation dirs** (`task/worktree.ts`): a wrapper dir with a
10
10
  * compact `m` subdir mounted/cloned by `natives.isoStart`. Legacy `merged`
11
- * subdirs are still recognized. These are ephemeral; `ensureIsolation`
12
- * removes the base before re-creating it, so leftovers are crashed runs.
11
+ * subdirs are still recognized. `ensureIsolation` writes an ownership
12
+ * marker naming the live omp process; a
13
+ * sandbox whose owner is still running is reported `live` and never
14
+ * removed without `--all`, so `clear` reclaims only crashed leftovers.
13
15
  *
14
16
  * Legacy entries from before the encoding change keep working because git still
15
17
  * tracks them by branch name. This command exists to GC them on demand.
@@ -18,6 +20,7 @@ import * as fs from "node:fs/promises";
18
20
  import * as path from "node:path";
19
21
  import { getWorktreesDir, isEnoent } from "@oh-my-pi/pi-utils";
20
22
  import chalk from "chalk";
23
+ import { hasLiveIsolationOwner, ISOLATION_OWNER_FILE } from "../task/isolation-ownership";
21
24
  import * as git from "../utils/git";
22
25
 
23
26
  type WorktreeKind = "pr-checkout" | "task-isolation" | "empty" | "stray";
@@ -211,16 +214,30 @@ async function classifyDir(dir: string): Promise<WorktreeEntry | null> {
211
214
  if (gitStat?.isFile()) {
212
215
  return classifyPrCheckout(dir, gitEntry);
213
216
  }
214
- for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
215
- const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
216
- if (!mountStat?.isDirectory()) continue;
217
- return {
218
- path: dir,
219
- kind: "task-isolation",
220
- orphanReason: "task-isolation leftover (no live task owns it)",
221
- };
217
+ // A task-isolation sandbox is identified by its ownership marker — written
218
+ // before the backend materialises the mount — or by the `m`/`merged` mount
219
+ // dir itself (legacy dirs and crashed pre-marker runs). Recognizing the
220
+ // marker alone keeps an in-progress sandbox from being mistaken for a stray
221
+ // during the window between marker creation and mount materialisation.
222
+ let isIsolation = await Bun.file(path.join(dir, ISOLATION_OWNER_FILE)).exists();
223
+ if (!isIsolation) {
224
+ for (const mountDir of TASK_ISOLATION_MOUNT_DIRS) {
225
+ const mountStat = await fs.stat(path.join(dir, mountDir)).catch(() => null);
226
+ if (mountStat?.isDirectory()) {
227
+ isIsolation = true;
228
+ break;
229
+ }
230
+ }
222
231
  }
223
- return null;
232
+ if (!isIsolation) return null;
233
+ const live = await hasLiveIsolationOwner(dir);
234
+ return {
235
+ path: dir,
236
+ kind: "task-isolation",
237
+ // Only after confirming no live owner is the "no live task" claim true.
238
+ // A running subagent's sandbox stays live so `clear` won't delete it.
239
+ orphanReason: live ? undefined : "task-isolation leftover (no live task owns it)",
240
+ };
224
241
  }
225
242
 
226
243
  async function classifyPrCheckout(dir: string, gitEntry: string): Promise<WorktreeEntry> {
@@ -18,6 +18,7 @@ export const commands: CommandEntry[] = [
18
18
  { name: "auth-gateway", load: () => import("./commands/auth-gateway").then(m => m.default) },
19
19
  { name: "agents", load: () => import("./commands/agents").then(m => m.default) },
20
20
  { name: "bench", load: () => import("./commands/bench").then(m => m.default) },
21
+ { name: "cleanse", load: () => import("./commands/cleanse").then(m => m.default) },
21
22
  { name: "commit", load: () => import("./commands/commit").then(m => m.default) },
22
23
  { name: "completions", load: () => import("./commands/completions").then(m => m.default) },
23
24
  { name: "__complete", load: () => import("./commands/complete").then(m => m.default) },
package/src/cli.ts CHANGED
@@ -25,6 +25,7 @@ import {
25
25
  VERSION,
26
26
  } from "@oh-my-pi/pi-utils/dirs";
27
27
  import { interceptUnhandledRejections } from "@oh-my-pi/pi-utils/postmortem";
28
+ import { setProcessName } from "@oh-my-pi/pi-utils/process-name";
28
29
  import { declareWorkerHostEntry, installWorkerInbox, isWorkerHostSelector } from "@oh-my-pi/pi-utils/worker-host";
29
30
  import { installProfileAlias, resolveProfileAliasCommandFromProcess } from "./cli/profile-alias";
30
31
  import { extractProfileFlags } from "./cli/profile-bootstrap";
@@ -42,7 +43,7 @@ if (Bun.semver.order(Bun.version, MIN_BUN_VERSION) < 0) {
42
43
  process.exit(1);
43
44
  }
44
45
 
45
- process.title = APP_NAME;
46
+ setProcessName(APP_NAME);
46
47
 
47
48
  // `Bun.build`-API compiled Windows executables report `import.meta.main ===
48
49
  // false`: the standalone loader keys the entry module with native backslashes
@@ -0,0 +1,45 @@
1
+ import { postmortem } from "@oh-my-pi/pi-utils";
2
+ import { Command, Flags } from "@oh-my-pi/pi-utils/cli";
3
+ import { runCleanseCommand } from "../cleanse";
4
+ import { CliUsageError } from "../cli/usage-error";
5
+
6
+ export default class Cleanse extends Command {
7
+ static description = "Detect and fix project diagnostics with weighted parallel subagents";
8
+
9
+ static flags = {
10
+ agents: Flags.integer({
11
+ char: "n",
12
+ description: "Maximum number of file-disjoint subagents",
13
+ default: 8,
14
+ }),
15
+ model: Flags.string({
16
+ char: "m",
17
+ description: "Subagent model selector",
18
+ default: "@smol",
19
+ }),
20
+ tests: Flags.boolean({
21
+ char: "t",
22
+ description: "Also run configured project test suites",
23
+ default: false,
24
+ }),
25
+ };
26
+
27
+ static examples = [
28
+ "omp cleanse",
29
+ "omp cleanse -n 4",
30
+ "omp cleanse -m opus",
31
+ "omp cleanse -t",
32
+ "omp cleanse --agents 12 --model anthropic/claude-opus-4-6",
33
+ ];
34
+
35
+ async run(): Promise<void> {
36
+ const { flags } = await this.parse(Cleanse);
37
+ if (flags.agents <= 0) throw new CliUsageError("--agents must be a positive integer");
38
+ const result = await runCleanseCommand({
39
+ maxAgents: flags.agents,
40
+ model: flags.model,
41
+ includeTests: flags.tests,
42
+ });
43
+ await postmortem.quit(result.exitCode);
44
+ }
45
+ }
@@ -3835,14 +3835,28 @@ export const SETTINGS_SCHEMA = {
3835
3835
  },
3836
3836
  },
3837
3837
 
3838
+ // Legacy boolean kept only for back-compat migration to `inspect_image.mode`
3839
+ // (see config/settings.ts). Hidden from UI.
3838
3840
  "inspect_image.enabled": {
3839
3841
  type: "boolean",
3840
3842
  default: false,
3843
+ },
3844
+
3845
+ "inspect_image.mode": {
3846
+ type: "enum",
3847
+ values: ["auto", "on", "off"] as const,
3848
+ default: "auto",
3841
3849
  ui: {
3842
3850
  tab: "tools",
3843
3851
  group: "Available Tools",
3844
3852
  label: "Inspect Image",
3845
- description: "Enable the inspect_image tool, delegating image understanding to a vision-capable model",
3853
+ description:
3854
+ "Controls the inspect_image tool, which delegates image understanding to a vision-capable model. 'auto' exposes it only when the active model lacks native image input; 'on' always exposes it; 'off' never does.",
3855
+ options: [
3856
+ { value: "auto", label: "Auto (only for models without vision)" },
3857
+ { value: "on", label: "On" },
3858
+ { value: "off", label: "Off" },
3859
+ ],
3846
3860
  },
3847
3861
  },
3848
3862
 
@@ -39,6 +39,7 @@ import { isLightTheme, setAutoThemeMapping, setColorBlindMode, setSymbolPreset }
39
39
  import { AgentStorage } from "../session/agent-storage";
40
40
  import { AUTO_IMAGE_PROVIDER_ORDER, isImageProviderId } from "../tools/image-providers";
41
41
  import { type EditMode, normalizeEditMode } from "../utils/edit-mode";
42
+ import { INSPECT_IMAGE_MODES } from "../utils/inspect-image-mode";
42
43
  import { isSearchProviderId, SEARCH_PROVIDER_ORDER } from "../web/search/types";
43
44
  import { withFileLock } from "./file-lock";
44
45
  import {
@@ -1366,6 +1367,40 @@ export class Settings {
1366
1367
  }
1367
1368
  }
1368
1369
 
1370
+ // inspect_image.enabled (boolean) -> inspect_image.mode (enum). Explicit
1371
+ // user choices are preserved: true -> "on", false -> "off". Configs with
1372
+ // no legacy key get the new "auto" default, which hides the tool for
1373
+ // models with native image input. Handles nested and quoted-dotted
1374
+ // ("inspect_image.enabled") sources; the target is always the nested
1375
+ // form, which is the only shape the resolver reads.
1376
+ const inspectImageObj = isRecord(raw.inspect_image) ? (raw.inspect_image as Record<string, unknown>) : undefined;
1377
+ const legacyEnabled =
1378
+ typeof inspectImageObj?.enabled === "boolean"
1379
+ ? inspectImageObj.enabled
1380
+ : typeof raw["inspect_image.enabled"] === "boolean"
1381
+ ? (raw["inspect_image.enabled"] as boolean)
1382
+ : undefined;
1383
+ if (legacyEnabled !== undefined) {
1384
+ if (!inspectImageObj) {
1385
+ raw.inspect_image = {};
1386
+ }
1387
+ const target = raw.inspect_image as Record<string, unknown>;
1388
+ const flatMode = raw["inspect_image.mode"];
1389
+ if (target.mode === undefined) {
1390
+ // A quoted-dotted explicit mode wins over the legacy boolean but
1391
+ // must be normalized into the nested form the resolver reads.
1392
+ target.mode =
1393
+ typeof flatMode === "string" && (INSPECT_IMAGE_MODES as readonly string[]).includes(flatMode)
1394
+ ? flatMode
1395
+ : legacyEnabled
1396
+ ? "on"
1397
+ : "off";
1398
+ }
1399
+ delete target.enabled;
1400
+ delete raw["inspect_image.enabled"];
1401
+ delete raw["inspect_image.mode"];
1402
+ }
1403
+
1369
1404
  // task.isolation.enabled (boolean) -> task.isolation.mode (enum)
1370
1405
  const taskObj = raw.task as Record<string, unknown> | undefined;
1371
1406
  const isolationObj = taskObj?.isolation as Record<string, unknown> | undefined;
package/src/cursor.ts CHANGED
@@ -25,7 +25,8 @@ interface CursorExecBridgeOptions {
25
25
  cwd: string;
26
26
  getCwd?: () => string;
27
27
  tools: Map<string, AgentTool>;
28
- getTool?: (name: string) => AgentTool | undefined;
28
+ /** Resolves execution overrides (mounted-device permission wrappers) before the canonical map. */
29
+ getExecutableTool?: (name: string) => AgentTool | undefined;
29
30
  getToolContext?: () => AgentToolContext | undefined;
30
31
  emitEvent?: (event: AgentEvent) => void;
31
32
  /**
@@ -81,7 +82,7 @@ async function executeTool(
81
82
  toolCallId: string,
82
83
  args: Record<string, unknown>,
83
84
  ): Promise<ToolResultMessage> {
84
- const tool = options.tools.get(toolName) ?? options.getTool?.(toolName);
85
+ const tool = options.getExecutableTool?.(toolName) ?? options.tools.get(toolName);
85
86
  if (!tool) {
86
87
  const result = buildToolErrorResult(`Tool "${toolName}" not available`);
87
88
  return createToolResultMessage(toolCallId, toolName, result, true);
@@ -497,7 +498,7 @@ export class CursorExecHandlers implements ICursorExecHandlers {
497
498
  async mcp(call: CursorMcpCall) {
498
499
  const toolName = call.toolName || call.name;
499
500
  const toolCallId = decodeToolCallId(call.toolCallId);
500
- const tool = this.options.tools.get(toolName) ?? this.options.getTool?.(toolName);
501
+ const tool = this.options.getExecutableTool?.(toolName) ?? this.options.tools.get(toolName);
501
502
  if (!tool) {
502
503
  const availableTools = Array.from(this.options.tools.keys()).filter(name => name.startsWith("mcp__"));
503
504
  const message = formatMcpToolErrorMessage(toolName, availableTools);
@@ -28,6 +28,7 @@ import tsNoAny from "./ts-no-any.md" with { type: "text" };
28
28
  import tsNoDeprecatedLeftovers from "./ts-no-deprecated-leftovers.md" with { type: "text" };
29
29
  import tsNoDynamicImport from "./ts-no-dynamic-import.md" with { type: "text" };
30
30
  import tsNoInlineCastAccess from "./ts-no-inline-cast-access.md" with { type: "text" };
31
+ import tsNoLocalIsRecord from "./ts-no-local-is-record.md" with { type: "text" };
31
32
  import tsNoReturnType from "./ts-no-return-type.md" with { type: "text" };
32
33
  import tsNoTestTimers from "./ts-no-test-timers.md" with { type: "text" };
33
34
  import tsNoTinyFunctions from "./ts-no-tiny-functions.md" with { type: "text" };
@@ -63,6 +64,7 @@ export const BUILTIN_RULE_SOURCES: readonly BuiltinRuleSource[] = [
63
64
  { name: "ts-no-deprecated-leftovers", content: tsNoDeprecatedLeftovers },
64
65
  { name: "ts-no-dynamic-import", content: tsNoDynamicImport },
65
66
  { name: "ts-no-inline-cast-access", content: tsNoInlineCastAccess },
67
+ { name: "ts-no-local-is-record", content: tsNoLocalIsRecord },
66
68
  { name: "ts-no-return-type", content: tsNoReturnType },
67
69
  { name: "ts-no-test-timers", content: tsNoTestTimers },
68
70
  { name: "ts-no-tiny-functions", content: tsNoTinyFunctions },
@@ -0,0 +1,48 @@
1
+ ---
2
+ description: "Never use isRecord"
3
+ condition:
4
+ - "\\bfunction\\s+isRecord(?:\\s*<[^>]*>)?\\s*\\("
5
+ - "\\b(?:const|let|var)\\s+isRecord\\b\\s*(?::[\\s\\S]{0,300}?)?=\\s*(?:async\\s+)?(?:function\\b|(?:<[^>\\n]*>\\s*)?(?:\\([^)]*\\)|[A-Za-z_$][\\w$]*)\\s*(?::[\\s\\S]{0,300}?)?=>)"
6
+ scope: "tool:edit(*.{ts,tsx,mts,cts}), tool:write(*.{ts,tsx,mts,cts})"
7
+ interruptMode: never
8
+ ---
9
+
10
+ ## Why it's wrong
11
+
12
+ - A `Record<string, unknown>` guard proves only an object, not its fields.
13
+ - It's either unnecessarily complicated, or not strong enough.
14
+ - Repeated guards hide the actual data contract from readers and TypeScript.
15
+
16
+ ## Use
17
+
18
+ `isRecord` narrows values to `Record<string, unknown>`; each field remains `unknown`.
19
+
20
+ For network, config, IPC, persisted, or reused data shapes, parse once at the boundary with the project's schema validator and consume its named output type:
21
+
22
+ ```typescript
23
+ const Config = z.object({ retries: z.number().int().nonnegative() });
24
+ type Config = z.infer<typeof Config>;
25
+
26
+ const config = Config.parse(raw);
27
+ ```
28
+
29
+ If the runtime shape is uncertain, check the properties you use with `typeof`, `Array.isArray`, `in`, or a discriminant. If an existing invariant guarantees the shape, assert the named type at that boundary instead of duplicating a guard:
30
+
31
+ ```typescript
32
+ const config = value as Config;
33
+ ```
34
+
35
+ ## Avoid
36
+
37
+ ```typescript
38
+ function isRecord(value: unknown): value is Record<string, unknown> {
39
+ return !!value && typeof value === "object" && !Array.isArray(value);
40
+ }
41
+
42
+ const isRecord = (value: unknown): value is Record<string, unknown> =>
43
+ value !== null && typeof value === "object";
44
+ ```
45
+
46
+ ## Exceptions
47
+
48
+ A standalone package without a shared type-guard module may define its single canonical guard. Export it from the package's type-guard module; never recreate it at individual call sites.