howtofixcli 0.7.7 → 0.7.9

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 (152) 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-63ZCQDXH.js +6 -0
  5. package/dist/chunk-6FUWMLWO.js +1 -0
  6. package/dist/chunk-7CHDNTH6.js +1 -0
  7. package/dist/chunk-ACOMNDUL.js +185 -0
  8. package/dist/chunk-AEVPET7X.js +2 -0
  9. package/dist/chunk-AGGIYXN7.js +2 -0
  10. package/dist/chunk-BHVBO4SC.js +1 -0
  11. package/dist/chunk-FPXVDP32.js +8 -0
  12. package/dist/chunk-GIUYIKH3.js +109 -0
  13. package/dist/chunk-GQCAJ56I.js +5 -0
  14. package/dist/chunk-GXB7RZHH.js +1 -0
  15. package/dist/chunk-IGEY3AWF.js +1 -0
  16. package/dist/chunk-ILDDDLRC.js +1 -0
  17. package/dist/chunk-JFYVRWJI.js +2 -0
  18. package/dist/chunk-MVLCGU3G.js +1 -0
  19. package/dist/chunk-N345KWWK.js +1 -0
  20. package/dist/chunk-NBQFPCRT.js +1 -0
  21. package/dist/chunk-OIPSLIZE.js +5 -0
  22. package/dist/chunk-ORJ2QZBG.js +3 -0
  23. package/dist/chunk-PCALBZOO.js +2 -0
  24. package/dist/chunk-PDNBGUYP.js +2 -0
  25. package/dist/chunk-S656RE4A.js +1 -0
  26. package/dist/chunk-TSEKPGK4.js +1 -0
  27. package/dist/chunk-U6CI6KXN.js +34 -0
  28. package/dist/chunk-UTYFLLSM.js +1 -0
  29. package/dist/chunk-V3R4FYTJ.js +1 -0
  30. package/dist/chunk-V656XVXV.js +8 -0
  31. package/dist/chunk-WKUJ7CFO.js +1 -0
  32. package/dist/chunk-WOT6VMZA.js +1 -0
  33. package/dist/chunk-WUPU6LTD.js +4 -0
  34. package/dist/chunk-WX3V5P7U.js +5 -0
  35. package/dist/chunk-ZKZ6WCI3.js +2 -0
  36. package/dist/main.js +94 -536
  37. package/package.json +15 -62
  38. package/dist/account-credential.js +0 -136
  39. package/dist/account-credential.test.js +0 -38
  40. package/dist/activity.js +0 -124
  41. package/dist/agent-defs.js +0 -237
  42. package/dist/agent-limiter.js +0 -68
  43. package/dist/agents.js +0 -554
  44. package/dist/anim.js +0 -361
  45. package/dist/args.js +0 -228
  46. package/dist/at-file.js +0 -28
  47. package/dist/brand-extension.js +0 -87
  48. package/dist/brand.js +0 -76
  49. package/dist/browser.js +0 -58
  50. package/dist/cache-report.js +0 -139
  51. package/dist/checkpoint.js +0 -437
  52. package/dist/claims.js +0 -167
  53. package/dist/cli-auth.js +0 -142
  54. package/dist/cli-checkin.js +0 -116
  55. package/dist/cli-credentials.js +0 -254
  56. package/dist/clipboard.js +0 -149
  57. package/dist/collapse.js +0 -129
  58. package/dist/commands.js +0 -301
  59. package/dist/compaction-guard.js +0 -81
  60. package/dist/context-extension.js +0 -17
  61. package/dist/dcp/commands.js +0 -172
  62. package/dist/dcp/compress-state.js +0 -139
  63. package/dist/dcp/compress.js +0 -329
  64. package/dist/dcp/dcp-extension.js +0 -288
  65. package/dist/dcp/dedup.js +0 -93
  66. package/dist/dcp/ids.js +0 -189
  67. package/dist/dcp/index.js +0 -25
  68. package/dist/dcp/prompts.js +0 -68
  69. package/dist/dcp/protected-content.js +0 -221
  70. package/dist/dcp/protected.js +0 -161
  71. package/dist/dcp/prune.js +0 -418
  72. package/dist/dcp/state.js +0 -176
  73. package/dist/device-id.js +0 -90
  74. package/dist/device-id.test.js +0 -23
  75. package/dist/errors.js +0 -63
  76. package/dist/extensions.js +0 -48
  77. package/dist/fatal.js +0 -35
  78. package/dist/feed.js +0 -498
  79. package/dist/fs-atomic.js +0 -54
  80. package/dist/gateway-errors.js +0 -90
  81. package/dist/git.js +0 -230
  82. package/dist/goal-extension.js +0 -88
  83. package/dist/goal.js +0 -222
  84. package/dist/hooks-extension.js +0 -176
  85. package/dist/hooks.js +0 -443
  86. package/dist/howtofix-account.js +0 -715
  87. package/dist/hud-line.js +0 -153
  88. package/dist/hwid.js +0 -130
  89. package/dist/interrupt.js +0 -60
  90. package/dist/json-events.js +0 -50
  91. package/dist/level-style.js +0 -112
  92. package/dist/loop-guard.js +0 -95
  93. package/dist/lsp-tools.js +0 -32
  94. package/dist/lsp.js +0 -347
  95. package/dist/mcp-config.js +0 -85
  96. package/dist/mcp-lifecycle.js +0 -147
  97. package/dist/model-catalog.js +0 -217
  98. package/dist/model-resolve.js +0 -65
  99. package/dist/notes.js +0 -78
  100. package/dist/oauth-login.js +0 -568
  101. package/dist/paths.js +0 -61
  102. package/dist/permissions.js +0 -298
  103. package/dist/plan-mode.js +0 -363
  104. package/dist/preset-command.js +0 -227
  105. package/dist/preset-extension.js +0 -35
  106. package/dist/preset-mcp.js +0 -292
  107. package/dist/presets.js +0 -727
  108. package/dist/prompt-files.js +0 -103
  109. package/dist/prompt-text.js +0 -41
  110. package/dist/proxy.js +0 -137
  111. package/dist/quality/ast.js +0 -196
  112. package/dist/quality/cognitive.js +0 -83
  113. package/dist/quality/cyclomatic.js +0 -30
  114. package/dist/quality/halstead.js +0 -83
  115. package/dist/quality/index.js +0 -18
  116. package/dist/quality/length.js +0 -3
  117. package/dist/quality/maintainability.js +0 -15
  118. package/dist/quality/nesting.js +0 -57
  119. package/dist/quality/report.js +0 -803
  120. package/dist/quality/smells.js +0 -67
  121. package/dist/quality-tool.js +0 -63
  122. package/dist/read-only-commands.js +0 -597
  123. package/dist/repo-map.js +0 -292
  124. package/dist/rtk.js +0 -193
  125. package/dist/sandbox.js +0 -105
  126. package/dist/schema.js +0 -214
  127. package/dist/session-lifecycle.js +0 -19
  128. package/dist/session-retention.js +0 -111
  129. package/dist/session.js +0 -446
  130. package/dist/sessions.js +0 -100
  131. package/dist/settings.js +0 -148
  132. package/dist/skills.js +0 -35
  133. package/dist/slop-tool.js +0 -96
  134. package/dist/spend-cap.js +0 -43
  135. package/dist/subagent.js +0 -171
  136. package/dist/tar.js +0 -146
  137. package/dist/theme.js +0 -79
  138. package/dist/thinking.js +0 -45
  139. package/dist/todo.js +0 -114
  140. package/dist/tool-loadout.js +0 -97
  141. package/dist/tool-render.js +0 -769
  142. package/dist/tools.js +0 -176
  143. package/dist/ui.js +0 -4751
  144. package/dist/update.js +0 -533
  145. package/dist/usage-segment.js +0 -69
  146. package/dist/web-tools.js +0 -46
  147. package/dist/web.js +0 -265
  148. package/dist/welcome.js +0 -179
  149. package/dist/workflow-engine.js +0 -246
  150. package/dist/workflow-progress.js +0 -446
  151. package/dist/workflow-view.js +0 -195
  152. package/dist/workflow.js +0 -349
