@sagentlab/navarch-runtime 0.1.91 → 0.1.93

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 CHANGED
@@ -9,6 +9,16 @@ environment, outside the coding agent's OS sandbox, with a fresh browser
9
9
  context for desktop and touch-enabled mobile captures. It neither uses the
10
10
  operator's browser profile nor executes agent-provided scripts.
11
11
 
12
+ The same server offers interactive `local_browser_open`, `local_browser_click`,
13
+ `local_browser_fill`, `local_browser_press`, `local_browser_snapshot`, and
14
+ `local_browser_screenshot` tools for walking through a service the agent starts
15
+ on this machine. That browser is limited to loopback (`localhost`,
16
+ `*.localhost`, `127.0.0.0/8`, `::1`). Every request, WebSocket, or navigation
17
+ to any other host is aborted and listed in the tool result. The agent's own
18
+ sandbox can already reach these services, so the tools add a browser without
19
+ adding network reach. Each lease gets one tab at a time, a fresh context per
20
+ viewport, and at most 300 actions.
21
+
12
22
  Install the runtime's pinned browser once on the agent host (and after a
13
23
  Playwright version update):
14
24
 
@@ -36,9 +46,13 @@ lifetime, so restart the agent after fixing the browser to re-probe.
36
46
  `navarch-runtime doctor` runs the same check.
37
47
 
38
48
  Codex's macOS seatbelt denies Chromium's Mach port registration
39
- (`bootstrap_check_in ... MachPortRendezvousServer: Permission denied`), so a
40
- sandboxed Codex machine on macOS does not take browser tasks. Run those with
41
- another agent such as Claude Code, or use Docker mode. Docker mode is not
49
+ (`bootstrap_check_in ... MachPortRendezvousServer: Permission denied`), and
50
+ `--single-process` crashes once a second page opens. When Node runs in the
51
+ sandbox and only Chromium fails, the probe still passes, with that reason. Codex
52
+ sessions then browse local services through the `local_browser_*` tools above,
53
+ and the session prompt tells them to. With an operator `NAVARCH_MCP_CONFIG_PATH`
54
+ those tools are absent, so the machine does not take browser tasks; run those
55
+ with another agent such as Claude Code, or use Docker mode. Docker mode is not
42
56
  probed because its image supplies the browser. Set `NAVARCH_BROWSER_PROBE=off`
43
57
  to trust the configured capability list without a check.
44
58
 
