@sagentlab/navarch-runtime 0.1.12 → 0.1.14
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 +8 -2
- package/dist/adapter-capacity.cjs +75 -0
- package/dist/adapters/claude.cjs +15 -0
- package/dist/adapters/codex.cjs +4 -2
- package/dist/claim-loop.cjs +9 -1
- package/dist/cli.cjs +15 -1
- package/dist/config.cjs +6 -1
- package/dist/exit-conditions.cjs +46 -14
- package/dist/session.cjs +13 -3
- package/dist/worktree-guard.cjs +37 -0
- package/dist/worktree-janitor.cjs +173 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -252,15 +252,17 @@ unchanged across the deployment.
|
|
|
252
252
|
| `NAVARCH_HEARTBEAT_INTERVAL_MS` | `60000` | Machine-level heartbeat interval. |
|
|
253
253
|
| `NAVARCH_LEASE_HEARTBEAT_INTERVAL_MS` | `300000` | Per-lease heartbeat interval; must stay well under the 15-minute lease TTL (schema-design.md §4). |
|
|
254
254
|
| `NAVARCH_SESSION_TIMEOUT_MS` | `2700000` (45 min) | Hard kill timeout for a single session. |
|
|
255
|
+
| `NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS` | `3600000` (1 hour) | How often the runtime scans for session worktrees abandoned by a hard exit or host restart. |
|
|
256
|
+
| `NAVARCH_WORKTREE_STALE_AFTER_MS` | `86400000` (24 hours) | Minimum inactivity age before an abandoned session worktree is removed. Active sessions are always protected. |
|
|
255
257
|
| `NAVARCH_GIT_AUTHOR_NAME` / `NAVARCH_GIT_AUTHOR_EMAIL` | `sagentlab` / `z@sagentlab.com` | Git identity forced into session commits so host-level personal config is not inherited; override both for a project-authorized bot. |
|
|
256
258
|
| `NAVARCH_SANDBOX_MODE` | `host` | `host` uses the resources already available to the agent process. Set `docker` explicitly for container isolation. |
|
|
257
|
-
| `NAVARCH_DOCKER_IMAGE` | `
|
|
259
|
+
| `NAVARCH_DOCKER_IMAGE` | `ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0` | Version-pinned per-session image with Node 20, git, GitHub CLI, ripgrep, jq, SSH, and Claude Code 2.1.218. Override with an image tag or digest you control. |
|
|
258
260
|
| `NAVARCH_AGENT` | saved choice, then `claude-code` | Local choice of agent CLI: `claude-code`, `codex`, or `gemini`. Overrides the choice saved by `connect`/`register`; `start --agent` has highest priority. |
|
|
259
261
|
| `NAVARCH_RUNTIMES` | selected `NAVARCH_AGENT` | Comma list of installed/authenticated adapters advertised to dispatch. The control plane chooses among these per project/task. |
|
|
260
262
|
| `NAVARCH_UPDATE_CHANNEL` | `stable` | Release channel advertised by the worker (`stable` or `canary`); the server-managed machine channel remains authoritative. |
|
|
261
263
|
| `NAVARCH_AUTO_UPDATE` | on under `supervise` | Set `off`, `false`, or `0` to report releases without staging or activating them. Automatic activation is always off under plain `start`. |
|
|
262
264
|
| `NAVARCH_CLAUDE_BIN` | `claude` | Path/name of the Claude Code CLI binary. |
|
|
263
|
-
| `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. |
|
|
265
|
+
| `NAVARCH_CLAUDE_EXTRA_ARGS` | — | Comma list of extra CLI args appended after `--mcp-config` (Claude Code). `--allowedTools`/`--disallowedTools` layer rules onto auto mode; an explicit `--permission-mode`, `--permission-prompt-tool`, or bypass flag replaces the unattended default `--permission-mode auto`. Runtime sessions default to an empty `--setting-sources` list so machine/user/project hooks cannot leak into temporary checkouts. The generated host-mode settings retain only the user-level `apiKeyHelper` needed for authentication; supply `--setting-sources=<sources>` here to opt into other settings deliberately. |
|
|
264
266
|
| `NAVARCH_CODEX_BIN` | `codex` | Path/name of the Codex CLI binary. |
|
|
265
267
|
| `NAVARCH_CODEX_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the generated MCP `-c` overrides and `--json` (Codex). |
|
|
266
268
|
| `NAVARCH_GEMINI_BIN` | `gemini` | Path/name of the Google Gemini CLI binary. |
|
|
@@ -280,6 +282,10 @@ supported coding agents, using each CLI's native enforcement point:
|
|
|
280
282
|
does not blanket-preapprove the lease-scoped Navarch MCP tools. A generated
|
|
281
283
|
settings file (`src/worktree-guard.cts`, passed as `--settings`) also installs
|
|
282
284
|
`bin/worktree-guard-hook.cjs` as a fail-closed `PreToolUse` boundary hook.
|
|
285
|
+
User, project, and local settings sources are disabled by default, preventing
|
|
286
|
+
host-only hooks and plugins from leaking into unattended sessions; the
|
|
287
|
+
explicit generated settings file remains active and carries forward only a
|
|
288
|
+
user-level `apiKeyHelper` when the machine uses one for authentication.
|
|
283
289
|
- **Codex:** the runtime passes a one-off native permission profile with
|
|
284
290
|
`approval_policy="on-request"` and `approvals_reviewer="auto_review"`.
|
|
285
291
|
Codex's OS sandbox grants read/write access only to the allowed roots and
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.detectAdapterCapacityLimit = detectAdapterCapacityLimit;
|
|
4
|
+
const exit_conditions_cjs_1 = require("./exit-conditions.cjs");
|
|
5
|
+
const DEFAULT_CAPACITY_COOLDOWN_MS = 15 * 60 * 1000;
|
|
6
|
+
const RESET_GRACE_MS = 30 * 1000;
|
|
7
|
+
const MAX_RESET_SEARCH_MINUTES = 48 * 60;
|
|
8
|
+
/**
|
|
9
|
+
* Detects a provider-side capacity response that applies beyond one task
|
|
10
|
+
* lease. Claude Code reports account session exhaustion as a successful CLI
|
|
11
|
+
* process containing a structured 429 result, so process exit status alone
|
|
12
|
+
* cannot distinguish it from an ordinary failed task.
|
|
13
|
+
*/
|
|
14
|
+
function detectAdapterCapacityLimit(result, nowMs = Date.now()) {
|
|
15
|
+
const parsed = (0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stdout) ??
|
|
16
|
+
(0, exit_conditions_cjs_1.parseClaudeJsonResult)(result.stderr);
|
|
17
|
+
if (parsed?.api_error_status !== 429)
|
|
18
|
+
return null;
|
|
19
|
+
const detail = parsed.result ?? "";
|
|
20
|
+
if (!/\b(?:session|usage|rate)\s+limit\b|\btoo many requests\b/i.test(detail)) {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
retryAtMs: parseResetTime(detail, nowMs) ??
|
|
25
|
+
nowMs + DEFAULT_CAPACITY_COOLDOWN_MS,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Resolves messages such as "resets 9:50pm (America/New_York)" without
|
|
30
|
+
* assuming the runtime host uses the provider's timezone. Searching minute
|
|
31
|
+
* boundaries also handles UTC offsets and daylight-saving transitions using
|
|
32
|
+
* the platform's IANA timezone database.
|
|
33
|
+
*/
|
|
34
|
+
function parseResetTime(detail, nowMs) {
|
|
35
|
+
const match = detail.match(/\bresets?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)\s*\(([^)]+)\)/i);
|
|
36
|
+
if (!match)
|
|
37
|
+
return null;
|
|
38
|
+
const hour12 = Number(match[1]);
|
|
39
|
+
const minute = Number(match[2] ?? "0");
|
|
40
|
+
const meridiem = match[3]?.toLowerCase();
|
|
41
|
+
const timeZone = match[4]?.trim();
|
|
42
|
+
if (!Number.isInteger(hour12) ||
|
|
43
|
+
hour12 < 1 ||
|
|
44
|
+
hour12 > 12 ||
|
|
45
|
+
!Number.isInteger(minute) ||
|
|
46
|
+
minute < 0 ||
|
|
47
|
+
minute > 59 ||
|
|
48
|
+
!timeZone) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const targetHour = hour12 % 12 + (meridiem === "pm" ? 12 : 0);
|
|
52
|
+
let formatter;
|
|
53
|
+
try {
|
|
54
|
+
formatter = new Intl.DateTimeFormat("en-US", {
|
|
55
|
+
timeZone,
|
|
56
|
+
hour: "2-digit",
|
|
57
|
+
minute: "2-digit",
|
|
58
|
+
hourCycle: "h23",
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
const firstMinuteMs = Math.ceil((nowMs + 1) / 60_000) * 60_000;
|
|
65
|
+
for (let offset = 0; offset < MAX_RESET_SEARCH_MINUTES; offset += 1) {
|
|
66
|
+
const candidateMs = firstMinuteMs + offset * 60_000;
|
|
67
|
+
const parts = formatter.formatToParts(new Date(candidateMs));
|
|
68
|
+
const hour = Number(parts.find((part) => part.type === "hour")?.value);
|
|
69
|
+
const candidateMinute = Number(parts.find((part) => part.type === "minute")?.value);
|
|
70
|
+
if (hour === targetHour && candidateMinute === minute) {
|
|
71
|
+
return candidateMs + RESET_GRACE_MS;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
package/dist/adapters/claude.cjs
CHANGED
|
@@ -26,7 +26,22 @@ const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
|
|
|
26
26
|
*/
|
|
27
27
|
async function runClaudeCodeAdapter(options) {
|
|
28
28
|
const args = ["-p", options.prompt];
|
|
29
|
+
const hasSettingSources = options.extraArgs.some((arg) => arg === "--setting-sources" || arg.startsWith("--setting-sources="));
|
|
29
30
|
const hasExplicitPermissionMode = options.extraArgs.some((arg) => ["--permission-mode", "--permission-prompt-tool", "--dangerously-skip-permissions"].some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
|
|
31
|
+
// Runtime sessions must not inherit an operator's personal or project-local
|
|
32
|
+
// Claude hooks. Apart from making execution machine-dependent, those hooks
|
|
33
|
+
// commonly reference helper files through CLAUDE_PROJECT_DIR; that variable
|
|
34
|
+
// points at the temporary session checkout, where a host-only helper does
|
|
35
|
+
// not exist, and a failing SessionEnd hook turns an otherwise successful
|
|
36
|
+
// task into an adapter failure.
|
|
37
|
+
//
|
|
38
|
+
// An empty source list disables user/project/local settings while preserving
|
|
39
|
+
// the explicit --settings file below ("flagSettings" in Claude Code), so the
|
|
40
|
+
// generated worktree-guard hook remains active. Operators can deliberately
|
|
41
|
+
// opt sources back in through NAVARCH_CLAUDE_EXTRA_ARGS.
|
|
42
|
+
if (!hasSettingSources) {
|
|
43
|
+
args.push("--setting-sources", "");
|
|
44
|
+
}
|
|
30
45
|
// Navarch sessions are unattended, so route permission decisions through
|
|
31
46
|
// Claude Code's native auto-mode classifier instead of prompting a human or
|
|
32
47
|
// bypassing checks. Operators can replace this with a different permission
|
package/dist/adapters/codex.cjs
CHANGED
|
@@ -149,8 +149,10 @@ function attachUsage(result) {
|
|
|
149
149
|
const reportText = (0, exit_conditions_cjs_1.extractFinalMessageFromCodexEvents)(events) ?? undefined;
|
|
150
150
|
return {
|
|
151
151
|
...result,
|
|
152
|
-
|
|
153
|
-
|
|
152
|
+
// A run killed before its first usage event has unknown usage, not a
|
|
153
|
+
// measured zero — leave the fields unset so aggregation skips them.
|
|
154
|
+
...(usage.tokensIn !== undefined ? { tokensIn: usage.tokensIn } : {}),
|
|
155
|
+
...(usage.tokensOut !== undefined ? { tokensOut: usage.tokensOut } : {}),
|
|
154
156
|
...(usage.costUsd !== undefined ? { costUsd: usage.costUsd } : {}),
|
|
155
157
|
...(reportText !== undefined ? { reportText } : {}),
|
|
156
158
|
};
|
package/dist/claim-loop.cjs
CHANGED
|
@@ -23,6 +23,7 @@ class ClaimLoop {
|
|
|
23
23
|
claimInFlight = false;
|
|
24
24
|
consecutiveFailures = 0;
|
|
25
25
|
nextClaimAt = 0;
|
|
26
|
+
capacityCooldownUntil = 0;
|
|
26
27
|
quiescenceWaiters = new Set();
|
|
27
28
|
constructor(api, config, capacity, runSession) {
|
|
28
29
|
this.api = api;
|
|
@@ -60,7 +61,7 @@ class ClaimLoop {
|
|
|
60
61
|
if (this.stopped ||
|
|
61
62
|
this.claimInFlight ||
|
|
62
63
|
!this.capacity.hasCapacity() ||
|
|
63
|
-
Date.now() < this.nextClaimAt)
|
|
64
|
+
Date.now() < Math.max(this.nextClaimAt, this.capacityCooldownUntil))
|
|
64
65
|
return;
|
|
65
66
|
this.claimInFlight = true;
|
|
66
67
|
try {
|
|
@@ -85,6 +86,13 @@ class ClaimLoop {
|
|
|
85
86
|
this.capacity.acquire(claimed.lease_id);
|
|
86
87
|
log.info(`claimed task ${claimed.task.id} (${claimed.task.task_type}) as lease ${claimed.lease_id}, session ${sessionId}`);
|
|
87
88
|
this.runSession(claimed, sessionId)
|
|
89
|
+
.then((outcome) => {
|
|
90
|
+
const cooldownUntil = outcome?.claimCooldownUntil;
|
|
91
|
+
if (!cooldownUntil || cooldownUntil <= Date.now())
|
|
92
|
+
return;
|
|
93
|
+
this.capacityCooldownUntil = Math.max(this.capacityCooldownUntil, cooldownUntil);
|
|
94
|
+
log.warn(`adapter capacity exhausted; pausing new claims until ${new Date(this.capacityCooldownUntil).toISOString()}`);
|
|
95
|
+
})
|
|
88
96
|
.catch((err) => log.error(`session ${sessionId} failed: ${String(err)}`))
|
|
89
97
|
.finally(() => this.capacity.release(claimed.lease_id));
|
|
90
98
|
}
|
package/dist/cli.cjs
CHANGED
|
@@ -17,6 +17,7 @@ const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
|
17
17
|
const logger_cjs_1 = require("./logger.cjs");
|
|
18
18
|
const update_coordinator_cjs_1 = require("./update-coordinator.cjs");
|
|
19
19
|
const supervisor_cjs_1 = require("./supervisor.cjs");
|
|
20
|
+
const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
|
|
20
21
|
const log = (0, logger_cjs_1.createLogger)("cli");
|
|
21
22
|
const PACKAGE_NAME = "@sagentlab/navarch-runtime";
|
|
22
23
|
/** Include a control-plane response's safe error detail in top-level CLI failures. */
|
|
@@ -191,7 +192,18 @@ async function startCommand(flags) {
|
|
|
191
192
|
const config = { ...baseConfig, agentType };
|
|
192
193
|
const api = new api_cjs_1.NavarchApiClient({ baseUrl: identity.api_base, token: identity.token });
|
|
193
194
|
const capacity = new capacity_cjs_1.CapacityTracker(config.maxSessions);
|
|
194
|
-
const
|
|
195
|
+
const activeSessionIds = new Set();
|
|
196
|
+
const worktreeJanitor = new worktree_janitor_cjs_1.WorktreeJanitor({
|
|
197
|
+
workspaceRoot: config.workspaceRoot,
|
|
198
|
+
intervalMs: config.worktreeCleanupIntervalMs,
|
|
199
|
+
staleAfterMs: config.worktreeStaleAfterMs,
|
|
200
|
+
isSessionActive: (sessionId) => activeSessionIds.has(sessionId),
|
|
201
|
+
});
|
|
202
|
+
const claimLoop = new claim_loop_cjs_1.ClaimLoop(api, config, capacity, (claimed, sessionId) => {
|
|
203
|
+
activeSessionIds.add(sessionId);
|
|
204
|
+
return (0, session_cjs_1.runSession)({ api, config }, claimed, sessionId)
|
|
205
|
+
.finally(() => activeSessionIds.delete(sessionId));
|
|
206
|
+
});
|
|
195
207
|
const bootId = (0, node_crypto_1.randomUUID)();
|
|
196
208
|
const updateCoordinatorRef = {};
|
|
197
209
|
let readySent = false;
|
|
@@ -208,6 +220,7 @@ async function startCommand(flags) {
|
|
|
208
220
|
heartbeat,
|
|
209
221
|
});
|
|
210
222
|
heartbeat.start();
|
|
223
|
+
worktreeJanitor.start();
|
|
211
224
|
claimLoop.start();
|
|
212
225
|
log.info(`navarch-runtime started: machine=${identity.name} agent=${config.agentType} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
|
|
213
226
|
let shuttingDown = false;
|
|
@@ -222,6 +235,7 @@ async function startCommand(flags) {
|
|
|
222
235
|
heartbeat.setUpdateState("draining");
|
|
223
236
|
await claimLoop.drain();
|
|
224
237
|
await capacity.waitForIdle();
|
|
238
|
+
worktreeJanitor.stop();
|
|
225
239
|
heartbeat.stop();
|
|
226
240
|
log.info("shutdown drain complete");
|
|
227
241
|
process.exit(0);
|
package/dist/config.cjs
CHANGED
|
@@ -3,10 +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.DEFAULT_SANDBOX_IMAGE = void 0;
|
|
6
7
|
exports.isRuntimeAgentType = isRuntimeAgentType;
|
|
7
8
|
exports.loadRuntimeConfig = loadRuntimeConfig;
|
|
8
9
|
const node_path_1 = __importDefault(require("node:path"));
|
|
9
10
|
const node_os_1 = __importDefault(require("node:os"));
|
|
11
|
+
/** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
|
|
12
|
+
exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
|
|
10
13
|
function isRuntimeAgentType(value) {
|
|
11
14
|
return value === "claude-code" || value === "codex" || value === "gemini";
|
|
12
15
|
}
|
|
@@ -54,6 +57,8 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
54
57
|
// interval must stay comfortably under that TTL.
|
|
55
58
|
leaseHeartbeatIntervalMs,
|
|
56
59
|
sessionTimeoutMs: envInt(env, "NAVARCH_SESSION_TIMEOUT_MS", 45 * 60 * 1000),
|
|
60
|
+
worktreeCleanupIntervalMs: envInt(env, "NAVARCH_WORKTREE_CLEANUP_INTERVAL_MS", 60 * 60 * 1000),
|
|
61
|
+
worktreeStaleAfterMs: envInt(env, "NAVARCH_WORKTREE_STALE_AFTER_MS", 24 * 60 * 60 * 1000),
|
|
57
62
|
agentType,
|
|
58
63
|
runtimes: runtimes.length > 0 ? runtimes : [agentType],
|
|
59
64
|
claudeBin: env.NAVARCH_CLAUDE_BIN ?? "claude",
|
|
@@ -66,7 +71,7 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
66
71
|
gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
|
|
67
72
|
mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
|
|
68
73
|
sandboxMode,
|
|
69
|
-
dockerImage: env.NAVARCH_DOCKER_IMAGE ??
|
|
74
|
+
dockerImage: env.NAVARCH_DOCKER_IMAGE ?? exports.DEFAULT_SANDBOX_IMAGE,
|
|
70
75
|
// Multiple sessions share one machine; keeping each agent inside its own
|
|
71
76
|
// worktree is the safe default, so disabling is the explicit opt-out.
|
|
72
77
|
worktreeGuard: !["off", "false", "0"].includes(env.NAVARCH_WORKTREE_GUARD ?? ""),
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -89,34 +89,48 @@ function parseCodexJsonEvents(stdout) {
|
|
|
89
89
|
return events;
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
|
-
* Extracts token/cost usage from parsed `codex exec --json` events.
|
|
93
|
-
*
|
|
92
|
+
* Extracts token/cost usage from parsed `codex exec --json` events.
|
|
93
|
+
*
|
|
94
|
+
* `turn.completed.usage` is per-turn (each turn reports only its own tokens),
|
|
95
|
+
* so multi-turn runs sum every turn's usage — keeping only the last event
|
|
96
|
+
* would under-report all earlier turns. Legacy `token_count` envelopes carry
|
|
97
|
+
* cumulative session totals instead, so for those the last event wins; they
|
|
98
|
+
* are only used when no verified `turn.completed` usage was seen.
|
|
99
|
+
*
|
|
100
|
+
* When no usage-bearing event is present at all (e.g. the run was killed
|
|
101
|
+
* before its first `turn.completed`), every field stays undefined so callers
|
|
102
|
+
* report usage as unknown rather than a fabricated measured zero.
|
|
94
103
|
*/
|
|
95
104
|
function extractUsageFromCodexEvents(events) {
|
|
96
|
-
let
|
|
97
|
-
let
|
|
105
|
+
let turnTokensIn = 0;
|
|
106
|
+
let turnTokensOut = 0;
|
|
107
|
+
let sawTurnUsage = false;
|
|
108
|
+
let legacyTokensIn;
|
|
109
|
+
let legacyTokensOut;
|
|
98
110
|
let costUsd;
|
|
99
111
|
for (const event of events) {
|
|
100
112
|
if (event.type === "turn.completed" && event.usage) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
113
|
+
sawTurnUsage = true;
|
|
114
|
+
turnTokensIn += event.usage.input_tokens ?? 0;
|
|
115
|
+
turnTokensOut += event.usage.output_tokens ?? 0;
|
|
116
|
+
if (typeof event.usage.total_cost_usd === "number") {
|
|
117
|
+
costUsd = (costUsd ?? 0) + event.usage.total_cost_usd;
|
|
118
|
+
}
|
|
105
119
|
}
|
|
106
120
|
if (event.msg?.type === "token_count") {
|
|
107
121
|
const nested = event.msg.info?.total_token_usage;
|
|
108
|
-
|
|
122
|
+
legacyTokensIn = nested
|
|
109
123
|
? (nested.input_tokens ?? 0)
|
|
110
124
|
: (event.msg.input_tokens ?? 0) + (event.msg.cached_input_tokens ?? 0);
|
|
111
|
-
|
|
125
|
+
legacyTokensOut = nested?.output_tokens ?? event.msg.output_tokens ?? 0;
|
|
112
126
|
if (typeof nested?.total_cost_usd === "number")
|
|
113
127
|
costUsd = nested.total_cost_usd;
|
|
114
128
|
}
|
|
115
129
|
if (event.type === "event_msg" && event.payload?.type === "token_count") {
|
|
116
130
|
const total = event.payload.info?.total_token_usage;
|
|
117
131
|
if (total) {
|
|
118
|
-
|
|
119
|
-
|
|
132
|
+
legacyTokensIn = total.input_tokens ?? 0;
|
|
133
|
+
legacyTokensOut = total.output_tokens ?? 0;
|
|
120
134
|
if (typeof total.total_cost_usd === "number")
|
|
121
135
|
costUsd = total.total_cost_usd;
|
|
122
136
|
}
|
|
@@ -125,7 +139,9 @@ function extractUsageFromCodexEvents(events) {
|
|
|
125
139
|
costUsd = event.msg.total_cost_usd;
|
|
126
140
|
}
|
|
127
141
|
}
|
|
128
|
-
|
|
142
|
+
if (sawTurnUsage)
|
|
143
|
+
return { tokensIn: turnTokensIn, tokensOut: turnTokensOut, costUsd };
|
|
144
|
+
return { tokensIn: legacyTokensIn, tokensOut: legacyTokensOut, costUsd };
|
|
129
145
|
}
|
|
130
146
|
/** The last completed agent message, with legacy `msg.agent_message` fallback. */
|
|
131
147
|
function extractFinalMessageFromCodexEvents(events) {
|
|
@@ -187,10 +203,26 @@ function mapExitCondition(result) {
|
|
|
187
203
|
};
|
|
188
204
|
}
|
|
189
205
|
if (result.exitCode !== 0) {
|
|
206
|
+
// Claude Code returns provider/auth failures as a structured JSON result
|
|
207
|
+
// on stdout while exiting non-zero. Prefer its human-readable `result`
|
|
208
|
+
// field over the raw metrics envelope (or incidental stderr warnings).
|
|
209
|
+
return {
|
|
210
|
+
leaseOutcome: "failed",
|
|
211
|
+
exitStatus: "failed",
|
|
212
|
+
reportSummary: summarize(parsedJson?.result || result.stderr || result.stdout) ||
|
|
213
|
+
`Adapter exited with code ${result.exitCode}.`,
|
|
214
|
+
evidenceUrls,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
// Some Claude Code releases exit 0 even when their structured result says
|
|
218
|
+
// the provider rejected the turn (for example a session-limit 429). Never
|
|
219
|
+
// report those synthetic zero-token turns as completed work.
|
|
220
|
+
if (parsedJson?.is_error) {
|
|
190
221
|
return {
|
|
191
222
|
leaseOutcome: "failed",
|
|
192
223
|
exitStatus: "failed",
|
|
193
|
-
reportSummary: summarize(result.stderr || result.stdout) ||
|
|
224
|
+
reportSummary: summarize(parsedJson.result || result.stderr || result.stdout) ||
|
|
225
|
+
"Claude Code reported an unsuccessful result.",
|
|
194
226
|
evidenceUrls,
|
|
195
227
|
};
|
|
196
228
|
}
|
package/dist/session.cjs
CHANGED
|
@@ -17,8 +17,10 @@ const prompt_cjs_1 = require("./prompt.cjs");
|
|
|
17
17
|
const mcp_config_cjs_1 = require("./mcp-config.cjs");
|
|
18
18
|
const logger_cjs_1 = require("./logger.cjs");
|
|
19
19
|
const git_worktree_cjs_1 = require("./git-worktree.cjs");
|
|
20
|
+
const worktree_janitor_cjs_1 = require("./worktree-janitor.cjs");
|
|
20
21
|
const github_pr_cjs_1 = require("./github-pr.cjs");
|
|
21
22
|
const worktree_guard_cjs_1 = require("./worktree-guard.cjs");
|
|
23
|
+
const adapter_capacity_cjs_1 = require("./adapter-capacity.cjs");
|
|
22
24
|
/** Filename the generated platform MCP config is written under inside the session metadata directory. */
|
|
23
25
|
const MCP_CONFIG_FILENAME = "mcp-config.json";
|
|
24
26
|
const GIT_CREDENTIAL_HELPER_FILENAME = "git-credential-navarch.cjs";
|
|
@@ -66,6 +68,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
66
68
|
// (heartbeat/complete/issue/transcript) still key on leaseId.
|
|
67
69
|
const workDir = node_path_1.default.join(config.workspaceRoot, "sessions", sessionId);
|
|
68
70
|
await node_fs_1.promises.mkdir(workDir, { recursive: true });
|
|
71
|
+
await (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
|
|
69
72
|
const promptText = (0, prompt_cjs_1.renderPrompt)(task, bundle);
|
|
70
73
|
await node_fs_1.promises.writeFile(node_path_1.default.join(workDir, "prompt.md"), promptText, "utf8");
|
|
71
74
|
// Secrets: initially fetched once, held only in memory (registry + env map below),
|
|
@@ -129,12 +132,14 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
129
132
|
let leaseLost = false;
|
|
130
133
|
let leaseGone = false;
|
|
131
134
|
let heartbeatInFlight = null;
|
|
135
|
+
const sessionOutcome = {};
|
|
132
136
|
const pollLease = () => {
|
|
133
137
|
if (heartbeatInFlight)
|
|
134
138
|
return heartbeatInFlight;
|
|
135
139
|
const request = api
|
|
136
140
|
.heartbeatLease(leaseId, { guidance_after: guidanceCursor })
|
|
137
|
-
.then((heartbeat) => {
|
|
141
|
+
.then(async (heartbeat) => {
|
|
142
|
+
await (0, worktree_janitor_cjs_1.markSessionWorkspaceActive)(workDir);
|
|
138
143
|
guidanceCursor = heartbeat.guidance_cursor ?? guidanceCursor;
|
|
139
144
|
const fresh = (heartbeat.guidance ?? []).filter((entry) => {
|
|
140
145
|
if (knownGuidanceIds.has(entry.id))
|
|
@@ -302,6 +307,10 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
302
307
|
});
|
|
303
308
|
activeAbortController = null;
|
|
304
309
|
attempts.push(turnResult);
|
|
310
|
+
const capacityLimit = (0, adapter_capacity_cjs_1.detectAdapterCapacityLimit)(turnResult);
|
|
311
|
+
if (capacityLimit) {
|
|
312
|
+
sessionOutcome.claimCooldownUntil = Math.max(sessionOutcome.claimCooldownUntil ?? 0, capacityLimit.retryAtMs);
|
|
313
|
+
}
|
|
305
314
|
// Close the small race between a naturally completed turn and the next
|
|
306
315
|
// scheduled heartbeat. If guidance landed, run another turn before the
|
|
307
316
|
// lease can be completed. A terminal heartbeat response means the
|
|
@@ -311,7 +320,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
311
320
|
await pollLease();
|
|
312
321
|
if (leaseGone) {
|
|
313
322
|
log.warn(`session ${leaseId} stopped without completion because its lease is no longer active.`);
|
|
314
|
-
return;
|
|
323
|
+
return sessionOutcome;
|
|
315
324
|
}
|
|
316
325
|
if (!leaseLost && pendingGuidance.length > 0)
|
|
317
326
|
continue;
|
|
@@ -383,7 +392,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
383
392
|
catch (err) {
|
|
384
393
|
if (isAlreadyReleasedCompletionError(err)) {
|
|
385
394
|
log.warn(`completion skipped for ${leaseId}: the lease was already released.`);
|
|
386
|
-
return;
|
|
395
|
+
return sessionOutcome;
|
|
387
396
|
}
|
|
388
397
|
const rejection = mapping.leaseOutcome === "completed" ? completionRemediationMessage(err) : null;
|
|
389
398
|
if (!rejection)
|
|
@@ -432,6 +441,7 @@ async function runSession(deps, claimed, sessionId) {
|
|
|
432
441
|
await gitWorktree.cleanup();
|
|
433
442
|
await node_fs_1.promises.rm(workDir, { recursive: true, force: true }).catch(() => undefined);
|
|
434
443
|
}
|
|
444
|
+
return sessionOutcome;
|
|
435
445
|
}
|
|
436
446
|
function adapterCommand(config, runtime) {
|
|
437
447
|
switch (runtime) {
|
package/dist/worktree-guard.cjs
CHANGED
|
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
|
4
4
|
};
|
|
5
5
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
6
|
exports.guardHookScriptPath = guardHookScriptPath;
|
|
7
|
+
exports.readClaudeApiKeyHelper = readClaudeApiKeyHelper;
|
|
7
8
|
exports.prepareWorktreeGuard = prepareWorktreeGuard;
|
|
8
9
|
exports.codexToolReadRoots = codexToolReadRoots;
|
|
9
10
|
exports.codexWorktreeGuardArgs = codexWorktreeGuardArgs;
|
|
@@ -12,6 +13,7 @@ const node_path_1 = __importDefault(require("node:path"));
|
|
|
12
13
|
const node_fs_1 = require("node:fs");
|
|
13
14
|
const node_os_1 = __importDefault(require("node:os"));
|
|
14
15
|
const CODEX_GUARD_PROFILE = "navarch-worktree";
|
|
16
|
+
const CLAUDE_USER_SETTINGS_FILENAME = "settings.json";
|
|
15
17
|
/**
|
|
16
18
|
* Tools the hook screens. Everything else — the lease-scoped Navarch MCP
|
|
17
19
|
* tools, WebFetch, Task, ... — carries no direct filesystem path and passes
|
|
@@ -26,6 +28,37 @@ const GUARDED_TOOL_MATCHER = "^(Read|Write|Edit|MultiEdit|NotebookEdit|Glob|Grep
|
|
|
26
28
|
function guardHookScriptPath() {
|
|
27
29
|
return node_path_1.default.join(__dirname, "..", "bin", "worktree-guard-hook.cjs");
|
|
28
30
|
}
|
|
31
|
+
/**
|
|
32
|
+
* Preserve Claude's authentication helper without inheriting the rest of the
|
|
33
|
+
* operator's user settings. Runtime sessions deliberately disable the normal
|
|
34
|
+
* user/project/local setting sources, but apiKeyHelper is a supported auth
|
|
35
|
+
* source and some unattended machines have no keychain/OAuth fallback.
|
|
36
|
+
*
|
|
37
|
+
* Invalid or missing user settings degrade to no helper, matching Claude
|
|
38
|
+
* print mode's tolerant treatment of invalid settings files.
|
|
39
|
+
*/
|
|
40
|
+
async function readClaudeApiKeyHelper(settingsPath = defaultClaudeUserSettingsPath()) {
|
|
41
|
+
if (!settingsPath)
|
|
42
|
+
return undefined;
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(await node_fs_1.promises.readFile(settingsPath, "utf8"));
|
|
45
|
+
if (typeof parsed === "object" &&
|
|
46
|
+
parsed !== null &&
|
|
47
|
+
!Array.isArray(parsed) &&
|
|
48
|
+
typeof parsed.apiKeyHelper === "string") {
|
|
49
|
+
const helper = parsed.apiKeyHelper.trim();
|
|
50
|
+
return helper || undefined;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
// Missing/invalid user settings are not a runtime startup failure.
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
function defaultClaudeUserSettingsPath() {
|
|
59
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR?.trim() || node_path_1.default.join(node_os_1.default.homedir(), ".claude");
|
|
60
|
+
return node_path_1.default.join(configDir, CLAUDE_USER_SETTINGS_FILENAME);
|
|
61
|
+
}
|
|
29
62
|
/**
|
|
30
63
|
* Writes the per-session guard config + Claude settings file into workDir and
|
|
31
64
|
* returns their paths. The settings file is passed to the CLI as `--settings`
|
|
@@ -45,7 +78,11 @@ async function prepareWorktreeGuard(options) {
|
|
|
45
78
|
// process.execPath rather than a bare `node`: the hook must run with the
|
|
46
79
|
// same interpreter as the runtime regardless of the agent's PATH.
|
|
47
80
|
const command = [process.execPath, hookScriptPath, configPath].map(shellQuote).join(" ");
|
|
81
|
+
const apiKeyHelper = await readClaudeApiKeyHelper(options.claudeUserSettingsPath === undefined
|
|
82
|
+
? defaultClaudeUserSettingsPath()
|
|
83
|
+
: options.claudeUserSettingsPath);
|
|
48
84
|
const settings = {
|
|
85
|
+
...(apiKeyHelper ? { apiKeyHelper } : {}),
|
|
49
86
|
hooks: {
|
|
50
87
|
PreToolUse: [
|
|
51
88
|
{
|
|
@@ -0,0 +1,173 @@
|
|
|
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.WorktreeJanitor = exports.SESSION_ACTIVITY_FILENAME = void 0;
|
|
7
|
+
exports.markSessionWorkspaceActive = markSessionWorkspaceActive;
|
|
8
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const logger_cjs_1 = require("./logger.cjs");
|
|
11
|
+
const sandbox_cjs_1 = require("./sandbox.cjs");
|
|
12
|
+
const log = (0, logger_cjs_1.createLogger)("worktree-janitor");
|
|
13
|
+
exports.SESSION_ACTIVITY_FILENAME = ".navarch-active";
|
|
14
|
+
/**
|
|
15
|
+
* Refreshes the durable activity marker used by janitors in other runtime
|
|
16
|
+
* processes that happen to share a workspace root.
|
|
17
|
+
*/
|
|
18
|
+
async function markSessionWorkspaceActive(sessionRoot) {
|
|
19
|
+
await node_fs_1.promises.mkdir(sessionRoot, { recursive: true });
|
|
20
|
+
await node_fs_1.promises.writeFile(node_path_1.default.join(sessionRoot, exports.SESSION_ACTIVITY_FILENAME), `${new Date().toISOString()}\n`, "utf8");
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Periodically removes session worktrees left behind by SIGKILL, a host
|
|
24
|
+
* restart, or another exit that bypassed runSession's finally block.
|
|
25
|
+
*
|
|
26
|
+
* The age threshold is deliberately conservative. A directory must be both
|
|
27
|
+
* absent from this process's active-session registry and stale on disk before
|
|
28
|
+
* it is eligible. Git worktree metadata is removed before the directory.
|
|
29
|
+
*/
|
|
30
|
+
class WorktreeJanitor {
|
|
31
|
+
options;
|
|
32
|
+
runner;
|
|
33
|
+
now;
|
|
34
|
+
timer = null;
|
|
35
|
+
sweepInFlight = null;
|
|
36
|
+
constructor(options) {
|
|
37
|
+
this.options = options;
|
|
38
|
+
this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
|
|
39
|
+
this.now = options.now ?? Date.now;
|
|
40
|
+
}
|
|
41
|
+
start() {
|
|
42
|
+
if (this.timer)
|
|
43
|
+
return;
|
|
44
|
+
void this.sweepAndLog();
|
|
45
|
+
this.timer = setInterval(() => void this.sweepAndLog(), this.options.intervalMs);
|
|
46
|
+
this.timer.unref?.();
|
|
47
|
+
}
|
|
48
|
+
stop() {
|
|
49
|
+
if (this.timer)
|
|
50
|
+
clearInterval(this.timer);
|
|
51
|
+
this.timer = null;
|
|
52
|
+
}
|
|
53
|
+
sweep() {
|
|
54
|
+
if (this.sweepInFlight)
|
|
55
|
+
return this.sweepInFlight;
|
|
56
|
+
this.sweepInFlight = this.runSweep().finally(() => {
|
|
57
|
+
this.sweepInFlight = null;
|
|
58
|
+
});
|
|
59
|
+
return this.sweepInFlight;
|
|
60
|
+
}
|
|
61
|
+
async sweepAndLog() {
|
|
62
|
+
try {
|
|
63
|
+
const summary = await this.sweep();
|
|
64
|
+
if (summary.removed > 0 || summary.errors > 0) {
|
|
65
|
+
log.info(`cleanup complete: scanned=${summary.scanned} removed=${summary.removed} ` +
|
|
66
|
+
`active=${summary.skippedActive} fresh=${summary.skippedFresh} errors=${summary.errors}`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
log.warn(`cleanup sweep failed: ${String(error)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async runSweep() {
|
|
74
|
+
const summary = {
|
|
75
|
+
scanned: 0,
|
|
76
|
+
removed: 0,
|
|
77
|
+
skippedActive: 0,
|
|
78
|
+
skippedFresh: 0,
|
|
79
|
+
errors: 0,
|
|
80
|
+
};
|
|
81
|
+
const sessionsRoot = node_path_1.default.resolve(this.options.workspaceRoot, "sessions");
|
|
82
|
+
const entries = await node_fs_1.promises.readdir(sessionsRoot, { withFileTypes: true }).catch((error) => {
|
|
83
|
+
if (isMissing(error))
|
|
84
|
+
return [];
|
|
85
|
+
throw error;
|
|
86
|
+
});
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
// Do not follow symlinks placed in the sessions directory.
|
|
89
|
+
if (!entry.isDirectory() || entry.isSymbolicLink())
|
|
90
|
+
continue;
|
|
91
|
+
summary.scanned += 1;
|
|
92
|
+
const sessionId = entry.name;
|
|
93
|
+
const sessionRoot = node_path_1.default.resolve(sessionsRoot, sessionId);
|
|
94
|
+
if (node_path_1.default.dirname(sessionRoot) !== sessionsRoot) {
|
|
95
|
+
summary.errors += 1;
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (this.options.isSessionActive?.(sessionId)) {
|
|
99
|
+
summary.skippedActive += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
try {
|
|
103
|
+
const lastActivity = await this.lastActivityAt(sessionRoot);
|
|
104
|
+
if (this.now() - lastActivity < this.options.staleAfterMs) {
|
|
105
|
+
summary.skippedFresh += 1;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// Close the in-process race with a session claim that started while
|
|
109
|
+
// this directory was being inspected.
|
|
110
|
+
if (this.options.isSessionActive?.(sessionId)) {
|
|
111
|
+
summary.skippedActive += 1;
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
await this.removeSessionWorktree(sessionRoot);
|
|
115
|
+
summary.removed += 1;
|
|
116
|
+
}
|
|
117
|
+
catch (error) {
|
|
118
|
+
summary.errors += 1;
|
|
119
|
+
log.warn(`could not clean abandoned session ${sessionId}: ${String(error)}`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return summary;
|
|
123
|
+
}
|
|
124
|
+
async lastActivityAt(sessionRoot) {
|
|
125
|
+
const marker = node_path_1.default.join(sessionRoot, exports.SESSION_ACTIVITY_FILENAME);
|
|
126
|
+
try {
|
|
127
|
+
return (await node_fs_1.promises.stat(marker)).mtimeMs;
|
|
128
|
+
}
|
|
129
|
+
catch (error) {
|
|
130
|
+
if (!isMissing(error))
|
|
131
|
+
throw error;
|
|
132
|
+
// Compatibility with worktrees created before activity markers existed.
|
|
133
|
+
return (await node_fs_1.promises.stat(sessionRoot)).mtimeMs;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
async removeSessionWorktree(sessionRoot) {
|
|
137
|
+
const worktreePath = node_path_1.default.join(sessionRoot, "repo");
|
|
138
|
+
const branchResult = await this.runner.run("git", ["-C", worktreePath, "symbolic-ref", "--quiet", "--short", "HEAD"]).catch(() => null);
|
|
139
|
+
const branch = branchResult?.code === 0 ? branchResult.stdout.trim() : "";
|
|
140
|
+
const repositoriesRoot = node_path_1.default.resolve(this.options.workspaceRoot, "repositories");
|
|
141
|
+
const repositories = await node_fs_1.promises.readdir(repositoriesRoot, { withFileTypes: true }).catch((error) => {
|
|
142
|
+
if (isMissing(error))
|
|
143
|
+
return [];
|
|
144
|
+
throw error;
|
|
145
|
+
});
|
|
146
|
+
for (const repository of repositories) {
|
|
147
|
+
if (!repository.isDirectory() || repository.isSymbolicLink())
|
|
148
|
+
continue;
|
|
149
|
+
const repositoryPath = node_path_1.default.resolve(repositoriesRoot, repository.name);
|
|
150
|
+
if (node_path_1.default.dirname(repositoryPath) !== repositoriesRoot)
|
|
151
|
+
continue;
|
|
152
|
+
const removal = await this.runner.run("git", ["--git-dir", repositoryPath, "worktree", "remove", "--force", worktreePath]).catch(() => null);
|
|
153
|
+
if (removal?.code === 0 && branch.startsWith("navarch/")) {
|
|
154
|
+
await this.runner.run("git", ["--git-dir", repositoryPath, "branch", "-D", branch]).catch(() => undefined);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
await node_fs_1.promises.rm(sessionRoot, { recursive: true, force: true });
|
|
158
|
+
// If worktree removal failed because stale metadata was already damaged,
|
|
159
|
+
// pruning after the directory disappears still clears the registration.
|
|
160
|
+
for (const repository of repositories) {
|
|
161
|
+
if (!repository.isDirectory() || repository.isSymbolicLink())
|
|
162
|
+
continue;
|
|
163
|
+
const repositoryPath = node_path_1.default.resolve(repositoriesRoot, repository.name);
|
|
164
|
+
if (node_path_1.default.dirname(repositoryPath) !== repositoriesRoot)
|
|
165
|
+
continue;
|
|
166
|
+
await this.runner.run("git", ["--git-dir", repositoryPath, "worktree", "prune"]).catch(() => undefined);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
exports.WorktreeJanitor = WorktreeJanitor;
|
|
171
|
+
function isMissing(error) {
|
|
172
|
+
return error instanceof Error && "code" in error && error.code === "ENOENT";
|
|
173
|
+
}
|
package/package.json
CHANGED