@sagentlab/navarch-runtime 0.1.1 → 0.1.3
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/README.md +123 -104
- package/dist/adapters/claude.cjs +13 -1
- package/dist/adapters/codex.cjs +73 -25
- package/dist/api.cjs +7 -12
- package/dist/claim-loop.cjs +7 -1
- package/dist/cli.cjs +38 -14
- package/dist/config.cjs +13 -4
- package/dist/exit-conditions.cjs +39 -21
- package/dist/git-worktree.cjs +145 -0
- package/dist/github-pr.cjs +87 -0
- package/dist/machine-store.cjs +3 -0
- package/dist/prompt.cjs +25 -1
- package/dist/sandbox.cjs +20 -6
- package/dist/session.cjs +181 -37
- package/dist/types.cjs +3 -10
- package/package.json +6 -6
package/dist/cli.cjs
CHANGED
|
@@ -29,6 +29,15 @@ function invocation(subcommand) {
|
|
|
29
29
|
const prefix = viaNpx ? `npx ${PACKAGE_NAME}` : "navarch-runtime";
|
|
30
30
|
return `${prefix} ${subcommand}`;
|
|
31
31
|
}
|
|
32
|
+
function agentFromFlag(flags) {
|
|
33
|
+
const value = flags.agent;
|
|
34
|
+
if (value === undefined)
|
|
35
|
+
return undefined;
|
|
36
|
+
if (!(0, config_cjs_1.isRuntimeAgentType)(value)) {
|
|
37
|
+
throw new Error("--agent must be either 'claude-code' or 'codex'.");
|
|
38
|
+
}
|
|
39
|
+
return value;
|
|
40
|
+
}
|
|
32
41
|
function parseArgs(argv) {
|
|
33
42
|
const [command, ...rest] = argv;
|
|
34
43
|
const flags = {};
|
|
@@ -70,6 +79,7 @@ async function registerCommand(flags) {
|
|
|
70
79
|
.map((s) => s.trim())
|
|
71
80
|
.filter(Boolean);
|
|
72
81
|
const ownerZone = flags["owner-zone"] ?? config.ownerZone;
|
|
82
|
+
const agentType = agentFromFlag(flags) ?? config.agentType;
|
|
73
83
|
const client = new api_cjs_1.NavarchApiClient({ baseUrl: apiBase });
|
|
74
84
|
const result = await client.registerMachine({
|
|
75
85
|
enrollment_token: enrollmentToken,
|
|
@@ -83,6 +93,7 @@ async function registerCommand(flags) {
|
|
|
83
93
|
token: result.token,
|
|
84
94
|
name,
|
|
85
95
|
api_base: apiBase,
|
|
96
|
+
agent_type: agentType,
|
|
86
97
|
});
|
|
87
98
|
// Printed exactly once. Never logged or echoed again after this point.
|
|
88
99
|
console.log("Machine registered.");
|
|
@@ -93,8 +104,8 @@ async function registerCommand(flags) {
|
|
|
93
104
|
/**
|
|
94
105
|
* `navarch-runtime connect` — "Connect an agent to a project"
|
|
95
106
|
* (docs/navarch/schema-design.md §7 "Agent connect"; self-hosted-runner
|
|
96
|
-
* style). The project-scoped sibling of `register`: redeems a
|
|
97
|
-
*
|
|
107
|
+
* style). The project-scoped sibling of `register`: redeems a single-use
|
|
108
|
+
* enrollment token a project owner minted from the platform UI
|
|
98
109
|
* (ConnectAgentPanel → POST /api/projects/:id/enrollment-tokens) instead of
|
|
99
110
|
* the global NAVARCH_ENROLLMENT_SECRET `register` needs. Prints the
|
|
100
111
|
* machine token exactly once, same discipline as `register`.
|
|
@@ -116,6 +127,7 @@ async function connectCommand(flags) {
|
|
|
116
127
|
.split(",")
|
|
117
128
|
.map((s) => s.trim())
|
|
118
129
|
.filter(Boolean);
|
|
130
|
+
const agentType = agentFromFlag(flags) ?? config.agentType;
|
|
119
131
|
const client = new api_cjs_1.NavarchApiClient({ baseUrl: apiBase });
|
|
120
132
|
const result = await client.connectMachine({
|
|
121
133
|
enrollment_token: enrollmentToken,
|
|
@@ -129,6 +141,7 @@ async function connectCommand(flags) {
|
|
|
129
141
|
token: result.token,
|
|
130
142
|
name,
|
|
131
143
|
api_base: apiBase,
|
|
144
|
+
agent_type: agentType,
|
|
132
145
|
});
|
|
133
146
|
// Printed exactly once. Never logged or echoed again after this point.
|
|
134
147
|
console.log("Machine connected.");
|
|
@@ -136,16 +149,22 @@ async function connectCommand(flags) {
|
|
|
136
149
|
console.log(` token: ${result.token}`);
|
|
137
150
|
console.log(`\nStored in ${config.configDir}/machine.json (mode 0600). Run \`${invocation("start")}\` to begin serving tasks.`);
|
|
138
151
|
}
|
|
139
|
-
async function startCommand() {
|
|
140
|
-
const
|
|
141
|
-
const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(
|
|
152
|
+
async function startCommand(flags) {
|
|
153
|
+
const baseConfig = (0, config_cjs_1.loadRuntimeConfig)();
|
|
154
|
+
const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(baseConfig.configDir, baseConfig.apiBase);
|
|
155
|
+
// Adapter selection belongs to the local machine: an explicit start flag
|
|
156
|
+
// wins, followed by NAVARCH_AGENT, the choice saved at connect/register
|
|
157
|
+
// time, and finally the backwards-compatible Claude Code default.
|
|
158
|
+
const agentType = agentFromFlag(flags) ??
|
|
159
|
+
(process.env.NAVARCH_AGENT ? baseConfig.agentType : identity.agent_type ?? baseConfig.agentType);
|
|
160
|
+
const config = { ...baseConfig, agentType };
|
|
142
161
|
const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
|
|
143
162
|
const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
|
|
144
163
|
const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity);
|
|
145
164
|
const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId));
|
|
146
165
|
heartbeat.start();
|
|
147
166
|
claimLoop.start();
|
|
148
|
-
log.info(`navarch-runtime started: machine=${identity.name} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
|
|
167
|
+
log.info(`navarch-runtime started: machine=${identity.name} agent=${config.agentType} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
|
|
149
168
|
const shutdown = () => {
|
|
150
169
|
log.info("shutting down...");
|
|
151
170
|
heartbeat.stop();
|
|
@@ -155,7 +174,7 @@ async function startCommand() {
|
|
|
155
174
|
process.on("SIGINT", shutdown);
|
|
156
175
|
process.on("SIGTERM", shutdown);
|
|
157
176
|
}
|
|
158
|
-
async function doctorCommand() {
|
|
177
|
+
async function doctorCommand(flags) {
|
|
159
178
|
const config = (0, config_cjs_1.loadRuntimeConfig)();
|
|
160
179
|
const dockerOk = await (0, sandbox_cjs_1.isDockerAvailable)();
|
|
161
180
|
console.log(`api_base: ${config.apiBase}`);
|
|
@@ -165,23 +184,28 @@ async function doctorCommand() {
|
|
|
165
184
|
console.log(`capabilities: ${config.capabilities.join(", ")}`);
|
|
166
185
|
console.log(`sandbox_mode: ${config.sandboxMode}`);
|
|
167
186
|
console.log(`docker: ${dockerOk ? "available" : "NOT AVAILABLE (docker-backed sessions will fail)"}`);
|
|
187
|
+
let identity;
|
|
168
188
|
try {
|
|
169
|
-
|
|
170
|
-
console.log(`machine: ${identity.name} (${identity.machine_id})`);
|
|
189
|
+
identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
|
|
171
190
|
}
|
|
172
191
|
catch {
|
|
173
192
|
console.log(`machine: not registered — run \`${invocation("register")}\``);
|
|
193
|
+
return;
|
|
174
194
|
}
|
|
195
|
+
const agentType = agentFromFlag(flags) ??
|
|
196
|
+
(process.env.NAVARCH_AGENT ? config.agentType : identity.agent_type ?? config.agentType);
|
|
197
|
+
console.log(`machine: ${identity.name} (${identity.machine_id})`);
|
|
198
|
+
console.log(`agent: ${agentType}`);
|
|
175
199
|
}
|
|
176
200
|
function helpText() {
|
|
177
201
|
return `navarch-runtime — Navarch machine-side session manager
|
|
178
202
|
|
|
179
203
|
Usage:
|
|
180
204
|
navarch-runtime register --token <enrollment-token> --name <machine-name> \\
|
|
181
|
-
[--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
|
|
205
|
+
[--agent claude-code|codex] [--capabilities a,b] [--max-sessions N] [--owner-zone z] [--api-base url]
|
|
182
206
|
navarch-runtime connect --token <enrollment-token> --name <machine-name> \\
|
|
183
|
-
[--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
|
|
184
|
-
navarch-runtime start
|
|
207
|
+
[--agent claude-code|codex] [--project <project-id>] [--capabilities a,b] [--max-sessions N] [--api-base url]
|
|
208
|
+
navarch-runtime start [--agent claude-code|codex]
|
|
185
209
|
navarch-runtime doctor
|
|
186
210
|
|
|
187
211
|
Configuration is via NAVARCH_* environment variables; see runtime/README.md.
|
|
@@ -198,10 +222,10 @@ async function main(argv = process.argv.slice(2)) {
|
|
|
198
222
|
await connectCommand(flags);
|
|
199
223
|
break;
|
|
200
224
|
case "start":
|
|
201
|
-
await startCommand();
|
|
225
|
+
await startCommand(flags);
|
|
202
226
|
break;
|
|
203
227
|
case "doctor":
|
|
204
|
-
await doctorCommand();
|
|
228
|
+
await doctorCommand(flags);
|
|
205
229
|
break;
|
|
206
230
|
case "help":
|
|
207
231
|
case "--help":
|
package/dist/config.cjs
CHANGED
|
@@ -3,9 +3,13 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
3
3
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.isRuntimeAgentType = isRuntimeAgentType;
|
|
6
7
|
exports.loadRuntimeConfig = loadRuntimeConfig;
|
|
7
8
|
const node_path_1 = __importDefault(require("node:path"));
|
|
8
9
|
const node_os_1 = __importDefault(require("node:os"));
|
|
10
|
+
function isRuntimeAgentType(value) {
|
|
11
|
+
return value === "claude-code" || value === "codex";
|
|
12
|
+
}
|
|
9
13
|
function envInt(env, name, fallback) {
|
|
10
14
|
const raw = env[name];
|
|
11
15
|
if (!raw)
|
|
@@ -30,12 +34,17 @@ function envList(env, name, fallback) {
|
|
|
30
34
|
function loadRuntimeConfig(env = process.env) {
|
|
31
35
|
const configDir = env.NAVARCH_CONFIG_DIR ?? node_path_1.default.join(node_os_1.default.homedir(), ".navarch");
|
|
32
36
|
const leaseHeartbeatIntervalMs = envInt(env, "NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS", 5 * 60 * 1000);
|
|
37
|
+
// Run the agent with the resources already available on its machine by
|
|
38
|
+
// default. Docker isolation is an explicit operator opt-in, not a
|
|
39
|
+
// prerequisite for claiming ordinary shell work.
|
|
40
|
+
const sandboxMode = env.NAVARCH_SANDBOX_MODE === "docker" ? "docker" : "host";
|
|
41
|
+
const defaultCapabilities = sandboxMode === "docker" ? ["docker-sandbox", "shell"] : ["shell"];
|
|
33
42
|
return {
|
|
34
43
|
apiBase: env.NAVARCH_API_BASE ?? "http://localhost:3000",
|
|
35
44
|
workspaceRoot: env.NAVARCH_WORKSPACE_ROOT ?? node_path_1.default.join(configDir, "sandboxes"),
|
|
36
45
|
configDir,
|
|
37
|
-
maxSessions: envInt(env, "NAVARCH_MAX_SESSIONS",
|
|
38
|
-
capabilities: envList(env, "NAVARCH_CAPABILITIES",
|
|
46
|
+
maxSessions: envInt(env, "NAVARCH_MAX_SESSIONS", 5),
|
|
47
|
+
capabilities: envList(env, "NAVARCH_CAPABILITIES", defaultCapabilities),
|
|
39
48
|
ownerZone: env.NAVARCH_OWNER_ZONE ?? "sagentlab",
|
|
40
49
|
pollIntervalMs: envInt(env, "NAVARCH_POLL_INTERVAL_MS", 5000),
|
|
41
50
|
machineHeartbeatIntervalMs: envInt(env, "NAVARCH_HEARTBEAT_INTERVAL_MS", 60_000),
|
|
@@ -43,13 +52,13 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
43
52
|
// interval must stay comfortably under that TTL.
|
|
44
53
|
leaseHeartbeatIntervalMs,
|
|
45
54
|
sessionTimeoutMs: envInt(env, "NAVARCH_SESSION_TIMEOUT_MS", 45 * 60 * 1000),
|
|
46
|
-
agentType: env.NAVARCH_AGENT
|
|
55
|
+
agentType: isRuntimeAgentType(env.NAVARCH_AGENT) ? env.NAVARCH_AGENT : "claude-code",
|
|
47
56
|
claudeBin: env.NAVARCH_CLAUDE_BIN ?? "claude",
|
|
48
57
|
claudeExtraArgs: envList(env, "NAVARCH_CLAUDE_EXTRA_ARGS", []),
|
|
49
58
|
codexBin: env.NAVARCH_CODEX_BIN ?? "codex",
|
|
50
59
|
codexExtraArgs: envList(env, "NAVARCH_CODEX_EXTRA_ARGS", []),
|
|
51
60
|
mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
|
|
52
|
-
sandboxMode
|
|
61
|
+
sandboxMode,
|
|
53
62
|
dockerImage: env.NAVARCH_DOCKER_IMAGE ?? "node:20-slim",
|
|
54
63
|
};
|
|
55
64
|
}
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -1,17 +1,11 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.extractPrUrl = extractPrUrl;
|
|
4
3
|
exports.parseClaudeJsonResult = parseClaudeJsonResult;
|
|
5
4
|
exports.extractUsageFromClaudeJson = extractUsageFromClaudeJson;
|
|
6
5
|
exports.parseCodexJsonEvents = parseCodexJsonEvents;
|
|
7
6
|
exports.extractUsageFromCodexEvents = extractUsageFromCodexEvents;
|
|
8
7
|
exports.extractFinalMessageFromCodexEvents = extractFinalMessageFromCodexEvents;
|
|
9
8
|
exports.mapExitCondition = mapExitCondition;
|
|
10
|
-
const PR_URL_PATTERN = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/\d+/;
|
|
11
|
-
function extractPrUrl(text) {
|
|
12
|
-
const match = text.match(PR_URL_PATTERN);
|
|
13
|
-
return match ? match[0] : null;
|
|
14
|
-
}
|
|
15
9
|
/**
|
|
16
10
|
* Best-effort parse of `claude -p --output-format json` stdout into the
|
|
17
11
|
* assumed result shape above. Tries the whole trimmed stdout first (the
|
|
@@ -69,10 +63,9 @@ function extractUsageFromClaudeJson(parsed) {
|
|
|
69
63
|
}
|
|
70
64
|
/**
|
|
71
65
|
* Best-effort line-by-line parse of `codex exec --json` stdout into the
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* null) when nothing parses, since a run can legitimately emit zero events.
|
|
66
|
+
* verified JSONL event shape above. Silently skips any line that isn't a
|
|
67
|
+
* recognized JSON event (blank lines, stray log lines, unrelated objects)
|
|
68
|
+
* rather than throwing; returns an empty array when nothing parses.
|
|
76
69
|
*/
|
|
77
70
|
function parseCodexJsonEvents(stdout) {
|
|
78
71
|
const events = [];
|
|
@@ -83,8 +76,10 @@ function parseCodexJsonEvents(stdout) {
|
|
|
83
76
|
for (const line of lines) {
|
|
84
77
|
try {
|
|
85
78
|
const parsed = JSON.parse(line);
|
|
86
|
-
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)
|
|
87
|
-
|
|
79
|
+
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) {
|
|
80
|
+
const event = parsed;
|
|
81
|
+
if (typeof event.type === "string" || event.msg !== undefined)
|
|
82
|
+
events.push(event);
|
|
88
83
|
}
|
|
89
84
|
}
|
|
90
85
|
catch {
|
|
@@ -94,17 +89,20 @@ function parseCodexJsonEvents(stdout) {
|
|
|
94
89
|
return events;
|
|
95
90
|
}
|
|
96
91
|
/**
|
|
97
|
-
* Extracts token/cost usage from parsed `codex exec --json` events.
|
|
98
|
-
*
|
|
99
|
-
* Claude Code's running usage updates), so the *last* such event wins rather
|
|
100
|
-
* than summing across events; if that assumption is wrong (incremental
|
|
101
|
-
* per-turn deltas instead), this under- or over-counts until reconciled here.
|
|
92
|
+
* Extracts token/cost usage from parsed `codex exec --json` events. The last
|
|
93
|
+
* `turn.completed` event wins. Legacy `token_count` events remain supported.
|
|
102
94
|
*/
|
|
103
95
|
function extractUsageFromCodexEvents(events) {
|
|
104
96
|
let tokensIn = 0;
|
|
105
97
|
let tokensOut = 0;
|
|
106
98
|
let costUsd = 0;
|
|
107
99
|
for (const event of events) {
|
|
100
|
+
if (event.type === "turn.completed" && event.usage) {
|
|
101
|
+
tokensIn = event.usage.input_tokens ?? 0;
|
|
102
|
+
tokensOut = event.usage.output_tokens ?? 0;
|
|
103
|
+
if (typeof event.usage.total_cost_usd === "number")
|
|
104
|
+
costUsd = event.usage.total_cost_usd;
|
|
105
|
+
}
|
|
108
106
|
if (event.msg?.type === "token_count") {
|
|
109
107
|
tokensIn = (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
|
|
110
108
|
tokensOut = event.msg.output_tokens ?? 0;
|
|
@@ -115,9 +113,15 @@ function extractUsageFromCodexEvents(events) {
|
|
|
115
113
|
}
|
|
116
114
|
return { tokensIn, tokensOut, costUsd };
|
|
117
115
|
}
|
|
118
|
-
/** The last
|
|
116
|
+
/** The last completed agent message, with legacy `msg.agent_message` fallback. */
|
|
119
117
|
function extractFinalMessageFromCodexEvents(events) {
|
|
120
118
|
for (let i = events.length - 1; i >= 0; i--) {
|
|
119
|
+
const item = events[i]?.item;
|
|
120
|
+
if (events[i]?.type === "item.completed" &&
|
|
121
|
+
item?.type === "agent_message" &&
|
|
122
|
+
typeof item.text === "string") {
|
|
123
|
+
return item.text;
|
|
124
|
+
}
|
|
121
125
|
const msg = events[i]?.msg;
|
|
122
126
|
if (msg?.type === "agent_message" && typeof msg.message === "string")
|
|
123
127
|
return msg.message;
|
|
@@ -139,8 +143,11 @@ function summarize(text, maxLen = 500) {
|
|
|
139
143
|
* exit > clean exit.
|
|
140
144
|
*/
|
|
141
145
|
function mapExitCondition(result) {
|
|
142
|
-
|
|
143
|
-
|
|
146
|
+
// Agent output is untrusted evidence, including the final completion
|
|
147
|
+
// message. session.cts resolves PR evidence independently through GitHub
|
|
148
|
+
// using this session's exact repository and worktree head branch.
|
|
149
|
+
const parsedJson = parseClaudeJsonResult(result.stdout);
|
|
150
|
+
const evidenceUrls = [];
|
|
144
151
|
if (result.killedByLeaseLoss) {
|
|
145
152
|
return {
|
|
146
153
|
leaseOutcome: "failed",
|
|
@@ -179,9 +186,20 @@ function mapExitCondition(result) {
|
|
|
179
186
|
// json` blob so report_summary doesn't end up being a dumped JSON object;
|
|
180
187
|
// falls back to raw stdout when neither is available (see
|
|
181
188
|
// parseClaudeJsonResult's doc comment).
|
|
182
|
-
const parsedJson = parseClaudeJsonResult(result.stdout);
|
|
183
189
|
const reportText = result.reportText ?? parsedJson?.result ?? result.stdout;
|
|
184
190
|
const reportSummary = summarize(reportText) || "Adapter completed with no report text.";
|
|
191
|
+
// Headless agent CLIs normally exit 0 after producing a final response,
|
|
192
|
+
// including when that response says the task could not start. Treat an
|
|
193
|
+
// explicit leading blocked verdict as a failed lease so the dispatcher can
|
|
194
|
+
// retry/escalate it instead of falsely moving unfinished work to Done.
|
|
195
|
+
if (/^blocked(?:\s|:|$)/i.test(reportText.trim())) {
|
|
196
|
+
return {
|
|
197
|
+
leaseOutcome: "failed",
|
|
198
|
+
exitStatus: "failed",
|
|
199
|
+
reportSummary,
|
|
200
|
+
evidenceUrls,
|
|
201
|
+
};
|
|
202
|
+
}
|
|
185
203
|
return {
|
|
186
204
|
leaseOutcome: "completed",
|
|
187
205
|
exitStatus: "completed",
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.GitWorktree = void 0;
|
|
7
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
10
|
+
const repositoryLocks = new Map();
|
|
11
|
+
/**
|
|
12
|
+
* Maintains one bare repository cache per project and checks out each session
|
|
13
|
+
* into its own uniquely named worktree. The cache avoids N full clones while
|
|
14
|
+
* git's worktree metadata keeps concurrent agents from sharing an index or
|
|
15
|
+
* working directory.
|
|
16
|
+
*/
|
|
17
|
+
class GitWorktree {
|
|
18
|
+
sessionRoot;
|
|
19
|
+
worktreePath;
|
|
20
|
+
repositoryPath;
|
|
21
|
+
branch;
|
|
22
|
+
runner;
|
|
23
|
+
cloneUrl;
|
|
24
|
+
githubToken;
|
|
25
|
+
constructor(options) {
|
|
26
|
+
const projectKey = safePathSegment(options.projectId);
|
|
27
|
+
const sessionKey = safePathSegment(options.sessionId);
|
|
28
|
+
this.sessionRoot = node_path_1.default.join(options.workspaceRoot, "sessions", sessionKey);
|
|
29
|
+
this.worktreePath = node_path_1.default.join(this.sessionRoot, "repo");
|
|
30
|
+
this.repositoryPath = node_path_1.default.join(options.workspaceRoot, "repositories", `${projectKey}.git`);
|
|
31
|
+
this.branch = `navarch/${safePathSegment(options.taskId).slice(0, 32)}-${sessionKey.slice(0, 12)}`;
|
|
32
|
+
this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
|
|
33
|
+
this.cloneUrl = options.cloneUrl;
|
|
34
|
+
this.githubToken = options.githubToken;
|
|
35
|
+
}
|
|
36
|
+
async prepare() {
|
|
37
|
+
await node_fs_1.promises.mkdir(node_path_1.default.dirname(this.repositoryPath), { recursive: true });
|
|
38
|
+
await node_fs_1.promises.mkdir(this.sessionRoot, { recursive: true });
|
|
39
|
+
await withRepositoryLock(this.repositoryPath, async () => {
|
|
40
|
+
if (!(await pathExists(node_path_1.default.join(this.repositoryPath, "HEAD")))) {
|
|
41
|
+
await this.runGit(["clone", "--bare", this.cloneUrl, this.repositoryPath], true);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const origin = await this.runGit(["--git-dir", this.repositoryPath, "remote", "get-url", "origin"], false);
|
|
45
|
+
if (normalizeCloneUrl(origin.stdout) !== normalizeCloneUrl(this.cloneUrl)) {
|
|
46
|
+
await this.runGit(["--git-dir", this.repositoryPath, "remote", "set-url", "origin", this.cloneUrl], false);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
const remoteHead = await this.runGit(["--git-dir", this.repositoryPath, "ls-remote", "--symref", "origin", "HEAD"], true);
|
|
50
|
+
const defaultBranch = parseRemoteHead(remoteHead.stdout);
|
|
51
|
+
const startRef = defaultBranch
|
|
52
|
+
? defaultBranch.replace(/^refs\/heads\//, "refs/remotes/origin/")
|
|
53
|
+
: "HEAD";
|
|
54
|
+
await this.runGit([
|
|
55
|
+
"--git-dir",
|
|
56
|
+
this.repositoryPath,
|
|
57
|
+
"fetch",
|
|
58
|
+
"--prune",
|
|
59
|
+
"origin",
|
|
60
|
+
"+refs/heads/*:refs/remotes/origin/*",
|
|
61
|
+
], true);
|
|
62
|
+
await this.runGit([
|
|
63
|
+
"--git-dir",
|
|
64
|
+
this.repositoryPath,
|
|
65
|
+
"worktree",
|
|
66
|
+
"add",
|
|
67
|
+
"-b",
|
|
68
|
+
this.branch,
|
|
69
|
+
this.worktreePath,
|
|
70
|
+
startRef,
|
|
71
|
+
], false);
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
async cleanup() {
|
|
75
|
+
await withRepositoryLock(this.repositoryPath, async () => {
|
|
76
|
+
await this.runner
|
|
77
|
+
.run("git", ["--git-dir", this.repositoryPath, "worktree", "remove", "--force", this.worktreePath])
|
|
78
|
+
.catch(() => undefined);
|
|
79
|
+
await this.runner
|
|
80
|
+
.run("git", ["--git-dir", this.repositoryPath, "branch", "-D", this.branch])
|
|
81
|
+
.catch(() => undefined);
|
|
82
|
+
await this.runner
|
|
83
|
+
.run("git", ["--git-dir", this.repositoryPath, "worktree", "prune"])
|
|
84
|
+
.catch(() => undefined);
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
async runGit(args, authenticated) {
|
|
88
|
+
const credentialArgs = authenticated && this.githubToken
|
|
89
|
+
? [
|
|
90
|
+
"-c",
|
|
91
|
+
'credential.helper=!f() { echo username=x-access-token; echo "password=$GITHUB_TOKEN"; }; f',
|
|
92
|
+
]
|
|
93
|
+
: [];
|
|
94
|
+
const result = await this.runner.run("git", [...credentialArgs, ...args], {
|
|
95
|
+
env: this.githubToken
|
|
96
|
+
? { ...process.env, GITHUB_TOKEN: this.githubToken, GIT_TERMINAL_PROMPT: "0" }
|
|
97
|
+
: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
98
|
+
});
|
|
99
|
+
if (result.code !== 0) {
|
|
100
|
+
throw new Error(`git ${args[0] ?? "command"} failed: ${result.stderr || result.stdout}`);
|
|
101
|
+
}
|
|
102
|
+
return result;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
exports.GitWorktree = GitWorktree;
|
|
106
|
+
async function withRepositoryLock(key, work) {
|
|
107
|
+
const previous = repositoryLocks.get(key) ?? Promise.resolve();
|
|
108
|
+
let release;
|
|
109
|
+
const current = new Promise((resolve) => {
|
|
110
|
+
release = resolve;
|
|
111
|
+
});
|
|
112
|
+
const queued = previous.then(() => current);
|
|
113
|
+
repositoryLocks.set(key, queued);
|
|
114
|
+
await previous;
|
|
115
|
+
try {
|
|
116
|
+
return await work();
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
release();
|
|
120
|
+
if (repositoryLocks.get(key) === queued)
|
|
121
|
+
repositoryLocks.delete(key);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
async function pathExists(value) {
|
|
125
|
+
try {
|
|
126
|
+
await node_fs_1.promises.access(value);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function safePathSegment(value) {
|
|
134
|
+
const safe = value.replace(/[^A-Za-z0-9_.-]/g, "-").replace(/^-+|-+$/g, "");
|
|
135
|
+
if (!safe)
|
|
136
|
+
throw new Error("Cannot create a git worktree without a valid project/session identifier.");
|
|
137
|
+
return safe;
|
|
138
|
+
}
|
|
139
|
+
function normalizeCloneUrl(value) {
|
|
140
|
+
return value.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
|
|
141
|
+
}
|
|
142
|
+
function parseRemoteHead(output) {
|
|
143
|
+
const match = output.match(/^ref:\s+(refs\/heads\/[A-Za-z0-9._/-]+)\s+HEAD$/m);
|
|
144
|
+
return match?.[1] ?? null;
|
|
145
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.findHeadBranchPullRequestUrl = findHeadBranchPullRequestUrl;
|
|
4
|
+
const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
5
|
+
async function readHostGhToken() {
|
|
6
|
+
// Keep the credential in memory and out of shell parsing, argv, and logs.
|
|
7
|
+
const result = await sandbox_cjs_1.nodeCommandRunner.run("gh", ["auth", "token", "--hostname", "github.com"], { timeoutMs: 5_000 });
|
|
8
|
+
if (result.code !== 0)
|
|
9
|
+
return undefined;
|
|
10
|
+
const token = result.stdout.trim();
|
|
11
|
+
return token && !/\s/.test(token) ? token : undefined;
|
|
12
|
+
}
|
|
13
|
+
async function resolveGitHubToken(options) {
|
|
14
|
+
if (options.githubToken)
|
|
15
|
+
return options.githubToken;
|
|
16
|
+
try {
|
|
17
|
+
// Resolution is intentionally best-effort so public-repository lookup can
|
|
18
|
+
// still proceed when gh is absent or has no authenticated host account.
|
|
19
|
+
return await (options.hostTokenProvider ?? readHostGhToken)();
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Finds the PR opened from this session's exact same-repository head branch.
|
|
27
|
+
* GitHub's head filter narrows the response, and the response is checked again
|
|
28
|
+
* before its URL is trusted so another repository or branch can never be
|
|
29
|
+
* attached as completion evidence.
|
|
30
|
+
*/
|
|
31
|
+
async function findHeadBranchPullRequestUrl(options) {
|
|
32
|
+
const [owner, name, ...extra] = options.repository.split("/");
|
|
33
|
+
if (!owner || !name || extra.length > 0) {
|
|
34
|
+
throw new Error(`Invalid GitHub repository name: ${options.repository}`);
|
|
35
|
+
}
|
|
36
|
+
const url = new URL(`https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/pulls`);
|
|
37
|
+
url.searchParams.set("state", "all");
|
|
38
|
+
url.searchParams.set("head", `${owner}:${options.headBranch}`);
|
|
39
|
+
url.searchParams.set("per_page", "10");
|
|
40
|
+
const headers = {
|
|
41
|
+
accept: "application/vnd.github+json",
|
|
42
|
+
"user-agent": "navarch-runtime",
|
|
43
|
+
"x-github-api-version": "2022-11-28",
|
|
44
|
+
};
|
|
45
|
+
const githubToken = await resolveGitHubToken(options);
|
|
46
|
+
if (githubToken)
|
|
47
|
+
headers.authorization = `Bearer ${githubToken}`;
|
|
48
|
+
const response = await (options.fetchImpl ?? fetch)(url, { headers });
|
|
49
|
+
if (!response.ok) {
|
|
50
|
+
throw new Error(`GitHub pull request lookup failed with HTTP ${response.status}`);
|
|
51
|
+
}
|
|
52
|
+
const body = await response.json();
|
|
53
|
+
if (!Array.isArray(body)) {
|
|
54
|
+
throw new Error("GitHub pull request lookup returned a non-array response");
|
|
55
|
+
}
|
|
56
|
+
const normalizedRepository = options.repository.toLowerCase();
|
|
57
|
+
for (const candidate of body) {
|
|
58
|
+
if (candidate.head?.ref !== options.headBranch)
|
|
59
|
+
continue;
|
|
60
|
+
const headRepository = candidate.head.repo?.full_name;
|
|
61
|
+
if (typeof headRepository !== "string")
|
|
62
|
+
continue;
|
|
63
|
+
if (headRepository.toLowerCase() !== normalizedRepository)
|
|
64
|
+
continue;
|
|
65
|
+
if (typeof candidate.html_url !== "string")
|
|
66
|
+
continue;
|
|
67
|
+
if (!isPullRequestUrlForRepository(candidate.html_url, normalizedRepository))
|
|
68
|
+
continue;
|
|
69
|
+
return candidate.html_url;
|
|
70
|
+
}
|
|
71
|
+
return null;
|
|
72
|
+
}
|
|
73
|
+
function isPullRequestUrlForRepository(value, repository) {
|
|
74
|
+
try {
|
|
75
|
+
const url = new URL(value);
|
|
76
|
+
if (url.protocol !== "https:" || url.hostname.toLowerCase() !== "github.com")
|
|
77
|
+
return false;
|
|
78
|
+
const parts = url.pathname.split("/").filter(Boolean);
|
|
79
|
+
return (parts.length === 4 &&
|
|
80
|
+
`${parts[0]}/${parts[1]}`.toLowerCase() === repository &&
|
|
81
|
+
parts[2] === "pull" &&
|
|
82
|
+
/^\d+$/.test(parts[3] ?? ""));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
package/dist/machine-store.cjs
CHANGED
|
@@ -48,6 +48,9 @@ async function resolveMachineIdentity(configDir, apiBaseFallback) {
|
|
|
48
48
|
token: envToken,
|
|
49
49
|
name: process.env.NAVARCH_MACHINE_NAME ?? envId,
|
|
50
50
|
api_base: process.env.NAVARCH_API_BASE ?? apiBaseFallback,
|
|
51
|
+
agent_type: process.env.NAVARCH_AGENT === "codex" || process.env.NAVARCH_AGENT === "claude-code"
|
|
52
|
+
? process.env.NAVARCH_AGENT
|
|
53
|
+
: undefined,
|
|
51
54
|
};
|
|
52
55
|
}
|
|
53
56
|
const stored = await loadMachineIdentity(configDir);
|
package/dist/prompt.cjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.renderPrompt = renderPrompt;
|
|
4
|
+
exports.renderGuidanceCorrectionPrompt = renderGuidanceCorrectionPrompt;
|
|
4
5
|
/**
|
|
5
6
|
* Renders the four-layer context bundle (project-plan.md §3.8: steering docs,
|
|
6
7
|
* task itself, task-type playbook, depends_on reports) into the single prompt
|
|
@@ -10,13 +11,19 @@ exports.renderPrompt = renderPrompt;
|
|
|
10
11
|
*/
|
|
11
12
|
function renderPrompt(task, bundle) {
|
|
12
13
|
if (bundle.task_context) {
|
|
13
|
-
|
|
14
|
+
const repositoryContext = bundle.repository
|
|
15
|
+
? `Repository: ${bundle.repository.full_name} (${bundle.repository.url})\nLocal checkout: this session's isolated git worktree (current working directory).`
|
|
16
|
+
: null;
|
|
17
|
+
return [repositoryContext, bundle.task_context.trim(), bundle.retry_context?.trim()]
|
|
14
18
|
.filter((section) => Boolean(section))
|
|
15
19
|
.join("\n\n") + "\n";
|
|
16
20
|
}
|
|
17
21
|
const sections = [];
|
|
18
22
|
sections.push(`# Task: ${task.summary}`);
|
|
19
23
|
sections.push(`Type: ${task.task_type} | Repo: ${task.repo} | Environment: ${task.environment_scope}`);
|
|
24
|
+
if (bundle.repository) {
|
|
25
|
+
sections.push(`Repository URL: ${bundle.repository.url}\nLocal checkout: this session's isolated git worktree (current working directory).`);
|
|
26
|
+
}
|
|
20
27
|
if (task.github_issue_url) {
|
|
21
28
|
sections.push(`GitHub issue: ${task.github_issue_url}`);
|
|
22
29
|
}
|
|
@@ -36,3 +43,20 @@ function renderPrompt(task, bundle) {
|
|
|
36
43
|
sections.push(bundle.playbook);
|
|
37
44
|
return sections.join("\n\n");
|
|
38
45
|
}
|
|
46
|
+
/**
|
|
47
|
+
* Builds a fresh agent turn after a human steers an active task. The runtime
|
|
48
|
+
* keeps the same worktree, so the replacement turn can inspect and continue
|
|
49
|
+
* partial edits instead of discarding mid-flight work.
|
|
50
|
+
*/
|
|
51
|
+
function renderGuidanceCorrectionPrompt(originalPrompt, guidance) {
|
|
52
|
+
const corrections = guidance.map((entry) => `- **${entry.given_at}**: ${entry.content}`);
|
|
53
|
+
return [
|
|
54
|
+
originalPrompt.trim(),
|
|
55
|
+
"## Mid-flight human guidance",
|
|
56
|
+
"",
|
|
57
|
+
"The task was actively steered while you were working. Human guidance takes precedence over earlier context. Continue in the same worktree: inspect the current changes, correct course, and then finish the task.",
|
|
58
|
+
"",
|
|
59
|
+
...corrections,
|
|
60
|
+
"",
|
|
61
|
+
].join("\n");
|
|
62
|
+
}
|
package/dist/sandbox.cjs
CHANGED
|
@@ -15,7 +15,7 @@ exports.SandboxUnavailableError = SandboxUnavailableError;
|
|
|
15
15
|
exports.nodeCommandRunner = {
|
|
16
16
|
run(cmd, args, opts = {}) {
|
|
17
17
|
return new Promise((resolve, reject) => {
|
|
18
|
-
const child = (0, node_child_process_1.spawn)(cmd, args, { cwd: opts.cwd });
|
|
18
|
+
const child = (0, node_child_process_1.spawn)(cmd, args, { cwd: opts.cwd, env: opts.env });
|
|
19
19
|
let stdout = "";
|
|
20
20
|
let stderr = "";
|
|
21
21
|
let settled = false;
|
|
@@ -91,14 +91,27 @@ class DockerSandbox {
|
|
|
91
91
|
runner;
|
|
92
92
|
workDir;
|
|
93
93
|
image;
|
|
94
|
+
containerWorkDir;
|
|
95
|
+
sharedGitDir;
|
|
94
96
|
constructor(opts) {
|
|
95
97
|
this.name = containerName(opts.sessionId);
|
|
96
98
|
this.runner = opts.runner ?? exports.nodeCommandRunner;
|
|
97
99
|
this.workDir = node_path_1.default.join(opts.workspaceRoot, opts.sessionId);
|
|
98
100
|
this.image = opts.image;
|
|
101
|
+
this.containerWorkDir = opts.containerWorkDir ?? null;
|
|
102
|
+
this.sharedGitDir = opts.sharedGitDir ?? null;
|
|
99
103
|
}
|
|
100
104
|
async create() {
|
|
101
105
|
await node_fs_1.promises.mkdir(this.workDir, { recursive: true });
|
|
106
|
+
const mounts = this.containerWorkDir
|
|
107
|
+
? [
|
|
108
|
+
"-v",
|
|
109
|
+
`${this.workDir}:${this.workDir}`,
|
|
110
|
+
...(this.sharedGitDir ? ["-v", `${this.sharedGitDir}:${this.sharedGitDir}`] : []),
|
|
111
|
+
"-w",
|
|
112
|
+
this.containerWorkDir,
|
|
113
|
+
]
|
|
114
|
+
: ["-v", `${this.workDir}:/workspace`, "-w", "/workspace"];
|
|
102
115
|
const result = await this.runner.run("docker", [
|
|
103
116
|
"run",
|
|
104
117
|
"-d",
|
|
@@ -110,10 +123,7 @@ class DockerSandbox {
|
|
|
110
123
|
"/tmp",
|
|
111
124
|
"--tmpfs",
|
|
112
125
|
"/run",
|
|
113
|
-
|
|
114
|
-
`${this.workDir}:/workspace`,
|
|
115
|
-
"-w",
|
|
116
|
-
"/workspace",
|
|
126
|
+
...mounts,
|
|
117
127
|
this.image,
|
|
118
128
|
"tail",
|
|
119
129
|
"-f",
|
|
@@ -156,9 +166,13 @@ class DockerSandbox {
|
|
|
156
166
|
}
|
|
157
167
|
/** Force-removes the container and the host-side workspace mount. Best-effort: never throws. */
|
|
158
168
|
async wipe() {
|
|
159
|
-
await this.
|
|
169
|
+
await this.stop();
|
|
160
170
|
await node_fs_1.promises.rm(this.workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
161
171
|
}
|
|
172
|
+
/** Stops/removes only the container; the session orchestrator owns worktree cleanup. */
|
|
173
|
+
async stop() {
|
|
174
|
+
await this.runner.run("docker", ["rm", "-f", this.name]).catch(() => undefined);
|
|
175
|
+
}
|
|
162
176
|
}
|
|
163
177
|
exports.DockerSandbox = DockerSandbox;
|
|
164
178
|
function shellQuote(value) {
|