howtofixcli 0.7.13 → 0.7.15

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 (161) hide show
  1. package/dist/chunk-27TRKD65.js +2 -0
  2. package/dist/chunk-3FYCFLNK.js +1 -0
  3. package/dist/chunk-56SEAGAQ.js +1 -0
  4. package/dist/chunk-6FUWMLWO.js +1 -0
  5. package/dist/chunk-7CHDNTH6.js +1 -0
  6. package/dist/chunk-7DTQKDO4.js +2 -0
  7. package/dist/chunk-AGGIYXN7.js +2 -0
  8. package/dist/chunk-AUDMRGWF.js +189 -0
  9. package/dist/chunk-CPJKKHGH.js +1 -0
  10. package/dist/chunk-DAPXGSGD.js +12 -0
  11. package/dist/chunk-DOD7MUGX.js +1 -0
  12. package/dist/chunk-DV2BBBBJ.js +2 -0
  13. package/dist/chunk-EIBOS5KO.js +5 -0
  14. package/dist/chunk-EYWOVCSS.js +6 -0
  15. package/dist/chunk-FPXVDP32.js +8 -0
  16. package/dist/chunk-GXB7RZHH.js +1 -0
  17. package/dist/chunk-HT3MFLAV.js +34 -0
  18. package/dist/chunk-ILDDDLRC.js +1 -0
  19. package/dist/chunk-JFYVRWJI.js +2 -0
  20. package/dist/chunk-JMKK26AT.js +2 -0
  21. package/dist/chunk-LAHVHRCS.js +2 -0
  22. package/dist/chunk-LOOFOBTS.js +1 -0
  23. package/dist/chunk-LSJC73DG.js +1 -0
  24. package/dist/chunk-MOKUBKE7.js +1 -0
  25. package/dist/chunk-N345KWWK.js +1 -0
  26. package/dist/chunk-ONZLNFQ2.js +3 -0
  27. package/dist/chunk-ORJ2QZBG.js +3 -0
  28. package/dist/chunk-PCALBZOO.js +2 -0
  29. package/dist/chunk-SX5VE6XA.js +110 -0
  30. package/dist/chunk-TSEKPGK4.js +1 -0
  31. package/dist/chunk-UNK5HRQR.js +1 -0
  32. package/dist/chunk-UTYFLLSM.js +1 -0
  33. package/dist/chunk-V2RPZMLK.js +1 -0
  34. package/dist/chunk-V3R4FYTJ.js +1 -0
  35. package/dist/chunk-V656XVXV.js +8 -0
  36. package/dist/chunk-VLMQSD25.js +1 -0
  37. package/dist/chunk-WKUJ7CFO.js +1 -0
  38. package/dist/chunk-WOT6VMZA.js +1 -0
  39. package/dist/chunk-WUPU6LTD.js +4 -0
  40. package/dist/chunk-WX3V5P7U.js +5 -0
  41. package/dist/chunk-Y7YM5R75.js +5 -0
  42. package/dist/chunk-YW7NORH3.js +2 -0
  43. package/dist/main.js +99 -540
  44. package/package.json +17 -64
  45. package/dist/account-credential.js +0 -136
  46. package/dist/account-credential.test.js +0 -38
  47. package/dist/activity.js +0 -124
  48. package/dist/agent-defs.js +0 -237
  49. package/dist/agent-limiter.js +0 -68
  50. package/dist/agents.js +0 -554
  51. package/dist/anim.js +0 -361
  52. package/dist/args.js +0 -228
  53. package/dist/at-file.js +0 -28
  54. package/dist/brand-extension.js +0 -87
  55. package/dist/brand.js +0 -76
  56. package/dist/browser.js +0 -58
  57. package/dist/cache-report.js +0 -139
  58. package/dist/checkpoint.js +0 -437
  59. package/dist/claims.js +0 -167
  60. package/dist/cli-auth.js +0 -142
  61. package/dist/cli-checkin.js +0 -120
  62. package/dist/cli-credentials.js +0 -254
  63. package/dist/clipboard.js +0 -149
  64. package/dist/collapse.js +0 -129
  65. package/dist/commands.js +0 -294
  66. package/dist/compaction-guard.js +0 -81
  67. package/dist/context-extension.js +0 -17
  68. package/dist/dcp/commands.js +0 -172
  69. package/dist/dcp/compress-state.js +0 -139
  70. package/dist/dcp/compress.js +0 -329
  71. package/dist/dcp/dcp-extension.js +0 -295
  72. package/dist/dcp/dedup.js +0 -93
  73. package/dist/dcp/ids.js +0 -189
  74. package/dist/dcp/index.js +0 -25
  75. package/dist/dcp/middle-prune.js +0 -76
  76. package/dist/dcp/prompts.js +0 -68
  77. package/dist/dcp/protected-content.js +0 -221
  78. package/dist/dcp/protected.js +0 -161
  79. package/dist/dcp/prune.js +0 -418
  80. package/dist/dcp/state.js +0 -176
  81. package/dist/device-id.js +0 -90
  82. package/dist/device-id.test.js +0 -23
  83. package/dist/errors.js +0 -63
  84. package/dist/extensions.js +0 -48
  85. package/dist/fatal.js +0 -35
  86. package/dist/feed.js +0 -498
  87. package/dist/fs-atomic.js +0 -54
  88. package/dist/gateway-errors.js +0 -90
  89. package/dist/git.js +0 -230
  90. package/dist/goal-extension.js +0 -88
  91. package/dist/goal.js +0 -222
  92. package/dist/hooks-extension.js +0 -176
  93. package/dist/hooks.js +0 -443
  94. package/dist/howtofix-account.js +0 -735
  95. package/dist/hud-line.js +0 -153
  96. package/dist/hwid.js +0 -130
  97. package/dist/interrupt.js +0 -60
  98. package/dist/json-events.js +0 -50
  99. package/dist/level-style.js +0 -112
  100. package/dist/loop-guard.js +0 -95
  101. package/dist/lsp-tools.js +0 -32
  102. package/dist/lsp.js +0 -429
  103. package/dist/mcp-config.js +0 -85
  104. package/dist/mcp-lifecycle.js +0 -147
  105. package/dist/model-catalog.js +0 -217
  106. package/dist/model-facts.js +0 -35
  107. package/dist/model-resolve.js +0 -65
  108. package/dist/notes.js +0 -78
  109. package/dist/oauth-login.js +0 -568
  110. package/dist/paths.js +0 -61
  111. package/dist/permissions.js +0 -298
  112. package/dist/plan-mode.js +0 -363
  113. package/dist/preset-command.js +0 -227
  114. package/dist/preset-extension.js +0 -35
  115. package/dist/preset-mcp.js +0 -292
  116. package/dist/presets.js +0 -727
  117. package/dist/prompt-files.js +0 -103
  118. package/dist/prompt-text.js +0 -41
  119. package/dist/proxy.js +0 -137
  120. package/dist/quality/ast.js +0 -196
  121. package/dist/quality/cognitive.js +0 -83
  122. package/dist/quality/cyclomatic.js +0 -30
  123. package/dist/quality/halstead.js +0 -83
  124. package/dist/quality/index.js +0 -18
  125. package/dist/quality/length.js +0 -3
  126. package/dist/quality/maintainability.js +0 -15
  127. package/dist/quality/nesting.js +0 -57
  128. package/dist/quality/report.js +0 -803
  129. package/dist/quality/smells.js +0 -67
  130. package/dist/quality-tool.js +0 -63
  131. package/dist/read-only-commands.js +0 -597
  132. package/dist/repo-map.js +0 -292
  133. package/dist/rtk.js +0 -193
  134. package/dist/sandbox.js +0 -105
  135. package/dist/schema.js +0 -214
  136. package/dist/session-lifecycle.js +0 -19
  137. package/dist/session-retention.js +0 -111
  138. package/dist/session.js +0 -447
  139. package/dist/sessions.js +0 -100
  140. package/dist/settings.js +0 -148
  141. package/dist/skills.js +0 -35
  142. package/dist/slop-tool.js +0 -96
  143. package/dist/spend-cap.js +0 -43
  144. package/dist/subagent.js +0 -171
  145. package/dist/tar.js +0 -146
  146. package/dist/theme.js +0 -79
  147. package/dist/thinking.js +0 -45
  148. package/dist/todo.js +0 -114
  149. package/dist/tool-loadout.js +0 -97
  150. package/dist/tool-render.js +0 -769
  151. package/dist/tools.js +0 -176
  152. package/dist/ui.js +0 -4778
  153. package/dist/update.js +0 -551
  154. package/dist/usage-segment.js +0 -69
  155. package/dist/web-tools.js +0 -46
  156. package/dist/web.js +0 -265
  157. package/dist/welcome.js +0 -179
  158. package/dist/workflow-engine.js +0 -246
  159. package/dist/workflow-progress.js +0 -446
  160. package/dist/workflow-view.js +0 -195
  161. package/dist/workflow.js +0 -349