package/dist/settings.js DELETED
@@ -1,148 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import { writeFileAtomicSync } from "./fs-atomic.js";
4
- export const DEFAULT_WORKBENCH_SETTINGS = {
5
- skipPermissions: false,
6
- autoUpdate: true,
7
- rightClickPaste: "auto",
8
- welcomeSeen: false,
9
- compressToolOutput: false,
10
- };
11
- function workbenchSettingsPath(agentDir) {
12
- return join(agentDir, "settings.json");
13
- }
14
- /**
15
- * The stored preferences, with anything missing filled from the defaults.
16
- *
17
- * A non-boolean `skipPermissions` is ignored rather than coerced: `"false"` is
18
- * truthy, and a value that did not come from this file is not a decision the
19
- * user made here. `rightClickPaste` is read the same way — only the three
20
- * spellings this CLI writes are accepted, because an unrecognised one is not a
21
- * preference and guessing at it would be inventing a setting.
22
- */
23
- export function readWorkbenchSettings(agentDir) {
24
- try {
25
- const parsed = JSON.parse(readFileSync(workbenchSettingsPath(agentDir), "utf8"));
26
- const boolean = (key) => {
27
- const value = parsed[key];
28
- return typeof value === "boolean" ? value : DEFAULT_WORKBENCH_SETTINGS[key];
29
- };
30
- const rightClick = parsed.rightClickPaste;
31
- return {
32
- skipPermissions: boolean("skipPermissions"),
33
- autoUpdate: boolean("autoUpdate"),
34
- rightClickPaste: rightClick === "on" || rightClick === "off" || rightClick === "auto" ? rightClick : DEFAULT_WORKBENCH_SETTINGS.rightClickPaste,
35
- welcomeSeen: boolean("welcomeSeen"),
36
- compressToolOutput: boolean("compressToolOutput"),
37
- // Only a positive integer is a preference. Anything else is an
38
- // unrecognised value, and guessing at it would be inventing a setting.
39
- ...(typeof parsed.agents === "number" && Number.isInteger(parsed.agents) && parsed.agents > 0 ? { agents: parsed.agents } : {}),
40
- };
41
- }
42
- catch {
43
- return { ...DEFAULT_WORKBENCH_SETTINGS };
44
- }
45
- }
46
- /**
47
- * Store one preference, leaving every other key in the file untouched.
48
- *
49
- * Returns false when the file exists but cannot be parsed, so the caller can
50
- * report it instead of silently discarding the change.
51
- */
52
- export function writeWorkbenchSetting(agentDir, key, value) {
53
- return writeSetting(agentDir, key, value);
54
- }
55
- /**
56
- * Record that the first-run greeting has been shown.
57
- *
58
- * Takes no value: the only writer is the welcome screen, which sets this once
59
- * and never clears it, so "already seen" has a single spelling and there is no
60
- * parameter through which it could ever be un-set.
61
- */
62
- export function markWelcomeSeen(agentDir) {
63
- return writeSetting(agentDir, "welcomeSeen", true);
64
- }
65
- /**
66
- * Store the right-click-paste preference.
67
- *
68
- * `"auto"` is stored by *removing* the key, so a file that never mentions it and
69
- * a file that says `"auto"` mean the same thing and there is one spelling of the
70
- * default rather than two.
71
- */
72
- export function writeRightClickPasteSetting(agentDir, value) {
73
- return writeSetting(agentDir, "rightClickPaste", value === "auto" ? undefined : value);
74
- }
75
- /**
76
- * Whether the CLI should paste on a right click.
77
- *
78
- * On Windows it must not: Windows Terminal and the legacy console both paste on
79
- * a right click themselves, and the SDK's own mouse handler passes the click
80
- * through to them, so handling it here as well pasted the clipboard twice. That
81
- * is the bug this setting exists for — the CLI used to answer this question with
82
- * a bare `process.platform !== "win32"` and the SDK with `platform === "win32"`,
83
- * so between the two of them nothing handled it.
84
- *
85
- * Elsewhere it must: mouse tracking has already stopped the terminal from
86
- * pasting, so the click reaches the app and a right click would do nothing at
87
- * all without this.
88
- *
89
- * `"on"` and `"off"` are the user overruling that, for a terminal configured
90
- * unlike its platform's default.
91
- */
92
- export function rightClickPastesHere(preference) {
93
- if (preference === "on")
94
- return true;
95
- if (preference === "off")
96
- return false;
97
- return process.platform !== "win32";
98
- }
99
- /**
100
- * Store a string preference, or clear it when the value is empty.
101
- *
102
- * A separate entry point from the boolean one because the two are not the same
103
- * kind of change: a cleared string is a key that should stop existing, so
104
- * `/proxy off` removes it rather than writing `""` — a file that says
105
- * `"httpsProxy": ""` invites the reader to wonder whether an empty proxy is a
106
- * proxy.
107
- */
108
- /**
109
- * Store the agent-concurrency preference.
110
- *
111
- * `undefined` (or anything that is not a positive integer) means "size it to the
112
- * machine", so the key is removed — one spelling of the default rather than two.
113
- */
114
- export function writeAgentsSetting(agentDir, value) {
115
- const wanted = typeof value === "number" && Number.isInteger(value) && value > 0 ? value : undefined;
116
- return writeSetting(agentDir, "agents", wanted);
117
- }
118
- export function writeStringSetting(agentDir, key, value) {
119
- const trimmed = value.trim();
120
- return writeSetting(agentDir, key, trimmed === "" ? undefined : trimmed);
121
- }
122
- function writeSetting(agentDir, key, value) {
123
- const path = workbenchSettingsPath(agentDir);
124
- let parsed = {};
125
- try {
126
- const candidate = JSON.parse(readFileSync(path, "utf8"));
127
- if (candidate && typeof candidate === "object" && !Array.isArray(candidate))
128
- parsed = candidate;
129
- }
130
- catch {
131
- // A file that exists but does not parse is the user's, and is not ours to
132
- // overwrite. A missing file is the normal first write.
133
- if (existsSync(path))
134
- return false;
135
- }
136
- if (value === undefined)
137
- delete parsed[key];
138
- else
139
- parsed[key] = value;
140
- try {
141
- mkdirSync(dirname(path), { recursive: true });
142
- writeFileAtomicSync(path, `${JSON.stringify(parsed, null, 2)}\n`);
143
- }
144
- catch {
145
- return false;
146
- }
147
- return true;
148
- }
package/dist/skills.js DELETED
@@ -1,35 +0,0 @@
1
- import { USER_SKILLS_DIR, projectSkillsDir } from "./paths.js";
2
- export function skillSearchPaths(cwd) {
3
- return [USER_SKILLS_DIR, projectSkillsDir(cwd)];
4
- }
5
- /**
6
- * Recognise a skill invocation and normalise it to `/skill:<name> [args]`.
7
- *
8
- * Accepts both `/skill:name` and the friendlier `/skill name`. The SDK's
9
- * expander only looks for `/skill:`, so the space form has to be rewritten
10
- * before it reaches `session.prompt`. Returns `undefined` for an unknown
11
- * skill name or any other leading slash — those stay with `runSlash`.
12
- */
13
- export function skillInvocation(text, names) {
14
- const trimmed = text.trim();
15
- const match = /^\/skill:([^\s]+)(?:\s+([\s\S]*))?$/.exec(trimmed) ?? /^\/skill\s+([^\s]+)(?:\s+([\s\S]*))?$/.exec(trimmed);
16
- if (!match)
17
- return undefined;
18
- const name = match[1] ?? "";
19
- if (!names.includes(name))
20
- return undefined;
21
- const args = (match[2] ?? "").trim();
22
- return args ? `/skill:${name} ${args}` : `/skill:${name}`;
23
- }
24
- export function formatSkillList(skills) {
25
- if (skills.length === 0) {
26
- const [userDir, projectDir] = skillSearchPaths(process.cwd());
27
- return ["no skills loaded.", `put a SKILL.md in ${userDir}`, `or in ${projectDir}`, "invoke with /skill:<name>"].join("\n");
28
- }
29
- return skills
30
- .map((skill) => {
31
- const flag = skill.disableModelInvocation ? " (invoke only)" : "";
32
- return `${skill.name}${flag}\n ${skill.description}\n ${skill.filePath}`;
33
- })
34
- .join("\n");
35
- }
package/dist/slop-tool.js DELETED
@@ -1,96 +0,0 @@
1
- /**
2
- * The `slop` tool — the gate, callable by the agent on itself.
3
- *
4
- * Read-only by construction: it analyses and reports, it never edits. That is
5
- * what makes it worth offering. A run that ends with "done" can be checked
6
- * against a list instead of a claim, and the list names a rule and a line, so
7
- * the agent can act on it before reporting rather than after being asked.
8
- *
9
- * It runs `scripts/slop.mjs` as a child process rather than importing it. The
10
- * script is the gate: it is what pre-commit runs and what CI would run, and a
11
- * second in-process implementation would be a different checker wearing the
12
- * same name. The contract is the CLI's own `--json` output.
13
- *
14
- * Defaults to the changed files. A whole-project scan takes a few seconds and
15
- * buries the one line the agent just wrote under 66 accepted findings, which is
16
- * the opposite of useful when the question is "did I introduce something".
17
- */
18
- import { spawnSync } from "node:child_process";
19
- import { join } from "node:path";
20
- import { defineTool } from "@netrodev/hxf-coding-agent";
21
- import { Type } from "typebox";
22
- /** Longest report handed back, so one call cannot flood the context. */
23
- const MAX_REPORT_CHARS = 6000;
24
- export const slopTool = defineTool({
25
- name: "slop",
26
- label: "slop",
27
- description: "Check code against the project's slop gate: naming, comments, placeholder values, swallowed errors, and the LLM tells that appear in model-written code. Read-only. Use it before reporting a task finished; it returns each finding as a rule id, a line, and what is wrong, so a claim of clean code becomes something the user can verify. Defaults to the changed files; pass a path to narrow it.",
28
- promptSnippet: "slop: naming, comment and placeholder findings for changed code",
29
- parameters: Type.Object({
30
- path: Type.Optional(Type.String({ description: "One file or directory, relative to the project root. Omit to scan the changed set." })),
31
- changes: Type.Optional(Type.Boolean({ description: "Scan only files changed against the base ref. Default true when path is omitted." })),
32
- base: Type.Optional(Type.String({ description: "Git ref for `changes`. Defaults to HEAD." })),
33
- }),
34
- async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
35
- const cwd = ctx.cwd ?? process.cwd();
36
- const script = join(cwd, "scripts", "slop.mjs");
37
- const args = [script, "--json"];
38
- if (params.path !== undefined)
39
- args.push(params.path);
40
- else if (params.changes ?? true)
41
- args.push("--changes");
42
- if (params.base !== undefined)
43
- args.push("--base", params.base);
44
- const run = spawnSync("node", args, { cwd, encoding: "utf8", timeout: 120_000 });
45
- // The gate reports "off" through its own switch, and its exit code carries
46
- // meaning: 0 is advisory, 1 would be a strict failure this mode never asks
47
- // for. An unparseable body is the failure to report, not the exit code.
48
- let parsed;
49
- try {
50
- parsed = JSON.parse(run.stdout);
51
- }
52
- catch {
53
- parsed = undefined;
54
- }
55
- if (!parsed) {
56
- // A spawn that never started, or one the timeout killed, leaves `stdout`
57
- // and `stderr` null and puts the reason in `error`/`signal` instead. Reading
58
- // only the streams reported "no output" for both — the one message that
59
- // tells the reader nothing, while the cause (`spawnSync node ENOENT`, a
60
- // 120 s timeout) was right there and discarded.
61
- const detail = (run.stderr || run.stdout || "").trim() || run.error?.message || (run.signal ? `killed by ${run.signal} (timed out after 120 s?)` : "no output");
62
- return {
63
- content: [{ type: "text", text: `slop gate produced no report: ${detail.slice(0, 400)}` }],
64
- details: { error: detail.slice(0, 400) },
65
- };
66
- }
67
- if (parsed.off) {
68
- return {
69
- content: [{ type: "text", text: "slop gate is off" }],
70
- details: { off: true },
71
- };
72
- }
73
- const files = parsed.files ?? {};
74
- const lines = [];
75
- let findings = 0;
76
- for (const [file, list] of Object.entries(files)) {
77
- lines.push(`${file}`);
78
- for (const finding of list) {
79
- findings += 1;
80
- lines.push(` ${finding.rule} L${finding.line} ${finding.message}`);
81
- }
82
- }
83
- if (findings === 0) {
84
- return {
85
- content: [{ type: "text", text: "slop gate: no findings in the scanned files" }],
86
- details: { files: Object.keys(files).length, findings: 0 },
87
- };
88
- }
89
- const report = lines.join("\n").slice(0, MAX_REPORT_CHARS);
90
- const tail = report.length < lines.join("\n").length ? "\n… truncated" : "";
91
- return {
92
- content: [{ type: "text", text: `${findings} finding${findings === 1 ? "" : "s"}:\n\n${report}${tail}` }],
93
- details: { files: Object.keys(files).length, findings },
94
- };
95
- },
96
- });
package/dist/spend-cap.js DELETED
@@ -1,43 +0,0 @@
1
- export function createSpendCap(limit) {
2
- let carried = 0;
3
- let hit;
4
- return {
5
- limit,
6
- close(cost) {
7
- carried += cost;
8
- },
9
- total(liveCost) {
10
- return carried + liveCost;
11
- },
12
- check(liveCost) {
13
- if (limit === undefined || hit !== undefined)
14
- return hit;
15
- const spent = carried + liveCost;
16
- // A carried total is a sum of doubles, so a session meant to land exactly
17
- // on the limit can come out a few ulps over it (0.9 + 0.05 is already
18
- // 0.9500000000000001) and fire a budget the user never overspent. A tenth
19
- // of a nano-dollar is far below any charge the gateway reports, and it is
20
- // what keeps "spent exactly the budget" from counting as over.
21
- if (spent - limit > 1e-9)
22
- hit = { spent, limit };
23
- return hit;
24
- },
25
- };
26
- }
27
- /**
28
- * The sentence both modes say, so `--print` and the TUI cannot drift into two
29
- * different descriptions of the same stop.
30
- */
31
- export function spendCapMessage(spent, limit) {
32
- return `stopped — spent $${spent.toFixed(4)} of a $${limit.toFixed(2)} budget.`;
33
- }
34
- /**
35
- * The refusal for a cap pointed at a model that declares no prices: every turn
36
- * reports $0, so the cap can never fire and only *looks* enforced. Refusing up
37
- * front is the honest version. Shared by both modes for the same reason.
38
- */
39
- export function unpricedBudgetMessage(modelLabel, stateDir) {
40
- return (`--max-budget-usd needs a model with prices, and ${modelLabel} declares none ` +
41
- `(its rates are zero, so every turn costs $0 and the budget would never fire). ` +
42
- `Add a cost to that model in ${stateDir}/models.json, or drop the flag.`);
43
- }
package/dist/subagent.js DELETED
@@ -1,171 +0,0 @@
1
- import { Type } from "@netrodev/hxf-ai";
2
- import { defineTool } from "@netrodev/hxf-coding-agent";
3
- import { agents, MAX_TASKS_PER_CALL, drain, modelRef, spawn, startBackgroundDrain, summarizeRun } from "./agents.js";
4
- import { createAgentWorktree } from "./git.js";
5
- export { listRuns, runs, showRun } from "./agents.js";
6
- /** The agents a caller can name, as one clause. */
7
- function describeAgents() {
8
- return [...agents.values()].map((agent) => `${agent.name} (${agent.description})`).join(", ");
9
- }
10
- /**
11
- * Build the `task` tool.
12
- *
13
- * A factory rather than a constant because the description has to list the
14
- * agents, and definition files are read after this module is imported. A
15
- * constant would advertise the four built-ins for the life of the process, so a
16
- * user's own agent would exist while the model was never told it is there —
17
- * which is the same as it not existing.
18
- */
19
- export function createTaskTool() {
20
- return defineTool({
21
- name: "task",
22
- label: "task",
23
- description: `Spawn subagents. Available: ${describeAgents()}. One call can carry tasks[]; each item may set agent, model, effort and isolation. Agents run concurrently, park when done, and can be resumed or messaged with /agents. Pass background: true to return as soon as they start instead of waiting for them. Use architect to design an approach before a large change. Set isolation: "worktree" on every task that writes files.`,
24
- promptSnippet: "task: spawn background agents",
25
- parameters: Type.Object({
26
- agent: Type.Optional(Type.String()),
27
- effort: Type.Optional(Type.String()),
28
- model: Type.Optional(Type.String()),
29
- task: Type.Optional(Type.String()),
30
- /**
31
- * `"worktree"` gives the tasks in this call their own checkouts. Off by
32
- * default: a copy costs a directory and a `git worktree add`, which buys
33
- * nothing for a read-only lookup.
34
- */
35
- isolation: Type.Optional(Type.String()),
36
- /** Start them and return, instead of waiting for the results. */
37
- background: Type.Optional(Type.Boolean()),
38
- tasks: Type.Optional(Type.Array(Type.Object({
39
- agent: Type.Optional(Type.String()),
40
- effort: Type.Optional(Type.String()),
41
- model: Type.Optional(Type.String()),
42
- task: Type.String(),
43
- isolation: Type.Optional(Type.String()),
44
- }))),
45
- }),
46
- executionMode: "parallel",
47
- async execute(_toolCallId, params, signal, onUpdate, ctx) {
48
- const asked = params.tasks && params.tasks.length > 0
49
- ? params.tasks.map((item) => ({
50
- agent: item.agent ?? params.agent ?? "task",
51
- task: item.task,
52
- effort: item.effort ?? params.effort,
53
- model: item.model ?? params.model,
54
- isolation: item.isolation ?? params.isolation,
55
- }))
56
- : [
57
- {
58
- agent: params.agent ?? "task",
59
- task: params.task ?? "",
60
- effort: params.effort,
61
- model: params.model,
62
- isolation: params.isolation,
63
- },
64
- ];
65
- // One call cannot queue more than the host can carry. Saying so beats
66
- // silently dropping the extras, which would read as children that never ran.
67
- const items = asked.slice(0, MAX_TASKS_PER_CALL);
68
- const dropped = asked.length - items.length;
69
- const background = Boolean(params.background);
70
- const mine = [];
71
- // One checkout per task, created before the run exists so `spawn` can be
72
- // pointed at it. `openAgentSession` resolves `run.worktree || run.cwd`, so a
73
- // run that never gets one behaves exactly as it did before this option.
74
- //
75
- // A task that asked for isolation and did not get it (not a git repository, or
76
- // `worktree add` refused) still runs, in the shared tree. It has to: refusing
77
- // would turn an unavailable optimisation into a failed task. The note says so,
78
- // because a silent fallback to the shared tree is the collision this option
79
- // exists to prevent, and the caller is the only one who can decide to retry.
80
- const created = [];
81
- const unisolated = [];
82
- // Directories too large to copy into a worktree. Reported rather than left
83
- // implicit: an agent that cannot install or build will otherwise report a pass it
84
- // never checked, and the caller would have no way to know why.
85
- const unshared = [];
86
- try {
87
- for (const item of items) {
88
- if (!item.task.trim())
89
- continue;
90
- let worktree;
91
- if (item.isolation === "worktree") {
92
- const made = await createAgentWorktree(ctx?.cwd ?? process.cwd(), item.agent);
93
- if (made) {
94
- created.push(made);
95
- worktree = made.path;
96
- for (const name of made.skipped)
97
- unshared.push(`${item.agent}:${name}`);
98
- }
99
- else {
100
- unisolated.push(item.agent);
101
- }
102
- }
103
- // The workspace the tool call runs in, so the child writes into the
104
- // session's project rather than the CLI process's own directory. The
105
- // parent's model rides along so a child that names none runs the model
106
- // the user is actually paying for, not the runtime's default.
107
- mine.push(await spawn({ ...item, cwd: ctx?.cwd, background, parentModel: modelRef(ctx?.model), worktree }));
108
- onUpdate?.({
109
- content: [],
110
- details: { agents: mine.map((run) => ({ id: run.id, agent: run.agent, status: run.status })) },
111
- });
112
- }
113
- if (mine.length === 0)
114
- throw new Error("task or tasks is required");
115
- const overflow = dropped > 0 ? `(only the first ${MAX_TASKS_PER_CALL} of ${asked.length} tasks ran)` : "";
116
- const isolated = mine.filter((run) => run.worktree).length;
117
- const notes = [
118
- overflow,
119
- unisolated.length > 0 ? `isolation: "worktree" was requested but not available for: ${unisolated.join(", ")}. Those agents ran in the shared working tree — check their edits for collisions before trusting them.` : "",
120
- unshared.length > 0 ? `too large to copy into the isolated worktree, so it was left out: ${unshared.join(", ")}. An agent that needs it cannot install or build — treat any "done" from those runs as unverified.` : "",
121
- ].filter(Boolean);
122
- const details = {
123
- agents: mine.map((run) => ({ id: run.id, status: run.status, isolated: Boolean(run.worktree) })),
124
- };
125
- if (background) {
126
- // The pump is started, not awaited: this turn ends here and the session
127
- // is free for the next one. Each run announces itself through the
128
- // finished-run handler as it settles.
129
- startBackgroundDrain();
130
- const ids = mine.map((run) => `${run.agent}#${run.id}`).join(", ");
131
- return {
132
- content: [
133
- {
134
- type: "text",
135
- text: `started ${mine.length} agent${mine.length === 1 ? "" : "s"} in the background: ${ids}.` +
136
- (isolated > 0 ? ` ${isolated} of them in their own worktree.` : "") +
137
- "\nTheir results are not part of this turn — keep working. Read them with /agents, or message one with /agents <id> <text>." +
138
- (notes.length > 0 ? `\n${notes.join("\n")}` : ""),
139
- },
140
- ],
141
- details,
142
- };
143
- }
144
- await drain(signal);
145
- const parts = mine.map((run) => `${summarizeRun(run)}${run.worktree ? " [isolated]" : ""}\n${run.result ?? ""}`);
146
- if (notes.length > 0)
147
- parts.push(notes.join("\n"));
148
- return { content: [{ type: "text", text: parts.join("\n\n") }], details };
149
- }
150
- finally {
151
- // The checkouts go when the call is done with them, successes and failures
152
- // alike. A worktree left behind is invisible to the project's own
153
- // `git status` (it lives in the temp dir) and would accumulate silently.
154
- //
155
- // Removal is safe here even though it looks like it discards work: a
156
- // detached worktree holds no branch, so the only edits that exist are
157
- // uncommitted ones, and they were made by an agent whose result is
158
- // already in `run.result`. `createAgentWorktree` places the tree at HEAD,
159
- // and the run's output — not the directory — is the deliverable.
160
- for (const worktree of created) {
161
- try {
162
- await worktree.remove();
163
- }
164
- catch {
165
- // Never throws by contract, but a throw here must not mask the result.
166
- }
167
- }
168
- }
169
- },
170
- });
171
- }
package/dist/tar.js DELETED
@@ -1,146 +0,0 @@
1
- /**
2
- * A minimal tar reader.
3
- *
4
- * Presets are installed from a GitHub tarball, and Node ships `zlib` but not a
5
- * tar parser. Adding a dependency for this would put a third-party package on
6
- * the path that runs whenever a user installs a preset — a large amount of
7
- * trust for reading a format that has not changed since 1988. The subset below
8
- * is the whole of what GitHub emits: ustar headers, pax extended headers for
9
- * long paths, and GNU long-name entries.
10
- *
11
- * Nothing here writes to disk. `parseTar` returns entries and the caller decides
12
- * what to do with them, so the path rules are in one place and testable without
13
- * a filesystem.
14
- */
15
- import { gunzipSync } from "node:zlib";
16
- const BLOCK = 512;
17
- function readCString(buf, start, length) {
18
- const slice = buf.subarray(start, start + length);
19
- const end = slice.indexOf(0);
20
- return slice
21
- .subarray(0, end === -1 ? slice.length : end)
22
- .toString("utf8")
23
- .trim();
24
- }
25
- function readOctal(buf, start, length) {
26
- const text = readCString(buf, start, length);
27
- if (!text)
28
- return 0;
29
- const value = Number.parseInt(text, 8);
30
- return Number.isFinite(value) ? value : 0;
31
- }
32
- /** Apply a pax extended header's records to the next entry. */
33
- function paxOverrides(data) {
34
- const out = {};
35
- let offset = 0;
36
- while (offset < data.length) {
37
- const space = data.indexOf(0x20, offset);
38
- if (space === -1)
39
- break;
40
- const length = Number.parseInt(data.toString("utf8", offset, space), 10);
41
- if (!Number.isFinite(length) || length <= 0)
42
- break;
43
- const record = data.toString("utf8", space + 1, offset + length - 1);
44
- const eq = record.indexOf("=");
45
- if (eq > 0)
46
- out[record.slice(0, eq)] = record.slice(eq + 1);
47
- offset += length;
48
- }
49
- return out;
50
- }
51
- /**
52
- * Parse a gzipped tar. Returns entries in archive order.
53
- *
54
- * The leading directory component is dropped, so paths are relative to the
55
- * repository root. GitHub names that component `<repo>-<sha>`, which would
56
- * otherwise have to be guessed by every caller.
57
- */
58
- export function parseTarGz(archive) {
59
- const tar = gunzipSync(archive);
60
- const entries = [];
61
- let offset = 0;
62
- let pendingName;
63
- let pendingPax = {};
64
- while (offset + BLOCK <= tar.length) {
65
- const header = tar.subarray(offset, offset + BLOCK);
66
- // Two zero blocks end the archive; a single one is enough to stop reading.
67
- if (header.every((b) => b === 0))
68
- break;
69
- const name = readCString(header, 0, 100);
70
- const prefix = readCString(header, 345, 155);
71
- const size = readOctal(header, 124, 12);
72
- const mode = readOctal(header, 100, 8);
73
- const typeFlag = String.fromCharCode(header[156] ?? 0x30);
74
- const linkName = readCString(header, 157, 100);
75
- const data = size > 0 ? tar.subarray(offset + BLOCK, offset + BLOCK + size) : Buffer.alloc(0);
76
- offset += BLOCK + Math.ceil(size / BLOCK) * BLOCK;
77
- if (typeFlag === "x" || typeFlag === "g") {
78
- pendingPax = paxOverrides(data);
79
- continue;
80
- }
81
- if (typeFlag === "L") {
82
- pendingName = data.toString("utf8").replace(/\0.*$/s, "").trim();
83
- continue;
84
- }
85
- let full = pendingPax.path ?? pendingName ?? (prefix ? `${prefix}/${name}` : name);
86
- pendingName = undefined;
87
- pendingPax = {};
88
- // Drop the archive's own root component: `<repo>-<sha>/…` → `…`
89
- const slash = full.indexOf("/");
90
- if (slash === -1)
91
- continue;
92
- full = full.slice(slash + 1);
93
- // Tar marks a directory with a trailing slash. Normalising here means every
94
- // consumer sees one spelling of a path: without this, a directory entry and a
95
- // path comparison disagree (`reference/` vs `reference`), and `subtreeEntries`
96
- // prefix matching has to guess which form it was given.
97
- full = full.replace(/\/+$/, "");
98
- if (!full)
99
- continue;
100
- const kind = typeFlag === "5" ? "directory" : typeFlag === "2" ? "symlink" : typeFlag === "0" || typeFlag === "\0" || typeFlag === "" ? "file" : "other";
101
- entries.push({
102
- path: full,
103
- kind,
104
- size,
105
- mode: mode & 0o777,
106
- ...(kind === "file" ? { data: Buffer.from(data) } : {}),
107
- ...(kind === "symlink" ? { link: linkName } : {}),
108
- });
109
- }
110
- return entries;
111
- }
112
- /**
113
- * True when a path from an archive may be written under a destination.
114
- *
115
- * A tarball is attacker-controlled once a preset points at someone else's
116
- * repository, and `../../.ssh/authorized_keys` is a valid tar path. Absolute
117
- * paths, any `..` component, and drive letters are all refused.
118
- */
119
- export function isSafeRelativePath(path) {
120
- if (!path || path.startsWith("/") || path.startsWith("\\"))
121
- return false;
122
- if (/^[a-zA-Z]:/.test(path))
123
- return false;
124
- // A trailing slash marks a directory entry in tar; it is not an empty
125
- // component. Dropping it is what makes `reference/` (which every archive
126
- // contains) pass, while `a//b` still fails below.
127
- const trimmed = path.replace(/\/+$/, "");
128
- if (!trimmed)
129
- return false;
130
- const parts = trimmed.split("/");
131
- return parts.every((part) => part !== "" && part !== "." && part !== "..");
132
- }
133
- /** Every entry under `subtree` (a repo-relative directory), with the prefix removed. */
134
- export function subtreeEntries(entries, subtree) {
135
- const prefix = `${subtree.replace(/\/+$/, "")}/`;
136
- const out = [];
137
- for (const entry of entries) {
138
- if (!entry.path.startsWith(prefix))
139
- continue;
140
- const path = entry.path.slice(prefix.length);
141
- if (!path)
142
- continue;
143
- out.push({ ...entry, path });
144
- }
145
- return out;
146
- }