@@ -460,6 +474,9 @@ unchanged across the deployment.
460
474
  | `NAVARCH_QODER_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the unattended defaults (`--setting-sources`, `--permission-mode auto`, `--output-format json`). An explicit `--output-format`, `--permission-mode`, `--settings`, `--model`, or `--reasoning-effort` here replaces the per-session default. Qoder selects its own account models; pin one via `--model` here or `connect --model` to override. |
461
475
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
462
476
  | `NAVARCH_WORKTREE_GUARD` | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set `off` to disable it for every runtime on the machine — required to run OpenCode on the host. |
477
+ | `NAVARCH_NAVI_CONVERSATIONS` | on | Serve read-only Navi repository conversations on this machine. Only the `claude-code` adapter on the host sandbox with the worktree guard on is supported; `navarch-runtime doctor` prints the `navi_chat:` state and reason. Set `off` to stop polling for conversations. |
478
+ | `NAVARCH_CONVERSATION_POLL_MS` | `5000` | Idle poll interval for queued conversation turns (1000–30000). |
479
+ | `NAVARCH_CONVERSATION_TURN_TIMEOUT_MS` | `540000` | Time limit for one conversation turn's agent process (at most nine minutes). |
463
480
  | `NAVARCH_GUARD_EXTRA_ROOTS` | — | `path.delimiter`-separated (`:` on POSIX) extra directories the worktree guard allows beyond the session worktree, shared bare repo, and temp dirs. |
464
481
 
465
482
  ## Worktree boundary guard (host mode)
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claudeConversationRunner = exports.ClaudeStreamParser = exports.CLAUDE_CONVERSATION_TOOLS = void 0;
4
+ exports.claudeConversationArgs = claudeConversationArgs;
5
+ exports.runClaudeConversationTurn = runClaudeConversationTurn;
6
+ const node_child_process_1 = require("node:child_process");
7
+ const node_perf_hooks_1 = require("node:perf_hooks");
8
+ const kill_agent_cjs_1 = require("../kill-agent.cjs");
9
+ /**
10
+ * Read-only tools a Navi repository question may use. Anything not listed
11
+ * (Bash, Edit, Write, WebFetch, Task, MCP tools) is absent from the model's
12
+ * tool list, so a question cannot itself authorize a code change, PR,
13
+ * deployment, or new task. Machine-wide NAVARCH_CLAUDE_EXTRA_ARGS are
14
+ * deliberately not applied here: they could add --dangerously-skip-permissions
15
+ * or --allowedTools and widen this boundary.
16
+ */
17
+ exports.CLAUDE_CONVERSATION_TOOLS = "Read,Grep,Glob";
18
+ const STDERR_TAIL_BYTES = 8 * 1024;
19
+ /**
20
+ * Incremental parser for `claude -p --output-format stream-json --verbose
21
+ * --include-partial-messages`. Chunks may split a record anywhere. Text is
22
+ * emitted from `text_delta` records the moment they are parsed. A complete
23
+ * `assistant` message is used only for a message that streamed no deltas
24
+ * (a CLI without partial-message support), so text is never emitted twice.
25
+ */
26
+ class ClaudeStreamParser {
27
+ emit;
28
+ onRecord;
29
+ buffer = "";
30
+ currentMessageId = null;
31
+ streamedMessages = new Set();
32
+ emittedText = false;
33
+ separatorPending = false;
34
+ result = null;
35
+ /** `onRecord` fires for each complete record before any event it produces is emitted. */
36
+ constructor(emit, onRecord = () => undefined) {
37
+ this.emit = emit;
38
+ this.onRecord = onRecord;
39
+ }
40
+ /** Returns the number of complete records parsed from this chunk. */
41
+ push(chunk) {
42
+ this.buffer += chunk;
43
+ let records = 0;
44
+ for (;;) {
45
+ const newline = this.buffer.indexOf("\n");
46
+ if (newline < 0)
47
+ break;
48
+ const line = this.buffer.slice(0, newline).trim();
49
+ this.buffer = this.buffer.slice(newline + 1);
50
+ if (!line)
51
+ continue;
52
+ records += 1;
53
+ this.onRecord();
54
+ this.record(line);
55
+ }
56
+ return records;
57
+ }
58
+ /** Parses a final record that lacks a trailing newline. */
59
+ flush() {
60
+ const line = this.buffer.trim();
61
+ this.buffer = "";
62
+ if (line) {
63
+ this.onRecord();
64
+ this.record(line);
65
+ }
66
+ }
67
+ /** Used when the agent finished without any streamed text. */
68
+ get hasEmittedText() {
69
+ return this.emittedText;
70
+ }
71
+ text(text) {
72
+ if (!text)
73
+ return;
74
+ this.emit({ type: "text", text: this.separatorPending ? `\n\n${text}` : text });
75
+ this.separatorPending = false;
76
+ this.emittedText = true;
77
+ }
78
+ record(line) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(line);
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ if (!parsed || typeof parsed !== "object")
87
+ return;
88
+ const record = parsed;
89
+ switch (record.type) {
90
+ case "stream_event":
91
+ this.streamEvent(record.event, record.parent_tool_use_id);
92
+ break;
93
+ case "assistant":
94
+ this.assistant(record);
95
+ break;
96
+ case "user":
97
+ this.toolResults(record);
98
+ break;
99
+ case "result":
100
+ this.result = {
101
+ isError: record.is_error === true || (typeof record.subtype === "string" && record.subtype !== "success"),
102
+ text: typeof record.result === "string" ? record.result : null,
103
+ };
104
+ if (this.result.isError)
105
+ this.emit({ type: "failure", reason: String(record.subtype ?? "error") });
106
+ break;
107
+ }
108
+ }
109
+ streamEvent(event, parentToolUseId) {
110
+ // Sub-agent output is not part of the user's answer.
111
+ if (!event || typeof event !== "object" || parentToolUseId)
112
+ return;
113
+ const e = event;
114
+ const message = asRec(e.message);
115
+ const delta = asRec(e.delta);
116
+ if (e.type === "message_start") {
117
+ this.currentMessageId = typeof message?.id === "string" ? message.id : null;
118
+ }
119
+ else if (e.type === "content_block_start") {
120
+ const block = asRec(e.content_block);
121
+ if (block?.type === "tool_use" && typeof block.name === "string") {
122
+ this.emit({ type: "tool_status", tool: block.name, phase: "started" });
123
+ }
124
+ else if (block?.type === "text" && this.emittedText) {
125
+ this.separatorPending = true;
126
+ }
127
+ }
128
+ else if (e.type === "content_block_delta" && delta?.type === "text_delta" && typeof delta.text === "string") {
129
+ if (this.currentMessageId)
130
+ this.streamedMessages.add(this.currentMessageId);
131
+ this.text(delta.text);
132
+ }
133
+ }
134
+ assistant(record) {
135
+ const message = asRec(record.message);
136
+ if (record.parent_tool_use_id || !message || !Array.isArray(message.content))
137
+ return;
138
+ if (typeof message.id === "string" && this.streamedMessages.has(message.id))
139
+ return;
140
+ for (const item of message.content) {
141
+ const block = asRec(item);
142
+ if (block?.type === "text" && typeof block.text === "string") {
143
+ if (this.emittedText)
144
+ this.separatorPending = true;
145
+ this.text(block.text);
146
+ }
147
+ else if (block?.type === "tool_use" && typeof block.name === "string" && !(typeof message.id === "string" && this.streamedMessages.has(message.id))) {
148
+ this.emit({ type: "tool_status", tool: block.name, phase: "started" });
149
+ }
150
+ }
151
+ }
152
+ toolResults(record) {
153
+ const content = asRec(record.message)?.content;
154
+ if (!Array.isArray(content))
155
+ return;
156
+ for (const item of content) {
157
+ const block = asRec(item);
158
+ if (block?.type === "tool_result" && block.is_error === true) {
159
+ this.emit({ type: "tool_status", tool: "tool", phase: "failed" });
160
+ }
161
+ }
162
+ }
163
+ }
164
+ exports.ClaudeStreamParser = ClaudeStreamParser;
165
+ function asRec(value) {
166
+ return value && typeof value === "object" ? value : null;
167
+ }
168
+ /** Fixed, read-only argument list for one conversation turn. */
169
+ function claudeConversationArgs(options) {
170
+ const args = [
171
+ "-p",
172
+ "--output-format", "stream-json",
173
+ "--verbose",
174
+ "--include-partial-messages",
175
+ // The server holds the bounded history. A local Claude transcript would
176
+ // be a second, unbounded private copy of the conversation.
177
+ "--no-session-persistence",
178
+ // No operator hooks, project settings or MCP servers (including the
179
+ // lease-scoped Navarch task tools) in a conversation.
180
+ "--setting-sources", "",
181
+ "--strict-mcp-config",
182
+ "--tools", exports.CLAUDE_CONVERSATION_TOOLS,
183
+ "--permission-mode", "default",
184
+ ];
185
+ if (options.settingsPath)
186
+ args.push("--settings", options.settingsPath);
187
+ if (options.model)
188
+ args.push("--model", options.model);
189
+ return args;
190
+ }
191
+ async function runClaudeConversationTurn(options) {
192
+ const idle = (aborted) => ({
193
+ exitCode: null, timedOut: false, aborted, succeeded: false, firstEventMs: null, firstTextMs: null, stderr: "",
194
+ });
195
+ if (options.signal.aborted)
196
+ return idle(true);
197
+ const startedAt = node_perf_hooks_1.performance.now();
198
+ let firstEventMs = null;
199
+ let firstTextMs = null;
200
+ let timedOut = false;
201
+ let aborted = false;
202
+ let closed = false;
203
+ let stderr = "";
204
+ const deliver = (event) => {
205
+ // After stop, timeout or exit, late output must not reach the caller.
206
+ if (closed || aborted || timedOut)
207
+ return;
208
+ if (event.type === "text" && firstTextMs === null)
209
+ firstTextMs = node_perf_hooks_1.performance.now() - startedAt;
210
+ options.onEvent(event);
211
+ };
212
+ const parser = new ClaudeStreamParser(deliver, () => {
213
+ if (firstEventMs === null)
214
+ firstEventMs = node_perf_hooks_1.performance.now() - startedAt;
215
+ });
216
+ return new Promise((resolve) => {
217
+ const child = (0, node_child_process_1.spawn)(options.bin, claudeConversationArgs(options), {
218
+ detached: process.platform !== "win32",
219
+ cwd: options.cwd,
220
+ env: options.env,
221
+ stdio: ["pipe", "pipe", "pipe"],
222
+ });
223
+ child.stdin?.on("error", () => undefined);
224
+ child.stdin?.end(options.prompt);
225
+ options.onProcessStarted?.(child.pid);
226
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
227
+ timedOut = true;
228
+ (0, kill_agent_cjs_1.killAgent)(child);
229
+ }, options.timeoutMs) : undefined;
230
+ const onAbort = () => {
231
+ aborted = true;
232
+ (0, kill_agent_cjs_1.killAgent)(child);
233
+ };
234
+ options.signal.addEventListener("abort", onAbort, { once: true });
235
+ child.stdout.setEncoding("utf8");
236
+ child.stdout.on("data", (chunk) => {
237
+ parser.push(chunk);
238
+ });
239
+ child.stderr.setEncoding("utf8");
240
+ child.stderr.on("data", (chunk) => {
241
+ stderr = (stderr + chunk).slice(-STDERR_TAIL_BYTES);
242
+ });
243
+ const finish = (exitCode) => {
244
+ if (closed)
245
+ return;
246
+ parser.flush();
247
+ // An answer delivered only in the final result record (no stream deltas).
248
+ if (!parser.hasEmittedText && parser.result && !parser.result.isError && parser.result.text) {
249
+ deliver({ type: "text", text: parser.result.text });
250
+ }
251
+ closed = true;
252
+ clearTimeout(timer);
253
+ options.signal.removeEventListener("abort", onAbort);
254
+ resolve({
255
+ exitCode, timedOut, aborted,
256
+ succeeded: exitCode === 0 && !aborted && !timedOut && !!parser.result && !parser.result.isError,
257
+ firstEventMs, firstTextMs, stderr,
258
+ });
259
+ };
260
+ child.on("error", (err) => {
261
+ stderr = (stderr + `\n${String(err)}`).slice(-STDERR_TAIL_BYTES);
262
+ finish(null);
263
+ });
264
+ child.on("close", (code) => finish(code));
265
+ if (options.signal.aborted)
266
+ onAbort();
267
+ });
268
+ }
269
+ exports.claudeConversationRunner = { run: runClaudeConversationTurn };
@@ -5,6 +5,7 @@ exports.runClaudeCodeAdapter = runClaudeCodeAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
7
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
8
+ const claude_conversation_cjs_1 = require("./claude-conversation.cjs");
8
9
  const adapter_capacity_cjs_1 = require("../adapter-capacity.cjs");
9
10
  const DEFAULT_CLAUDE_MODEL = "claude-opus-5-5";
10
11
  const FALLBACK_CLAUDE_MODEL = "claude-opus-5-5";
@@ -281,4 +282,5 @@ function shellQuote(value) {
281
282
  exports.claudeCodeAdapter = {
282
283
  agentType: "claude-code",
283
284
  run: runClaudeCodeAdapter,
285
+ conversation: claude_conversation_cjs_1.claudeConversationRunner,
284
286
  };
package/dist/api.cjs CHANGED
@@ -158,6 +158,20 @@ class NavarchApiClient {
158
158
  throw new Error("issueSecrets: empty response from control plane.");
159
159
  return result;
160
160
  }
161
+ /** Navi runtime v1: advertises conversation support for 90 s and returns queued turns for this machine. */
162
+ async pollConversations(machineId) {
163
+ const result = await this.request("POST", `/api/machines/${encodeURIComponent(machineId)}/conversations`, { protocol: 1 });
164
+ if (!result)
165
+ throw new Error("pollConversations: empty response from control plane.");
166
+ return result;
167
+ }
168
+ /** Navi runtime v1 claim / renew / append for one turn. */
169
+ async conversationCommand(machineId, conversationId, command) {
170
+ const result = await this.request("POST", `/api/machines/${encodeURIComponent(machineId)}/conversations/${encodeURIComponent(conversationId)}`, command);
171
+ if (!result)
172
+ throw new Error("conversationCommand: empty response from control plane.");
173
+ return result;
174
+ }
161
175
  /** Create a signed upload target for this lease's redacted transcript. */
162
176
  async getTranscriptUploadUrl(leaseId) {
163
177
  const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/transcript-upload-url`, {});