@@ -1,298 +0,0 @@
1
- import { noteToolCall, repeatRefusal } from "./loop-guard.js";
2
- import { isReadOnlyCommand } from "./read-only-commands.js";
3
- export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
4
- export const APPROVAL_LABELS = {
5
- suggest: "suggest: ask before every edit and command",
6
- "auto-edit": "auto-edit: edits run, commands ask",
7
- "full-auto": "full-auto: nothing asks",
8
- };
9
- export function isApprovalMode(value) {
10
- return APPROVAL_MODES.includes(value);
11
- }
12
- /** Ordering of capability tiers, least to most privileged. */
13
- export const TIER_RANK = { read: 0, write: 1, exec: 2 };
14
- /** The highest tier a mode runs without asking. */
15
- export const MODE_MAX_TIER = {
16
- suggest: "read",
17
- "auto-edit": "write",
18
- "full-auto": "exec",
19
- };
20
- /**
21
- * The SDK's built-in tools, by tier. It declares no approval metadata of its own, so
22
- * the built-ins have to be listed here; everything else falls back to a tier
23
- * chosen by {@link resolveTier}.
24
- */
25
- const BUILTIN_TIERS = {
26
- read: "read",
27
- grep: "read",
28
- find: "read",
29
- ls: "read",
30
- think: "read",
31
- ask: "read",
32
- todo: "read",
33
- context: "read",
34
- // Lookup only: the URL policy in `web.ts` refuses private targets before the
35
- // network is touched, so neither tool prompts in `suggest`.
36
- web_search: "read",
37
- web_fetch: "read",
38
- // A language-server query reads a file and answers; it has no write verb.
39
- lsp: "read",
40
- quality: "read",
41
- // A workflow runs agents, which run tools — including shell. Its own tier is
42
- // the strongest one so a run cannot start in a mode that would silently allow
43
- // what the child agents then do.
44
- workflow: "exec",
45
- // The goal tool only writes session state; it runs nothing.
46
- goal: "read",
47
- // Plan mode tools only flip session state and read the plan file. The real
48
- // enforcement is the plan gate, which is registered ahead of this one.
49
- enter_plan_mode: "read",
50
- exit_plan_mode: "read",
51
- edit: "write",
52
- write: "write",
53
- bash: "exec",
54
- powershell: "exec",
55
- task: "exec",
56
- };
57
- /**
58
- * Tiers for the tools this project registers as custom tools. `task` spawns
59
- * headless agents, so it sits at `exec`; `think` and `ask` only produce text.
60
- */
61
- export function resolveTier(toolName) {
62
- const builtin = BUILTIN_TIERS[toolName];
63
- if (builtin)
64
- return builtin;
65
- // An MCP server tool can do anything its server exposes, and its own
66
- // annotations are unverified. Every MCP tool is pinned to `write`; a
67
- // tool that turns out to be read-only costs one extra prompt.
68
- if (toolName.startsWith("mcp__"))
69
- return "write";
70
- // Safe default for unknown custom tools: `exec`.
71
- return "exec";
72
- }
73
- /**
74
- * Commands that only read. They never ask, in any mode.
75
- *
76
- * The decision lives in `read-only-commands.ts`, which ports Claude Code's
77
- * classifier: a compound command is read-only only if *every* segment is, and a
78
- * segment only if its flags are all in a per-subcommand allowlist. The old
79
- * single regex got both directions wrong — it prompted on `ls | head`, which is
80
- * how you read a codebase, and it auto-allowed `git branch -D`.
81
- */
82
- export { isReadOnlyCommand } from "./read-only-commands.js";
83
- /**
84
- * Commands that are refused in every mode, including `full-auto`. These are the
85
- * patterns treated as "critical": an approval prompt is the wrong
86
- * answer when the command is a fork bomb or a wipe of the root filesystem.
87
- */
88
- const CRITICAL_COMMANDS = [
89
- { pattern: /\brm\s+(?:-[a-zA-Z]+\s+)*(?:\/|\/\*|~|\$HOME)(?:\s|$)/, reason: "recursive delete of the filesystem root or home" },
90
- { pattern: /\bmkfs(?:\.[a-z0-9]+)?\b/, reason: "filesystem format" },
91
- { pattern: /\bdd\b[^|;]*\bof=\/dev\/(?:sd|nvme|hd|vd)/, reason: "raw write to a block device" },
92
- { pattern: /:\s*\(\s*\)\s*\{.*\}\s*;\s*:/, reason: "fork bomb" },
93
- { pattern: />\s*\/etc\/(?:passwd|shadow|sudoers)/, reason: "overwrite of a system account file" },
94
- { pattern: /\b(?:shutdown|reboot|halt|poweroff)\b/, reason: "host shutdown" },
95
- ];
96
- /**
97
- * The critical pattern a command matches, if any.
98
- *
99
- * Only the **command being run** is matched, never the whole line. A line-wide
100
- * regex made every mention of the word fatal: `grep -rn shutdown src/`,
101
- * `cat reboot-notes.md`, `git log --grep=reboot` and `echo "reboot later"` were
102
- * all refused as "host shutdown", so an agent could not even search its own
103
- * codebase for the word. The list exists to stop the action, not the noun, so
104
- * the head of the command is the only part that counts.
105
- *
106
- * `sudo shutdown` and `env reboot` are still caught: any leading word that is
107
- * itself a launcher (`sudo`, `env`, `nohup`, `time`, `exec`, …) is skipped
108
- * before the match, so the check reads the command that will actually run.
109
- */
110
- const LAUNCHER_WORDS = new Set(["sudo", "doas", "env", "nohup", "time", "exec", "command", "nice", "setsid", "stdbuf"]);
111
- /** Launcher flags that consume the token after them, so `sudo -u root shutdown`
112
- * does not leave `root` looking like the command. */
113
- const LAUNCHER_VALUE_FLAGS = new Set(["-u", "-g", "-p", "-C", "-S", "-U", "--user", "--group", "--prompt", "--close-from", "--chdir", "--setenv", "--unset"]);
114
- export function criticalCommand(command) {
115
- // Quote-aware tokenization: `grep -rn shutdown src/` splits into words, and
116
- // the argument is simply not the first one.
117
- const words = (command.match(/"[^"]*"|'[^']*'|\S+/g) ?? []).map((word) => word.replace(/^['"]|['"]$/g, ""));
118
- let head = 0;
119
- // Skip leading `VAR=value` assignments, then the launcher prefix — both the
120
- // launcher word and its own flags, so `sudo -n reboot` still reads as
121
- // `reboot`. A value-taking launcher flag takes the next token with it.
122
- while (head < words.length) {
123
- const current = words[head] ?? "";
124
- if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(current) || LAUNCHER_WORDS.has(current)) {
125
- head++;
126
- continue;
127
- }
128
- if (current.startsWith("-")) {
129
- head += LAUNCHER_VALUE_FLAGS.has(current) ? 2 : 1;
130
- continue;
131
- }
132
- break;
133
- }
134
- const token = words[head];
135
- if (token === undefined)
136
- return undefined;
137
- // The command word, without its directory: `/sbin/shutdown` is `shutdown`.
138
- const word = token.split("/").pop() ?? token;
139
- // `rm` is only critical when it is recursive *and* aimed at the root, home or
140
- // a wildcard — `rm -rf build/` and `rm -rf /tmp/x` are ordinary work.
141
- if (word === "rm") {
142
- const args = words.slice(head + 1);
143
- const recursive = args.some((arg) => /^-[a-zA-Z]*r[a-zA-Z]*$/i.test(arg) || arg === "--recursive");
144
- const target = args.find((arg) => !arg.startsWith("-"));
145
- if (recursive && (target === undefined || /^(?:\/|\/\*|~|~\/\*|\$HOME|\$HOME\/\*|\*)$/.test(target))) {
146
- return "recursive delete of the filesystem root or home";
147
- }
148
- return undefined;
149
- }
150
- // `dd … of=/dev/sdX` is the raw-device write; a bare `dd` is not.
151
- if (word === "dd") {
152
- return words.slice(head + 1).some((arg) => /^of=\/dev\/(?:sd|nvme|hd|vd)/.test(arg)) ? "raw write to a block device" : undefined;
153
- }
154
- if (/^mkfs(?:\.[a-z0-9]+)?$/.test(word))
155
- return "filesystem format";
156
- if (/:\(\)\s*\{.*\}\s*;:/.test(command))
157
- return "fork bomb";
158
- if (/^(?:shutdown|reboot|halt|poweroff)$/.test(word))
159
- return "host shutdown";
160
- // The redirect target is not the command word, so it is checked separately.
161
- if (/>\s*\/etc\/(?:passwd|shadow|sudoers)/.test(command))
162
- return "overwrite of a system account file";
163
- return undefined;
164
- }
165
- let currentMode = "full-auto";
166
- let currentPrompt = async () => false;
167
- /**
168
- * When set, a tool call runs without consulting the mode, the critical-command
169
- * list, or the loop guard. `--dangerously-skip-permissions` sets it for print
170
- * mode, where a refusal has no human to answer it.
171
- *
172
- * What it does NOT turn off: an extension's own `tool_call` handler (this gate
173
- * is one of several), and the provider's own limits. It is the same trade
174
- * Claude Code makes with `--dangerously-skip-permissions`, and it is named the
175
- * same way on purpose — the name is the warning.
176
- */
177
- let dangerousBypass = false;
178
- /** Change the policy. Read at tool-call time, so it applies immediately. */
179
- export function setApprovalMode(mode) {
180
- currentMode = mode;
181
- }
182
- export function getApprovalMode() {
183
- return currentMode;
184
- }
185
- export function setDangerousBypass(on) {
186
- dangerousBypass = on;
187
- }
188
- export function isDangerousBypass() {
189
- return dangerousBypass;
190
- }
191
- /** Install the dialog used when a call needs approval. Print mode leaves the deny default. */
192
- export function setApprovalPrompt(prompt) {
193
- currentPrompt = prompt;
194
- }
195
- /**
196
- * The whole policy in one place, so it can be exercised without a session.
197
- *
198
- * `full-auto` runs everything except a critical command; `auto-edit` adds the
199
- * `write` tier (file edits); `suggest` runs only `read`. Read-only shell
200
- * commands run in every mode, as the README promises.
201
- */
202
- export function decideToolCall(mode, toolName, input) {
203
- // The bypass short-circuits the whole policy, including the critical list:
204
- // that is the point of the flag, and it is why the flag is named the way it is.
205
- if (dangerousBypass)
206
- return { action: "run" };
207
- const command = typeof input?.command === "string" ? input.command : "";
208
- if (toolName === "bash" || toolName === "powershell") {
209
- const critical = criticalCommand(command);
210
- if (critical)
211
- return { action: "deny", reason: `Refused in every mode: ${critical}.` };
212
- if (isReadOnlyCommand(command))
213
- return { action: "run" };
214
- }
215
- const tier = resolveTier(toolName);
216
- if (TIER_RANK[tier] <= TIER_RANK[MODE_MAX_TIER[mode]])
217
- return { action: "run" };
218
- return { action: "prompt", reason: `this is a ${tier}-tier tool` };
219
- }
220
- /** Human summary of a tool call, used in the approval dialog. */
221
- export function describeToolCall(toolName, input) {
222
- if (input && typeof input === "object") {
223
- const record = input;
224
- if ((toolName === "bash" || toolName === "powershell") && typeof record.command === "string")
225
- return record.command;
226
- if ((toolName === "edit" || toolName === "write") && typeof record.path === "string") {
227
- const edits = Array.isArray(record.edits) ? ` (${record.edits.length} edits)` : "";
228
- return `${record.path}${edits}`;
229
- }
230
- // A tool we do not know by name (MCP, another extension): show its
231
- // arguments, so the choice is informed instead of a bare tool name.
232
- try {
233
- const args = JSON.stringify(record);
234
- if (args && args !== "{}")
235
- return args.length > 300 ? `${args.slice(0, 297)}...` : args;
236
- }
237
- catch {
238
- // Unserializable input falls through to the tool name.
239
- }
240
- }
241
- return toolName;
242
- }
243
- /**
244
- * The approval prompt body: the tool, where it came
245
- * from, why it needs approval, then the concrete arguments.
246
- */
247
- export function formatApprovalPrompt(toolName, input, reason) {
248
- const lines = [`Allow tool: ${toolName}`];
249
- if (toolName.startsWith("mcp__"))
250
- lines.push("Origin: MCP server tool");
251
- lines.push(`Reason: ${reason}`);
252
- const details = describeToolCall(toolName, input);
253
- if (details !== toolName)
254
- lines.push(details);
255
- return lines.join("\n");
256
- }
257
- /**
258
- * Gate tool calls behind user approval, by declared capability tier. With no
259
- * interactive prompt installed (print mode), anything above the mode's tier is
260
- * denied; a critical command is denied outright.
261
- *
262
- * The loop guard runs before the approval check, because a call the model has
263
- * already issued identically three times needs no human decision — approving it
264
- * again would just spend the budget on the same non-progress. It blocks in every
265
- * mode, `full-auto` included.
266
- */
267
- export function createPermissionExtension(options = {}) {
268
- // A subagent has no UI, so it must never reach for the shared approval dialog:
269
- // the dialog belongs to the parent's screen, and a child that awaits it while
270
- // the parent is mid-turn deadlocks both — measured, the child's prompt never
271
- // even reached the provider. A non-interactive gate decides with the same
272
- // policy and denies what it cannot run, which is the fail-closed behaviour the
273
- // parent's print mode already has.
274
- return (hxf) => {
275
- hxf.on("tool_call", async (event) => {
276
- // The bypass skips the loop guard too: it exists to stop a model spinning
277
- // on a call a human would have to approve, and there is no human here.
278
- if (dangerousBypass)
279
- return undefined;
280
- const repeat = noteToolCall(event.toolName, event.input);
281
- if (repeat)
282
- return { block: true, reason: repeatRefusal(event.toolName, repeat.repeats) };
283
- const mode = currentMode;
284
- const decision = decideToolCall(mode, event.toolName, event.input);
285
- if (decision.action === "run")
286
- return undefined;
287
- if (decision.action === "deny")
288
- return { block: true, reason: decision.reason };
289
- if (options.interactive === false) {
290
- return { block: true, reason: `Blocked: this is a ${resolveTier(event.toolName)}-tier tool and no UI can approve it here (approval mode: ${mode}).` };
291
- }
292
- const allowed = await currentPrompt(`${event.toolName} wants to run`, formatApprovalPrompt(event.toolName, event.input, decision.reason));
293
- if (allowed)
294
- return undefined;
295
- return { block: true, reason: `Blocked by the user (approval mode: ${mode}).` };
296
- });
297
- };
298
- }
package/dist/plan-mode.js DELETED
@@ -1,363 +0,0 @@
1
- /**
2
- * Plan mode: a read-only planning phase, ported from xAI's Grok Build.
3
- *
4
- * Why this exists: planning is a phase, not a message. Both Grok Build
5
- * (`crates/codegen/xai-grok-shell/src/session/plan_mode.rs`) and Claude Code
6
- * ship it as a first-class mode, and the reason is the same in both — an agent
7
- * that starts editing before the approach is agreed wastes the whole task when
8
- * the approach is wrong. The mode is the harness enforcing "design first",
9
- * rather than asking the model nicely in a prompt it can forget after a
10
- * compaction.
11
- *
12
- * Three properties are load-bearing, and all three are copied from the
13
- * reference rather than invented:
14
- *
15
- * 1. **Plan mode blocks edits to everything except the plan file, in every
16
- * approval mode.** Grok documents this explicitly: "This holds in every
17
- * permission mode, including always-approve." The edit gate is registered
18
- * *before* the approval gate, and the SDK returns on the first `block`, so
19
- * `full-auto` cannot punch through it.
20
- * 2. **The plan is read from disk, not from the model.** `exit_plan_mode`
21
- * takes no plan content as input; it reads the plan file and presents what
22
- * is actually on disk. Grok's own comment is the justification: it prevents
23
- * "divergence between the model's in-context plan and the actual file
24
- * content" — the user reviews what will be built, not a summary of it.
25
- * 3. **The state machine is pure.** Every transition is a function over a
26
- * small snapshot, so the whole lifecycle is testable with no session, no
27
- * model, and no terminal. Grok's tracker is built the same way.
28
- *
29
- * One deliberate divergence from the reference: Grok alternates between a full
30
- * reminder and a one-line sparse one, to save tokens. That is a cache hazard
31
- * here. Our reminder is appended to the system message, which is the cacheable
32
- * prefix of every request, so alternating the text would invalidate the prefix
33
- * on every other turn — the same class of mistake that cost Claude Code 10.2% of
34
- * fleet-wide cache-creation tokens. A single stable block costs ~80 tokens a
35
- * turn and never invalidates anything, so the alternation is dropped and the
36
- * state machine carries only what it needs.
37
- *
38
- * Honest limits, both inherited from the reference and stated here so nobody
39
- * assumes more isolation than exists:
40
- * - Shell commands are not inspected for writes. Plan mode blocks the edit
41
- * tools, not `bash > file`. Grok documents the same gap.
42
- * - A subagent starts with its own tracker (inactive), so a write-capable
43
- * subagent is not covered by the parent's gate. Grok documents this too.
44
- */
45
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
46
- import { dirname, join } from "node:path";
47
- import { defineTool } from "@netrodev/hxf-coding-agent";
48
- import { Type } from "typebox";
49
- import { writeFileAtomicSync } from "./fs-atomic.js";
50
- export function emptyPlanSnapshot() {
51
- return { state: "inactive", wasActive: false };
52
- }
53
- /** Collapse transient state, the way a restore must. */
54
- export function restorePlanSnapshot(snapshot) {
55
- if (!snapshot)
56
- return emptyPlanSnapshot();
57
- if (snapshot.state === "pending")
58
- return { ...snapshot, state: "inactive" };
59
- return { ...snapshot };
60
- }
61
- /** User toggled the mode on. */
62
- export function enterPending(snapshot) {
63
- if (snapshot.state !== "inactive")
64
- return snapshot;
65
- return { ...snapshot, state: "pending" };
66
- }
67
- /** The first prompt while pending activates the mode. */
68
- export function activate(snapshot) {
69
- if (snapshot.state !== "pending")
70
- return snapshot;
71
- return { state: "active", wasActive: true };
72
- }
73
- /** The agent asked to enter and the user approved: straight to active. */
74
- export function activateFromTool(snapshot) {
75
- if (snapshot.state !== "inactive")
76
- return snapshot;
77
- return { state: "active", wasActive: true };
78
- }
79
- /** The plan was approved, so the mode ends. */
80
- export function deactivateApproved(snapshot) {
81
- if (snapshot.state !== "active")
82
- return snapshot;
83
- return { ...snapshot, state: "inactive" };
84
- }
85
- /** The user toggled the mode off. */
86
- export function userExit(snapshot) {
87
- if (snapshot.state === "inactive")
88
- return snapshot;
89
- return { ...snapshot, state: "inactive" };
90
- }
91
- export function isPlanActive(snapshot) {
92
- return snapshot.state === "active";
93
- }
94
- /** Whether a write target is the plan file. Exact match, as in the reference. */
95
- export function isPlanFileWrite(target, planFile) {
96
- return target === planFile;
97
- }
98
- /**
99
- * The per-turn reminder, appended to the system message.
100
- *
101
- * Stable on purpose: it changes only when the plan file gains its first content,
102
- * which happens once per planning session. Nothing volatile may enter it, or the
103
- * cached prefix is lost on every turn.
104
- */
105
- export function planReminder(planFile, hasContent) {
106
- const where = hasContent ? `A plan file exists at ${planFile}. Read it and refine it with the \`edit\` tool.` : `No plan is written yet. Write yours to ${planFile} with the \`write\` tool.`;
107
- return [
108
- "<plan-mode>",
109
- "Plan mode is active. Do not make any edits or writes to the system.",
110
- where,
111
- "That file is the only path you may edit.",
112
- "Explore first: read, search, and run read-only commands until the approach is clear.",
113
- "End your turn with either `ask` to clarify a requirement, or `exit_plan_mode` to present",
114
- "the plan for approval. Do not start implementing before the plan is approved.",
115
- "</plan-mode>",
116
- ].join("\n");
117
- }
118
- /** What the model is told when it tries to edit outside the plan file. */
119
- export function planEditRefusal(planFile) {
120
- return `Rejected: file edits are not allowed in plan mode. The only editable file is the plan file (${planFile}). Present the plan with exit_plan_mode first.`;
121
- }
122
- /** The plan file for the current session, or undefined before one is known. */
123
- let planFilePath;
124
- let snapshot = emptyPlanSnapshot();
125
- let sessionDir;
126
- export function currentPlanSnapshot() {
127
- return snapshot;
128
- }
129
- export function currentPlanFile() {
130
- return planFilePath;
131
- }
132
- export function planActive() {
133
- return isPlanActive(snapshot);
134
- }
135
- /** Read the plan from disk. Empty or missing reads as absent. */
136
- export function readPlan() {
137
- if (!planFilePath || !existsSync(planFilePath))
138
- return undefined;
139
- try {
140
- const text = readFileSync(planFilePath, "utf8");
141
- return text.trim() ? text : undefined;
142
- }
143
- catch {
144
- return undefined;
145
- }
146
- }
147
- export function planHasContent() {
148
- return readPlan() !== undefined;
149
- }
150
- /** The short form for the HUD. Empty when the mode is off. */
151
- export function planSegment() {
152
- if (snapshot.state === "inactive")
153
- return "";
154
- if (snapshot.state === "pending")
155
- return "plan pending";
156
- return planHasContent() ? "plan ● ready" : "plan ● planning";
157
- }
158
- /** Path of the small state file that survives a resume, beside the plan. */
159
- function statePath() {
160
- return sessionDir ? join(sessionDir, "plan_mode.json") : undefined;
161
- }
162
- function persistSnapshot() {
163
- const path = statePath();
164
- if (!path)
165
- return;
166
- try {
167
- mkdirSync(dirname(path), { recursive: true });
168
- writeFileAtomicSync(path, JSON.stringify(snapshot));
169
- }
170
- catch {
171
- // A session directory that cannot be written is not worth failing a turn
172
- // over: the mode still works for this process.
173
- }
174
- }
175
- function loadSnapshot() {
176
- const path = statePath();
177
- if (!path || !existsSync(path))
178
- return emptyPlanSnapshot();
179
- try {
180
- return restorePlanSnapshot(JSON.parse(readFileSync(path, "utf8")));
181
- }
182
- catch {
183
- return emptyPlanSnapshot();
184
- }
185
- }
186
- /**
187
- * Point the mode at this session's directory and restore what a previous run
188
- * left there. Plan mode is session state, so a resume must not silently drop it.
189
- */
190
- export function openPlanSession(dir) {
191
- sessionDir = dir;
192
- planFilePath = join(dir, "plan.md");
193
- snapshot = loadSnapshot();
194
- }
195
- /** Apply a transition and persist it. Every mutation goes through here. */
196
- export function transition(next) {
197
- snapshot = next;
198
- persistSnapshot();
199
- return snapshot;
200
- }
201
- /** Test seam: forget the session, the mode, and the file. Test-only; see check-wiring.mjs. */
202
- export function resetPlanMode() {
203
- planFilePath = undefined;
204
- sessionDir = undefined;
205
- snapshot = emptyPlanSnapshot();
206
- }
207
- /**
208
- * The edit gate. Registered before the approval gate, and it blocks in every
209
- * approval mode — the SDK returns on the first `block`, so `full-auto` cannot
210
- * override it. `bash` is deliberately not inspected: see the module comment.
211
- */
212
- export function createPlanGateExtension() {
213
- return (hxf) => {
214
- hxf.on("tool_call", async (event) => {
215
- if (!isPlanActive(snapshot))
216
- return undefined;
217
- if (event.toolName !== "edit" && event.toolName !== "write")
218
- return undefined;
219
- const path = event.input?.path;
220
- if (typeof path !== "string")
221
- return undefined;
222
- if (!planFilePath || isPlanFileWrite(path, planFilePath))
223
- return undefined;
224
- return { block: true, reason: planEditRefusal(planFilePath) };
225
- });
226
- };
227
- }
228
- /**
229
- * Keeps the reminder in front of the model, and the mode alive across a resume.
230
- *
231
- * The reminder is appended to the system message, following the pattern the
232
- * shared-notes extension already proved in this codebase: it is the leading
233
- * message the provider reads the prompt from, and it is the only place a
234
- * per-request instruction can live without becoming a fake user turn.
235
- */
236
- export function createPlanPromptExtension() {
237
- return (hxf) => {
238
- hxf.on("session_start", async (_event, ctx) => {
239
- openPlanSession(ctx.sessionManager.getSessionDir());
240
- });
241
- // The first prompt after `/plan` is what activates the mode: the model must
242
- // not be told about plan mode until it is about to run under it.
243
- hxf.on("before_agent_start", async () => {
244
- if (snapshot.state === "pending")
245
- transition(activate(snapshot));
246
- });
247
- hxf.on("context_with_system", async (event) => {
248
- if (!isPlanActive(snapshot) || !planFilePath)
249
- return undefined;
250
- const head = event.messages[0];
251
- if (!head || head.role !== "system")
252
- return undefined;
253
- const reminder = planReminder(planFilePath, planHasContent());
254
- const combined = `${typeof head.content === "string" ? head.content : ""}\n\n${reminder}`;
255
- const messages = event.messages.map((message) => (message === head ? { ...head, content: combined } : message));
256
- return { messages };
257
- });
258
- };
259
- }
260
- /** Longest plan the exit tool will present, so a runaway file cannot flood the UI. */
261
- export const MAX_PLAN_CHARS = 20_000;
262
- function ensurePlanFile() {
263
- if (!planFilePath)
264
- return undefined;
265
- try {
266
- mkdirSync(dirname(planFilePath), { recursive: true });
267
- if (!existsSync(planFilePath))
268
- writeFileAtomicSync(planFilePath, "");
269
- }
270
- catch {
271
- return undefined;
272
- }
273
- return planFilePath;
274
- }
275
- export const enterPlanModeTool = defineTool({
276
- name: "enter_plan_mode",
277
- label: "enter_plan_mode",
278
- description: "Enter plan mode to design an approach before writing code. Use it when a task has genuine ambiguity; several reasonable designs, or a restructuring where the wrong approach wastes significant effort. Do not use it for a clear fix, a rename, or a task that follows existing conventions. The user must approve entering.",
279
- promptSnippet: "enter_plan_mode: design first when the approach is genuinely ambiguous",
280
- parameters: Type.Object({}),
281
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
282
- if (snapshot.state !== "inactive") {
283
- return { content: [{ type: "text", text: `Plan mode is already ${snapshot.state}.` }], details: {} };
284
- }
285
- if (!ctx.hasUI) {
286
- return {
287
- content: [{ type: "text", text: "Plan mode needs an interactive session: there is no one to approve the plan. Proceed normally." }],
288
- details: {},
289
- isError: true,
290
- };
291
- }
292
- const approved = await ctx.ui.confirm("Enter plan mode?", "The agent will explore and design an approach before editing anything.");
293
- if (!approved) {
294
- return { content: [{ type: "text", text: "The user declined plan mode. Proceed normally." }], details: {} };
295
- }
296
- transition(activateFromTool(snapshot));
297
- const file = ensurePlanFile();
298
- return {
299
- content: [
300
- {
301
- type: "text",
302
- text: ["Plan mode is active. Edits are blocked except for the plan file.", file ? `Write your plan to ${file}.` : "Write your plan to the session plan file."].join("\n"),
303
- },
304
- ],
305
- details: { planFile: file },
306
- };
307
- },
308
- });
309
- export const exitPlanModeTool = defineTool({
310
- name: "exit_plan_mode",
311
- label: "exit_plan_mode",
312
- description: "Present your plan for approval when you have finished planning. The plan is read from the plan file on disk, so write the file first. If the user asks for changes, stay in plan mode and revise it.",
313
- promptSnippet: "exit_plan_mode: present the written plan for approval",
314
- parameters: Type.Object({}),
315
- // Sequential: the plan is read from disk, so a `write` to the plan file issued
316
- // in the same batch must have finished first. Run in parallel, this tool reads
317
- // the file before the write lands and reports an empty plan — observed in a
318
- // real run. A tool that depends on a sibling's file effect must not race it.
319
- executionMode: "sequential",
320
- async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
321
- if (!isPlanActive(snapshot)) {
322
- return { content: [{ type: "text", text: "Plan mode is not active." }], details: {}, isError: true };
323
- }
324
- const plan = readPlan();
325
- if (!plan) {
326
- return {
327
- content: [
328
- {
329
- type: "text",
330
- text: `No plan has been written yet. Write it to ${planFilePath ?? "the plan file"} before exiting plan mode.`,
331
- },
332
- ],
333
- details: {},
334
- isError: true,
335
- };
336
- }
337
- if (!ctx.hasUI) {
338
- return {
339
- content: [{ type: "text", text: "Plan approval needs an interactive session." }],
340
- details: {},
341
- isError: true,
342
- };
343
- }
344
- const shown = plan.length > MAX_PLAN_CHARS ? `${plan.slice(0, MAX_PLAN_CHARS)}\n… (${plan.length - MAX_PLAN_CHARS} more characters)` : plan;
345
- const approved = await ctx.ui.confirm("Approve this plan?", shown);
346
- if (!approved) {
347
- return {
348
- content: [
349
- {
350
- type: "text",
351
- text: "The user did not approve the plan. Stay in plan mode, revise the plan file with the feedback, and present it again.",
352
- },
353
- ],
354
- details: { approved: false },
355
- };
356
- }
357
- transition(deactivateApproved(snapshot));
358
- return {
359
- content: [{ type: "text", text: "Plan approved. You may now implement it." }],
360
- details: { approved: true, planFile: planFilePath },
361
- };
362
- },
363
- });