@njeybe/nativ 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +40 -0
- package/LICENSE +21 -0
- package/README.md +334 -0
- package/bin/cli.js +31 -0
- package/dist/commands/bench.d.ts +8 -0
- package/dist/commands/bench.js +52 -0
- package/dist/commands/db.d.ts +22 -0
- package/dist/commands/db.js +415 -0
- package/dist/commands/doctor.d.ts +38 -0
- package/dist/commands/doctor.js +169 -0
- package/dist/commands/hook.d.ts +31 -0
- package/dist/commands/hook.js +105 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.js +144 -0
- package/dist/commands/mcp.d.ts +5 -0
- package/dist/commands/mcp.js +16 -0
- package/dist/commands/setup.d.ts +17 -0
- package/dist/commands/setup.js +51 -0
- package/dist/commands/status.d.ts +5 -0
- package/dist/commands/status.js +75 -0
- package/dist/commands/task.d.ts +83 -0
- package/dist/commands/task.js +807 -0
- package/dist/commands/test-gen.d.ts +14 -0
- package/dist/commands/test-gen.js +52 -0
- package/dist/commands/triage.d.ts +70 -0
- package/dist/commands/triage.js +343 -0
- package/dist/commands/update.d.ts +14 -0
- package/dist/commands/update.js +112 -0
- package/dist/commands/validate.d.ts +1 -0
- package/dist/commands/validate.js +89 -0
- package/dist/commands/verify.d.ts +8 -0
- package/dist/commands/verify.js +65 -0
- package/dist/commands/worktree.d.ts +47 -0
- package/dist/commands/worktree.js +396 -0
- package/dist/core/benchmark.d.ts +57 -0
- package/dist/core/benchmark.js +772 -0
- package/dist/core/enforcement.d.ts +80 -0
- package/dist/core/enforcement.js +271 -0
- package/dist/core/legacy-templates.d.ts +24 -0
- package/dist/core/legacy-templates.js +60 -0
- package/dist/core/lock-manager.d.ts +54 -0
- package/dist/core/lock-manager.js +260 -0
- package/dist/core/root-resolver.d.ts +32 -0
- package/dist/core/root-resolver.js +149 -0
- package/dist/core/setup-assets.d.ts +123 -0
- package/dist/core/setup-assets.js +438 -0
- package/dist/core/telemetry.d.ts +156 -0
- package/dist/core/telemetry.js +416 -0
- package/dist/core/test-generator-types.d.ts +227 -0
- package/dist/core/test-generator-types.js +179 -0
- package/dist/core/test-generator.d.ts +34 -0
- package/dist/core/test-generator.js +1690 -0
- package/dist/core/tier1-liaison.d.ts +139 -0
- package/dist/core/tier1-liaison.js +320 -0
- package/dist/core/verifier.d.ts +52 -0
- package/dist/core/verifier.js +249 -0
- package/dist/core/version.d.ts +5 -0
- package/dist/core/version.js +14 -0
- package/dist/core/worktree-merge.d.ts +30 -0
- package/dist/core/worktree-merge.js +98 -0
- package/dist/db/data-engine.d.ts +46 -0
- package/dist/db/data-engine.js +802 -0
- package/dist/db/diff.d.ts +39 -0
- package/dist/db/diff.js +420 -0
- package/dist/db/env-parser.d.ts +86 -0
- package/dist/db/env-parser.js +549 -0
- package/dist/db/introspector.d.ts +21 -0
- package/dist/db/introspector.js +835 -0
- package/dist/db/types.d.ts +217 -0
- package/dist/db/types.js +9 -0
- package/dist/governor/circuit-breaker.d.ts +100 -0
- package/dist/governor/circuit-breaker.js +231 -0
- package/dist/governor/evaluator.d.ts +67 -0
- package/dist/governor/evaluator.js +851 -0
- package/dist/governor/index.d.ts +6 -0
- package/dist/governor/index.js +6 -0
- package/dist/governor/rules/api-rules.d.ts +2 -0
- package/dist/governor/rules/api-rules.js +140 -0
- package/dist/governor/rules/db-rules.d.ts +2 -0
- package/dist/governor/rules/db-rules.js +180 -0
- package/dist/governor/store.d.ts +33 -0
- package/dist/governor/store.js +84 -0
- package/dist/governor/types.d.ts +64 -0
- package/dist/governor/types.js +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +358 -0
- package/dist/mcp/server.d.ts +3 -0
- package/dist/mcp/server.js +309 -0
- package/dist/providers/claude-api.d.ts +5 -0
- package/dist/providers/claude-api.js +75 -0
- package/dist/providers/claude-cli.d.ts +13 -0
- package/dist/providers/claude-cli.js +134 -0
- package/dist/providers/gemini.d.ts +8 -0
- package/dist/providers/gemini.js +78 -0
- package/dist/providers/index.d.ts +5 -0
- package/dist/providers/index.js +5 -0
- package/dist/providers/registry.d.ts +38 -0
- package/dist/providers/registry.js +121 -0
- package/dist/providers/types.d.ts +74 -0
- package/dist/providers/types.js +25 -0
- package/dist/runner/agent-supervisor.d.ts +363 -0
- package/dist/runner/agent-supervisor.js +1672 -0
- package/dist/scanner/context-builder.d.ts +2 -0
- package/dist/scanner/context-builder.js +29 -0
- package/dist/scanner/detector.d.ts +2 -0
- package/dist/scanner/detector.js +760 -0
- package/dist/scanner/types.d.ts +80 -0
- package/dist/scanner/types.js +50 -0
- package/dist/server/studio-server.d.ts +39 -0
- package/dist/server/studio-server.js +1931 -0
- package/dist/server/studio-ui.d.ts +15 -0
- package/dist/server/studio-ui.js +5321 -0
- package/package.json +70 -0
- package/plugin/.claude-plugin/plugin.json +18 -0
- package/plugin/.mcp.json +10 -0
- package/plugin/agents/architect.md +28 -0
- package/plugin/agents/verifier.md +28 -0
- package/plugin/agents/worker.md +26 -0
- package/plugin/hooks/hooks.json +28 -0
- package/plugin/skills/nativ/SKILL.md +75 -0
- package/templates/.claudeignore +45 -0
- package/templates/AGENTS.md +67 -0
- package/templates/CLAUDE.md +32 -0
- package/templates/GEMINI.md +66 -0
- package/templates/claude-agents/architect.md +28 -0
- package/templates/claude-agents/verifier.md +28 -0
- package/templates/claude-agents/worker.md +26 -0
- package/templates/dot-ai/api_contracts.json +66 -0
- package/templates/dot-ai/context.md +38 -0
- package/templates/dot-ai/db_schema.json +39 -0
- package/templates/dot-ai/master_plan.json +28 -0
- package/templates/dot-ai/subagents/backend.md +11 -0
- package/templates/dot-ai/subagents/database.md +10 -0
- package/templates/dot-ai/subagents/db-migration.md +11 -0
- package/templates/dot-ai/subagents/devops-agent.md +11 -0
- package/templates/dot-ai/subagents/flutter-developer.md +11 -0
- package/templates/dot-ai/subagents/frontend.md +126 -0
- package/templates/dot-ai/subagents/qa-tester.md +13 -0
- package/templates/dot-ai/subagents/security-auditor.md +11 -0
- package/templates/dot-ai/ui_specs.md +78 -0
|
@@ -0,0 +1,1672 @@
|
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { EventEmitter } from 'node:events';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
7
|
+
import { loadMasterPlan, executeVerification } from '../core/verifier.js';
|
|
8
|
+
import { cacheHitRate, computeActualCostUsd, emptyActualUsage, mergeActualUsage, recordRunnerUsage, } from '../core/telemetry.js';
|
|
9
|
+
import { mergeAgentWorktree } from '../core/worktree-merge.js';
|
|
10
|
+
import { resolveProjectRoot, linkWorktreeAiDirectory, safeUnlinkWorktreeAiDirectory, linkWorktreeNodeModules, safeUnlinkWorktreeNodeModules, } from '../core/root-resolver.js';
|
|
11
|
+
import { buildDesiredConfig, cliString, configuredInvocation, resolveCliInvocation } from '../core/setup-assets.js';
|
|
12
|
+
export const ACTIVE_RUNNER_STATUSES = [
|
|
13
|
+
'spawning_worktree',
|
|
14
|
+
'running',
|
|
15
|
+
'verifying',
|
|
16
|
+
'merging',
|
|
17
|
+
];
|
|
18
|
+
export function isActiveStatus(status) {
|
|
19
|
+
return ACTIVE_RUNNER_STATUSES.includes(status);
|
|
20
|
+
}
|
|
21
|
+
/** Structured failure carrying the `{ code, message }` shape used by the pipeline API. */
|
|
22
|
+
export class SupervisorError extends Error {
|
|
23
|
+
code;
|
|
24
|
+
status;
|
|
25
|
+
constructor(code, message, status = 400) {
|
|
26
|
+
super(message);
|
|
27
|
+
this.code = code;
|
|
28
|
+
this.status = status;
|
|
29
|
+
this.name = 'SupervisorError';
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export const DEFAULT_RUNNER_COMMAND = 'claude';
|
|
33
|
+
/** Hard cap per run. Real agent tasks routinely take tens of minutes; hung runs are caught by the idle watchdog. */
|
|
34
|
+
export const DEFAULT_TIMEOUT_SECONDS = 3600;
|
|
35
|
+
/** A run that produces no output (log lines, tool activity, API turns) for this long is treated as hung. */
|
|
36
|
+
export const DEFAULT_IDLE_TIMEOUT_SECONDS = 900;
|
|
37
|
+
export const DEFAULT_MAX_LOG_BUFFER_BYTES = 512 * 1024;
|
|
38
|
+
export const DEFAULT_KILL_GRACE_MS = 5_000;
|
|
39
|
+
export const DEFAULT_MAX_HISTORY = 50;
|
|
40
|
+
export const DEFAULT_RUNNER_ENGINE = 'cli';
|
|
41
|
+
export const DEFAULT_NATIVE_MODEL = 'claude-opus-5-5';
|
|
42
|
+
export const DEFAULT_NATIVE_EFFORT = 'high';
|
|
43
|
+
export const DEFAULT_NATIVE_MAX_TURNS = 150;
|
|
44
|
+
export const DEFAULT_NATIVE_TOOL_TIMEOUT_MS = 10 * 60 * 1000;
|
|
45
|
+
export const NATIVE_MAX_OUTPUT_TOKENS = 64_000;
|
|
46
|
+
/** Characters of tool output handed back to the model per call. */
|
|
47
|
+
export const NATIVE_TOOL_OUTPUT_LIMIT = 30_000;
|
|
48
|
+
const NATIVE_TOOL_CAPTURE_LIMIT = 1024 * 1024;
|
|
49
|
+
const ROLE_GUIDE_LIMIT = 20_000;
|
|
50
|
+
const SERVER_FALLBACK_BETA = 'server-side-fallback-2026-07-01';
|
|
51
|
+
export const DEFAULT_NATIVE_ALLOWED_COMMANDS = [
|
|
52
|
+
'nativ', 'node', 'npm', 'npx', 'tsx', 'tsc', 'git',
|
|
53
|
+
'cd', 'ls', 'dir', 'pwd', 'cat', 'head', 'tail', 'grep', 'rg', 'find', 'wc', 'diff', 'sort', 'uniq',
|
|
54
|
+
'echo', 'printf', 'mkdir', 'touch', 'cp', 'mv', 'rm', 'sed', 'awk', 'test', 'true', 'false',
|
|
55
|
+
];
|
|
56
|
+
/**
|
|
57
|
+
* Terminates a detached process *group*. Agent runners spawn shells that spawn
|
|
58
|
+
* their own children, so killing only the leader leaves orphans holding the
|
|
59
|
+
* worktree open.
|
|
60
|
+
*/
|
|
61
|
+
function killProcessTree(pid, signal = 'SIGTERM') {
|
|
62
|
+
if (process.platform === 'win32') {
|
|
63
|
+
spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
process.kill(-pid, signal);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
try {
|
|
71
|
+
process.kill(pid, signal);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Already gone.
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
function isPidAlive(pid) {
|
|
79
|
+
try {
|
|
80
|
+
process.kill(pid, 0);
|
|
81
|
+
return true;
|
|
82
|
+
}
|
|
83
|
+
catch (err) {
|
|
84
|
+
return err?.code === 'EPERM';
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function tailLines(text, count) {
|
|
88
|
+
if (count <= 0)
|
|
89
|
+
return '';
|
|
90
|
+
const lines = text.split('\n');
|
|
91
|
+
if (lines.length <= count)
|
|
92
|
+
return text;
|
|
93
|
+
return lines.slice(lines.length - count).join('\n');
|
|
94
|
+
}
|
|
95
|
+
/** Extracts the executable binary or command name, handling quoted Windows paths. */
|
|
96
|
+
export function extractBinary(cmd) {
|
|
97
|
+
const trimmed = cmd.trim();
|
|
98
|
+
if (trimmed.startsWith('"')) {
|
|
99
|
+
const nextQuote = trimmed.indexOf('"', 1);
|
|
100
|
+
if (nextQuote !== -1)
|
|
101
|
+
return trimmed.slice(1, nextQuote);
|
|
102
|
+
}
|
|
103
|
+
if (trimmed.startsWith("'")) {
|
|
104
|
+
const nextQuote = trimmed.indexOf("'", 1);
|
|
105
|
+
if (nextQuote !== -1)
|
|
106
|
+
return trimmed.slice(1, nextQuote);
|
|
107
|
+
}
|
|
108
|
+
return trimmed.split(/\s+/)[0] || '';
|
|
109
|
+
}
|
|
110
|
+
/** Pre-flight check verifying whether a command binary exists in PATH or on the filesystem. */
|
|
111
|
+
export function isExecutableInPath(cmd) {
|
|
112
|
+
const bin = extractBinary(cmd);
|
|
113
|
+
if (!bin)
|
|
114
|
+
return false;
|
|
115
|
+
if (path.isAbsolute(bin) ||
|
|
116
|
+
bin.startsWith('./') ||
|
|
117
|
+
bin.startsWith('.\\') ||
|
|
118
|
+
bin.startsWith('../') ||
|
|
119
|
+
bin.startsWith('..\\')) {
|
|
120
|
+
return fs.existsSync(bin);
|
|
121
|
+
}
|
|
122
|
+
try {
|
|
123
|
+
const checkCmd = process.platform === 'win32' ? 'where.exe' : 'which';
|
|
124
|
+
const res = spawnSync(checkCmd, [bin], { stdio: 'ignore', windowsHide: true });
|
|
125
|
+
if (res.status === 0)
|
|
126
|
+
return true;
|
|
127
|
+
}
|
|
128
|
+
catch { }
|
|
129
|
+
const pathEnv = process.env.PATH || '';
|
|
130
|
+
const delimiter = path.delimiter;
|
|
131
|
+
const dirs = pathEnv.split(delimiter);
|
|
132
|
+
const extensions = process.platform === 'win32'
|
|
133
|
+
? (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';')
|
|
134
|
+
: [''];
|
|
135
|
+
for (const dir of dirs) {
|
|
136
|
+
if (!dir)
|
|
137
|
+
continue;
|
|
138
|
+
for (const ext of extensions) {
|
|
139
|
+
const full = path.join(dir, bin + (ext.startsWith('.') ? ext : `.${ext}`));
|
|
140
|
+
try {
|
|
141
|
+
if (fs.existsSync(full))
|
|
142
|
+
return true;
|
|
143
|
+
}
|
|
144
|
+
catch { }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return false;
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* What a dispatched agent may run without asking. `claude -p` cannot prompt, so anything not listed here is
|
|
151
|
+
* denied and the agent blocks or escalates instead of running it.
|
|
152
|
+
*/
|
|
153
|
+
const RUNNER_BASE_ALLOWED_TOOLS = [
|
|
154
|
+
'Bash(nativ *)',
|
|
155
|
+
'Bash(git status *)',
|
|
156
|
+
'Bash(git diff *)',
|
|
157
|
+
'Bash(git log *)',
|
|
158
|
+
'Bash(git add *)',
|
|
159
|
+
'Bash(git commit *)',
|
|
160
|
+
'Bash(git checkout -- *)',
|
|
161
|
+
];
|
|
162
|
+
/** The pieces of a compound command as Claude Code matches them: each must satisfy an allow rule on its own. */
|
|
163
|
+
export function splitCompoundCommand(command) {
|
|
164
|
+
return [...new Set(command.split(/&&|\|\||[;|\n]/).map((part) => part.trim()).filter(Boolean))];
|
|
165
|
+
}
|
|
166
|
+
/** Operator-supplied extra rules from NATIV_RUNNER_ALLOW, comma separated, e.g. `Bash(pnpm test *),Bash(cargo test *)`. */
|
|
167
|
+
export function parseRunnerAllowList(raw) {
|
|
168
|
+
return String(raw ?? '')
|
|
169
|
+
.split(',')
|
|
170
|
+
.map((rule) => rule.trim())
|
|
171
|
+
.filter((rule) => /^[A-Za-z_][A-Za-z0-9_]*(\(.*\))?$/.test(rule));
|
|
172
|
+
}
|
|
173
|
+
/** True when `.claude/settings.json` already registers a nativ `hook check` command. */
|
|
174
|
+
function projectHasEnforcementHook(rootDir) {
|
|
175
|
+
try {
|
|
176
|
+
const text = fs.readFileSync(path.join(rootDir, '.claude', 'settings.json'), 'utf8');
|
|
177
|
+
return /"command"\s*:\s*"[^"]*\bhook check\b/.test(text);
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* Settings handed to a dispatched agent with `--settings`. The deny and ask rules and the enforcement hook come
|
|
185
|
+
* from the same source as `nativ setup`, so a dispatched agent is guarded even in a project where setup never ran.
|
|
186
|
+
*/
|
|
187
|
+
export function buildRunnerPermissions(task, rootDir, extraAllowed = []) {
|
|
188
|
+
const invocation = configuredInvocation(rootDir) ?? resolveCliInvocation({});
|
|
189
|
+
const desired = buildDesiredConfig(invocation);
|
|
190
|
+
const cli = cliString(invocation);
|
|
191
|
+
const allow = [
|
|
192
|
+
...RUNNER_BASE_ALLOWED_TOOLS,
|
|
193
|
+
...(cli === 'nativ' ? [] : [`Bash(${cli} *)`]),
|
|
194
|
+
...splitCompoundCommand(task.verificationCommand ?? '').map((command) => `Bash(${command})`),
|
|
195
|
+
...extraAllowed,
|
|
196
|
+
];
|
|
197
|
+
return {
|
|
198
|
+
permissions: { allow: [...new Set(allow)], deny: desired.deny, ask: desired.ask },
|
|
199
|
+
...(projectHasEnforcementHook(rootDir)
|
|
200
|
+
? {}
|
|
201
|
+
: { hooks: { PreToolUse: [{ matcher: desired.preToolUse.matcher, hooks: [{ type: 'command', command: desired.preToolUse.command, timeout: desired.preToolUse.timeout }] }] } }),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
/** Where a task's runner settings live: a subdirectory, because run history parses every top-level .json in runs/. */
|
|
205
|
+
export function runnerSettingsPath(runsDir, taskId) {
|
|
206
|
+
return path.join(runsDir, 'permissions', `${taskId}.json`).replace(/\\/g, '/');
|
|
207
|
+
}
|
|
208
|
+
export function writeRunnerSettings(runsDir, taskId, settings) {
|
|
209
|
+
const file = runnerSettingsPath(runsDir, taskId);
|
|
210
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
211
|
+
fs.writeFileSync(file, JSON.stringify(settings, null, 2) + '\n', 'utf8');
|
|
212
|
+
return file;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Builds the autonomous prompt and arguments required by Claude Code CLI
|
|
216
|
+
* to execute non-interactively in a headless child process.
|
|
217
|
+
*
|
|
218
|
+
* It runs in `acceptEdits` mode with the rules in `settingsFile` (see buildRunnerPermissions); it does not bypass
|
|
219
|
+
* permissions. Without a settings file only the file-edit tools and read-only commands are pre-approved.
|
|
220
|
+
*/
|
|
221
|
+
export function buildDefaultClaudeCommand(task, model, settingsFile) {
|
|
222
|
+
const parts = [
|
|
223
|
+
`Execute task ${task.id} (${task.title}).`,
|
|
224
|
+
task.description ? `Description: ${task.description}.` : '',
|
|
225
|
+
task.verificationCommand ? `Verify your work using: ${task.verificationCommand}.` : '',
|
|
226
|
+
`Start by running: nativ task start ${task.id}. When finished and verified, run: nativ task complete ${task.id}.`,
|
|
227
|
+
`If blocked, follow the Human-Centric Communication Protocol in CLAUDE.md: explain the user experience symptom, root cause in plain English, and clear options without technical jargon.`,
|
|
228
|
+
]
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
.join(' ');
|
|
231
|
+
const escapedPrompt = parts.replace(/"/g, '\\"');
|
|
232
|
+
const modelFlag = model ? ` --model ${model}` : '';
|
|
233
|
+
// Plain `claude -p` prints nothing until it finishes; stream-json emits every message and tool call as it happens.
|
|
234
|
+
const settingsFlag = settingsFile ? ` --settings "${settingsFile}"` : '';
|
|
235
|
+
return `claude -p "${escapedPrompt}"${modelFlag} --output-format stream-json --verbose --permission-mode acceptEdits${settingsFlag}`;
|
|
236
|
+
}
|
|
237
|
+
/** True when a runner command asks Claude Code for its line-delimited JSON event stream. */
|
|
238
|
+
export function usesClaudeStreamJson(command) {
|
|
239
|
+
return /--output-format(?:=|\s+)stream-json\b/.test(command);
|
|
240
|
+
}
|
|
241
|
+
function firstLine(text, max = 200) {
|
|
242
|
+
const line = String(text ?? '').split('\n').find((l) => l.trim()) ?? '';
|
|
243
|
+
return line.length > max ? `${line.slice(0, max)}...` : line.trim();
|
|
244
|
+
}
|
|
245
|
+
function describeToolInput(input) {
|
|
246
|
+
if (!input || typeof input !== 'object')
|
|
247
|
+
return '';
|
|
248
|
+
const i = input;
|
|
249
|
+
const pick = i.command ?? i.file_path ?? i.path ?? i.pattern ?? i.url ?? i.description;
|
|
250
|
+
return typeof pick === 'string' ? firstLine(pick, 160) : firstLine(JSON.stringify(input), 160);
|
|
251
|
+
}
|
|
252
|
+
const finiteCount = (value) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : 0);
|
|
253
|
+
/**
|
|
254
|
+
* Turns one line of `claude -p --output-format stream-json` into a log line. Anything that is not
|
|
255
|
+
* a recognised event (warnings, custom runners, future event types) passes through or is dropped,
|
|
256
|
+
* never thrown on.
|
|
257
|
+
*/
|
|
258
|
+
export function formatClaudeStreamLine(line) {
|
|
259
|
+
const trimmed = line.trim();
|
|
260
|
+
if (!trimmed)
|
|
261
|
+
return { text: null };
|
|
262
|
+
let event;
|
|
263
|
+
try {
|
|
264
|
+
event = JSON.parse(trimmed);
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
return { text: `${line}\n` };
|
|
268
|
+
}
|
|
269
|
+
if (!event || typeof event !== 'object' || Array.isArray(event))
|
|
270
|
+
return { text: `${line}\n` };
|
|
271
|
+
switch (event.type) {
|
|
272
|
+
case 'system': {
|
|
273
|
+
if (event.subtype !== 'init')
|
|
274
|
+
return { text: null };
|
|
275
|
+
const model = typeof event.model === 'string' ? event.model : undefined;
|
|
276
|
+
const tools = Array.isArray(event.tools) ? ` · ${event.tools.length} tools` : '';
|
|
277
|
+
return { text: `[claude] session started · model ${model ?? 'default'}${tools}\n`, model };
|
|
278
|
+
}
|
|
279
|
+
case 'assistant': {
|
|
280
|
+
const lines = [];
|
|
281
|
+
for (const block of Array.isArray(event.message?.content) ? event.message.content : []) {
|
|
282
|
+
if (block?.type === 'text' && String(block.text ?? '').trim())
|
|
283
|
+
lines.push(String(block.text).trim());
|
|
284
|
+
else if (block?.type === 'thinking' && String(block.thinking ?? '').trim())
|
|
285
|
+
lines.push(`[claude:thinking] ${firstLine(block.thinking, 300)}`);
|
|
286
|
+
else if (block?.type === 'tool_use')
|
|
287
|
+
lines.push(`[claude] ${block.name}: ${describeToolInput(block.input)}`);
|
|
288
|
+
}
|
|
289
|
+
return { text: lines.length ? `${lines.join('\n')}\n` : null };
|
|
290
|
+
}
|
|
291
|
+
case 'user': {
|
|
292
|
+
const lines = [];
|
|
293
|
+
for (const block of Array.isArray(event.message?.content) ? event.message.content : []) {
|
|
294
|
+
if (block?.type !== 'tool_result')
|
|
295
|
+
continue;
|
|
296
|
+
const body = typeof block.content === 'string'
|
|
297
|
+
? block.content
|
|
298
|
+
: Array.isArray(block.content) ? block.content.map((c) => c?.text ?? '').join('\n') : '';
|
|
299
|
+
const summary = firstLine(body, 160);
|
|
300
|
+
if (block.is_error)
|
|
301
|
+
lines.push(`[claude] error: ${summary || 'tool failed'}`);
|
|
302
|
+
else if (summary)
|
|
303
|
+
lines.push(`[claude] ${summary}`);
|
|
304
|
+
}
|
|
305
|
+
return { text: lines.length ? `${lines.join('\n')}\n` : null };
|
|
306
|
+
}
|
|
307
|
+
case 'result': {
|
|
308
|
+
const usage = event.usage ?? {};
|
|
309
|
+
const cost = typeof event.total_cost_usd === 'number' && Number.isFinite(event.total_cost_usd) ? event.total_cost_usd : null;
|
|
310
|
+
const turns = finiteCount(event.num_turns);
|
|
311
|
+
const seconds = finiteCount(event.duration_ms) ? ` · ${Math.round(event.duration_ms / 1000)}s` : '';
|
|
312
|
+
const kind = event.subtype && event.subtype !== 'success' ? ` (${event.subtype})` : '';
|
|
313
|
+
const text = `[claude] ${event.is_error ? 'finished with an error' : 'finished'}${kind}` +
|
|
314
|
+
(turns ? ` · ${turns} turns` : '') + (cost !== null ? ` · $${cost.toFixed(4)}` : '') + seconds + '\n';
|
|
315
|
+
return {
|
|
316
|
+
text,
|
|
317
|
+
result: {
|
|
318
|
+
isError: Boolean(event.is_error),
|
|
319
|
+
turns,
|
|
320
|
+
costUsd: cost,
|
|
321
|
+
inputTokens: finiteCount(usage.input_tokens),
|
|
322
|
+
outputTokens: finiteCount(usage.output_tokens),
|
|
323
|
+
cacheCreationTokens: finiteCount(usage.cache_creation_input_tokens),
|
|
324
|
+
cacheReadTokens: finiteCount(usage.cache_read_input_tokens),
|
|
325
|
+
},
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
default:
|
|
329
|
+
return { text: null };
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
// ─── Native engine: request shaping ─────────────────────────────────────────
|
|
333
|
+
/** Models that predate adaptive thinking and still take `thinking.budget_tokens`. */
|
|
334
|
+
export function usesLegacyThinkingBudget(model) {
|
|
335
|
+
return /^claude-(3-|haiku-4-5|sonnet-4-5|opus-4-5|opus-4-1|(sonnet|opus)-4-\d{8})/.test(model);
|
|
336
|
+
}
|
|
337
|
+
/**
|
|
338
|
+
* Buckets a token budget into an effort level. Current models reject `budget_tokens`, so the
|
|
339
|
+
* contract's numeric `thinkingBudget` maps onto effort. The buckets follow the Studio's budget
|
|
340
|
+
* chips: 0 ("None, fast") is the least thinking a model allows, 2,048 ("Standard") is the
|
|
341
|
+
* standard effort, 4,096 ("Deep") is high, and larger budgets reach xhigh and max.
|
|
342
|
+
*/
|
|
343
|
+
export function thinkingBudgetToEffort(budget) {
|
|
344
|
+
if (budget <= 0)
|
|
345
|
+
return 'low';
|
|
346
|
+
if (budget <= 2_048)
|
|
347
|
+
return 'medium';
|
|
348
|
+
if (budget <= 8_192)
|
|
349
|
+
return 'high';
|
|
350
|
+
if (budget <= 32_768)
|
|
351
|
+
return 'xhigh';
|
|
352
|
+
return 'max';
|
|
353
|
+
}
|
|
354
|
+
export function resolveNativeThinking(model, budget) {
|
|
355
|
+
if (usesLegacyThinkingBudget(model)) {
|
|
356
|
+
// budget_tokens must be >= 1024 and below max_tokens; no budget (or 0) means no thinking.
|
|
357
|
+
const budgetTokens = budget === null || budget <= 0 ? null : Math.min(Math.max(Math.floor(budget), 1024), NATIVE_MAX_OUTPUT_TOKENS - 1);
|
|
358
|
+
return { budget, effort: null, budgetTokens };
|
|
359
|
+
}
|
|
360
|
+
let effort = budget === null ? defaultNativeEffort(model) : thinkingBudgetToEffort(budget);
|
|
361
|
+
// xhigh arrived with Opus 4.7; the 4.6 generation tops out below it.
|
|
362
|
+
if (effort === 'xhigh' && /-4-6(\b|$)/.test(model))
|
|
363
|
+
effort = 'high';
|
|
364
|
+
return { budget, effort, budgetTokens: null };
|
|
365
|
+
}
|
|
366
|
+
/**
|
|
367
|
+
* Effort used when a dispatch sets no `thinkingBudget`. Claude Opus 5.5 at
|
|
368
|
+
* `medium` outperforms Claude Opus 5 at `high` on coding work, so it starts lower.
|
|
369
|
+
*/
|
|
370
|
+
export function defaultNativeEffort(model) {
|
|
371
|
+
return model === 'claude-opus-5-5' ? 'medium' : DEFAULT_NATIVE_EFFORT;
|
|
372
|
+
}
|
|
373
|
+
/** Models that opt into server-side refusal fallbacks by default. */
|
|
374
|
+
export function supportsServerFallback(model) {
|
|
375
|
+
// Disabled to prevent automatic re-routing to expensive models (like Fable) and protect user budget.
|
|
376
|
+
return false;
|
|
377
|
+
}
|
|
378
|
+
// Frozen so the tools + system prefix stays byte-identical and cacheable across
|
|
379
|
+
// turns and runs: nothing per-task, per-run, or time-dependent belongs here.
|
|
380
|
+
const NATIVE_SYSTEM_PROMPT = `You are an autonomous engineering agent dispatched by nativ, the middle tier of a three-tier development pipeline. You complete one task from .ai/master_plan.json on your own, with no human watching each step.
|
|
381
|
+
|
|
382
|
+
Your environment:
|
|
383
|
+
- The working directory is the task's workspace root. Each bash command runs in a fresh POSIX shell started there, so \`cd\` and exported variables do not persist between commands; chain with && when you need them.
|
|
384
|
+
- Use str_replace_based_edit_tool with paths relative to the workspace root. Files under .ai/, node_modules/ and .git/ are read-only.
|
|
385
|
+
- Commands are checked against an allowlist of executables. Command substitution, background jobs, absolute paths, parent-directory paths, secret files and publishing (git push, npm publish) are rejected with a reason; adjust and continue.
|
|
386
|
+
|
|
387
|
+
How to work:
|
|
388
|
+
1. Run \`nativ task start <taskId>\` before changing code.
|
|
389
|
+
2. Change only the files in the task's targetFiles. Follow the contracts in .ai/ exactly: table and column names, routes and schemas, and design tokens. Match the style of the surrounding code.
|
|
390
|
+
3. Run the task's verificationCommand. If it fails, fix the cause and run it again; you have at most three fix attempts. If it still fails, run \`git checkout -- <targetFiles>\`, then \`nativ task block <taskId> --reason "Verification failed after 3 attempts: <short error>"\`, and stop.
|
|
391
|
+
4. If a contract in .ai/ lacks something the task needs, do not edit it. Run \`nativ task escalate <taskId> --type schema_flaw --details "<the gap>"\` and stop.
|
|
392
|
+
5. When verification passes, run \`nativ task complete <taskId>\`.
|
|
393
|
+
|
|
394
|
+
Secrets: never open .env files (.env.example is fine), *.pem, *.key or .nativ/*.local.json, and never print environment variables. For database structure use \`nativ db status|inspect|diff --json\`; never read table data.
|
|
395
|
+
|
|
396
|
+
When you finish or get blocked, end with a short plain-English summary: what changed, the verification result, and anything the operator must decide. Do not call a tool in that final message.`;
|
|
397
|
+
const NATIVE_TOOLS = [
|
|
398
|
+
{ type: 'bash_20250124', name: 'bash' },
|
|
399
|
+
{ type: 'text_editor_20250728', name: 'str_replace_based_edit_tool', max_characters: NATIVE_TOOL_OUTPUT_LIMIT },
|
|
400
|
+
];
|
|
401
|
+
function buildNativeTaskPrompt(task, roleGuide, useWorktree) {
|
|
402
|
+
const spec = {
|
|
403
|
+
id: task.id,
|
|
404
|
+
title: task.title,
|
|
405
|
+
description: task.description,
|
|
406
|
+
assignedSubagent: task.assignedSubagent,
|
|
407
|
+
targetFiles: task.targetFiles,
|
|
408
|
+
verificationCommand: task.verificationCommand,
|
|
409
|
+
dependencies: task.dependencies,
|
|
410
|
+
notes: task.notes,
|
|
411
|
+
};
|
|
412
|
+
return [
|
|
413
|
+
`Execute task ${task.id}.`,
|
|
414
|
+
`<task>\n${JSON.stringify(spec, null, 2)}\n</task>`,
|
|
415
|
+
roleGuide ? `<role_guide path=".ai/subagents/${task.assignedSubagent}.md">\n${roleGuide}\n</role_guide>` : '',
|
|
416
|
+
'Load only the contract slice your role needs (.ai/api_contracts.json, .ai/db_schema.json, .ai/ui_specs.md, .ai/context.md) with the editor view command.',
|
|
417
|
+
useWorktree
|
|
418
|
+
? 'You are in an isolated git worktree on the agent branch. After `nativ task complete` succeeds, commit the target files there (`git add <targetFiles> && git commit -m "<type>(<scope>): <summary>"`) so the operator can merge the branch.'
|
|
419
|
+
: 'You are working directly in the project checkout; do not commit.',
|
|
420
|
+
]
|
|
421
|
+
.filter(Boolean)
|
|
422
|
+
.join('\n\n');
|
|
423
|
+
}
|
|
424
|
+
function describeNativeError(err) {
|
|
425
|
+
if (err instanceof Anthropic.AuthenticationError) {
|
|
426
|
+
return 'Claude API authentication failed. Set ANTHROPIC_API_KEY (or run `ant auth login`) in the environment that launches nativ.';
|
|
427
|
+
}
|
|
428
|
+
if (err instanceof Anthropic.PermissionDeniedError)
|
|
429
|
+
return `Claude API permission denied: ${err.message}`;
|
|
430
|
+
if (err instanceof Anthropic.NotFoundError)
|
|
431
|
+
return `Claude API returned 404 (check the model ID): ${err.message}`;
|
|
432
|
+
if (err instanceof Anthropic.RateLimitError)
|
|
433
|
+
return `Claude API rate limit persisted after retries: ${err.message}`;
|
|
434
|
+
if (err instanceof Anthropic.BadRequestError)
|
|
435
|
+
return `Claude API rejected the request: ${err.message}`;
|
|
436
|
+
if (err instanceof Anthropic.APIError)
|
|
437
|
+
return `Claude API error${err.status ? ` ${err.status}` : ''}: ${err.message}`;
|
|
438
|
+
return err instanceof Error ? err.message : String(err);
|
|
439
|
+
}
|
|
440
|
+
// ─── Native engine: local tools ─────────────────────────────────────────────
|
|
441
|
+
/** Rejection returned to the model as an `is_error` tool result so it can adjust. */
|
|
442
|
+
class ToolInputError extends Error {
|
|
443
|
+
}
|
|
444
|
+
/** .env* (except .env.example), *.pem, *.key and .nativ|.agentj/*.local.json. */
|
|
445
|
+
const SECRET_PATH_PATTERN = /(^|[\\/\s'"=])(\.env(?!\.example\b)[\w.-]*|[\w.-]+\.(pem|key)|\.(nativ|agentj)[\\/][\w.-]*\.local\.json)(?=$|[\s'";|&)])/i;
|
|
446
|
+
/** Top-level directories of POSIX, macOS and Git Bash (/c/, /d/ drive mounts) filesystems. */
|
|
447
|
+
const FILESYSTEM_ROOT = /^\/(etc|usr|bin|sbin|lib|lib32|lib64|libx32|opt|var|tmp|home|root|proc|sys|dev|mnt|media|srv|boot|run|snap|private|System|Users|Volumes|Library|Applications|[A-Za-z])(\/|$)/;
|
|
448
|
+
/** Credentials never reach the model's shell: it could print them. */
|
|
449
|
+
const SECRET_ENV_PATTERN = /^ANTHROPIC_|^CLAUDE_CODE_OAUTH|SECRET|TOKEN|PASSWORD|PASSWD|API_KEY|PRIVATE_KEY|DATABASE_URL|_DSN$|CREDENTIAL/i;
|
|
450
|
+
function buildToolEnv(...sources) {
|
|
451
|
+
const env = {};
|
|
452
|
+
for (const source of sources) {
|
|
453
|
+
for (const [key, value] of Object.entries(source ?? {})) {
|
|
454
|
+
if (value !== undefined && !SECRET_ENV_PATTERN.test(key))
|
|
455
|
+
env[key] = value;
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return env;
|
|
459
|
+
}
|
|
460
|
+
/** Claude Code's own credentials and provider settings: the cli runner cannot start without them. */
|
|
461
|
+
const CLAUDE_CODE_AUTH_ENV = /^(ANTHROPIC_(API_KEY|AUTH_TOKEN|BASE_URL|MODEL|SMALL_FAST_MODEL|CUSTOM_HEADERS|BETAS|VERTEX_PROJECT_ID)|CLAUDE_CODE_[A-Z0-9_]+|CLAUDE_CONFIG_DIR|CLOUD_ML_REGION|VERTEX_REGION_[A-Z0-9_]+)$/;
|
|
462
|
+
const AWS_ENV = /^AWS_[A-Z0-9_]+$/;
|
|
463
|
+
/**
|
|
464
|
+
* Environment for cli runners. The agent inside can run the commands its allowlist permits (and any custom
|
|
465
|
+
* runner command runs unrestricted), so it can print anything it inherits: credential-looking variables are
|
|
466
|
+
* withheld, except the ones Claude
|
|
467
|
+
* Code itself authenticates with (and AWS credentials when it is configured for Bedrock).
|
|
468
|
+
* NATIV_RUNNER_PASS_ENV="NAME1,NAME2" passes extra variables through deliberately; variables an
|
|
469
|
+
* operator hands to dispatch() explicitly are also passed as-is.
|
|
470
|
+
*/
|
|
471
|
+
export function buildRunnerEnv(base, explicit) {
|
|
472
|
+
const passThrough = new Set(String(base.NATIV_RUNNER_PASS_ENV ?? '')
|
|
473
|
+
.split(',')
|
|
474
|
+
.map((name) => name.trim())
|
|
475
|
+
.filter(Boolean));
|
|
476
|
+
const bedrock = Boolean(base.CLAUDE_CODE_USE_BEDROCK);
|
|
477
|
+
const env = {};
|
|
478
|
+
for (const [key, value] of Object.entries(base)) {
|
|
479
|
+
if (value === undefined)
|
|
480
|
+
continue;
|
|
481
|
+
const allowed = !SECRET_ENV_PATTERN.test(key) || CLAUDE_CODE_AUTH_ENV.test(key) || passThrough.has(key) || (bedrock && AWS_ENV.test(key));
|
|
482
|
+
if (allowed)
|
|
483
|
+
env[key] = value;
|
|
484
|
+
}
|
|
485
|
+
return { ...env, ...explicit };
|
|
486
|
+
}
|
|
487
|
+
/**
|
|
488
|
+
* Best-effort policy for model-authored shell commands. The worktree is the
|
|
489
|
+
* real boundary (as with the cli engine); this blocks the obvious escapes and
|
|
490
|
+
* keeps every executable on an allowlist. Returns the rejection reason, or null.
|
|
491
|
+
*/
|
|
492
|
+
export function checkNativeBashCommand(command, allowed) {
|
|
493
|
+
if (/`|\$\(/.test(command))
|
|
494
|
+
return 'Command substitution (backticks or $(...)) is not allowed.';
|
|
495
|
+
if (/(^|[^&>])&(?![&>])/.test(command))
|
|
496
|
+
return 'Background jobs (&) are not allowed.';
|
|
497
|
+
if (SECRET_PATH_PATTERN.test(command)) {
|
|
498
|
+
return 'Secret files (.env, *.pem, *.key, .nativ/*.local.json) are off-limits; only .env.example may be read.';
|
|
499
|
+
}
|
|
500
|
+
if (/\bgit\s+push\b|\bnpm\s+publish\b/.test(command)) {
|
|
501
|
+
return 'Publishing actions (git push, npm publish) are reserved for the operator.';
|
|
502
|
+
}
|
|
503
|
+
for (const token of command.split(/\s+/)) {
|
|
504
|
+
const raw = token.replace(/^\d*[<>]+&?/, '');
|
|
505
|
+
// Quoting must not smuggle a path past the checks: look through one layer of quotes.
|
|
506
|
+
const quoted = /^['"]/.test(raw);
|
|
507
|
+
const bare = raw.replace(/^['"]+|['"]+$/g, '');
|
|
508
|
+
if (!bare || bare === '/dev/null')
|
|
509
|
+
continue;
|
|
510
|
+
if (/^(~|\$\{?(HOME|USERPROFILE)\}?|%USERPROFILE%)/i.test(bare)) {
|
|
511
|
+
return `Home-directory path '${bare}' points outside the workspace; use paths relative to the workspace root.`;
|
|
512
|
+
}
|
|
513
|
+
// Unquoted slash paths are always paths. A quoted one may be a search pattern ("/api/events"),
|
|
514
|
+
// so it is refused only when it starts at a real filesystem root.
|
|
515
|
+
if (/^[A-Za-z]:[\\/]/.test(bare) || (bare.startsWith('/') && (!quoted || FILESYSTEM_ROOT.test(bare)))) {
|
|
516
|
+
return `Absolute path '${bare}' points outside the workspace; use paths relative to the workspace root.`;
|
|
517
|
+
}
|
|
518
|
+
if (/(^|[\\/])\.\.([\\/]|$)/.test(bare))
|
|
519
|
+
return `Parent-directory path '${bare}' escapes the workspace.`;
|
|
520
|
+
}
|
|
521
|
+
for (const segment of command.split(/&&|\|\||[;|\n]/)) {
|
|
522
|
+
const words = segment.trim().split(/\s+/).filter(Boolean);
|
|
523
|
+
while (words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[0]))
|
|
524
|
+
words.shift();
|
|
525
|
+
if (!words.length)
|
|
526
|
+
continue;
|
|
527
|
+
const bin = path.basename(words[0].replace(/^['"]|['"]$/g, '')).replace(/\.(exe|cmd|bat)$/i, '');
|
|
528
|
+
if (!allowed.has(bin)) {
|
|
529
|
+
return `'${bin}' is not on the command allowlist (${[...allowed].join(', ')}).`;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
/** POSIX bash for the model's commands; Git Bash on Windows, else the platform shell. */
|
|
535
|
+
function resolveToolShell() {
|
|
536
|
+
const override = process.env.NATIV_BASH_PATH;
|
|
537
|
+
if (override && fs.existsSync(override))
|
|
538
|
+
return override;
|
|
539
|
+
if (process.platform !== 'win32')
|
|
540
|
+
return fs.existsSync('/bin/bash') ? '/bin/bash' : true;
|
|
541
|
+
const candidates = [
|
|
542
|
+
process.env.ProgramFiles && path.join(process.env.ProgramFiles, 'Git', 'bin', 'bash.exe'),
|
|
543
|
+
process.env['ProgramFiles(x86)'] && path.join(process.env['ProgramFiles(x86)'], 'Git', 'bin', 'bash.exe'),
|
|
544
|
+
process.env.LOCALAPPDATA && path.join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'bin', 'bash.exe'),
|
|
545
|
+
].filter((p) => Boolean(p));
|
|
546
|
+
return candidates.find((p) => fs.existsSync(p)) ?? true;
|
|
547
|
+
}
|
|
548
|
+
function clipToolOutput(text, omitted = 0) {
|
|
549
|
+
const total = text.length + omitted;
|
|
550
|
+
if (total <= NATIVE_TOOL_OUTPUT_LIMIT)
|
|
551
|
+
return text;
|
|
552
|
+
const head = Math.floor(NATIVE_TOOL_OUTPUT_LIMIT * 0.4);
|
|
553
|
+
const tail = NATIVE_TOOL_OUTPUT_LIMIT - head;
|
|
554
|
+
return `${text.slice(0, head)}\n\n[... ${total - NATIVE_TOOL_OUTPUT_LIMIT} characters omitted ...]\n\n${text.slice(-tail)}`;
|
|
555
|
+
}
|
|
556
|
+
function isWithin(root, target) {
|
|
557
|
+
const rel = path.relative(root, target);
|
|
558
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${path.sep}`) && !path.isAbsolute(rel));
|
|
559
|
+
}
|
|
560
|
+
/** Resolves symlinks/junctions on the nearest existing ancestor of a possibly new path. */
|
|
561
|
+
function canonicalize(target) {
|
|
562
|
+
let current = target;
|
|
563
|
+
const rest = [];
|
|
564
|
+
while (!fs.existsSync(current)) {
|
|
565
|
+
const parent = path.dirname(current);
|
|
566
|
+
if (parent === current)
|
|
567
|
+
break;
|
|
568
|
+
rest.unshift(path.basename(current));
|
|
569
|
+
current = parent;
|
|
570
|
+
}
|
|
571
|
+
try {
|
|
572
|
+
return path.join(fs.realpathSync.native(current), ...rest);
|
|
573
|
+
}
|
|
574
|
+
catch {
|
|
575
|
+
return target;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Confines a model-supplied editor path to the workspace. Reads may follow the
|
|
580
|
+
* `.ai/` and `node_modules/` junctions the supervisor mounts into worktrees;
|
|
581
|
+
* writes may not touch them at all.
|
|
582
|
+
*/
|
|
583
|
+
function resolveEditorPath(workspace, projectRoot, raw, mode) {
|
|
584
|
+
if (typeof raw !== 'string' || !raw.trim())
|
|
585
|
+
throw new ToolInputError('"path" is required.');
|
|
586
|
+
if (/%2e|%2f|%5c/i.test(raw))
|
|
587
|
+
throw new ToolInputError('URL-encoded path segments are not allowed.');
|
|
588
|
+
const root = path.resolve(workspace);
|
|
589
|
+
const target = path.resolve(root, raw);
|
|
590
|
+
if (!isWithin(root, target))
|
|
591
|
+
throw new ToolInputError(`Path '${raw}' is outside the workspace.`);
|
|
592
|
+
const rel = path.relative(root, target);
|
|
593
|
+
if (SECRET_PATH_PATTERN.test(` ${rel}`)) {
|
|
594
|
+
throw new ToolInputError('Secret files (.env, *.pem, *.key, .nativ/*.local.json) are off-limits; only .env.example may be read.');
|
|
595
|
+
}
|
|
596
|
+
const top = rel.split(/[\\/]/)[0];
|
|
597
|
+
if (mode === 'write' && (top === '.ai' || top === 'node_modules' || top === '.git')) {
|
|
598
|
+
throw new ToolInputError(`'${top}/' is read-only; contract changes go through \`nativ task escalate\`.`);
|
|
599
|
+
}
|
|
600
|
+
const allowedRoots = [canonicalize(root)];
|
|
601
|
+
if (mode === 'read') {
|
|
602
|
+
allowedRoots.push(canonicalize(path.join(projectRoot, '.ai')), canonicalize(path.join(projectRoot, 'node_modules')));
|
|
603
|
+
}
|
|
604
|
+
if (!allowedRoots.some((r) => isWithin(r, canonicalize(target)))) {
|
|
605
|
+
throw new ToolInputError(`Path '${raw}' resolves outside the workspace.`);
|
|
606
|
+
}
|
|
607
|
+
return target;
|
|
608
|
+
}
|
|
609
|
+
function requireString(input, key, allowEmpty = false) {
|
|
610
|
+
const value = input[key];
|
|
611
|
+
if (typeof value !== 'string' || (!allowEmpty && value === '')) {
|
|
612
|
+
throw new ToolInputError(`"${key}" must be a${allowEmpty ? '' : ' non-empty'} string.`);
|
|
613
|
+
}
|
|
614
|
+
return value;
|
|
615
|
+
}
|
|
616
|
+
/** Client-side implementation of the text_editor_20250728 commands. */
|
|
617
|
+
function runEditorCommand(workspace, projectRoot, input) {
|
|
618
|
+
const command = input.command;
|
|
619
|
+
const rawPath = input.path;
|
|
620
|
+
switch (command) {
|
|
621
|
+
case 'view': {
|
|
622
|
+
const target = resolveEditorPath(workspace, projectRoot, rawPath, 'read');
|
|
623
|
+
if (!fs.existsSync(target))
|
|
624
|
+
throw new ToolInputError(`Path '${rawPath}' does not exist.`);
|
|
625
|
+
if (fs.statSync(target).isDirectory()) {
|
|
626
|
+
const entries = fs
|
|
627
|
+
.readdirSync(target, { withFileTypes: true })
|
|
628
|
+
.filter((e) => e.name !== '.git')
|
|
629
|
+
.map((e) => (e.isDirectory() ? `${e.name}/` : e.name))
|
|
630
|
+
.sort();
|
|
631
|
+
return entries.join('\n') || '(empty directory)';
|
|
632
|
+
}
|
|
633
|
+
const lines = fs.readFileSync(target, 'utf8').split(/\r?\n/);
|
|
634
|
+
let start = 1;
|
|
635
|
+
let end = lines.length;
|
|
636
|
+
if (input.view_range !== undefined) {
|
|
637
|
+
const range = input.view_range;
|
|
638
|
+
if (!Array.isArray(range) || range.length !== 2 || !range.every(Number.isInteger)) {
|
|
639
|
+
throw new ToolInputError('"view_range" must be [startLine, endLine] (endLine -1 reads to the end).');
|
|
640
|
+
}
|
|
641
|
+
start = Math.max(1, range[0]);
|
|
642
|
+
end = range[1] === -1 ? lines.length : Math.min(lines.length, range[1]);
|
|
643
|
+
if (start > end)
|
|
644
|
+
throw new ToolInputError(`Invalid view_range [${range.join(', ')}] for a ${lines.length}-line file.`);
|
|
645
|
+
}
|
|
646
|
+
const numbered = lines.slice(start - 1, end).map((line, i) => `${start + i}\t${line}`).join('\n');
|
|
647
|
+
return clipToolOutput(numbered);
|
|
648
|
+
}
|
|
649
|
+
case 'create': {
|
|
650
|
+
const target = resolveEditorPath(workspace, projectRoot, rawPath, 'write');
|
|
651
|
+
const text = requireString(input, 'file_text', true);
|
|
652
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
653
|
+
fs.writeFileSync(target, text, 'utf8');
|
|
654
|
+
return `File created successfully at: ${rawPath}`;
|
|
655
|
+
}
|
|
656
|
+
case 'str_replace': {
|
|
657
|
+
const target = resolveEditorPath(workspace, projectRoot, rawPath, 'write');
|
|
658
|
+
if (!fs.existsSync(target))
|
|
659
|
+
throw new ToolInputError(`File '${rawPath}' does not exist.`);
|
|
660
|
+
const content = fs.readFileSync(target, 'utf8');
|
|
661
|
+
let oldStr = requireString(input, 'old_str');
|
|
662
|
+
let newStr = input.new_str === undefined ? '' : requireString(input, 'new_str', true);
|
|
663
|
+
let count = content.split(oldStr).length - 1;
|
|
664
|
+
if (count === 0 && content.includes('\r\n') && !oldStr.includes('\r\n')) {
|
|
665
|
+
// The model writes LF; retry against CRLF files before reporting a miss.
|
|
666
|
+
oldStr = oldStr.replace(/\n/g, '\r\n');
|
|
667
|
+
newStr = newStr.replace(/\r?\n/g, '\r\n');
|
|
668
|
+
count = content.split(oldStr).length - 1;
|
|
669
|
+
}
|
|
670
|
+
if (count === 0) {
|
|
671
|
+
throw new ToolInputError('No match found for old_str. Check whitespace and indentation against a fresh view of the file.');
|
|
672
|
+
}
|
|
673
|
+
if (count > 1) {
|
|
674
|
+
throw new ToolInputError(`Found ${count} matches for old_str; include more surrounding context so it is unique.`);
|
|
675
|
+
}
|
|
676
|
+
fs.writeFileSync(target, content.replace(oldStr, () => newStr), 'utf8');
|
|
677
|
+
return 'Successfully replaced text at exactly one location.';
|
|
678
|
+
}
|
|
679
|
+
case 'insert': {
|
|
680
|
+
const target = resolveEditorPath(workspace, projectRoot, rawPath, 'write');
|
|
681
|
+
if (!fs.existsSync(target))
|
|
682
|
+
throw new ToolInputError(`File '${rawPath}' does not exist.`);
|
|
683
|
+
const content = fs.readFileSync(target, 'utf8');
|
|
684
|
+
const text = requireString(input, 'insert_text', true);
|
|
685
|
+
const eol = content.includes('\r\n') ? '\r\n' : '\n';
|
|
686
|
+
const lines = content.split(/\r?\n/);
|
|
687
|
+
const lineCount = lines[lines.length - 1] === '' ? lines.length - 1 : lines.length;
|
|
688
|
+
const after = input.insert_line;
|
|
689
|
+
if (typeof after !== 'number' || !Number.isInteger(after) || after < 0 || after > lineCount) {
|
|
690
|
+
throw new ToolInputError(`"insert_line" must be an integer between 0 and ${lineCount}.`);
|
|
691
|
+
}
|
|
692
|
+
lines.splice(after, 0, ...text.split(/\r?\n/));
|
|
693
|
+
fs.writeFileSync(target, lines.join(eol), 'utf8');
|
|
694
|
+
return `Inserted text after line ${after}.`;
|
|
695
|
+
}
|
|
696
|
+
default:
|
|
697
|
+
throw new ToolInputError(`Unsupported editor command '${String(command)}'. Use view, create, str_replace or insert.`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
function normalizeEngine(value, fallback) {
|
|
701
|
+
if (value === undefined || value === null || value === '')
|
|
702
|
+
return fallback;
|
|
703
|
+
if (value === 'native' || value === 'cli')
|
|
704
|
+
return value;
|
|
705
|
+
throw new SupervisorError('VALIDATION_ERROR', `"runnerEngine" must be 'native' or 'cli'`);
|
|
706
|
+
}
|
|
707
|
+
/** 0 is a valid budget: "as little thinking as the model allows". */
|
|
708
|
+
function normalizeThinkingBudget(value) {
|
|
709
|
+
if (value === undefined || value === null)
|
|
710
|
+
return null;
|
|
711
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
|
|
712
|
+
throw new SupervisorError('VALIDATION_ERROR', '"thinkingBudget" must be a non-negative number');
|
|
713
|
+
}
|
|
714
|
+
return Math.floor(value);
|
|
715
|
+
}
|
|
716
|
+
function normalizeModel(value) {
|
|
717
|
+
if (value === undefined || value === null || value === '')
|
|
718
|
+
return null;
|
|
719
|
+
if (typeof value !== 'string' || !/^[A-Za-z0-9][\w.:@-]{0,127}$/.test(value.trim())) {
|
|
720
|
+
throw new SupervisorError('VALIDATION_ERROR', '"model" must be a model ID such as claude-opus-5');
|
|
721
|
+
}
|
|
722
|
+
return value.trim();
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Supervises autonomous agent runs: detached spawn inside an isolated git
|
|
726
|
+
* worktree, a timeout killswitch, a bounded streaming log buffer, and
|
|
727
|
+
* local-first persistence under `.nativ/runs/` (structure only, never secrets).
|
|
728
|
+
*
|
|
729
|
+
* Emits `runner_status` and `runner_log` events for SSE fan-out.
|
|
730
|
+
*/
|
|
731
|
+
export class AgentSupervisor extends EventEmitter {
|
|
732
|
+
rootDir;
|
|
733
|
+
runsDir;
|
|
734
|
+
defaultRunnerCommand;
|
|
735
|
+
defaultTimeoutSeconds;
|
|
736
|
+
maxLogBufferBytes;
|
|
737
|
+
killGraceMs;
|
|
738
|
+
idleTimeoutMs;
|
|
739
|
+
maxHistory;
|
|
740
|
+
defaultEngine;
|
|
741
|
+
defaultNativeModel;
|
|
742
|
+
nativeMaxTurns;
|
|
743
|
+
nativeToolTimeoutMs;
|
|
744
|
+
nativeAllowedCommands;
|
|
745
|
+
anthropicClientFactory;
|
|
746
|
+
toolShell;
|
|
747
|
+
active = new Map();
|
|
748
|
+
history = [];
|
|
749
|
+
closed = false;
|
|
750
|
+
constructor(options = {}) {
|
|
751
|
+
super();
|
|
752
|
+
this.rootDir = resolveProjectRoot(options.cwd);
|
|
753
|
+
this.runsDir = path.join(this.rootDir, '.nativ', 'runs');
|
|
754
|
+
this.defaultRunnerCommand =
|
|
755
|
+
options.defaultRunnerCommand ?? process.env.NATIV_RUNNER_COMMAND ?? DEFAULT_RUNNER_COMMAND;
|
|
756
|
+
this.defaultTimeoutSeconds = options.defaultTimeoutSeconds ?? DEFAULT_TIMEOUT_SECONDS;
|
|
757
|
+
this.maxLogBufferBytes = options.maxLogBufferBytes ?? DEFAULT_MAX_LOG_BUFFER_BYTES;
|
|
758
|
+
this.killGraceMs = options.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
|
|
759
|
+
const idleSeconds = options.idleTimeoutSeconds ?? Number(process.env.NATIV_RUNNER_IDLE_TIMEOUT ?? DEFAULT_IDLE_TIMEOUT_SECONDS);
|
|
760
|
+
this.idleTimeoutMs = Number.isFinite(idleSeconds) && idleSeconds > 0 ? idleSeconds * 1000 : 0;
|
|
761
|
+
this.maxHistory = options.maxHistory ?? DEFAULT_MAX_HISTORY;
|
|
762
|
+
this.defaultEngine = normalizeEngine(options.defaultEngine ?? process.env.NATIV_RUNNER_ENGINE, DEFAULT_RUNNER_ENGINE);
|
|
763
|
+
this.defaultNativeModel =
|
|
764
|
+
normalizeModel(options.defaultNativeModel ?? process.env.NATIV_NATIVE_MODEL) ?? DEFAULT_NATIVE_MODEL;
|
|
765
|
+
this.nativeMaxTurns = options.nativeMaxTurns ?? DEFAULT_NATIVE_MAX_TURNS;
|
|
766
|
+
this.nativeToolTimeoutMs = options.nativeToolTimeoutMs ?? DEFAULT_NATIVE_TOOL_TIMEOUT_MS;
|
|
767
|
+
this.nativeAllowedCommands = new Set(options.nativeAllowedCommands ?? DEFAULT_NATIVE_ALLOWED_COMMANDS);
|
|
768
|
+
this.anthropicClientFactory = options.anthropicClientFactory ?? (() => new Anthropic());
|
|
769
|
+
this.toolShell = options.nativeShell ?? resolveToolShell();
|
|
770
|
+
this.restoreHistory();
|
|
771
|
+
}
|
|
772
|
+
get projectRoot() {
|
|
773
|
+
return this.rootDir;
|
|
774
|
+
}
|
|
775
|
+
// ─── Dispatch ───────────────────────────────────────────────────────────────
|
|
776
|
+
async dispatch(options) {
|
|
777
|
+
if (this.closed)
|
|
778
|
+
throw new SupervisorError('SHUTTING_DOWN', 'Supervisor is shutting down', 503);
|
|
779
|
+
const taskId = (options.taskId || '').trim();
|
|
780
|
+
if (!taskId)
|
|
781
|
+
throw new SupervisorError('VALIDATION_ERROR', '"taskId" is required');
|
|
782
|
+
if (this.active.has(taskId)) {
|
|
783
|
+
throw new SupervisorError('TASK_ALREADY_RUNNING', `Task '${taskId}' already has a runner in flight`);
|
|
784
|
+
}
|
|
785
|
+
const task = this.requireTask(taskId);
|
|
786
|
+
this.assertDependenciesMet(task);
|
|
787
|
+
const engine = normalizeEngine(options.runnerEngine, this.defaultEngine);
|
|
788
|
+
const thinkingBudget = normalizeThinkingBudget(options.thinkingBudget);
|
|
789
|
+
const requestedModel = normalizeModel(options.model);
|
|
790
|
+
const timeoutSeconds = this.normalizeTimeout(options.timeoutSeconds);
|
|
791
|
+
const useWorktree = options.useWorktree !== false;
|
|
792
|
+
const worktreeDir = useWorktree ? path.join(this.rootDir, '.worktrees', `task-${taskId}`) : this.rootDir;
|
|
793
|
+
let command;
|
|
794
|
+
let native = null;
|
|
795
|
+
let thinking = null;
|
|
796
|
+
if (engine === 'native') {
|
|
797
|
+
// The model writes POSIX shell; Windows' cmd.exe fallback would fail on nearly every command.
|
|
798
|
+
if (process.platform === 'win32' && this.toolShell === true) {
|
|
799
|
+
throw new SupervisorError('BASH_NOT_FOUND', 'The native engine runs the model\'s commands in bash, which was not found. Install Git for Windows (it ships Git Bash) or set NATIV_BASH_PATH to bash.exe.');
|
|
800
|
+
}
|
|
801
|
+
const model = requestedModel ?? this.defaultNativeModel;
|
|
802
|
+
thinking = resolveNativeThinking(model, thinkingBudget);
|
|
803
|
+
native = {
|
|
804
|
+
client: this.createAnthropicClient(),
|
|
805
|
+
model,
|
|
806
|
+
thinking,
|
|
807
|
+
controller: new AbortController(),
|
|
808
|
+
env: options.env,
|
|
809
|
+
};
|
|
810
|
+
command = `nativ native-engine --model ${model}`;
|
|
811
|
+
}
|
|
812
|
+
else {
|
|
813
|
+
let rawCommand = (options.runnerCommand || '').trim() || this.defaultRunnerCommand;
|
|
814
|
+
if (rawCommand === 'claude') {
|
|
815
|
+
if (!isExecutableInPath('claude')) {
|
|
816
|
+
throw new SupervisorError('CLAUDE_NOT_FOUND', "The 'claude' CLI executable was not found in PATH. Install Claude Code (npm install -g @anthropic-ai/claude-code), configure runnerCommand / NATIV_RUNNER_COMMAND, or dispatch with runnerEngine 'native'.");
|
|
817
|
+
}
|
|
818
|
+
const extraAllowed = [...parseRunnerAllowList(process.env.NATIV_RUNNER_ALLOW), ...(options.allowedTools ?? [])];
|
|
819
|
+
const settingsFile = writeRunnerSettings(this.runsDir, taskId, buildRunnerPermissions(task, this.rootDir, extraAllowed));
|
|
820
|
+
rawCommand = buildDefaultClaudeCommand(task, requestedModel, settingsFile);
|
|
821
|
+
}
|
|
822
|
+
command = rawCommand
|
|
823
|
+
.replace(/\{taskId\}/g, taskId)
|
|
824
|
+
.replace(/\{taskTitle\}/g, task.title)
|
|
825
|
+
.replace(/\{worktreeDir\}/g, worktreeDir);
|
|
826
|
+
// 0 ("no extra thinking") leaves Claude Code on its own default rather than exporting MAX_THINKING_TOKENS=0.
|
|
827
|
+
if (thinkingBudget !== null)
|
|
828
|
+
thinking = { budget: thinkingBudget, effort: null, budgetTokens: thinkingBudget > 0 ? thinkingBudget : null };
|
|
829
|
+
}
|
|
830
|
+
const record = {
|
|
831
|
+
runId: randomUUID(),
|
|
832
|
+
taskId,
|
|
833
|
+
status: useWorktree ? 'spawning_worktree' : 'running',
|
|
834
|
+
engine,
|
|
835
|
+
model: native?.model ?? requestedModel,
|
|
836
|
+
thinking,
|
|
837
|
+
usage: native ? emptyActualUsage(native.model) : null,
|
|
838
|
+
pid: null,
|
|
839
|
+
branch: useWorktree ? `agent/task-${taskId}` : null,
|
|
840
|
+
worktreeDir,
|
|
841
|
+
command,
|
|
842
|
+
startedAt: new Date().toISOString(),
|
|
843
|
+
endedAt: null,
|
|
844
|
+
durationMs: null,
|
|
845
|
+
exitCode: null,
|
|
846
|
+
signal: null,
|
|
847
|
+
timeoutSeconds,
|
|
848
|
+
timedOut: false,
|
|
849
|
+
logBytes: 0,
|
|
850
|
+
logFile: path.join(this.runsDir, `${taskId}.log`),
|
|
851
|
+
error: null,
|
|
852
|
+
abortReason: null,
|
|
853
|
+
verification: null,
|
|
854
|
+
};
|
|
855
|
+
const run = {
|
|
856
|
+
record,
|
|
857
|
+
task,
|
|
858
|
+
child: null,
|
|
859
|
+
timer: null,
|
|
860
|
+
killTimer: null,
|
|
861
|
+
buffer: '',
|
|
862
|
+
aborting: false,
|
|
863
|
+
settled: false,
|
|
864
|
+
native,
|
|
865
|
+
toolPid: null,
|
|
866
|
+
lastActivity: Date.now(),
|
|
867
|
+
idleTimer: null,
|
|
868
|
+
streamJson: !native && usesClaudeStreamJson(command),
|
|
869
|
+
lineBuffer: '',
|
|
870
|
+
streamModel: null,
|
|
871
|
+
};
|
|
872
|
+
this.active.set(taskId, run);
|
|
873
|
+
this.resetLogFile(record);
|
|
874
|
+
this.emitStatus(record);
|
|
875
|
+
if (useWorktree) {
|
|
876
|
+
try {
|
|
877
|
+
this.provisionWorktree(record);
|
|
878
|
+
}
|
|
879
|
+
catch (err) {
|
|
880
|
+
this.finalize(run, 'failed', { error: `Worktree provisioning failed: ${err.message}` });
|
|
881
|
+
throw new SupervisorError('WORKTREE_FAILED', `Worktree provisioning failed: ${err.message}`);
|
|
882
|
+
}
|
|
883
|
+
record.status = 'running';
|
|
884
|
+
this.emitStatus(record);
|
|
885
|
+
}
|
|
886
|
+
try {
|
|
887
|
+
if (native)
|
|
888
|
+
this.startNativeRun(run, options, useWorktree);
|
|
889
|
+
else
|
|
890
|
+
this.spawnRunner(run, task, options);
|
|
891
|
+
}
|
|
892
|
+
catch (err) {
|
|
893
|
+
this.finalize(run, 'failed', { error: `Failed to spawn runner: ${err.message}` });
|
|
894
|
+
throw new SupervisorError('SPAWN_FAILED', `Failed to spawn runner: ${err.message}`);
|
|
895
|
+
}
|
|
896
|
+
this.persist(record);
|
|
897
|
+
return { ...record };
|
|
898
|
+
}
|
|
899
|
+
// ─── Abort ──────────────────────────────────────────────────────────────────
|
|
900
|
+
async abort(taskId, reason) {
|
|
901
|
+
const run = this.active.get(taskId);
|
|
902
|
+
if (!run)
|
|
903
|
+
throw new SupervisorError('TASK_NOT_RUNNING', `No active runner for task '${taskId}'`);
|
|
904
|
+
run.aborting = true;
|
|
905
|
+
run.record.abortReason = reason ?? 'Aborted by operator';
|
|
906
|
+
this.clearTimers(run);
|
|
907
|
+
this.interruptNative(run);
|
|
908
|
+
const pid = run.record.pid;
|
|
909
|
+
if (pid !== null) {
|
|
910
|
+
killProcessTree(pid, 'SIGTERM');
|
|
911
|
+
run.killTimer = setTimeout(() => {
|
|
912
|
+
if (this.active.has(taskId) && isPidAlive(pid))
|
|
913
|
+
killProcessTree(pid, 'SIGKILL');
|
|
914
|
+
}, this.killGraceMs);
|
|
915
|
+
run.killTimer.unref?.();
|
|
916
|
+
}
|
|
917
|
+
else {
|
|
918
|
+
this.finalize(run, 'aborted', { error: run.record.abortReason });
|
|
919
|
+
}
|
|
920
|
+
return {
|
|
921
|
+
taskId,
|
|
922
|
+
runId: run.record.runId,
|
|
923
|
+
status: 'aborted',
|
|
924
|
+
message: `Runner for task '${taskId}' terminated: ${run.record.abortReason}`,
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
// ─── Queries ────────────────────────────────────────────────────────────────
|
|
928
|
+
getRun(taskId) {
|
|
929
|
+
const run = this.active.get(taskId);
|
|
930
|
+
if (run)
|
|
931
|
+
return { ...run.record };
|
|
932
|
+
const past = this.history.find((r) => r.taskId === taskId);
|
|
933
|
+
return past ? { ...past } : null;
|
|
934
|
+
}
|
|
935
|
+
isRunning(taskId) {
|
|
936
|
+
return this.active.has(taskId);
|
|
937
|
+
}
|
|
938
|
+
listRuns(query = {}) {
|
|
939
|
+
const live = [...this.active.values()].map((r) => ({ ...r.record }));
|
|
940
|
+
const runs = query.activeOnly ? live : [...live, ...this.history.map((r) => ({ ...r }))];
|
|
941
|
+
const filtered = query.taskId ? runs.filter((r) => r.taskId === query.taskId) : runs;
|
|
942
|
+
return filtered.sort((a, b) => b.startedAt.localeCompare(a.startedAt));
|
|
943
|
+
}
|
|
944
|
+
/**
|
|
945
|
+
* Returns the tail of a run's log. Live runs are served from the in-memory
|
|
946
|
+
* ring buffer; finished runs fall back to `.nativ/runs/<taskId>.log`.
|
|
947
|
+
*/
|
|
948
|
+
getLogs(taskId, tail = 200) {
|
|
949
|
+
const run = this.active.get(taskId);
|
|
950
|
+
const record = run?.record ?? this.history.find((r) => r.taskId === taskId) ?? null;
|
|
951
|
+
let text = run?.buffer ?? '';
|
|
952
|
+
let truncated = run ? run.record.logBytes > Buffer.byteLength(text, 'utf8') : false;
|
|
953
|
+
if (!run) {
|
|
954
|
+
const file = record?.logFile ?? path.join(this.runsDir, `${taskId}.log`);
|
|
955
|
+
try {
|
|
956
|
+
text = fs.readFileSync(file, 'utf8');
|
|
957
|
+
}
|
|
958
|
+
catch {
|
|
959
|
+
text = '';
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
const sliced = tailLines(text, tail);
|
|
963
|
+
if (sliced.length < text.length)
|
|
964
|
+
truncated = true;
|
|
965
|
+
return {
|
|
966
|
+
taskId,
|
|
967
|
+
runId: record?.runId ?? null,
|
|
968
|
+
status: record?.status ?? null,
|
|
969
|
+
totalBytes: record?.logBytes ?? Buffer.byteLength(text, 'utf8'),
|
|
970
|
+
log: sliced,
|
|
971
|
+
truncated,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
/** Kills every in-flight runner and releases timers. Safe to call twice. */
|
|
975
|
+
shutdown(reason = 'Supervisor shutdown') {
|
|
976
|
+
this.closed = true;
|
|
977
|
+
for (const [taskId, run] of [...this.active]) {
|
|
978
|
+
this.clearTimers(run);
|
|
979
|
+
this.interruptNative(run);
|
|
980
|
+
if (run.record.pid !== null)
|
|
981
|
+
killProcessTree(run.record.pid, 'SIGKILL');
|
|
982
|
+
this.finalize(run, 'aborted', { error: reason });
|
|
983
|
+
this.active.delete(taskId);
|
|
984
|
+
}
|
|
985
|
+
this.removeAllListeners();
|
|
986
|
+
}
|
|
987
|
+
// ─── Internals ──────────────────────────────────────────────────────────────
|
|
988
|
+
normalizeTimeout(value) {
|
|
989
|
+
if (value === undefined || value === null)
|
|
990
|
+
return this.defaultTimeoutSeconds;
|
|
991
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
992
|
+
throw new SupervisorError('VALIDATION_ERROR', '"timeoutSeconds" must be a positive number');
|
|
993
|
+
}
|
|
994
|
+
return Math.floor(value);
|
|
995
|
+
}
|
|
996
|
+
requireTask(taskId) {
|
|
997
|
+
const { plan, error } = loadMasterPlan(this.rootDir);
|
|
998
|
+
if (!plan)
|
|
999
|
+
throw new SupervisorError('PLAN_NOT_FOUND', error ?? 'No .ai/master_plan.json found');
|
|
1000
|
+
for (const milestone of plan.milestones) {
|
|
1001
|
+
const task = milestone.tasks.find((t) => t.id === taskId);
|
|
1002
|
+
if (task)
|
|
1003
|
+
return task;
|
|
1004
|
+
}
|
|
1005
|
+
throw new SupervisorError('TASK_NOT_FOUND', `Task '${taskId}' is not declared in .ai/master_plan.json`);
|
|
1006
|
+
}
|
|
1007
|
+
assertDependenciesMet(task) {
|
|
1008
|
+
if (!task.dependencies?.length)
|
|
1009
|
+
return;
|
|
1010
|
+
const { plan } = loadMasterPlan(this.rootDir);
|
|
1011
|
+
if (!plan)
|
|
1012
|
+
return;
|
|
1013
|
+
const byId = new Map();
|
|
1014
|
+
for (const milestone of plan.milestones)
|
|
1015
|
+
for (const t of milestone.tasks)
|
|
1016
|
+
byId.set(t.id, t);
|
|
1017
|
+
const unmet = task.dependencies.filter((id) => byId.get(id)?.status !== 'completed');
|
|
1018
|
+
if (unmet.length) {
|
|
1019
|
+
throw new SupervisorError('UNMET_DEPENDENCIES', `Task '${task.id}' has unmet dependencies: ${unmet.join(', ')}`);
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
/** Creates `.worktrees/task-<id>` on `agent/task-<id>`, reusing an existing one. */
|
|
1023
|
+
provisionWorktree(record) {
|
|
1024
|
+
const worktreeDir = record.worktreeDir;
|
|
1025
|
+
const branch = record.branch;
|
|
1026
|
+
// Prune stale git worktrees so git doesn't refuse creation
|
|
1027
|
+
try {
|
|
1028
|
+
spawnSync('git', ['worktree', 'prune'], { cwd: this.rootDir, stdio: 'ignore', windowsHide: true });
|
|
1029
|
+
}
|
|
1030
|
+
catch { }
|
|
1031
|
+
if (fs.existsSync(worktreeDir)) {
|
|
1032
|
+
const gitRef = path.join(worktreeDir, '.git');
|
|
1033
|
+
if (fs.existsSync(gitRef)) {
|
|
1034
|
+
linkWorktreeAiDirectory(worktreeDir, this.rootDir);
|
|
1035
|
+
linkWorktreeNodeModules(worktreeDir, this.rootDir);
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
// Zombie directory without .git: clean it up so git worktree add doesn't fail
|
|
1039
|
+
try {
|
|
1040
|
+
safeUnlinkWorktreeAiDirectory(worktreeDir);
|
|
1041
|
+
safeUnlinkWorktreeNodeModules(worktreeDir);
|
|
1042
|
+
fs.rmSync(worktreeDir, { recursive: true, force: true });
|
|
1043
|
+
}
|
|
1044
|
+
catch { }
|
|
1045
|
+
}
|
|
1046
|
+
fs.mkdirSync(path.join(this.rootDir, '.worktrees'), { recursive: true });
|
|
1047
|
+
// `-b` fails when the agent branch survived a previous run; reuse it instead.
|
|
1048
|
+
let res = spawnSync('git', ['worktree', 'add', worktreeDir, '-b', branch], {
|
|
1049
|
+
cwd: this.rootDir,
|
|
1050
|
+
encoding: 'utf8',
|
|
1051
|
+
windowsHide: true,
|
|
1052
|
+
});
|
|
1053
|
+
if (res.status !== 0) {
|
|
1054
|
+
res = spawnSync('git', ['worktree', 'add', worktreeDir, branch], {
|
|
1055
|
+
cwd: this.rootDir,
|
|
1056
|
+
encoding: 'utf8',
|
|
1057
|
+
windowsHide: true,
|
|
1058
|
+
});
|
|
1059
|
+
}
|
|
1060
|
+
if (res.status !== 0) {
|
|
1061
|
+
throw new Error(String(res.stderr || res.stdout || 'git worktree add failed').trim());
|
|
1062
|
+
}
|
|
1063
|
+
linkWorktreeAiDirectory(worktreeDir, this.rootDir);
|
|
1064
|
+
linkWorktreeNodeModules(worktreeDir, this.rootDir);
|
|
1065
|
+
}
|
|
1066
|
+
spawnRunner(run, task, options) {
|
|
1067
|
+
const { record } = run;
|
|
1068
|
+
const child = spawn(record.command, {
|
|
1069
|
+
cwd: record.worktreeDir,
|
|
1070
|
+
shell: true,
|
|
1071
|
+
// POSIX: a new process group so the killswitch can reap the whole agent
|
|
1072
|
+
// tree. Windows: detaching hands the child a fresh console and severs our
|
|
1073
|
+
// stdio pipes, so we stay attached and let `taskkill /T` walk the tree.
|
|
1074
|
+
detached: process.platform !== 'win32',
|
|
1075
|
+
windowsHide: true,
|
|
1076
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1077
|
+
env: {
|
|
1078
|
+
...buildRunnerEnv(process.env, options.env),
|
|
1079
|
+
...(record.thinking?.budgetTokens ? { MAX_THINKING_TOKENS: String(record.thinking.budgetTokens) } : {}),
|
|
1080
|
+
NATIV_RUN_ID: record.runId,
|
|
1081
|
+
NATIV_TASK_ID: record.taskId,
|
|
1082
|
+
NATIV_PROJECT_ROOT: this.rootDir,
|
|
1083
|
+
},
|
|
1084
|
+
});
|
|
1085
|
+
run.child = child;
|
|
1086
|
+
record.pid = child.pid ?? null;
|
|
1087
|
+
child.stdout?.on('data', (chunk) => {
|
|
1088
|
+
if (run.streamJson)
|
|
1089
|
+
this.consumeStreamJson(run, chunk.toString());
|
|
1090
|
+
else
|
|
1091
|
+
this.appendLog(run, 'stdout', chunk.toString());
|
|
1092
|
+
});
|
|
1093
|
+
child.stderr?.on('data', (chunk) => this.appendLog(run, 'stderr', chunk.toString()));
|
|
1094
|
+
child.on('error', (err) => {
|
|
1095
|
+
record.error = err.message;
|
|
1096
|
+
this.appendLog(run, 'stderr', `\n[supervisor] spawn error: ${err.message}\n`);
|
|
1097
|
+
});
|
|
1098
|
+
child.on('close', (code, signal) => {
|
|
1099
|
+
if (run.streamJson && run.lineBuffer) {
|
|
1100
|
+
// The final event may arrive without a trailing newline.
|
|
1101
|
+
const rest = run.lineBuffer;
|
|
1102
|
+
run.lineBuffer = '';
|
|
1103
|
+
this.handleStreamLine(run, rest);
|
|
1104
|
+
}
|
|
1105
|
+
void this.handleExit(run, task, options, code, signal);
|
|
1106
|
+
});
|
|
1107
|
+
// The parent (studio server / CLI) must stay free to exit on its own terms.
|
|
1108
|
+
child.unref();
|
|
1109
|
+
run.timer = setTimeout(() => this.handleTimeout(run), record.timeoutSeconds * 1000);
|
|
1110
|
+
run.timer.unref?.();
|
|
1111
|
+
this.startIdleWatchdog(run);
|
|
1112
|
+
}
|
|
1113
|
+
/** Terminates runs that go silent: a hung agent is caught long before the hard cap. */
|
|
1114
|
+
startIdleWatchdog(run) {
|
|
1115
|
+
if (!this.idleTimeoutMs)
|
|
1116
|
+
return;
|
|
1117
|
+
run.lastActivity = Date.now();
|
|
1118
|
+
const every = Math.max(100, Math.min(this.idleTimeoutMs / 4, 15_000));
|
|
1119
|
+
run.idleTimer = setInterval(() => {
|
|
1120
|
+
if (Date.now() - run.lastActivity >= this.idleTimeoutMs)
|
|
1121
|
+
this.handleTimeout(run, 'idle');
|
|
1122
|
+
}, every);
|
|
1123
|
+
run.idleTimer.unref?.();
|
|
1124
|
+
}
|
|
1125
|
+
handleTimeout(run, kind = 'budget') {
|
|
1126
|
+
if (!this.active.has(run.record.taskId) || run.record.timedOut)
|
|
1127
|
+
return;
|
|
1128
|
+
if (run.idleTimer)
|
|
1129
|
+
clearInterval(run.idleTimer);
|
|
1130
|
+
run.idleTimer = null;
|
|
1131
|
+
run.record.timedOut = true;
|
|
1132
|
+
run.record.error =
|
|
1133
|
+
kind === 'idle'
|
|
1134
|
+
? `Runner produced no output for ${Math.round(this.idleTimeoutMs / 1000)}s (inactivity watchdog)`
|
|
1135
|
+
: `Runner exceeded the ${run.record.timeoutSeconds}s timeout budget`;
|
|
1136
|
+
this.appendLog(run, 'stderr', `\n[supervisor] ${run.record.error} — terminating process tree\n`);
|
|
1137
|
+
this.interruptNative(run);
|
|
1138
|
+
const pid = run.record.pid;
|
|
1139
|
+
if (pid === null) {
|
|
1140
|
+
this.finalize(run, 'failed', {});
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
killProcessTree(pid, 'SIGTERM');
|
|
1144
|
+
run.killTimer = setTimeout(() => {
|
|
1145
|
+
if (isPidAlive(pid))
|
|
1146
|
+
killProcessTree(pid, 'SIGKILL');
|
|
1147
|
+
}, this.killGraceMs);
|
|
1148
|
+
run.killTimer.unref?.();
|
|
1149
|
+
}
|
|
1150
|
+
async handleExit(run, task, options, code, signal) {
|
|
1151
|
+
const { record } = run;
|
|
1152
|
+
this.clearTimers(run);
|
|
1153
|
+
record.exitCode = code;
|
|
1154
|
+
record.signal = signal ?? null;
|
|
1155
|
+
if (run.aborting) {
|
|
1156
|
+
this.finalize(run, 'aborted', { error: record.abortReason });
|
|
1157
|
+
return;
|
|
1158
|
+
}
|
|
1159
|
+
if (record.timedOut) {
|
|
1160
|
+
this.finalize(run, 'failed', {});
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
if (code !== 0) {
|
|
1164
|
+
let errDetail = record.error;
|
|
1165
|
+
if (!errDetail) {
|
|
1166
|
+
const lastErrLine = run.buffer.trim().split('\n').filter(Boolean).pop()?.trim();
|
|
1167
|
+
const baseMsg = `Runner exited with code ${code ?? 'null'}${signal ? ` (${signal})` : ''}`;
|
|
1168
|
+
errDetail = lastErrLine && lastErrLine.length < 200 ? `${baseMsg}: ${lastErrLine}` : baseMsg;
|
|
1169
|
+
}
|
|
1170
|
+
this.finalize(run, 'failed', {
|
|
1171
|
+
error: errDetail,
|
|
1172
|
+
});
|
|
1173
|
+
return;
|
|
1174
|
+
}
|
|
1175
|
+
await this.completeSuccessfulRun(run, task, options);
|
|
1176
|
+
}
|
|
1177
|
+
/** Shared tail of a clean runner exit: optional verification, optional merge, then completion. */
|
|
1178
|
+
async completeSuccessfulRun(run, task, options) {
|
|
1179
|
+
const { record } = run;
|
|
1180
|
+
if (options.verify) {
|
|
1181
|
+
const passed = await this.runVerification(run, task);
|
|
1182
|
+
if (!passed) {
|
|
1183
|
+
this.finalize(run, 'failed', { error: 'Verification command failed' });
|
|
1184
|
+
return;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (options.autoMerge && record.branch) {
|
|
1188
|
+
record.status = 'merging';
|
|
1189
|
+
this.emitStatus(record);
|
|
1190
|
+
try {
|
|
1191
|
+
this.mergeWorktree(run);
|
|
1192
|
+
}
|
|
1193
|
+
catch (err) {
|
|
1194
|
+
this.finalize(run, 'failed', { error: `Merge failed: ${err.message}` });
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
this.finalize(run, 'completed', {});
|
|
1199
|
+
}
|
|
1200
|
+
// ─── Claude Code stream-json (cli engine) ──────────────────────────────────
|
|
1201
|
+
consumeStreamJson(run, chunk) {
|
|
1202
|
+
run.lastActivity = Date.now();
|
|
1203
|
+
run.lineBuffer += chunk;
|
|
1204
|
+
let newline;
|
|
1205
|
+
while ((newline = run.lineBuffer.indexOf('\n')) !== -1) {
|
|
1206
|
+
const line = run.lineBuffer.slice(0, newline).replace(/\r$/, '');
|
|
1207
|
+
run.lineBuffer = run.lineBuffer.slice(newline + 1);
|
|
1208
|
+
this.handleStreamLine(run, line);
|
|
1209
|
+
}
|
|
1210
|
+
// A runaway line without newlines must not grow without bound; show it raw instead.
|
|
1211
|
+
if (run.lineBuffer.length > this.maxLogBufferBytes) {
|
|
1212
|
+
const raw = run.lineBuffer;
|
|
1213
|
+
run.lineBuffer = '';
|
|
1214
|
+
this.appendLog(run, 'stdout', `${raw}\n`);
|
|
1215
|
+
}
|
|
1216
|
+
}
|
|
1217
|
+
handleStreamLine(run, line) {
|
|
1218
|
+
const parsed = formatClaudeStreamLine(line);
|
|
1219
|
+
if (parsed.model)
|
|
1220
|
+
run.streamModel = parsed.model;
|
|
1221
|
+
if (parsed.text)
|
|
1222
|
+
this.appendLog(run, 'stdout', parsed.text);
|
|
1223
|
+
if (parsed.result)
|
|
1224
|
+
this.recordStreamUsage(run, parsed.result);
|
|
1225
|
+
}
|
|
1226
|
+
/** Claude Code reports the run's total cost and tokens once, in its final result event. */
|
|
1227
|
+
recordStreamUsage(run, result) {
|
|
1228
|
+
const { record } = run;
|
|
1229
|
+
const model = record.model ?? run.streamModel ?? 'unknown';
|
|
1230
|
+
const slice = {
|
|
1231
|
+
inputTokens: result.inputTokens,
|
|
1232
|
+
outputTokens: result.outputTokens,
|
|
1233
|
+
cacheCreationTokens: result.cacheCreationTokens,
|
|
1234
|
+
cacheReadTokens: result.cacheReadTokens,
|
|
1235
|
+
};
|
|
1236
|
+
const usage = {
|
|
1237
|
+
model,
|
|
1238
|
+
turns: result.turns || 1,
|
|
1239
|
+
...slice,
|
|
1240
|
+
thinkingTokens: 0,
|
|
1241
|
+
costUsd: result.costUsd ?? computeActualCostUsd(model, slice),
|
|
1242
|
+
};
|
|
1243
|
+
record.usage = usage;
|
|
1244
|
+
const event = {
|
|
1245
|
+
runId: record.runId,
|
|
1246
|
+
taskId: record.taskId,
|
|
1247
|
+
model,
|
|
1248
|
+
turn: usage.turns,
|
|
1249
|
+
delta: usage,
|
|
1250
|
+
total: { ...usage, cacheHitRate: cacheHitRate(usage) },
|
|
1251
|
+
at: new Date().toISOString(),
|
|
1252
|
+
};
|
|
1253
|
+
this.emit('runner_token_usage', event);
|
|
1254
|
+
this.persist(record);
|
|
1255
|
+
}
|
|
1256
|
+
// ─── Native engine ──────────────────────────────────────────────────────────
|
|
1257
|
+
createAnthropicClient() {
|
|
1258
|
+
try {
|
|
1259
|
+
return this.anthropicClientFactory();
|
|
1260
|
+
}
|
|
1261
|
+
catch (err) {
|
|
1262
|
+
throw new SupervisorError('ANTHROPIC_CREDENTIALS_MISSING', `Could not initialise the Claude API client (${err?.message ?? err}). Set ANTHROPIC_API_KEY (or run \`ant auth login\`) in the environment that launches nativ.`);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
/** Cancels the in-flight API stream and reaps the running bash tool command, if any. */
|
|
1266
|
+
interruptNative(run) {
|
|
1267
|
+
if (!run.native)
|
|
1268
|
+
return;
|
|
1269
|
+
run.native.controller.abort();
|
|
1270
|
+
if (run.toolPid !== null) {
|
|
1271
|
+
killProcessTree(run.toolPid, 'SIGKILL');
|
|
1272
|
+
run.toolPid = null;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
startNativeRun(run, options, useWorktree) {
|
|
1276
|
+
const { record } = run;
|
|
1277
|
+
const native = run.native;
|
|
1278
|
+
run.timer = setTimeout(() => this.handleTimeout(run), record.timeoutSeconds * 1000);
|
|
1279
|
+
run.timer.unref?.();
|
|
1280
|
+
this.startIdleWatchdog(run);
|
|
1281
|
+
const { effort, budgetTokens } = native.thinking;
|
|
1282
|
+
this.appendLog(run, 'stdout', `[native] model=${native.model}` +
|
|
1283
|
+
(effort ? ` effort=${effort}` : '') +
|
|
1284
|
+
(budgetTokens ? ` budget_tokens=${budgetTokens}` : '') +
|
|
1285
|
+
` fallbacks=${supportsServerFallback(native.model) ? 'default' : 'off'}\n`);
|
|
1286
|
+
void this.driveNativeRun(run, options, useWorktree);
|
|
1287
|
+
}
|
|
1288
|
+
async driveNativeRun(run, options, useWorktree) {
|
|
1289
|
+
try {
|
|
1290
|
+
await this.runNativeLoop(run, useWorktree);
|
|
1291
|
+
}
|
|
1292
|
+
catch (err) {
|
|
1293
|
+
if (run.settled)
|
|
1294
|
+
return; // aborted or timed out: the stream rejection is expected
|
|
1295
|
+
const message = describeNativeError(err);
|
|
1296
|
+
this.appendLog(run, 'stderr', `\n[native] ${message}\n`);
|
|
1297
|
+
this.finalize(run, 'failed', { error: message });
|
|
1298
|
+
return;
|
|
1299
|
+
}
|
|
1300
|
+
if (run.settled)
|
|
1301
|
+
return;
|
|
1302
|
+
this.clearTimers(run);
|
|
1303
|
+
run.record.exitCode = 0;
|
|
1304
|
+
await this.completeSuccessfulRun(run, run.task, options);
|
|
1305
|
+
}
|
|
1306
|
+
/**
|
|
1307
|
+
* Manual agentic loop over the Messages API: stream a turn, capture usage,
|
|
1308
|
+
* execute the requested local tools, repeat until the model ends its turn.
|
|
1309
|
+
*/
|
|
1310
|
+
async runNativeLoop(run, useWorktree) {
|
|
1311
|
+
const native = run.native;
|
|
1312
|
+
const { thinking } = native;
|
|
1313
|
+
const fallback = supportsServerFallback(native.model);
|
|
1314
|
+
const messages = [
|
|
1315
|
+
{ role: 'user', content: buildNativeTaskPrompt(run.task, this.readRoleGuide(run.task), useWorktree) },
|
|
1316
|
+
];
|
|
1317
|
+
for (let turn = 1;; turn++) {
|
|
1318
|
+
if (run.settled)
|
|
1319
|
+
return;
|
|
1320
|
+
if (turn > this.nativeMaxTurns) {
|
|
1321
|
+
throw new Error(`Native engine stopped after the ${this.nativeMaxTurns}-turn budget without finishing`);
|
|
1322
|
+
}
|
|
1323
|
+
const stream = native.client.beta.messages.stream({
|
|
1324
|
+
model: native.model,
|
|
1325
|
+
max_tokens: NATIVE_MAX_OUTPUT_TOKENS,
|
|
1326
|
+
// Breakpoint 1 caches tools + the frozen system prompt; the top-level
|
|
1327
|
+
// breakpoint follows the growing conversation so each turn re-reads
|
|
1328
|
+
// everything before it from cache.
|
|
1329
|
+
system: [{ type: 'text', text: NATIVE_SYSTEM_PROMPT, cache_control: { type: 'ephemeral' } }],
|
|
1330
|
+
cache_control: { type: 'ephemeral' },
|
|
1331
|
+
tools: NATIVE_TOOLS,
|
|
1332
|
+
messages,
|
|
1333
|
+
...(thinking.budgetTokens
|
|
1334
|
+
? { thinking: { type: 'enabled', budget_tokens: thinking.budgetTokens } }
|
|
1335
|
+
: thinking.effort
|
|
1336
|
+
? { thinking: { type: 'adaptive', display: 'summarized' } }
|
|
1337
|
+
: {}),
|
|
1338
|
+
...(thinking.effort ? { output_config: { effort: thinking.effort } } : {}),
|
|
1339
|
+
...(fallback ? { betas: [SERVER_FALLBACK_BETA], fallbacks: 'default' } : {}),
|
|
1340
|
+
}, { signal: native.controller.signal });
|
|
1341
|
+
// Newer models (Opus 5.5, Fable 5.1) put their between-tool progress notes
|
|
1342
|
+
// in thinking blocks, so both channels go to the log or long turns look idle.
|
|
1343
|
+
let streaming = null;
|
|
1344
|
+
const switchTo = (kind) => {
|
|
1345
|
+
if (streaming === kind)
|
|
1346
|
+
return;
|
|
1347
|
+
this.appendLog(run, 'stdout', `${streaming ? '\n' : ''}${kind === 'thinking' ? '[native:thinking] ' : ''}`);
|
|
1348
|
+
streaming = kind;
|
|
1349
|
+
};
|
|
1350
|
+
stream.on('thinking', (delta) => {
|
|
1351
|
+
if (!delta)
|
|
1352
|
+
return;
|
|
1353
|
+
switchTo('thinking');
|
|
1354
|
+
this.appendLog(run, 'stdout', delta);
|
|
1355
|
+
});
|
|
1356
|
+
stream.on('text', (delta) => {
|
|
1357
|
+
switchTo('text');
|
|
1358
|
+
this.appendLog(run, 'stdout', delta);
|
|
1359
|
+
});
|
|
1360
|
+
const message = await stream.finalMessage();
|
|
1361
|
+
if (streaming)
|
|
1362
|
+
this.appendLog(run, 'stdout', '\n');
|
|
1363
|
+
if (run.settled)
|
|
1364
|
+
return;
|
|
1365
|
+
this.recordTurnUsage(run, message);
|
|
1366
|
+
if (message.stop_reason === 'refusal') {
|
|
1367
|
+
const category = message.stop_details?.category;
|
|
1368
|
+
throw new Error(`Model declined the task (refusal${category ? `: ${category}` : ''})`);
|
|
1369
|
+
}
|
|
1370
|
+
if (message.stop_reason === 'pause_turn') {
|
|
1371
|
+
messages.push({ role: 'assistant', content: message.content });
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
const toolUses = message.content.filter((b) => b.type === 'tool_use');
|
|
1375
|
+
if (message.stop_reason === 'max_tokens') {
|
|
1376
|
+
// A tool input cut off here can still parse; never run it.
|
|
1377
|
+
throw new Error(`Model output hit max_tokens (${NATIVE_MAX_OUTPUT_TOKENS}) mid-turn`);
|
|
1378
|
+
}
|
|
1379
|
+
if (toolUses.length === 0)
|
|
1380
|
+
return;
|
|
1381
|
+
messages.push({ role: 'assistant', content: message.content });
|
|
1382
|
+
const results = [];
|
|
1383
|
+
for (const block of toolUses) {
|
|
1384
|
+
if (run.settled)
|
|
1385
|
+
return;
|
|
1386
|
+
results.push(await this.executeNativeTool(run, block));
|
|
1387
|
+
}
|
|
1388
|
+
// All results for one assistant turn go back in a single user message.
|
|
1389
|
+
messages.push({ role: 'user', content: results });
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
recordTurnUsage(run, message) {
|
|
1393
|
+
const { record } = run;
|
|
1394
|
+
const usage = message.usage;
|
|
1395
|
+
// The served model differs from the requested one after a refusal fallback.
|
|
1396
|
+
const model = message.model || run.native.model;
|
|
1397
|
+
const details = usage.output_tokens_details;
|
|
1398
|
+
const slice = {
|
|
1399
|
+
inputTokens: usage.input_tokens ?? 0,
|
|
1400
|
+
outputTokens: usage.output_tokens ?? 0,
|
|
1401
|
+
cacheCreationTokens: usage.cache_creation_input_tokens ?? 0,
|
|
1402
|
+
cacheReadTokens: usage.cache_read_input_tokens ?? 0,
|
|
1403
|
+
};
|
|
1404
|
+
const delta = {
|
|
1405
|
+
model,
|
|
1406
|
+
turns: 1,
|
|
1407
|
+
...slice,
|
|
1408
|
+
thinkingTokens: details?.thinking_tokens ?? 0,
|
|
1409
|
+
costUsd: computeActualCostUsd(model, slice),
|
|
1410
|
+
};
|
|
1411
|
+
record.usage = mergeActualUsage(record.usage ?? emptyActualUsage(model), delta);
|
|
1412
|
+
this.appendLog(run, 'stdout', `[native] turn ${record.usage.turns}: in ${delta.inputTokens} · out ${delta.outputTokens}` +
|
|
1413
|
+
` · cache read ${delta.cacheReadTokens} / write ${delta.cacheCreationTokens} · $${delta.costUsd.toFixed(4)}\n`);
|
|
1414
|
+
const event = {
|
|
1415
|
+
runId: record.runId,
|
|
1416
|
+
taskId: record.taskId,
|
|
1417
|
+
model,
|
|
1418
|
+
turn: record.usage.turns,
|
|
1419
|
+
delta,
|
|
1420
|
+
total: { ...record.usage, cacheHitRate: cacheHitRate(record.usage) },
|
|
1421
|
+
at: new Date().toISOString(),
|
|
1422
|
+
};
|
|
1423
|
+
this.emit('runner_token_usage', event);
|
|
1424
|
+
this.persist(record);
|
|
1425
|
+
}
|
|
1426
|
+
async executeNativeTool(run, block) {
|
|
1427
|
+
const input = (block.input && typeof block.input === 'object' ? block.input : {});
|
|
1428
|
+
try {
|
|
1429
|
+
let content;
|
|
1430
|
+
if (block.name === 'bash') {
|
|
1431
|
+
content = await this.runBashTool(run, input);
|
|
1432
|
+
}
|
|
1433
|
+
else if (block.name === 'str_replace_based_edit_tool') {
|
|
1434
|
+
this.appendLog(run, 'stdout', `[native] edit ${String(input.command)} ${String(input.path)}\n`);
|
|
1435
|
+
content = runEditorCommand(run.record.worktreeDir, this.rootDir, input);
|
|
1436
|
+
}
|
|
1437
|
+
else {
|
|
1438
|
+
throw new ToolInputError(`Unknown tool '${block.name}'.`);
|
|
1439
|
+
}
|
|
1440
|
+
return { type: 'tool_result', tool_use_id: block.id, content };
|
|
1441
|
+
}
|
|
1442
|
+
catch (err) {
|
|
1443
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
1444
|
+
this.appendLog(run, 'stderr', `[native] ${block.name} error: ${message.split('\n')[0]}\n`);
|
|
1445
|
+
return { type: 'tool_result', tool_use_id: block.id, content: message, is_error: true };
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
/** Runs one model-authored command in a fresh shell at the workspace root. */
|
|
1449
|
+
async runBashTool(run, input) {
|
|
1450
|
+
if (input.restart === true)
|
|
1451
|
+
return 'Shell restarted. Every command already starts in a fresh shell at the workspace root.';
|
|
1452
|
+
const command = requireString(input, 'command').trim();
|
|
1453
|
+
if (!command)
|
|
1454
|
+
throw new ToolInputError('"command" must be a non-empty string.');
|
|
1455
|
+
const rejection = checkNativeBashCommand(command, this.nativeAllowedCommands);
|
|
1456
|
+
if (rejection)
|
|
1457
|
+
throw new ToolInputError(`Command rejected: ${rejection}`);
|
|
1458
|
+
this.appendLog(run, 'stdout', `[native] $ ${command}\n`);
|
|
1459
|
+
const child = spawn(command, {
|
|
1460
|
+
cwd: run.record.worktreeDir,
|
|
1461
|
+
shell: this.toolShell,
|
|
1462
|
+
detached: process.platform !== 'win32',
|
|
1463
|
+
windowsHide: true,
|
|
1464
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
1465
|
+
env: {
|
|
1466
|
+
...buildToolEnv(process.env, run.native?.env),
|
|
1467
|
+
NATIV_RUN_ID: run.record.runId,
|
|
1468
|
+
NATIV_TASK_ID: run.record.taskId,
|
|
1469
|
+
NATIV_PROJECT_ROOT: this.rootDir,
|
|
1470
|
+
},
|
|
1471
|
+
});
|
|
1472
|
+
run.toolPid = child.pid ?? null;
|
|
1473
|
+
let output = '';
|
|
1474
|
+
let omitted = 0;
|
|
1475
|
+
const capture = (chunk) => {
|
|
1476
|
+
output += chunk.toString();
|
|
1477
|
+
if (output.length > NATIVE_TOOL_CAPTURE_LIMIT) {
|
|
1478
|
+
// Keep the head and the newest tail; the middle of huge logs is rarely useful.
|
|
1479
|
+
const head = Math.floor(NATIVE_TOOL_CAPTURE_LIMIT * 0.2);
|
|
1480
|
+
const excess = output.length - NATIVE_TOOL_CAPTURE_LIMIT;
|
|
1481
|
+
output = output.slice(0, head) + output.slice(head + excess);
|
|
1482
|
+
omitted += excess;
|
|
1483
|
+
}
|
|
1484
|
+
};
|
|
1485
|
+
child.stdout?.on('data', capture);
|
|
1486
|
+
child.stderr?.on('data', capture);
|
|
1487
|
+
let timedOut = false;
|
|
1488
|
+
const timer = setTimeout(() => {
|
|
1489
|
+
timedOut = true;
|
|
1490
|
+
if (child.pid)
|
|
1491
|
+
killProcessTree(child.pid, 'SIGKILL');
|
|
1492
|
+
}, this.nativeToolTimeoutMs);
|
|
1493
|
+
timer.unref?.();
|
|
1494
|
+
const exitCode = await new Promise((resolve) => {
|
|
1495
|
+
child.on('error', (err) => {
|
|
1496
|
+
capture(Buffer.from(`\n${err.message}\n`));
|
|
1497
|
+
resolve(null);
|
|
1498
|
+
});
|
|
1499
|
+
child.on('close', (code) => resolve(code));
|
|
1500
|
+
});
|
|
1501
|
+
clearTimeout(timer);
|
|
1502
|
+
run.toolPid = null;
|
|
1503
|
+
const clipped = clipToolOutput(output, omitted);
|
|
1504
|
+
if (clipped)
|
|
1505
|
+
this.appendLog(run, 'stdout', clipped.endsWith('\n') ? clipped : `${clipped}\n`);
|
|
1506
|
+
if (timedOut) {
|
|
1507
|
+
throw new ToolInputError(`Command exceeded the ${Math.round(this.nativeToolTimeoutMs / 1000)}s tool budget and was terminated.\n${clipped}`);
|
|
1508
|
+
}
|
|
1509
|
+
const body = clipped || '(no output)';
|
|
1510
|
+
return exitCode === 0 ? body : `${body}\n[exit code ${exitCode ?? 'unknown'}]`;
|
|
1511
|
+
}
|
|
1512
|
+
readRoleGuide(task) {
|
|
1513
|
+
if (!/^[\w-]+$/.test(task.assignedSubagent || ''))
|
|
1514
|
+
return null;
|
|
1515
|
+
try {
|
|
1516
|
+
const text = fs.readFileSync(path.join(this.rootDir, '.ai', 'subagents', `${task.assignedSubagent}.md`), 'utf8');
|
|
1517
|
+
return text.length > ROLE_GUIDE_LIMIT ? `${text.slice(0, ROLE_GUIDE_LIMIT)}\n[... truncated ...]` : text;
|
|
1518
|
+
}
|
|
1519
|
+
catch {
|
|
1520
|
+
return null;
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
async runVerification(run, task) {
|
|
1524
|
+
const { record } = run;
|
|
1525
|
+
record.status = 'verifying';
|
|
1526
|
+
this.emitStatus(record);
|
|
1527
|
+
const result = await executeVerification(task.verificationCommand, record.worktreeDir);
|
|
1528
|
+
record.verification = {
|
|
1529
|
+
command: result.command,
|
|
1530
|
+
success: result.success,
|
|
1531
|
+
exitCode: result.exitCode,
|
|
1532
|
+
durationMs: result.durationMs,
|
|
1533
|
+
skipped: Boolean(result.skipped),
|
|
1534
|
+
};
|
|
1535
|
+
const output = [result.stdout, result.stderr].filter(Boolean).join('\n');
|
|
1536
|
+
if (output)
|
|
1537
|
+
this.appendLog(run, 'stdout', `\n[supervisor] verification output:\n${output}\n`);
|
|
1538
|
+
return result.success;
|
|
1539
|
+
}
|
|
1540
|
+
/** Quiet counterpart to `nativ worktree merge`: same safe merge, no stdout noise inside a server. */
|
|
1541
|
+
mergeWorktree(run) {
|
|
1542
|
+
const outcome = mergeAgentWorktree(this.rootDir, run.record.taskId);
|
|
1543
|
+
this.appendLog(run, 'stdout', `\n[supervisor] ${outcome.message}\n`);
|
|
1544
|
+
}
|
|
1545
|
+
// ─── Log buffer ─────────────────────────────────────────────────────────────
|
|
1546
|
+
resetLogFile(record) {
|
|
1547
|
+
try {
|
|
1548
|
+
fs.mkdirSync(this.runsDir, { recursive: true });
|
|
1549
|
+
fs.writeFileSync(record.logFile, '', 'utf8');
|
|
1550
|
+
}
|
|
1551
|
+
catch {
|
|
1552
|
+
// A read-only workspace must not abort the run; logs stay in memory.
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
appendLog(run, stream, chunk) {
|
|
1556
|
+
if (!chunk)
|
|
1557
|
+
return;
|
|
1558
|
+
const { record } = run;
|
|
1559
|
+
run.lastActivity = Date.now();
|
|
1560
|
+
record.logBytes += Buffer.byteLength(chunk, 'utf8');
|
|
1561
|
+
run.buffer += chunk;
|
|
1562
|
+
if (Buffer.byteLength(run.buffer, 'utf8') > this.maxLogBufferBytes) {
|
|
1563
|
+
// Drop from the head, then realign to a line boundary so the oldest
|
|
1564
|
+
// retained entry is never a half-line.
|
|
1565
|
+
run.buffer = run.buffer.slice(run.buffer.length - this.maxLogBufferBytes);
|
|
1566
|
+
const nl = run.buffer.indexOf('\n');
|
|
1567
|
+
if (nl !== -1)
|
|
1568
|
+
run.buffer = run.buffer.slice(nl + 1);
|
|
1569
|
+
}
|
|
1570
|
+
try {
|
|
1571
|
+
fs.appendFileSync(record.logFile, chunk, 'utf8');
|
|
1572
|
+
}
|
|
1573
|
+
catch {
|
|
1574
|
+
// Best-effort persistence only.
|
|
1575
|
+
}
|
|
1576
|
+
this.emit('runner_log', {
|
|
1577
|
+
runId: record.runId,
|
|
1578
|
+
taskId: record.taskId,
|
|
1579
|
+
stream,
|
|
1580
|
+
chunk,
|
|
1581
|
+
at: new Date().toISOString(),
|
|
1582
|
+
});
|
|
1583
|
+
}
|
|
1584
|
+
// ─── State transitions & persistence ────────────────────────────────────────
|
|
1585
|
+
clearTimers(run) {
|
|
1586
|
+
if (run.timer)
|
|
1587
|
+
clearTimeout(run.timer);
|
|
1588
|
+
if (run.killTimer)
|
|
1589
|
+
clearTimeout(run.killTimer);
|
|
1590
|
+
if (run.idleTimer)
|
|
1591
|
+
clearInterval(run.idleTimer);
|
|
1592
|
+
run.timer = null;
|
|
1593
|
+
run.killTimer = null;
|
|
1594
|
+
run.idleTimer = null;
|
|
1595
|
+
}
|
|
1596
|
+
finalize(run, status, patch) {
|
|
1597
|
+
// A process exit or aborted API stream can land after shutdown/abort already
|
|
1598
|
+
// settled the run; the first terminal status wins.
|
|
1599
|
+
if (run.settled)
|
|
1600
|
+
return;
|
|
1601
|
+
run.settled = true;
|
|
1602
|
+
const { record } = run;
|
|
1603
|
+
this.clearTimers(run);
|
|
1604
|
+
record.status = status;
|
|
1605
|
+
record.endedAt = new Date().toISOString();
|
|
1606
|
+
record.durationMs = Date.parse(record.endedAt) - Date.parse(record.startedAt);
|
|
1607
|
+
if (patch.error !== undefined)
|
|
1608
|
+
record.error = patch.error;
|
|
1609
|
+
record.pid = null;
|
|
1610
|
+
this.active.delete(record.taskId);
|
|
1611
|
+
this.history = [{ ...record }, ...this.history.filter((r) => r.runId !== record.runId)].slice(0, this.maxHistory);
|
|
1612
|
+
this.persist(record);
|
|
1613
|
+
this.emitStatus(record);
|
|
1614
|
+
if (record.usage && record.usage.turns > 0) {
|
|
1615
|
+
// Spend is recorded whatever the outcome: failed and aborted runs are billed too.
|
|
1616
|
+
recordRunnerUsage(this.rootDir, run.task, record.usage).catch(() => {
|
|
1617
|
+
// Telemetry is advisory; the run record keeps the authoritative usage.
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
}
|
|
1621
|
+
emitStatus(record) {
|
|
1622
|
+
this.emit('runner_status', { ...record, at: new Date().toISOString() });
|
|
1623
|
+
}
|
|
1624
|
+
persist(record) {
|
|
1625
|
+
try {
|
|
1626
|
+
fs.mkdirSync(this.runsDir, { recursive: true });
|
|
1627
|
+
fs.writeFileSync(path.join(this.runsDir, `${record.taskId}.json`), `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
1628
|
+
}
|
|
1629
|
+
catch {
|
|
1630
|
+
// Local-first persistence is advisory; in-memory state stays authoritative.
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
/**
|
|
1634
|
+
* Rebuilds the recent-run list from `.nativ/runs/*.json` so a restarted
|
|
1635
|
+
* supervisor can still answer `/tasks/runs` and `/tasks/logs`. Records left
|
|
1636
|
+
* in an active phase by a crashed supervisor are reconciled to `failed`.
|
|
1637
|
+
*/
|
|
1638
|
+
restoreHistory() {
|
|
1639
|
+
let entries;
|
|
1640
|
+
try {
|
|
1641
|
+
entries = fs.readdirSync(this.runsDir).filter((f) => f.endsWith('.json'));
|
|
1642
|
+
}
|
|
1643
|
+
catch {
|
|
1644
|
+
return;
|
|
1645
|
+
}
|
|
1646
|
+
const restored = [];
|
|
1647
|
+
for (const file of entries) {
|
|
1648
|
+
try {
|
|
1649
|
+
const record = JSON.parse(fs.readFileSync(path.join(this.runsDir, file), 'utf8'));
|
|
1650
|
+
if (!record?.runId || !record?.taskId)
|
|
1651
|
+
continue;
|
|
1652
|
+
// Records persisted before dual-mode dispatch carry no engine fields.
|
|
1653
|
+
record.engine = record.engine ?? 'cli';
|
|
1654
|
+
record.model = record.model ?? null;
|
|
1655
|
+
record.thinking = record.thinking ?? null;
|
|
1656
|
+
record.usage = record.usage ?? null;
|
|
1657
|
+
if (isActiveStatus(record.status) && !(record.pid !== null && isPidAlive(record.pid))) {
|
|
1658
|
+
record.status = 'failed';
|
|
1659
|
+
record.error = record.error ?? 'Supervisor restarted while the run was in flight';
|
|
1660
|
+
record.endedAt = record.endedAt ?? new Date().toISOString();
|
|
1661
|
+
record.pid = null;
|
|
1662
|
+
}
|
|
1663
|
+
restored.push(record);
|
|
1664
|
+
}
|
|
1665
|
+
catch {
|
|
1666
|
+
// Skip unreadable or corrupt run files.
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
this.history = restored.sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, this.maxHistory);
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
export default AgentSupervisor;
|