@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/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 short-lived,
97
- * single-use enrollment token a project owner minted from the platform UI
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 config = (0, config_cjs_1.loadRuntimeConfig)();
141
- const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
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
- const identity = await (0, machine_store_cjs_1.resolveMachineIdentity)(config.configDir, config.apiBase);
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", 3),
38
- capabilities: envList(env, "NAVARCH_CAPABILITIES", ["docker-sandbox", "shell"]),
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 === "codex" ? "codex" : "claude-code",
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: env.NAVARCH_SANDBOX_MODE === "host" ? "host" : "docker",
61
+ sandboxMode,
53
62
  dockerImage: env.NAVARCH_DOCKER_IMAGE ?? "node:20-slim",
54
63
  };
55
64
  }
@@ -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
- * assumed JSONL event shape above. Silently skips any line that isn't a JSON
73
- * object carrying a `msg` field (blank lines, stray log lines, a shape that
74
- * doesn't match the guess) rather than throwing; returns an empty array (not
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) && "msg" in parsed) {
87
- events.push(parsed);
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. Assumes
98
- * each `token_count` event reports the run's cumulative totals so far (like
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 `agent_message` event's text — assumed to be the run's final agent-visible output, mirroring Claude JSON result's `result` field. Returns null when no such event parsed (see parseCodexJsonEvents doc comment). */
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
- const prUrl = extractPrUrl(result.stdout) ?? extractPrUrl(result.stderr);
143
- const evidenceUrls = prUrl ? [prUrl] : [];
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
+ }
@@ -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
- return [bundle.task_context.trim(), bundle.retry_context?.trim()]
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
- "-v",
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.runner.run("docker", ["rm", "-f", this.name]).catch(() => undefined);
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) {