howtofixcli 0.7.24 → 0.7.26
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.
- package/dist/account-credential.js +136 -0
- package/dist/acp.js +234 -0
- package/dist/activity.js +124 -0
- package/dist/agent-defs.js +237 -0
- package/dist/agent-limiter.js +68 -0
- package/dist/agents.js +554 -0
- package/dist/anim.js +361 -0
- package/dist/args.js +250 -0
- package/dist/at-file.js +28 -0
- package/dist/brand-extension.js +87 -0
- package/dist/brand.js +76 -0
- package/dist/browser.js +58 -0
- package/dist/cache-report.js +139 -0
- package/dist/checkpoint.js +437 -0
- package/dist/claims.js +167 -0
- package/dist/cli-auth.js +142 -0
- package/dist/cli-checkin.js +121 -0
- package/dist/cli-credentials.js +254 -0
- package/dist/clipboard.js +149 -0
- package/dist/collapse.js +129 -0
- package/dist/commands.js +381 -0
- package/dist/compaction-guard.js +81 -0
- package/dist/connect-prompt.js +22 -0
- package/dist/context-extension.js +17 -0
- package/dist/dcp/commands.js +172 -0
- package/dist/dcp/compress-state.js +139 -0
- package/dist/dcp/compress.js +329 -0
- package/dist/dcp/dcp-extension.js +295 -0
- package/dist/dcp/dedup.js +93 -0
- package/dist/dcp/ids.js +189 -0
- package/dist/dcp/index.js +25 -0
- package/dist/dcp/middle-prune.js +76 -0
- package/dist/dcp/prompts.js +68 -0
- package/dist/dcp/protected-content.js +221 -0
- package/dist/dcp/protected.js +161 -0
- package/dist/dcp/prune.js +418 -0
- package/dist/dcp/state.js +176 -0
- package/dist/errors.js +63 -0
- package/dist/extensions.js +48 -0
- package/dist/fatal.js +35 -0
- package/dist/feed.js +498 -0
- package/dist/fs-atomic.js +54 -0
- package/dist/gateway-errors.js +107 -0
- package/dist/git.js +230 -0
- package/dist/goal-extension.js +88 -0
- package/dist/goal.js +222 -0
- package/dist/hooks-extension.js +176 -0
- package/dist/hooks.js +443 -0
- package/dist/howtofix-account.js +834 -0
- package/dist/hud-line.js +158 -0
- package/dist/hwid.js +130 -0
- package/dist/interrupt.js +60 -0
- package/dist/json-events.js +50 -0
- package/dist/level-style.js +112 -0
- package/dist/loop-guard.js +95 -0
- package/dist/lsp-tools.js +32 -0
- package/dist/lsp.js +429 -0
- package/dist/main.js +573 -98
- package/dist/mcp-config.js +85 -0
- package/dist/mcp-lifecycle.js +147 -0
- package/dist/model-catalog.js +217 -0
- package/dist/model-facts.js +35 -0
- package/dist/model-resolve.js +65 -0
- package/dist/notes.js +78 -0
- package/dist/oauth-login.js +568 -0
- package/dist/paths.js +61 -0
- package/dist/permissions.js +298 -0
- package/dist/plan-mode.js +363 -0
- package/dist/preset-command.js +227 -0
- package/dist/preset-extension.js +35 -0
- package/dist/preset-mcp.js +292 -0
- package/dist/presets/mcp/rea/preset.json +28 -0
- package/dist/presets/mcp/rea/prompt.md +5 -0
- package/dist/presets/mcp/rea/skills/mcp-rea/SKILL.md +6 -0
- package/dist/presets/mcp/remotion/preset.json +27 -0
- package/dist/presets/mcp/remotion/prompt.md +7 -0
- package/dist/presets/mcp/remotion/skills/mcp-remotion/SKILL.md +6 -0
- package/dist/presets.js +727 -0
- package/dist/prompt-files.js +103 -0
- package/dist/prompt-text.js +41 -0
- package/dist/proxy.js +137 -0
- package/dist/quality/ast.js +196 -0
- package/dist/quality/cognitive.js +83 -0
- package/dist/quality/cyclomatic.js +30 -0
- package/dist/quality/halstead.js +83 -0
- package/dist/quality/index.js +18 -0
- package/dist/quality/length.js +3 -0
- package/dist/quality/maintainability.js +15 -0
- package/dist/quality/nesting.js +57 -0
- package/dist/quality/report.js +803 -0
- package/dist/quality/smells.js +67 -0
- package/dist/quality-tool.js +63 -0
- package/dist/read-only-commands.js +597 -0
- package/dist/repo-map.js +292 -0
- package/dist/roblox-connect.js +125 -0
- package/dist/rpc.js +91 -0
- package/dist/rtk.js +193 -0
- package/dist/sandbox.js +105 -0
- package/dist/schema.js +214 -0
- package/dist/serve.js +50 -0
- package/dist/session-lifecycle.js +19 -0
- package/dist/session-retention.js +111 -0
- package/dist/session.js +460 -0
- package/dist/sessions.js +100 -0
- package/dist/settings.js +148 -0
- package/dist/skills.js +35 -0
- package/dist/slop-tool.js +96 -0
- package/dist/spend-cap.js +43 -0
- package/dist/subagent.js +171 -0
- package/dist/tar.js +146 -0
- package/dist/theme.js +79 -0
- package/dist/thinking.js +45 -0
- package/dist/todo.js +114 -0
- package/dist/tool-loadout.js +102 -0
- package/dist/tool-render.js +769 -0
- package/dist/tools.js +176 -0
- package/dist/ui.js +4931 -0
- package/dist/update.js +551 -0
- package/dist/usage-segment.js +74 -0
- package/dist/web-tools.js +46 -0
- package/dist/web.js +265 -0
- package/dist/welcome.js +179 -0
- package/dist/workflow-engine.js +246 -0
- package/dist/workflow-progress.js +446 -0
- package/dist/workflow-view.js +195 -0
- package/dist/workflow.js +349 -0
- package/package.json +62 -15
- package/dist/chunk-27TRKD65.js +0 -2
- package/dist/chunk-3FYCFLNK.js +0 -1
- package/dist/chunk-3LZR7OEU.js +0 -1
- package/dist/chunk-56SEAGAQ.js +0 -1
- package/dist/chunk-5AWTL34S.js +0 -1
- package/dist/chunk-75P2SCOT.js +0 -1
- package/dist/chunk-7CHDNTH6.js +0 -1
- package/dist/chunk-7PHRNXLN.js +0 -34
- package/dist/chunk-AGGIYXN7.js +0 -2
- package/dist/chunk-AUDMRGWF.js +0 -189
- package/dist/chunk-C5E3SFOQ.js +0 -2
- package/dist/chunk-CMTKCUL5.js +0 -1
- package/dist/chunk-DAPXGSGD.js +0 -12
- package/dist/chunk-DOD7MUGX.js +0 -1
- package/dist/chunk-FPXVDP32.js +0 -8
- package/dist/chunk-GXB7RZHH.js +0 -1
- package/dist/chunk-I7KVLBMF.js +0 -2
- package/dist/chunk-ILDDDLRC.js +0 -1
- package/dist/chunk-JFYVRWJI.js +0 -2
- package/dist/chunk-LAHVHRCS.js +0 -2
- package/dist/chunk-LSJC73DG.js +0 -1
- package/dist/chunk-MNDKS6UH.js +0 -6
- package/dist/chunk-N345KWWK.js +0 -1
- package/dist/chunk-N3J2J7EQ.js +0 -5
- package/dist/chunk-ONZLNFQ2.js +0 -3
- package/dist/chunk-ORJ2QZBG.js +0 -3
- package/dist/chunk-PCALBZOO.js +0 -2
- package/dist/chunk-QPHSHL7H.js +0 -2
- package/dist/chunk-TKROS3BE.js +0 -5
- package/dist/chunk-TSEKPGK4.js +0 -1
- package/dist/chunk-UP7ENLEL.js +0 -117
- package/dist/chunk-UTYFLLSM.js +0 -1
- package/dist/chunk-V3R4FYTJ.js +0 -1
- package/dist/chunk-V656XVXV.js +0 -8
- package/dist/chunk-WKUJ7CFO.js +0 -1
- package/dist/chunk-WOT6VMZA.js +0 -1
- package/dist/chunk-WUPU6LTD.js +0 -4
- package/dist/chunk-WX3V5P7U.js +0 -5
- package/dist/chunk-XW6LMQGX.js +0 -1
- package/dist/chunk-YW7NORH3.js +0 -2
package/dist/agents.js
ADDED
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { createAgentSession, DefaultResourceLoader, SessionManager } from "@netrodev/hxf-coding-agent";
|
|
4
|
+
import { AGENT_DIR, AGENTS_STORE_DIR, additionalSkillPaths } from "./paths.js";
|
|
5
|
+
import { contextTool } from "./notes.js";
|
|
6
|
+
import { createContextExtension } from "./context-extension.js";
|
|
7
|
+
import { createPermissionExtension } from "./permissions.js";
|
|
8
|
+
import { todoTool } from "./todo.js";
|
|
9
|
+
import { webFetchTool, webSearchTool } from "./web-tools.js";
|
|
10
|
+
import { lspTool } from "./lsp-tools.js";
|
|
11
|
+
import { qualityTool } from "./quality-tool.js";
|
|
12
|
+
import { claimTool } from "./tools.js";
|
|
13
|
+
import { writeFileAtomicSync } from "./fs-atomic.js";
|
|
14
|
+
import { acquireAgentSlot } from "./agent-limiter.js";
|
|
15
|
+
import { resolveModel } from "./model-resolve.js";
|
|
16
|
+
/**
|
|
17
|
+
* Coordination rule for subagents. Siblings edit the same working
|
|
18
|
+
* tree concurrently, so a project-wide build or test run started by one of them
|
|
19
|
+
* blocks on another's half-finished change and reports failures that are not
|
|
20
|
+
* real. Scoped proof of the agent's own change is what it should run.
|
|
21
|
+
*/
|
|
22
|
+
const SUBAGENT_COOP = [
|
|
23
|
+
"You are one agent on a shared task; siblings may be editing the same working tree right now.",
|
|
24
|
+
"Stay inside the files your assignment names. A file another agent owns is theirs to change: report what you need changed instead of changing it yourself, and never edit a file two agents were both given.",
|
|
25
|
+
"Never run project-wide validation (full builds, whole test suites, formatters, linters) unless your assignment explicitly tells you to.",
|
|
26
|
+
"Siblings' unfinished edits will make those runs fail for reasons that are not yours. Prove your own change with a scoped test, a targeted repro, or a smoke run instead.",
|
|
27
|
+
];
|
|
28
|
+
/**
|
|
29
|
+
* The planning agent, ported from Claude Code's built-in `Plan` agent
|
|
30
|
+
* (`src/tools/AgentTool/built-in/planAgent.ts`).
|
|
31
|
+
*
|
|
32
|
+
* The shape is the reference's, and the point is that it is read-only by
|
|
33
|
+
* construction rather than by instruction: the edit tools are not in its
|
|
34
|
+
* `tools` list, so it cannot write a file even if its prompt is ignored or
|
|
35
|
+
* overridden. The reference enforces the same thing with `disallowedTools`;
|
|
36
|
+
* naming only the read tools achieves it here.
|
|
37
|
+
*
|
|
38
|
+
* One deliberate difference: the reference gives its plan agent `Bash` and
|
|
39
|
+
* relies on the prompt to keep it to read-only commands ("NEVER use Bash for:
|
|
40
|
+
* mkdir, touch, rm, cp, mv, git add, git commit, npm install..."). This agent
|
|
41
|
+
* gets no `bash` at all, because a prompt-level promise is not a guarantee and
|
|
42
|
+
* shell redirection is exactly how a read-only agent writes a file by accident.
|
|
43
|
+
* The cost is that it cannot run `git log` to read history; it can still read
|
|
44
|
+
* every file, which is what planning needs.
|
|
45
|
+
*/
|
|
46
|
+
const ARCHITECT_PROMPT = `You are a software architect planning an implementation. Your job is to explore the codebase and return a plan, not to write code.
|
|
47
|
+
|
|
48
|
+
## Read-only, by construction
|
|
49
|
+
You have no edit tool, no write tool, and no shell. Do not attempt to modify, create, move, or delete anything — the call would fail. Your output is text.
|
|
50
|
+
|
|
51
|
+
## Your process
|
|
52
|
+
1. Understand the requirement. If it is ambiguous in a way that changes the design, say so and name the options rather than silently picking one.
|
|
53
|
+
2. Explore: read the files involved, find the existing patterns and conventions with grep/find/ls, and trace the code paths the change touches. Follow the codebase's existing approach instead of inventing a new one.
|
|
54
|
+
3. Design: state the approach you recommend, not every alternative you considered. Note the trade-off that decided it.
|
|
55
|
+
4. Sequence: give the implementation as ordered steps, naming the files and functions to change at each one, and what to verify after each.
|
|
56
|
+
|
|
57
|
+
## Required output
|
|
58
|
+
End your reply with exactly this section, listing the 3-5 files most critical to the implementation, most important first:
|
|
59
|
+
|
|
60
|
+
### Critical Files for Implementation
|
|
61
|
+
- path/to/file.ts
|
|
62
|
+
- path/to/file.ts
|
|
63
|
+
|
|
64
|
+
Keep the plan concrete enough to execute without re-deriving it: name real paths and real function names you saw while exploring. If you could not confirm something, say which part you are unsure of instead of guessing.`;
|
|
65
|
+
export const agents = new Map([
|
|
66
|
+
[
|
|
67
|
+
"scout",
|
|
68
|
+
{
|
|
69
|
+
name: "scout",
|
|
70
|
+
description: "read-only lookup",
|
|
71
|
+
tools: ["read", "grep", "find", "ls", "context", "web_search", "web_fetch", "lsp"],
|
|
72
|
+
prompt: ["Inspect only what the task needs. Do not edit.", ...SUBAGENT_COOP].join("\n"),
|
|
73
|
+
effort: "low",
|
|
74
|
+
},
|
|
75
|
+
],
|
|
76
|
+
[
|
|
77
|
+
"architect",
|
|
78
|
+
{
|
|
79
|
+
name: "architect",
|
|
80
|
+
description: "designs an implementation plan",
|
|
81
|
+
// No edit/write, and no bash: see ARCHITECT_PROMPT. Read-only is the point.
|
|
82
|
+
// `web_search`/`web_fetch` are lookup, so they belong here too; `lsp` is a
|
|
83
|
+
// read-only language-server query, so the planner gets it as well.
|
|
84
|
+
tools: ["read", "grep", "find", "ls", "context", "web_search", "web_fetch", "lsp"],
|
|
85
|
+
prompt: ARCHITECT_PROMPT,
|
|
86
|
+
// Planning is where thinking pays for itself: the reference inherits the
|
|
87
|
+
// parent model, and the equivalent knob here is effort.
|
|
88
|
+
effort: "high",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
[
|
|
92
|
+
"task",
|
|
93
|
+
{
|
|
94
|
+
name: "task",
|
|
95
|
+
description: "implements the assignment",
|
|
96
|
+
tools: ["read", "bash", "edit", "write", "grep", "find", "ls", "context", "todo", "web_search", "web_fetch", "lsp"],
|
|
97
|
+
prompt: ["Do the assignment, then report files changed.", ...SUBAGENT_COOP].join("\n"),
|
|
98
|
+
effort: "medium",
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
[
|
|
102
|
+
"reviewer",
|
|
103
|
+
{
|
|
104
|
+
name: "reviewer",
|
|
105
|
+
description: "finds concrete issues",
|
|
106
|
+
tools: ["read", "grep", "find", "ls", "context", "web_search", "web_fetch", "lsp"],
|
|
107
|
+
prompt: ["Return issues with file evidence. Do not edit.", ...SUBAGENT_COOP].join("\n"),
|
|
108
|
+
effort: "medium",
|
|
109
|
+
},
|
|
110
|
+
],
|
|
111
|
+
]);
|
|
112
|
+
/**
|
|
113
|
+
* The names shipped in this package.
|
|
114
|
+
*
|
|
115
|
+
* Captured once, right after the map is built, because the map is also where
|
|
116
|
+
* definition files land. Comparing against the live map would make a file
|
|
117
|
+
* definition look like a built-in the second time the loader ran, and the
|
|
118
|
+
* "replaces the built-in" notice would then never fire.
|
|
119
|
+
*/
|
|
120
|
+
export const BUILTIN_AGENT_NAMES = new Set(agents.keys());
|
|
121
|
+
/** `56.6k` for thousands, the raw count below that. */
|
|
122
|
+
export function formatTokens(count) {
|
|
123
|
+
return count >= 1000 ? `${(count / 1000).toFixed(1)}k` : String(count);
|
|
124
|
+
}
|
|
125
|
+
/** `23.7s` under a minute, `1m 34.3s` above it. */
|
|
126
|
+
export function formatDuration(ms) {
|
|
127
|
+
if (ms < 60_000)
|
|
128
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
129
|
+
return `${Math.floor(ms / 60_000)}m ${((ms % 60_000) / 1000).toFixed(1)}s`;
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* One line saying what the child did and what it cost.
|
|
133
|
+
*
|
|
134
|
+
* The report itself is the parent's business — it is what the model reads next —
|
|
135
|
+
* but a reader watching the screen needs the shape of the work: which agent ran,
|
|
136
|
+
* how it ended, and how much it took. Without the numbers a fan-out is opaque,
|
|
137
|
+
* and the one thing that would show a runaway child is exactly what is missing.
|
|
138
|
+
*
|
|
139
|
+
* `parked` reads as `done`: the run finished and can be resumed, which is the
|
|
140
|
+
* same thing a reader means by finished.
|
|
141
|
+
*/
|
|
142
|
+
export function summarizeRun(run) {
|
|
143
|
+
const status = run.status === "parked" ? "done" : run.status === "error" ? "failed" : run.status;
|
|
144
|
+
const bits = [];
|
|
145
|
+
// The model is part of the shape of the work: a fan-out whose children ran on
|
|
146
|
+
// different models cannot be read from the numbers alone.
|
|
147
|
+
if (run.model)
|
|
148
|
+
bits.push(run.model);
|
|
149
|
+
if (typeof run.toolUses === "number")
|
|
150
|
+
bits.push(`${run.toolUses} tool use${run.toolUses === 1 ? "" : "s"}`);
|
|
151
|
+
if (typeof run.tokens === "number" && run.tokens > 0)
|
|
152
|
+
bits.push(formatTokens(run.tokens));
|
|
153
|
+
if (run.startedAt && run.endedAt)
|
|
154
|
+
bits.push(formatDuration(run.endedAt - run.startedAt));
|
|
155
|
+
return `## ${run.agent}#${run.id} ${status}${bits.length > 0 ? ` (${bits.join(" · ")})` : ""}`;
|
|
156
|
+
}
|
|
157
|
+
export const runs = [];
|
|
158
|
+
/**
|
|
159
|
+
* How many children this path runs at once. The width itself lives in the shared
|
|
160
|
+
* limiter, so a `workflow` running at the same time is counted against the same
|
|
161
|
+
* budget rather than doubling it.
|
|
162
|
+
*/
|
|
163
|
+
const MAX_PARALLEL = 3;
|
|
164
|
+
/** Follow-up prompts one run accepts before it is considered out of budget. */
|
|
165
|
+
const MAX_REQUESTS = 8;
|
|
166
|
+
/**
|
|
167
|
+
* How many children one call may ask for.
|
|
168
|
+
*
|
|
169
|
+
* A fan-out is the point of the tool, so the cap is above `MAX_PARALLEL` — a call
|
|
170
|
+
* for six scouts runs three, then three, and the model gets them all. What it
|
|
171
|
+
* stops is the unbounded case: nothing used to limit `tasks[]`, so one call could
|
|
172
|
+
* queue forty children, each opening a session and its own provider stream on a
|
|
173
|
+
* shared box. Measured failure mode without a cap: a fan-out the host cannot
|
|
174
|
+
* carry looks like a hang, not an error.
|
|
175
|
+
*/
|
|
176
|
+
export const MAX_TASKS_PER_CALL = 6;
|
|
177
|
+
/**
|
|
178
|
+
* How many children may be live or parked at once, across calls.
|
|
179
|
+
*
|
|
180
|
+
* Parked runs stay resumable, so they are not free: each one holds a session
|
|
181
|
+
* record and an entry in `/agents`. Without a ceiling a long session accumulates
|
|
182
|
+
* them until every spawn adds to a list nobody prunes.
|
|
183
|
+
*/
|
|
184
|
+
export const MAX_AGENTS = 12;
|
|
185
|
+
function persist(run) {
|
|
186
|
+
mkdirSync(AGENTS_STORE_DIR, { recursive: true });
|
|
187
|
+
writeFileAtomicSync(join(AGENTS_STORE_DIR, `${run.id}.json`), JSON.stringify({ ...run, session: undefined }, null, 1));
|
|
188
|
+
}
|
|
189
|
+
function loadParked() {
|
|
190
|
+
let names;
|
|
191
|
+
try {
|
|
192
|
+
names = readdirSync(AGENTS_STORE_DIR);
|
|
193
|
+
}
|
|
194
|
+
catch {
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
for (const name of names) {
|
|
198
|
+
if (!name.endsWith(".json"))
|
|
199
|
+
continue;
|
|
200
|
+
try {
|
|
201
|
+
const saved = JSON.parse(readFileSync(join(AGENTS_STORE_DIR, name), "utf8"));
|
|
202
|
+
if (saved.status !== "parked" || runs.some((run) => run.id === saved.id))
|
|
203
|
+
continue;
|
|
204
|
+
runs.push({ ...saved, mail: saved.mail ?? [] });
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
// ignore unreadable run files
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
// Parked runs are resumable, which is why they are kept — and why they are
|
|
211
|
+
// not free. `spawn` already refuses past MAX_AGENTS, but it only counts what
|
|
212
|
+
// is in memory, and a fresh process starts empty and then loads every file in
|
|
213
|
+
// the store. Without a trim here the ceiling the cap exists to enforce is
|
|
214
|
+
// crossed the moment the process restarts: measured at 20 loaded against a
|
|
215
|
+
// cap of 12. The oldest go, and a session they still hold is released rather
|
|
216
|
+
// than left attached to a run nothing references any more.
|
|
217
|
+
if (runs.length > MAX_AGENTS) {
|
|
218
|
+
const dropped = runs.splice(0, runs.length - MAX_AGENTS);
|
|
219
|
+
for (const run of dropped) {
|
|
220
|
+
const session = run.session;
|
|
221
|
+
run.session = undefined;
|
|
222
|
+
if (session)
|
|
223
|
+
void session.dispose();
|
|
224
|
+
// The file is what reloads the run. Leaving it means the next process
|
|
225
|
+
// loads the same surplus and drops it again, so the store only ever grows.
|
|
226
|
+
try {
|
|
227
|
+
unlinkSync(join(AGENTS_STORE_DIR, `${run.id}.json`));
|
|
228
|
+
}
|
|
229
|
+
catch {
|
|
230
|
+
// Already gone is the state we wanted.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
export function listRuns() {
|
|
236
|
+
loadParked();
|
|
237
|
+
if (runs.length === 0)
|
|
238
|
+
return "no agents";
|
|
239
|
+
const lines = runs.map((run) => `${run.id} ${run.agent} ${run.status}` + `${run.model ? ` model=${run.model}` : ""}` + `${run.background ? " background" : ""}` + ` requests=${run.requests} ${run.task}`);
|
|
240
|
+
// A background pump is the one thing a reader cannot infer from the rows: every
|
|
241
|
+
// run can read `running` while the queue behind them has not been picked up yet.
|
|
242
|
+
if (backgroundDrain)
|
|
243
|
+
lines.unshift("background work in flight");
|
|
244
|
+
return lines.join("\n");
|
|
245
|
+
}
|
|
246
|
+
export function showRun(id) {
|
|
247
|
+
loadParked();
|
|
248
|
+
const run = runs.find((item) => item.id === id);
|
|
249
|
+
if (!run)
|
|
250
|
+
return `no agent ${id}`;
|
|
251
|
+
return `${run.agent}#${run.id} ${run.status}\n${run.result ?? run.task}`;
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Open the session a run executes in.
|
|
255
|
+
*
|
|
256
|
+
* Exported so the wiring can be checked against a real session rather than a
|
|
257
|
+
* mock: `scripts/check-subagent-context.mjs` uses it to read back the prompt a
|
|
258
|
+
* subagent actually receives.
|
|
259
|
+
*/
|
|
260
|
+
export async function openAgentSession(run) {
|
|
261
|
+
const spec = agents.get(run.agent);
|
|
262
|
+
if (!spec)
|
|
263
|
+
throw new Error(`unknown agent ${run.agent}`);
|
|
264
|
+
// The workspace is the one the run was started in, not the process's own
|
|
265
|
+
// directory. `process.cwd()` is the shell the CLI was launched from, so a
|
|
266
|
+
// subagent spawned from a session in another folder wrote its files into the
|
|
267
|
+
// CLI's directory — measured: the child reported "Files changed: DIRECT.txt"
|
|
268
|
+
// and the file appeared next to the binary instead of in the project.
|
|
269
|
+
const cwd = run.worktree || run.cwd || process.cwd();
|
|
270
|
+
// The agent's own instructions belong in the system prompt, and this loader is
|
|
271
|
+
// the only place that can put them there: `createAgentSession` has no prompt
|
|
272
|
+
// option, so a `prompt` field on the agent definition is inert unless it is
|
|
273
|
+
// handed to the loader. It was previously passed only as the leading text of
|
|
274
|
+
// the first user message, which left the child's system prompt identical to
|
|
275
|
+
// the parent's — the model read the coordination rules as part of one turn's
|
|
276
|
+
// input rather than as its standing instruction, and `check-subagent-prompt`
|
|
277
|
+
// was red because no request carried them in the system message.
|
|
278
|
+
// The shared notes only reach a subagent if the extension that injects them is
|
|
279
|
+
// loaded into the subagent's own resource loader — `customTools` alone would
|
|
280
|
+
// give it the tool but never put the notes in its prompt.
|
|
281
|
+
// The permission gate belongs on a subagent's session too. A spawned agent has
|
|
282
|
+
// no interactive UI, so `currentPrompt` keeps its deny default and every call
|
|
283
|
+
// above the mode's tier is refused instead of run — the fail-closed behaviour
|
|
284
|
+
// a sibling harness shipped as a bug when one tool skipped it. Without this the
|
|
285
|
+
// `task` tool handed its children `bash`, `edit` and `write` with nothing in
|
|
286
|
+
// front of them: `full-auto` from the parent's perspective, in a process the
|
|
287
|
+
// user never sees. Registered first so the gate decides before the context
|
|
288
|
+
// extension's hooks run.
|
|
289
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
290
|
+
cwd,
|
|
291
|
+
agentDir: AGENT_DIR,
|
|
292
|
+
// The agent's own instructions, as a standing system prompt rather than the
|
|
293
|
+
// first line of a user turn. See the note above `cwd`.
|
|
294
|
+
appendSystemPrompt: [spec.prompt],
|
|
295
|
+
// A child that cannot see project skills will ignore a convention the
|
|
296
|
+
// parent just learned; the user dir is already discovered from `agentDir`.
|
|
297
|
+
// Same builder as the main session, so a subagent resolves the antislop
|
|
298
|
+
// switch exactly the way its parent did.
|
|
299
|
+
additionalSkillPaths: additionalSkillPaths(cwd),
|
|
300
|
+
extensionFactories: [createPermissionExtension({ interactive: false }), createContextExtension()],
|
|
301
|
+
});
|
|
302
|
+
await resourceLoader.reload();
|
|
303
|
+
const { session } = await createAgentSession({
|
|
304
|
+
cwd,
|
|
305
|
+
agentDir: AGENT_DIR,
|
|
306
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
307
|
+
resourceLoader,
|
|
308
|
+
tools: spec.tools,
|
|
309
|
+
// Registered for every subagent, activated only when named in `tools` above.
|
|
310
|
+
// `context` is what lets a subagent's discovery reach the parent and its
|
|
311
|
+
// siblings; `todo` is for the implementing agent, which has the longest run.
|
|
312
|
+
// The web tools and `lsp` are named in every agent's `tools` list, so they
|
|
313
|
+
// need a definition here or the name alone would resolve to nothing.
|
|
314
|
+
customTools: [contextTool, todoTool, webSearchTool, webFetchTool, lspTool, qualityTool, claimTool],
|
|
315
|
+
thinkingLevel: run.effort,
|
|
316
|
+
});
|
|
317
|
+
// A child runs on the parent's model unless the run or its definition names
|
|
318
|
+
// one. Resolved here rather than at spawn because the available list belongs
|
|
319
|
+
// to a model runtime, and there is no session to ask before this point.
|
|
320
|
+
const requested = requestedModel(run, spec);
|
|
321
|
+
if (requested) {
|
|
322
|
+
const available = await session.modelRuntime.getAvailable();
|
|
323
|
+
const resolved = resolveModel(available, requested);
|
|
324
|
+
if (!resolved) {
|
|
325
|
+
session.dispose();
|
|
326
|
+
// Where the model came from matters: an inherited model that does not
|
|
327
|
+
// resolve is a different problem from a typo in a definition.
|
|
328
|
+
const origin = run.model ? "asked for by the task" : spec.model ? `named by the ${run.agent} definition${spec.source ? ` (${spec.source})` : ""}` : "inherited from the parent session";
|
|
329
|
+
throw new Error(`unknown model '${requested}' ${origin}. /model lists what this account can reach.`);
|
|
330
|
+
}
|
|
331
|
+
await session.setModel(resolved);
|
|
332
|
+
}
|
|
333
|
+
return session;
|
|
334
|
+
}
|
|
335
|
+
/** Message a parked or running agent. Returns a status line. */
|
|
336
|
+
export async function sendMail(id, text) {
|
|
337
|
+
loadParked();
|
|
338
|
+
const run = runs.find((item) => item.id === id);
|
|
339
|
+
if (!run)
|
|
340
|
+
return `no agent ${id}`;
|
|
341
|
+
const session = run.session ?? (await openAgentSession(run));
|
|
342
|
+
run.session = session;
|
|
343
|
+
if (run.status === "running" || session.isStreaming) {
|
|
344
|
+
run.mail.push(text);
|
|
345
|
+
persist(run);
|
|
346
|
+
return `queued for ${id}`;
|
|
347
|
+
}
|
|
348
|
+
if (run.requests >= MAX_REQUESTS)
|
|
349
|
+
return `${id} is out of budget (${run.requests} requests)`;
|
|
350
|
+
run.status = "running";
|
|
351
|
+
try {
|
|
352
|
+
await session.prompt(text);
|
|
353
|
+
run.requests += 1;
|
|
354
|
+
run.result = session.getLastAssistantText()?.trim() || run.result;
|
|
355
|
+
run.status = "parked";
|
|
356
|
+
}
|
|
357
|
+
catch (error) {
|
|
358
|
+
run.result = error instanceof Error ? error.message : String(error);
|
|
359
|
+
run.status = "error";
|
|
360
|
+
}
|
|
361
|
+
persist(run);
|
|
362
|
+
return `${id} ${run.status}`;
|
|
363
|
+
}
|
|
364
|
+
/**
|
|
365
|
+
* The model a child should run on: the task's own request, then its definition,
|
|
366
|
+
* then the parent session's model.
|
|
367
|
+
*
|
|
368
|
+
* The last one is the point. Without it a child runs whatever the model runtime
|
|
369
|
+
* happens to default to, which is how a `task` call came back with "model
|
|
370
|
+
* opencode/kimi-k2.6 answered nothing" while the parent was on a model the
|
|
371
|
+
* account could actually reach. An empty string counts as absent, so a blank
|
|
372
|
+
* `model:` in a definition cannot shadow the parent's.
|
|
373
|
+
*/
|
|
374
|
+
export function requestedModel(input, spec) {
|
|
375
|
+
// Each candidate is trimmed before it counts, so a whitespace-only value falls
|
|
376
|
+
// through to the next one instead of winning and then resolving to nothing.
|
|
377
|
+
for (const candidate of [input.model, spec.model, input.parentModel]) {
|
|
378
|
+
const trimmed = candidate?.trim();
|
|
379
|
+
if (trimmed)
|
|
380
|
+
return trimmed;
|
|
381
|
+
}
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* `provider/id` for a session's current model, or undefined when it has none.
|
|
386
|
+
*
|
|
387
|
+
* Children resolve their model from a reference rather than from the parent's
|
|
388
|
+
* model object, because the two runtimes are separate instances.
|
|
389
|
+
*/
|
|
390
|
+
export function modelRef(model) {
|
|
391
|
+
if (!model?.provider || !model.id)
|
|
392
|
+
return undefined;
|
|
393
|
+
return `${model.provider}/${model.id}`;
|
|
394
|
+
}
|
|
395
|
+
export async function spawn(input) {
|
|
396
|
+
const spec = agents.get(input.agent);
|
|
397
|
+
if (!spec)
|
|
398
|
+
throw new Error(`unknown agent ${input.agent}. known: ${[...agents.keys()].join(", ")}`);
|
|
399
|
+
// Counted before the run exists, so a refused spawn does not itself grow the
|
|
400
|
+
// list it was refused for.
|
|
401
|
+
if (runs.length >= MAX_AGENTS) {
|
|
402
|
+
throw new Error(`${MAX_AGENTS} agents is the limit (${runs.length} live or parked). Finish with the existing ones, or clear them with /agents, before spawning more.`);
|
|
403
|
+
}
|
|
404
|
+
const run = {
|
|
405
|
+
id: `${Date.now().toString(36)}-${runs.length + 1}`,
|
|
406
|
+
cwd: input.cwd,
|
|
407
|
+
agent: spec.name,
|
|
408
|
+
task: input.task.trim(),
|
|
409
|
+
effort: (input.effort || spec.effort),
|
|
410
|
+
// The call wins over the definition; both are optional, and an empty string
|
|
411
|
+
// is the same as absent so a blank `model:` cannot shadow the parent's.
|
|
412
|
+
model: (input.model || spec.model)?.trim() || undefined,
|
|
413
|
+
parentModel: input.parentModel?.trim() || undefined,
|
|
414
|
+
background: input.background,
|
|
415
|
+
status: "queued",
|
|
416
|
+
requests: 0,
|
|
417
|
+
mail: [],
|
|
418
|
+
worktree: input.worktree,
|
|
419
|
+
};
|
|
420
|
+
runs.push(run);
|
|
421
|
+
persist(run);
|
|
422
|
+
return run;
|
|
423
|
+
}
|
|
424
|
+
async function execute(run, signal) {
|
|
425
|
+
const spec = agents.get(run.agent);
|
|
426
|
+
if (!spec)
|
|
427
|
+
return;
|
|
428
|
+
const session = run.session ?? (await openAgentSession(run));
|
|
429
|
+
run.session = session;
|
|
430
|
+
run.startedAt = Date.now();
|
|
431
|
+
try {
|
|
432
|
+
const mail = run.mail.splice(0).join("\n");
|
|
433
|
+
// The agent's instructions now live in the system prompt (see
|
|
434
|
+
// `openAgentSession`), so the turn carries only the assignment.
|
|
435
|
+
await session.prompt(`Task:\n${run.task}${mail ? `\n\nMessages:\n${mail}` : ""}`);
|
|
436
|
+
run.requests += 1;
|
|
437
|
+
while (run.mail.length > 0 && run.requests < MAX_REQUESTS) {
|
|
438
|
+
await session.followUp(run.mail.splice(0).join("\n"));
|
|
439
|
+
run.requests += 1;
|
|
440
|
+
}
|
|
441
|
+
if (signal?.aborted)
|
|
442
|
+
throw new Error("aborted");
|
|
443
|
+
// A child that produced no assistant text and called no tool did not do the
|
|
444
|
+
// work, whatever the session reports. `prompt()` can resolve without ever
|
|
445
|
+
// reaching the provider — a session that fails to issue a request still
|
|
446
|
+
// appends an empty assistant message and returns — so "it resolved" is not
|
|
447
|
+
// evidence that the run happened. Reporting that as `parked` (which
|
|
448
|
+
// `summarizeRun` prints as `done`) told the parent a child had finished when
|
|
449
|
+
// nothing had run at all. That is worse than an error: the parent acts on a
|
|
450
|
+
// result that was never produced. Marked `error` with the reason, so the
|
|
451
|
+
// report says what actually happened.
|
|
452
|
+
const lastText = session.getLastAssistantText()?.trim();
|
|
453
|
+
const stats = session.getSessionStats?.();
|
|
454
|
+
if (!lastText && (stats?.toolCalls ?? 0) === 0) {
|
|
455
|
+
throw new Error(`agent produced no output (model ${session.model?.provider}/${session.model?.id} answered nothing; 0 tool calls). ` + `Check that the provider is reachable and the model is configured.`);
|
|
456
|
+
}
|
|
457
|
+
run.result = lastText;
|
|
458
|
+
run.status = "parked";
|
|
459
|
+
}
|
|
460
|
+
catch (error) {
|
|
461
|
+
run.result = error instanceof Error ? error.message : String(error);
|
|
462
|
+
run.status = "error";
|
|
463
|
+
}
|
|
464
|
+
finally {
|
|
465
|
+
// The child's own session is the only place its work is measurable, and it
|
|
466
|
+
// is dropped before the run is persisted — so read the numbers here, while
|
|
467
|
+
// there is still something to read them from.
|
|
468
|
+
run.endedAt = Date.now();
|
|
469
|
+
const stats = session.getSessionStats?.();
|
|
470
|
+
run.toolUses = stats?.toolCalls;
|
|
471
|
+
run.tokens = stats?.tokens?.total;
|
|
472
|
+
// The session has done its work. Holding it on the run keeps the child's
|
|
473
|
+
// whole transcript, its model runtime and its extension subscriptions alive
|
|
474
|
+
// for as long as the run stays parked — and a parked run outlives the task
|
|
475
|
+
// that spawned it. A later `/agents` message reopens one through
|
|
476
|
+
// `sendMail`, which already handles a run with no session.
|
|
477
|
+
run.session = undefined;
|
|
478
|
+
session.dispose();
|
|
479
|
+
persist(run);
|
|
480
|
+
try {
|
|
481
|
+
runFinished?.(run);
|
|
482
|
+
}
|
|
483
|
+
catch {
|
|
484
|
+
// A listener is a convenience for the UI. It must never be able to take a
|
|
485
|
+
// run down with it, and a run that has already recorded its outcome has
|
|
486
|
+
// nothing left to fail.
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
let runFinished;
|
|
491
|
+
/**
|
|
492
|
+
* Register the callback a settled run is announced through.
|
|
493
|
+
*
|
|
494
|
+
* Only background runs need this: a foreground fan-out returns its results in
|
|
495
|
+
* the tool call itself, so announcing those as well would print everything
|
|
496
|
+
* twice.
|
|
497
|
+
*/
|
|
498
|
+
export function setRunFinishedHandler(handler) {
|
|
499
|
+
runFinished = handler;
|
|
500
|
+
}
|
|
501
|
+
let backgroundDrain;
|
|
502
|
+
/**
|
|
503
|
+
* Start draining without waiting for it.
|
|
504
|
+
*
|
|
505
|
+
* This is what `background: true` uses: the runs are queued, a pump is started,
|
|
506
|
+
* and the caller returns so the session can keep working. One pump at a time —
|
|
507
|
+
* a second `background` call joins the one in flight rather than starting a
|
|
508
|
+
* rival pool, because both would be pulling from the same queue anyway.
|
|
509
|
+
*/
|
|
510
|
+
export function startBackgroundDrain() {
|
|
511
|
+
if (backgroundDrain)
|
|
512
|
+
return;
|
|
513
|
+
backgroundDrain = drain()
|
|
514
|
+
.catch(() => {
|
|
515
|
+
// There is no caller to report to; every run recorded its own outcome.
|
|
516
|
+
})
|
|
517
|
+
.finally(() => {
|
|
518
|
+
backgroundDrain = undefined;
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* Run every queued agent, at most MAX_PARALLEL at a time.
|
|
523
|
+
*/
|
|
524
|
+
export async function drain(signal) {
|
|
525
|
+
// A foreground caller is asking for every queued run to finish, including the
|
|
526
|
+
// ones a `background: true` call left behind. Without this the workers below
|
|
527
|
+
// can find an empty queue — the background pump already took them — and report
|
|
528
|
+
// a fan-out as done while its children are still running.
|
|
529
|
+
if (backgroundDrain)
|
|
530
|
+
await backgroundDrain.catch(() => { });
|
|
531
|
+
const takeNext = () => runs.find((run) => run.status === "queued");
|
|
532
|
+
const worker = async () => {
|
|
533
|
+
for (;;) {
|
|
534
|
+
if (signal?.aborted)
|
|
535
|
+
throw new Error("aborted");
|
|
536
|
+
const run = takeNext();
|
|
537
|
+
if (!run)
|
|
538
|
+
return;
|
|
539
|
+
run.status = "running";
|
|
540
|
+
// The slot is held for the child's whole life, so `workflow` and `task`
|
|
541
|
+
// queue against each other instead of each honouring its own width.
|
|
542
|
+
const release = await acquireAgentSlot();
|
|
543
|
+
try {
|
|
544
|
+
await execute(run, signal);
|
|
545
|
+
}
|
|
546
|
+
finally {
|
|
547
|
+
release();
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
await Promise.all(Array.from({ length: MAX_PARALLEL }, worker));
|
|
552
|
+
if (signal?.aborted)
|
|
553
|
+
throw new Error("aborted");
|
|
554
|
+
}
|