@sagentlab/navarch-runtime 0.1.34 → 0.1.36

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
@@ -233,6 +233,15 @@ accepted. Rollouts are assigned by the machine's server-managed `stable` or
233
233
  `canary` channel and deterministic rollout percentage. Set
234
234
  `NAVARCH_AUTO_UPDATE=off` to keep telemetry but disable automatic activation.
235
235
 
236
+ Project owners can also request a same-version restart from the project Agents
237
+ page. Only a worker running under `navarch-runtime supervise` advertises this
238
+ capability. The restart command arrives on the normal machine heartbeat; the
239
+ worker stops claiming, reports zero capacity, drains active sessions, and exits
240
+ with a restart-only handoff code. The supervisor respawns the current verified
241
+ binary without staging an update. The control plane acknowledges the one-shot
242
+ command only after a heartbeat reports a different boot ID, preventing a stale
243
+ command from causing a restart loop.
244
+
236
245
  Plain `navarch-runtime start` continues to work and reports available releases,
237
246
  but deliberately does not activate them because no parent process would exist
238
247
  to perform a safe handoff or rollback.
@@ -3,7 +3,8 @@ 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.piAdapter = exports.DEFAULT_HOSTED_PI_MODEL = exports.HOSTED_PI_MODELS = exports.DEEPSEEK_API_KEY_ENV = exports.PI_PROVIDER_NAME = void 0;
6
+ exports.piAdapter = exports.HOSTED_PI_MAX_PROVIDER_TURNS = exports.DEFAULT_HOSTED_PI_MODEL = exports.HOSTED_PI_MODELS = exports.DEEPSEEK_API_KEY_ENV = exports.PI_PROVIDER_NAME = void 0;
7
+ exports.buildHostedPiBudgetExtension = buildHostedPiBudgetExtension;
7
8
  exports.resolveHostedPiModel = resolveHostedPiModel;
8
9
  exports.buildPiProviderConfig = buildPiProviderConfig;
9
10
  exports.parsePiJsonEvents = parsePiJsonEvents;
@@ -27,9 +28,9 @@ const node_path_1 = __importDefault(require("node:path"));
27
28
  * shared parsing/provider-config contract.
28
29
  *
29
30
  * Secret hygiene: the generated models.json references the DeepSeek key ONLY
30
- * as a `$DEEPSEEK_API_KEY` environment interpolation (Pi expands it from the
31
- * child environment at load time). The literal key never appears in argv, in
32
- * the config file, or in any file that survives cleanup. A missing
31
+ * as the `DEEPSEEK_API_KEY` environment-variable name (Pi resolves it from
32
+ * the child environment at request time). The literal key never appears in
33
+ * argv, in the config file, or in any file that survives cleanup. A missing
33
34
  * DEEPSEEK_API_KEY in the session environment is a hard configuration
34
35
  * failure — the adapter refuses to launch rather than fall back to an
35
36
  * ambient credential.
@@ -47,6 +48,17 @@ exports.PI_PROVIDER_NAME = "deepseek";
47
48
  exports.DEEPSEEK_API_KEY_ENV = "DEEPSEEK_API_KEY";
48
49
  exports.HOSTED_PI_MODELS = ["deepseek-v4-flash", "deepseek-v4-pro"];
49
50
  exports.DEFAULT_HOSTED_PI_MODEL = "deepseek-v4-flash";