@@ -13,6 +13,7 @@ const mcp_js_1 = require("@modelcontextprotocol/sdk/server/mcp.js");
13
13
  const streamableHttp_js_1 = require("@modelcontextprotocol/sdk/server/streamableHttp.js");
14
14
  const playwright_1 = require("playwright");
15
15
  const zod_1 = require("zod");
16
+ const local_browser_cjs_1 = require("./local-browser.cjs");
16
17
  const node_child_process_1 = require("node:child_process");
17
18
  const node_util_1 = require("node:util");
18
19
  const node_path_1 = __importDefault(require("node:path"));
@@ -46,7 +47,8 @@ const captureShape = {
46
47
  exports.captureSchema = zod_1.z.object(captureShape);
47
48
  /** Runs in the supervisor, outside the coding agent's OS sandbox. Fresh
48
49
  * contexts have no user cookies, arbitrary script execution, or file access.
49
- * Only the narrow capture operation is exposed to the lease. */
50
+ * The lease gets the narrow capture operation plus the loopback-only
51
+ * local_browser_* tools (local-browser.cts). */
50
52
  async function startBrowserEvidence(options) {
51
53
  const token = (0, node_crypto_1.randomBytes)(32).toString("hex");
52
54
  const fetchImpl = options.fetchImpl ?? fetch;
@@ -57,6 +59,10 @@ async function startBrowserEvidence(options) {
57
59
  let browser;
58
60
  let activeCapture;
59
61
  let attempts = 0;
62
+ const localBrowser = (0, local_browser_cjs_1.createLocalBrowser)({
63
+ launch: options.launch ?? launchCaptureBrowser,
64
+ isActive: () => !closed && options.isActive(),
65
+ });
60
66
  const assertActive = () => {
61
67
  if (closed || !options.isActive())
62
68
  throw new Error("Browser evidence lease is no longer active.");
@@ -204,6 +210,7 @@ async function startBrowserEvidence(options) {
204
210
  return { isError: true, content: [{ type: "text", text: String(error) }] };
205
211
  }
206
212
  });
213
+ (0, local_browser_cjs_1.registerLocalBrowserTools)(server, localBrowser);
207
214
  res.on("close", () => { void transport.close(); void server.close(); });
208
215
  try {
209
216
  await server.connect(transport);
@@ -253,6 +260,7 @@ async function startBrowserEvidence(options) {
253
260
  const stopped = new Promise((resolve) => http.close(() => resolve()));
254
261
  http.closeAllConnections();
255
262
  await browser?.close().catch(() => undefined);
263
+ await localBrowser.close();
256
264
  await activeCapture?.catch(() => undefined);
257
265
  await stopped;
258
266
  },
@@ -74,8 +74,19 @@ async function probeBrowserCapability(config, deps = {}) {
74
74
  const platform = deps.platform ?? process.platform;
75
75
  if (codexSandboxNeedsProbe(config, platform)) {
76
76
  const sandboxFailure = await probeCodexSandbox(config, deps);
77
+ if (sandboxFailure?.browserOnly && !config.mcpConfigPath) {
78
+ // Node runs in the sandbox, so the agent can start its services there;
79
+ // only Chromium cannot. Sessions get the supervisor's local_browser_*
80
+ // tools (local-browser.cts) instead, which reach the same loopback.
81
+ return {
82
+ status: "passed",
83
+ reason: truncate(`Headless Chromium cannot start inside the Codex sandbox (${sandboxFailure.detail}); ` +
84
+ "Codex sessions browse local services through Navarch's local_browser tools, which run outside it."),
85
+ checked_at: now().toISOString(),
86
+ };
87
+ }
77
88
  if (sandboxFailure) {
78
- return { status: "failed", reason: truncate(sandboxFailure), checked_at: now().toISOString() };
89
+ return { status: "failed", reason: truncate(sandboxFailure.reason), checked_at: now().toISOString() };
79
90
  }
80
91
  }
81
92
  return { status: "passed", checked_at: now().toISOString() };
@@ -215,8 +226,9 @@ async function probeCodexSandbox(config, deps) {
215
226
  // sandbox view: a path outside the granted read roots, not an old CLI.
216
227
  const hidden = /bwrap: execvp .*No such file or directory/.test(output);
217
228
  const opensslConfig = /OpenSSL configuration error/.test(output);
218
- return ("Could not verify a browser inside the Codex sandbox: `codex sandbox` did not run the probe " +
219
- `(${result.timedOut ? "timed out" : output.trim() ? summarizeLaunchFailure(output) : `exit ${result.code}`}). ` +
229
+ const detail = result.timedOut ? "timed out" : output.trim() ? summarizeLaunchFailure(output) : `exit ${result.code}`;
230
+ const reason = ("Could not verify a browser inside the Codex sandbox: `codex sandbox` did not run the probe " +
231
+ `(${detail}). ` +
220
232
  (hidden
221
233
  ? "The sandbox cannot see that executable; add its install directory to NAVARCH_GUARD_EXTRA_ROOTS, " +
222
234
  "or run browser tasks with another agent."
@@ -224,19 +236,22 @@ async function probeCodexSandbox(config, deps) {
224
236
  ? "Node cannot read its OpenSSL config inside the sandbox, so Codex sessions cannot run node either; " +
225
237
  "update the Navarch runtime, or add that file's directory to NAVARCH_GUARD_EXTRA_ROOTS."
226
238
  : "Update the Codex CLI, or run browser tasks with another agent."));
239
+ return { reason, detail, browserOnly: false };
227
240
  }
228
241
  const detail = result.timedOut ? "the launch timed out." : summarizeLaunchFailure(output);
229
242
  const machPort = /bootstrap_check_in|MachPortRendezvous|mach_port_rendezvous/i.test(output);
230
- return (`Headless Chromium cannot start inside the Codex sandbox: ${detail} ` +
243
+ const reason = (`Headless Chromium cannot start inside the Codex sandbox: ${detail} ` +
231
244
  (machPort
232
245
  ? "Codex's macOS sandbox denies Chromium's Mach port registration. "
233
246
  : "") +
234
247
  "Run browser tasks with another agent (for example Claude Code), use NAVARCH_SANDBOX_MODE=docker, " +
235
248
  "or run Codex without its sandbox.");
249
+ return { reason, detail, browserOnly: true };
236
250
  }
237
251
  catch (error) {
238
252
  log.warn(`codex sandbox browser probe could not run: ${String(error)}`);
239
- return `Could not verify a browser inside the Codex sandbox: ${summarizeLaunchFailure(String(error))}`;
253
+ const detail = summarizeLaunchFailure(String(error));
254
+ return { reason: `Could not verify a browser inside the Codex sandbox: ${detail}`, detail, browserOnly: false };
240
255
  }
241
256
  finally {
242
257
  await node_fs_1.promises.rm(probeRoot, { recursive: true, force: true }).catch(() => undefined);
@@ -6,6 +6,7 @@ const api_cjs_1 = require("./api.cjs");
6
6
  const logger_cjs_1 = require("./logger.cjs");
7
7
  const log = (0, logger_cjs_1.createLogger)("claim");
8
8
  const MAX_CLAIM_BACKOFF_MS = 60_000;
9
+ const pendingClaimSlot = "pending-claim-request";
9
10
  /**
10
11
  * Polls dispatch/claim on an interval, gated by available capacity
11
12
  * (implementation-plan.md WP-07 "Claim loop: poll dispatch, receive context
@@ -69,13 +70,24 @@ class ClaimLoop {
69
70
  // the dispatcher records it on the lease and excludes it from review
70
71
  // tasks of PRs this session authors.
71
72
  const sessionId = (0, node_crypto_1.randomUUID)();
72
- const claimed = await this.api.claim({
73
- available_capacity: this.capacity.available(),
74
- capabilities: this.config.capabilities,
75
- agent_type: this.config.agentType,
76
- runtimes: this.config.runtimes,
77
- session_id: sessionId,
78
- });
73
+ // Hold the slot for the duration of the claim request. Navi conversation
74
+ // turns also take slots; without this reservation one could take the last
75
+ // slot while the control plane is granting a lease, stranding that lease.
76
+ const availableCapacity = this.capacity.available();
77
+ this.capacity.acquire(pendingClaimSlot);
78
+ let claimed;
79
+ try {
80
+ claimed = await this.api.claim({
81
+ available_capacity: availableCapacity,
82
+ capabilities: this.config.capabilities,
83
+ agent_type: this.config.agentType,
84
+ runtimes: this.config.runtimes,
85
+ session_id: sessionId,
86
+ });
87
+ }
88
+ finally {
89
+ this.capacity.release(pendingClaimSlot);
90
+ }
79
91
  if (this.consecutiveFailures > 0) {
80
92
  log.info(`claim polling recovered after ${this.consecutiveFailures} failed attempt(s)`);
81
93
  }
package/dist/cli.cjs CHANGED
@@ -12,6 +12,8 @@ const api_cjs_1 = require("./api.cjs");
12
12
  const capacity_cjs_1 = require("./capacity.cjs");
13
13
  const heartbeat_loop_cjs_1 = require("./heartbeat-loop.cjs");
14
14
  const claim_loop_cjs_1 = require("./claim-loop.cjs");
15
+ const loop_cjs_1 = require("./conversation/loop.cjs");
16
+ const capability_cjs_1 = require("./conversation/capability.cjs");
15
17
  const session_cjs_1 = require("./session.cjs");
16
18
  const sandbox_cjs_1 = require("./sandbox.cjs");
17
19
  const logger_cjs_1 = require("./logger.cjs");
@@ -246,7 +248,7 @@ async function startCommand(flags) {
246
248
  log.warn(`not advertising browser-use: ${browserProbe.reason}`);
247
249
  }
248
250
  else if (browserProbe.status === "passed") {
249
- log.info("browser probe passed; advertising browser-use");
251
+ log.info(`browser probe passed; advertising browser-use${browserProbe.reason ? ` (${browserProbe.reason})` : ""}`);
250
252
  }
251
253
  const config = {
252
254
  ...configuredConfig,
@@ -267,6 +269,13 @@ async function startCommand(flags) {
267
269
  return (0, session_cjs_1.runSession)({ api, config, shutdownSignal: runtimeShutdown.signal }, claimed, sessionId)
268
270
  .finally(() => activeSessionIds.delete(sessionId));
269
271
  });
272
+ const conversationLoop = new loop_cjs_1.ConversationLoop({
273
+ api,
274
+ machineId: identity.machine_id,
275
+ config,
276
+ capacity,
277
+ shutdownSignal: runtimeShutdown.signal,
278
+ });
270
279
  const bootId = (0, node_crypto_1.randomUUID)();
271
280
  const updateCoordinatorRef = {};
272
281
  let readySent = false;
@@ -282,6 +291,7 @@ async function startCommand(flags) {
282
291
  }
283
292
  }, (status) => {
284
293
  claimLoop.stop();
294
+ conversationLoop.stop();
285
295
  worktreeJanitor.stop();
286
296
  log.error(status === 410
287
297
  ? `agent ${identity.name} has been removed from the fleet; it can no longer claim tasks.`
@@ -298,12 +308,14 @@ async function startCommand(flags) {
298
308
  updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
299
309
  config,
300
310
  claimLoop,
311
+ conversationLoop,
301
312
  capacity,
302
313
  heartbeat,
303
314
  });
304
315
  heartbeat.start();
305
316
  worktreeJanitor.start();
306
317
  claimLoop.start();
318
+ conversationLoop.start();
307
319
  log.info(`navarch-runtime started: machine=${identity.name} agent=${config.agentType} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
308
320
  let shuttingDown = false;
309
321
  const shutdown = () => {
@@ -316,6 +328,7 @@ async function startCommand(flags) {
316
328
  log.info("shutting down: draining active sessions...");
317
329
  heartbeat.setUpdateState("draining");
318
330
  await claimLoop.drain();
331
+ await conversationLoop.drain();
319
332
  await capacity.waitForIdle();
320
333
  worktreeJanitor.stop();
321
334
  heartbeat.stop();
@@ -391,6 +404,8 @@ async function doctorCommand(flags) {
391
404
  console.log(`sandbox_mode: ${config.sandboxMode}`);
392
405
  console.log(`worktree_guard: ${config.worktreeGuard ? "on" : "off"}`);
393
406
  console.log(`runtimes: ${config.runtimes?.join(", ") ?? config.agentType}`);
407
+ const conversation = (0, capability_cjs_1.conversationCapability)(config);
408
+ console.log(`navi_chat: ${conversation.state.toUpperCase()}${conversation.state === "unsupported" ? ` — ${conversation.reason}` : ` (${config.agentType}, read-only)`}`);
394
409
  console.log(`docker: ${dockerOk ? "available" : "NOT AVAILABLE (docker-backed sessions will fail)"}`);
395
410
  const browserProbe = await (0, browser_probe_cjs_1.probeBrowserCapability)(config);
396
411
  console.log(`browser: ${browserProbe.status.toUpperCase()}${browserProbe.reason ? ` — ${browserProbe.reason}` : ""}`);
package/dist/config.cjs CHANGED
@@ -178,6 +178,11 @@ function loadRuntimeConfig(env = process.env) {
178
178
  // by default; operators who would rather share nothing can opt out.
179
179
  reportUsageLimits: !["off", "false", "0"].includes(env.NAVARCH_USAGE_LIMITS ?? ""),
180
180
  browserProbe: !["off", "false", "0"].includes(env.NAVARCH_BROWSER_PROBE ?? ""),
181
+ naviConversations: !["off", "false", "0"].includes(env.NAVARCH_NAVI_CONVERSATIONS ?? ""),
182
+ // The server stops waiting for a capability advertisement after 90 s and
183
+ // asks for a poll at least every 30 s; the floor only bounds control-plane load.
184
+ conversationPollIntervalMs: Math.min(30_000, Math.max(1_000, envInt(env, "NAVARCH_CONVERSATION_POLL_MS", 5_000))),
185
+ conversationTurnTimeoutMs: Math.min(9 * 60_000, envInt(env, "NAVARCH_CONVERSATION_TURN_TIMEOUT_MS", 9 * 60_000)),
181
186
  codexHome: defaultCodexHome(env),
182
187
  claudeConfigPath: defaultClaudeConfigPath(env),
183
188
  };
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.conversationCapability = conversationCapability;
4
+ exports.conversationSupportMatrix = conversationSupportMatrix;
5
+ const index_cjs_1 = require("../adapters/index.cjs");
6
+ /**
7
+ * Whether this runtime can execute a Navi conversation turn, with the reason
8
+ * when it cannot. The runtime polls (and so advertises conversation support
9
+ * to the control plane) only when this returns `supported`.
10
+ *
11
+ * A conversation always runs on the machine's configured primary adapter
12
+ * (`agentType`). An unsupported primary adapter is reported, never replaced
13
+ * by another installed agent: the user selected this runtime for the agent it
14
+ * runs.
15
+ */
16
+ function conversationCapability(config) {
17
+ if (!config.naviConversations) {
18
+ return { state: "unsupported", reason: "Disabled by NAVARCH_NAVI_CONVERSATIONS=off." };
19
+ }
20
+ const adapter = (0, index_cjs_1.selectAdapter)(config.agentType);
21
+ if (!adapter.conversation) {
22
+ return {
23
+ state: "unsupported",
24
+ reason: `The ${config.agentType} adapter has no streamed read-only conversation path.`,
25
+ };
26
+ }
27
+ if (config.sandboxMode !== "host") {
28
+ return {
29
+ state: "unsupported",
30
+ reason: "Conversations run on the host; NAVARCH_SANDBOX_MODE=docker is not supported yet.",
31
+ };
32
+ }
33
+ if (!config.worktreeGuard) {
34
+ return {
35
+ state: "unsupported",
36
+ reason: "Conversations need the worktree read boundary; NAVARCH_WORKTREE_GUARD is off.",
37
+ };
38
+ }
39
+ return { state: "supported" };
40
+ }
41
+ /** Per-adapter support table for docs and diagnostics (host sandbox, guard on, feature enabled). */
42
+ function conversationSupportMatrix() {
43
+ const agents = ["claude-code", "codex", "gemini", "opencode", "acp", "qoder", "pi-local", "pi"];
44
+ return agents.map((agentType) => ({
45
+ agentType,
46
+ capability: conversationCapability({
47
+ agentType,
48
+ sandboxMode: "host",
49
+ worktreeGuard: true,
50
+ naviConversations: true,
51
+ }),
52
+ }));
53
+ }