51
+ exports.HOSTED_PI_MAX_PROVIDER_TURNS = 3;
52
+ function buildHostedPiBudgetExtension() {
53
+ return `export default function navarchHostedPiBudget(pi) {
54
+ let providerTurns = 0;
55
+ pi.on("turn_start", (_event, ctx) => {
56
+ providerTurns += 1;
57
+ if (providerTurns > ${exports.HOSTED_PI_MAX_PROVIDER_TURNS}) ctx.abort();
58
+ });
59
+ }
60
+ `;
61
+ }
50
62
  function resolveHostedPiModel(configured) {
51
63
  const trimmed = (configured ?? "").trim();
52
64
  const bare = trimmed.startsWith(`${exports.PI_PROVIDER_NAME}/`)
@@ -88,7 +100,7 @@ function buildPiProviderConfig() {
88
100
  [exports.PI_PROVIDER_NAME]: {
89
101
  baseUrl: "https://api.deepseek.com",
90
102
  api: "openai-completions",
91
- apiKey: `$${exports.DEEPSEEK_API_KEY_ENV}`,
103
+ apiKey: exports.DEEPSEEK_API_KEY_ENV,
92
104
  models,
93
105
  },
94
106
  },
@@ -195,6 +207,13 @@ function attachPiOutput(result) {
195
207
  // Invocation
196
208
  // ---------------------------------------------------------------------------
197
209
  const CONTAINER_PI_DIR = "/tmp/navarch-pi-agent";
210
+ const BUDGET_EXTENSION_NAME = "navarch-budget.js";
211
+ function withBudgetExtension(args, extensionPath) {
212
+ const promptIndex = args.indexOf("-p");
213
+ if (promptIndex < 0)
214
+ return [...args, "--extension", extensionPath];
215
+ return [...args.slice(0, promptIndex), "--extension", extensionPath, ...args.slice(promptIndex)];
216
+ }
198
217
  function hasArg(args, ...flags) {
199
218
  return args.some((arg) => flags.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
200
219
  }
@@ -205,7 +224,7 @@ async function runPiAdapter(options) {
205
224
  throw new Error(`pi adapter: ${exports.DEEPSEEK_API_KEY_ENV} is missing from the session environment; ` +
206
225
  "the managed provider credential must be injected by the hosted runner");
207
226
  }
208
- const args = ["--mode", "json"];
227
+ const args = ["--mode", "json", "--no-extensions"];
209
228
  if (!hasArg(options.extraArgs, "--no-session")) {
210
229
  // Ephemeral by default: session persistence would leave conversation
211
230
  // files (which may quote repository content) behind after cleanup.
@@ -215,9 +234,9 @@ async function runPiAdapter(options) {
215
234
  const model = resolveHostedPiModel(options.model);
216
235
  args.push("--model", `${exports.PI_PROVIDER_NAME}/${model}`);
217
236
  if (options.reasoningEffort) {
218
- // Pi's --thinking accepts off|minimal|low|medium|high|xhigh|max; the
219
- // resolved efforts are a subset, so they map directly.
220
- args.push("--thinking", options.reasoningEffort);
237
+ // Pi 0.73.1 tops out at xhigh. Navarch's max effort must clamp rather
238
+ // than passing an invalid value that Pi warns about and ignores.
239
+ args.push("--thinking", options.reasoningEffort === "max" ? "xhigh" : options.reasoningEffort);
221
240
  }
222
241
  args.push("-p", options.prompt);
223
242
  const raw = options.dockerExec
@@ -230,14 +249,19 @@ async function runOnHost(options, args) {
230
249
  // reads) an operator's ~/.pi configuration/credentials.
231
250
  const agentDir = await node_fs_1.promises.mkdtemp(node_path_1.default.join(node_os_1.default.tmpdir(), "navarch-pi-agent-"));
232
251
  await node_fs_1.promises.writeFile(node_path_1.default.join(agentDir, "models.json"), buildPiProviderConfig(), { mode: 0o600 });
252
+ const budgetExtensionPath = node_path_1.default.join(agentDir, BUDGET_EXTENSION_NAME);
253
+ await node_fs_1.promises.writeFile(budgetExtensionPath, buildHostedPiBudgetExtension(), {
254
+ mode: 0o600,
255
+ });
233
256
  const env = { ...options.env, PI_CODING_AGENT_DIR: agentDir };
257
+ const boundedArgs = withBudgetExtension(args, budgetExtensionPath);
234
258
  try {
235
259
  return await new Promise((resolve) => {
236
260
  let stdout = "";
237
261
  let stderr = "";
238
262
  let timedOut = false;
239
263
  let killedByLeaseLoss = false;
240
- const child = (0, node_child_process_1.spawn)(options.bin, args, {
264
+ const child = (0, node_child_process_1.spawn)(options.bin, boundedArgs, {
241
265
  cwd: options.cwd,
242
266
  env: { ...process.env, ...env },
243
267
  });
@@ -261,12 +285,24 @@ async function runOnHost(options, args) {
261
285
  clearTimeout(timer);
262
286
  options.signal?.removeEventListener("abort", onAbort);
263
287
  stderr += `\n${String(err)}`;
264
- resolve({ exitCode: null, timedOut, killedByLeaseLoss, stdout, stderr });
288
+ resolve({
289
+ exitCode: null,
290
+ timedOut,
291
+ killedByLeaseLoss,
292
+ stdout,
293
+ stderr,
294
+ });
265
295
  });
266
296
  child.on("close", (code) => {
267
297
  clearTimeout(timer);
268
298
  options.signal?.removeEventListener("abort", onAbort);
269
- resolve({ exitCode: code, timedOut, killedByLeaseLoss, stdout, stderr });
299
+ resolve({
300
+ exitCode: code,
301
+ timedOut,
302
+ killedByLeaseLoss,
303
+ stdout,
304
+ stderr,
305
+ });
270
306
  });
271
307
  });
272
308
  }
@@ -276,12 +312,19 @@ async function runOnHost(options, args) {
276
312
  }
277
313
  async function runViaDocker(options, args) {
278
314
  const { containerName, runner } = options.dockerExec;
279
- // The provider config carries no secret values (only the $DEEPSEEK_API_KEY
315
+ // The provider config carries no secret values (only the DEEPSEEK_API_KEY
280
316
  // env reference), so materializing it through the container shell is safe.
281
317
  const command = `[ -f /tmp/session.env ] && . /tmp/session.env; cd repo 2>/dev/null; ` +
282
318
  `mkdir -p ${CONTAINER_PI_DIR} && cat > ${CONTAINER_PI_DIR}/models.json <<'NAVARCH_PI_EOF'\n` +
283
319
  `${buildPiProviderConfig()}NAVARCH_PI_EOF\n` +
284
- `PI_CODING_AGENT_DIR=${CONTAINER_PI_DIR} ${[options.bin, ...args].map(shellQuote).join(" ")}`;
320
+ `cat > ${CONTAINER_PI_DIR}/${BUDGET_EXTENSION_NAME} <<'NAVARCH_PI_BUDGET_EOF'\n` +
321
+ `${buildHostedPiBudgetExtension()}NAVARCH_PI_BUDGET_EOF\n` +
322
+ `PI_CODING_AGENT_DIR=${CONTAINER_PI_DIR} ${[
323
+ options.bin,
324
+ ...withBudgetExtension(args, `${CONTAINER_PI_DIR}/${BUDGET_EXTENSION_NAME}`),
325
+ ]
326
+ .map(shellQuote)
327
+ .join(" ")}`;
285
328
  let killedByLeaseLoss = false;
286
329
  const onAbort = () => {
287
330
  killedByLeaseLoss = true;
@@ -290,7 +333,10 @@ async function runViaDocker(options, args) {
290
333
  options.signal?.addEventListener("abort", onAbort, { once: true });
291
334
  try {
292
335
  const forwardedEnv = Object.keys(options.env).flatMap((name) => ["--env", name]);
293
- const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], { timeoutMs: options.timeoutMs, env: { ...process.env, ...options.env } });
336
+ const result = await runner.run("docker", ["exec", ...forwardedEnv, containerName, "sh", "-c", command], {
337
+ timeoutMs: options.timeoutMs,
338
+ env: { ...process.env, ...options.env },
339
+ });
294
340
  return {
295
341
  exitCode: result.code,
296
342
  timedOut: false,
package/dist/cli.cjs CHANGED
@@ -207,7 +207,12 @@ async function startCommand(flags) {
207
207
  const bootId = (0, node_crypto_1.randomUUID)();
208
208
  const updateCoordinatorRef = {};
209
209
  let readySent = false;
210
- const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity, bootId, (result) => updateCoordinatorRef.current?.consider(result.update), () => {
210
+ const heartbeat = new heartbeat_loop_cjs_1.MachineHeartbeatLoop(api, identity.machine_id, config, capacity, bootId, (result) => {
211
+ if (result.restart)
212
+ updateCoordinatorRef.current?.considerRestart(result.restart);
213
+ else
214
+ updateCoordinatorRef.current?.consider(result.update);
215
+ }, () => {
211
216
  if (!readySent && process.send) {
212
217
  readySent = true;
213
218
  process.send({ type: "navarch-ready", boot_id: bootId });
package/dist/config.cjs CHANGED
@@ -103,6 +103,7 @@ function loadRuntimeConfig(env = process.env) {
103
103
  .map((s) => s.trim())
104
104
  .filter(Boolean),
105
105
  updateChannel: env.NAVARCH_UPDATE_CHANNEL === "canary" ? "canary" : "stable",
106
+ supervised: env.NAVARCH_SUPERVISED === "1",
106
107
  autoUpdate: env.NAVARCH_SUPERVISED === "1" &&
107
108
  !["off", "false", "0"].includes(env.NAVARCH_AUTO_UPDATE ?? ""),
108
109
  // Reads only the quota numbers providers already write to disk, so it is on
@@ -70,6 +70,8 @@ class MachineHeartbeatLoop {
70
70
  ? await (0, usage_limits_cjs_1.collectAgentUsage)({
71
71
  codexHome: this.config.codexHome,
72
72
  claudeConfigPath: this.config.claudeConfigPath,
73
+ providers: [...new Set([this.config.agentType, ...(this.config.runtimes ?? [])])]
74
+ .filter((runtime) => runtime === "codex" || runtime === "claude-code"),
73
75
  })
74
76
  : null;
75
77
  const result = await this.api.machineHeartbeat(this.machineId, {
@@ -82,6 +84,7 @@ class MachineHeartbeatLoop {
82
84
  channel: this.config.updateChannel,
83
85
  state: this.updateState,
84
86
  boot_id: this.bootId,
87
+ supervised: this.config.supervised,
85
88
  ...(this.lastUpdateError ? { last_update_error: this.lastUpdateError } : {}),
86
89
  },
87
90
  });
@@ -1,11 +1,13 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.REMOTE_RESTART_EXIT_CODE = void 0;
3
4
  exports.superviseRuntime = superviseRuntime;
4
5
  const node_child_process_1 = require("node:child_process");
5
6
  const logger_cjs_1 = require("./logger.cjs");
6
7
  const update_installer_cjs_1 = require("./update-installer.cjs");
7
8
  const log = (0, logger_cjs_1.createLogger)("supervisor");
8
9
  const UPDATE_RESTART_EXIT_CODE = 75;
10
+ exports.REMOTE_RESTART_EXIT_CODE = 76;
9
11
  function runWorker(binPath, args, healthTimeoutMs, workerEnv, setCurrentChild, onHealthy) {
10
12
  return new Promise((resolve) => {
11
13
  const child = (0, node_child_process_1.spawn)(process.execPath, [binPath, "start", ...args], {
@@ -126,6 +128,10 @@ async function superviseRuntime(configDir, initialBinPath, workerArgs = [], heal
126
128
  await (0, update_installer_cjs_1.clearPendingUpdate)(configDir);
127
129
  continue;
128
130
  }
131
+ if (result.code === exports.REMOTE_RESTART_EXIT_CODE) {
132
+ log.info("remote restart requested; respawning current runtime");
133
+ continue;
134
+ }
129
135
  if (result.code !== UPDATE_RESTART_EXIT_CODE)
130
136
  return result.code;
131
137
  const pending = await (0, update_installer_cjs_1.readPendingUpdate)(configDir);
@@ -4,16 +4,18 @@ exports.RuntimeUpdateCoordinator = void 0;
4
4
  const logger_cjs_1 = require("./logger.cjs");
5
5
  const update_installer_cjs_1 = require("./update-installer.cjs");
6
6
  const version_cjs_1 = require("./version.cjs");
7
+ const supervisor_cjs_1 = require("./supervisor.cjs");
7
8
  const log = (0, logger_cjs_1.createLogger)("update");
8
9
  class RuntimeUpdateCoordinator {
9
10
  options;
10
11
  targetInProgress = null;
12
+ restartInProgress = null;
11
13
  warnedUnsupervised = false;
12
14
  constructor(options) {
13
15
  this.options = options;
14
16
  }
15
17
  consider(directive) {
16
- if (!directive || directive.target_version === version_cjs_1.RUNTIME_VERSION)
18
+ if (!directive || directive.target_version === version_cjs_1.RUNTIME_VERSION || this.restartInProgress)
17
19
  return;
18
20
  if (!this.options.config.autoUpdate) {
19
21
  if (!this.warnedUnsupervised) {
@@ -27,6 +29,34 @@ class RuntimeUpdateCoordinator {
27
29
  this.targetInProgress = directive.target_version;
28
30
  void this.apply(directive);
29
31
  }
32
+ considerRestart(directive) {
33
+ if (!directive || this.targetInProgress || this.restartInProgress)
34
+ return;
35
+ if (!this.options.config.supervised) {
36
+ log.warn(`ignoring remote restart ${directive.command_id}: runtime is not supervised`);
37
+ return;
38
+ }
39
+ this.restartInProgress = directive.command_id;
40
+ void this.applyRestart(directive);
41
+ }
42
+ async applyRestart(directive) {
43
+ const { heartbeat, claimLoop, capacity } = this.options;
44
+ try {
45
+ heartbeat.setUpdateState("draining");
46
+ log.info(`remote restart ${directive.command_id} received; draining active sessions`);
47
+ await claimLoop.drain();
48
+ await capacity.waitForIdle();
49
+ log.info(`remote restart ${directive.command_id} ready; handing off to supervisor`);
50
+ (this.options.requestRestart ?? ((code) => process.exit(code)))(supervisor_cjs_1.REMOTE_RESTART_EXIT_CODE);
51
+ }
52
+ catch (error) {
53
+ const message = error instanceof Error ? error.message : String(error);
54
+ log.error(`remote restart failed: ${message}`);
55
+ heartbeat.setUpdateState("failed", message.slice(0, 500));
56
+ claimLoop.start();
57
+ this.restartInProgress = null;
58
+ }
59
+ }
30
60
  async apply(directive) {
31
61
  const { config, heartbeat, claimLoop, capacity } = this.options;
32
62
  try {
@@ -359,7 +359,9 @@ async function collectClaudeUsage(options) {
359
359
  }
360
360
  const DEFAULT_COLLECT_TIMEOUT_MS = 5_000;
361
361
  /**
362
- * Gathers every provider report available on this machine. Providers are read
362
+ * Gathers reports for the providers this runtime actually advertises. Provider
363
+ * CLIs can share a host, so scanning every state directory would attribute a
364
+ * different agent's quota to this machine. Selected providers are still read
363
365
  * independently so one unreadable or malformed source cannot suppress another,
364
366
  * and any thrown error degrades to "no report" rather than failing the caller.
365
367
  */
@@ -372,10 +374,15 @@ async function collectAgentUsage(options) {
372
374
  timer.unref?.();
373
375
  });
374
376
  try {
375
- const collection = Promise.allSettled([
376
- collectCodexUsage({ codexHome: options.codexHome, now: options.now }),
377
- collectClaudeUsage({ configPath: options.claudeConfigPath, now: options.now }),
378
- ]);
377
+ const providers = new Set(options.providers ?? ["codex", "claude-code"]);
378
+ const collectors = [];
379
+ if (providers.has("codex")) {
380
+ collectors.push(collectCodexUsage({ codexHome: options.codexHome, now: options.now }));
381
+ }
382
+ if (providers.has("claude-code")) {
383
+ collectors.push(collectClaudeUsage({ configPath: options.claudeConfigPath, now: options.now }));
384
+ }
385
+ const collection = Promise.allSettled(collectors);
379
386
  const settled = await Promise.race([collection, deadline]);
380
387
  if (!settled) {
381
388
  log.warn(`usage collection timed out after ${timeoutMs}ms`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sagentlab/navarch-runtime",
3
- "version": "0.1.34",
3
+ "version": "0.1.36",
4
4
  "description": "Navarch machine-side session manager: claims delivery tasks and runs them through Claude Code, Codex, Gemini, or OpenCode.",
5
5
  "type": "commonjs",
6
6
  "license": "MIT",