@sagentlab/navarch-runtime 0.1.112 → 0.1.117
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 +56 -18
- package/bin/muse-mcp-bridge.cjs +3 -0
- package/dist/adapters/index.cjs +6 -1
- package/dist/adapters/muse.cjs +433 -0
- package/dist/agent-auth.cjs +2 -1
- package/dist/cli.cjs +4 -1
- package/dist/config.cjs +9 -1
- package/dist/conversation/capability.cjs +1 -1
- package/dist/conversation/loop.cjs +16 -0
- package/dist/conversation/mirror.cjs +225 -0
- package/dist/conversation/workspace.cjs +11 -4
- package/dist/exit-conditions.cjs +1 -1
- package/dist/muse-mcp-bridge.cjs +167 -0
- package/dist/session.cjs +30 -8
- package/dist/worktree-guard.cjs +137 -3
- package/node_modules/@muse-code/sdk/LICENSE +21 -0
- package/node_modules/@muse-code/sdk/README.md +88 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.d.ts +149 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/connection.js +581 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.d.ts +188 -0
- package/node_modules/@muse-code/sdk/dist/src/connection/spawn.js +496 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.d.ts +71 -0
- package/node_modules/@muse-code/sdk/dist/src/errors.js +76 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.d.ts +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/approval.js +293 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.d.ts +171 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/client.js +442 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.d.ts +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/discarded.js +36 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.d.ts +80 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/gap-fill.js +388 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.d.ts +139 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-death.js +96 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.d.ts +62 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/host-start.js +81 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.d.ts +309 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/session.js +654 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.d.ts +156 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-handle.js +271 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.d.ts +72 -0
- package/node_modules/@muse-code/sdk/dist/src/facade/turn-submit.js +183 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.d.ts +37 -0
- package/node_modules/@muse-code/sdk/dist/src/fingerprint.js +54 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.d.ts +110 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/item-store.js +169 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.d.ts +536 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/session-fold.js +427 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.d.ts +68 -0
- package/node_modules/@muse-code/sdk/dist/src/fold/state-store.js +89 -0
- package/node_modules/@muse-code/sdk/dist/src/index.d.ts +65 -0
- package/node_modules/@muse-code/sdk/dist/src/index.js +57 -0
- package/node_modules/@muse-code/sdk/dist/src/msp.d.ts +3380 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.d.ts +361 -0
- package/node_modules/@muse-code/sdk/dist/src/pending/pending-command-set.js +468 -0
- package/node_modules/@muse-code/sdk/package.json +47 -0
- package/package.json +8 -4
package/dist/cli.cjs
CHANGED
|
@@ -303,7 +303,10 @@ async function startCommand(flags) {
|
|
|
303
303
|
// runSession checkpoints or retains the worktree before releasing its
|
|
304
304
|
// capacity slot.
|
|
305
305
|
runtimeShutdown.abort();
|
|
306
|
-
|
|
306
|
+
// Mirror warming can outlive its conversation's capacity slot. Wait for
|
|
307
|
+
// its killed process and private-cache cleanup before exiting too.
|
|
308
|
+
void Promise.all([capacity.waitForIdle(), conversationLoop.drain()])
|
|
309
|
+
.then(() => process.exit(CREDENTIALS_REJECTED_EXIT_CODE));
|
|
307
310
|
}, browserProbe);
|
|
308
311
|
updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
|
|
309
312
|
config,
|
package/dist/config.cjs
CHANGED
|
@@ -10,6 +10,7 @@ exports.opencodeBlockedOnHost = opencodeBlockedOnHost;
|
|
|
10
10
|
exports.loadRuntimeConfig = loadRuntimeConfig;
|
|
11
11
|
const node_path_1 = __importDefault(require("node:path"));
|
|
12
12
|
const node_os_1 = __importDefault(require("node:os"));
|
|
13
|
+
const muse_cjs_1 = require("./adapters/muse.cjs");
|
|
13
14
|
const sandbox_profile_cjs_1 = require("./sandbox-profile.cjs");
|
|
14
15
|
/** Published image containing git, GitHub CLI, and the pinned Claude Code CLI. */
|
|
15
16
|
exports.DEFAULT_SANDBOX_IMAGE = "ghcr.io/sagentlab/navarch-sandbox-agent:0.1.0";
|
|
@@ -25,6 +26,7 @@ exports.RUNTIME_AGENT_TYPES = [
|
|
|
25
26
|
"opencode",
|
|
26
27
|
"acp",
|
|
27
28
|
"qoder",
|
|
29
|
+
"muse",
|
|
28
30
|
"pi-local",
|
|
29
31
|
];
|
|
30
32
|
function isRuntimeAgentType(value) {
|
|
@@ -116,10 +118,13 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
116
118
|
? sandboxMode === "host" && !worktreeGuard
|
|
117
119
|
: runtime === "antigravity"
|
|
118
120
|
? process.platform !== "win32" && !opencodeBlockedOnHost(sandboxMode, worktreeGuard)
|
|
119
|
-
: runtime !== "opencode" || !opencodeBlockedOnHost(sandboxMode, worktreeGuard));
|
|
121
|
+
: (runtime !== "opencode" && runtime !== "muse") || !opencodeBlockedOnHost(sandboxMode, worktreeGuard));
|
|
120
122
|
if (requestedRuntimes.includes("pi-local") && runnableRuntimes.length === 0) {
|
|
121
123
|
throw new Error("Pi (local) requires NAVARCH_SANDBOX_MODE=host and NAVARCH_WORKTREE_GUARD=off");
|
|
122
124
|
}
|
|
125
|
+
if (requestedRuntimes.includes("muse") && runnableRuntimes.length === 0) {
|
|
126
|
+
throw new Error(muse_cjs_1.MUSE_HOST_GUARD_MESSAGE);
|
|
127
|
+
}
|
|
123
128
|
if (requestedRuntimes.includes("antigravity") && runnableRuntimes.length === 0) {
|
|
124
129
|
if (process.platform === "win32")
|
|
125
130
|
throw new Error("Antigravity's private lease MCP relay requires a Linux or macOS worker; Windows is unsupported.");
|
|
@@ -168,6 +173,8 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
168
173
|
piExtraArgs: envList(env, "NAVARCH_PI_EXTRA_ARGS", []),
|
|
169
174
|
qoderBin: env.NAVARCH_QODER_BIN ?? "qoder",
|
|
170
175
|
qoderExtraArgs: envList(env, "NAVARCH_QODER_EXTRA_ARGS", []),
|
|
176
|
+
museBin: env.NAVARCH_MUSE_BIN ?? "muse",
|
|
177
|
+
museExtraArgs: envList(env, "NAVARCH_MUSE_EXTRA_ARGS", []),
|
|
171
178
|
gitAuthorName: env.NAVARCH_GIT_AUTHOR_NAME ?? "sagentlab",
|
|
172
179
|
gitAuthorEmail: env.NAVARCH_GIT_AUTHOR_EMAIL ?? "z@sagentlab.com",
|
|
173
180
|
mcpConfigPath: env.NAVARCH_MCP_CONFIG_PATH ?? null,
|
|
@@ -195,6 +202,7 @@ function loadRuntimeConfig(env = process.env) {
|
|
|
195
202
|
browserProbe: !["off", "false", "0"].includes(env.NAVARCH_BROWSER_PROBE ?? ""),
|
|
196
203
|
naviWorkspaceChat: ["on", "true", "1"].includes(env.NAVARCH_NAVI_WORKSPACE_CHAT ?? ""),
|
|
197
204
|
naviConversations: !["off", "false", "0"].includes(env.NAVARCH_NAVI_CONVERSATIONS ?? ""),
|
|
205
|
+
conversationMirror: !["off", "false", "0"].includes(env.NAVARCH_CONVERSATION_MIRROR ?? ""),
|
|
198
206
|
// The server stops waiting for a capability advertisement after 90 s and
|
|
199
207
|
// asks for a poll at least every 30 s; the floor only bounds control-plane load.
|
|
200
208
|
conversationPollIntervalMs: Math.min(30_000, Math.max(1_000, envInt(env, "NAVARCH_CONVERSATION_POLL_MS", 5_000))),
|
|
@@ -40,7 +40,7 @@ function conversationCapability(config) {
|
|
|
40
40
|
}
|
|
41
41
|
/** Per-adapter support table for docs and diagnostics (host sandbox, guard on, feature enabled). */
|
|
42
42
|
function conversationSupportMatrix() {
|
|
43
|
-
const agents = ["claude-code", "codex", "gemini", "antigravity", "opencode", "acp", "qoder", "pi-local", "pi"];
|
|
43
|
+
const agents = ["claude-code", "codex", "gemini", "antigravity", "opencode", "acp", "qoder", "muse", "pi-local", "pi"];
|
|
44
44
|
return agents.map((agentType) => ({
|
|
45
45
|
agentType,
|
|
46
46
|
capability: conversationCapability({
|
|
@@ -7,6 +7,7 @@ const worktree_guard_cjs_1 = require("../worktree-guard.cjs");
|
|
|
7
7
|
const capability_cjs_1 = require("./capability.cjs");
|
|
8
8
|
const journal_cjs_1 = require("./journal.cjs");
|
|
9
9
|
const turn_runner_cjs_1 = require("./turn-runner.cjs");
|
|
10
|
+
const mirror_cjs_1 = require("./mirror.cjs");
|
|
10
11
|
const workspace_cjs_1 = require("./workspace.cjs");
|
|
11
12
|
const MAX_BACKOFF_MS = 60_000;
|
|
12
13
|
/** After a turn, poll fast for a while: a follow-up is likely and the poll is the only delivery path. */
|
|
@@ -16,6 +17,8 @@ const SWEEP_INTERVAL_MS = 30_000;
|
|
|
16
17
|
const PRUNE_INTERVAL_MS = 6 * 60 * 60_000;
|
|
17
18
|
/** The server keeps idempotency records for 24 hours; keep ours longer. */
|
|
18
19
|
const JOURNAL_RETENTION_MS = 48 * 60 * 60_000;
|
|
20
|
+
/** A repository mirror nobody has asked about for this long is removed; the next conversation rebuilds it. */
|
|
21
|
+
const MIRROR_RETENTION_MS = 14 * 24 * 60 * 60_000;
|
|
19
22
|
/**
|
|
20
23
|
* Polls for queued Navi conversation turns and runs each on this machine's
|
|
21
24
|
* configured adapter. Polling is also the capability advertisement: this loop
|
|
@@ -34,6 +37,7 @@ class ConversationLoop {
|
|
|
34
37
|
lastPruneAt = -Infinity;
|
|
35
38
|
active = new Set();
|
|
36
39
|
journal;
|
|
40
|
+
mirrors;
|
|
37
41
|
log;
|
|
38
42
|
now;
|
|
39
43
|
outcomes = [];
|
|
@@ -42,6 +46,13 @@ class ConversationLoop {
|
|
|
42
46
|
this.journal = options.journal ?? new journal_cjs_1.ConversationJournal(options.config.workspaceRoot);
|
|
43
47
|
this.log = options.log ?? (0, logger_cjs_1.createLogger)("navi");
|
|
44
48
|
this.now = options.now ?? Date.now;
|
|
49
|
+
this.mirrors = options.config.conversationMirror
|
|
50
|
+
? new mirror_cjs_1.ConversationMirrors(options.config.workspaceRoot, {
|
|
51
|
+
...(options.gitRunner ? { runner: options.gitRunner } : {}),
|
|
52
|
+
...(options.shutdownSignal ? { signal: options.shutdownSignal } : {}),
|
|
53
|
+
log: this.log,
|
|
54
|
+
})
|
|
55
|
+
: null;
|
|
45
56
|
}
|
|
46
57
|
/** Outcomes of turns this process ran, newest last; used for latency reporting and tests. */
|
|
47
58
|
get recentOutcomes() {
|
|
@@ -59,6 +70,7 @@ class ConversationLoop {
|
|
|
59
70
|
return;
|
|
60
71
|
}
|
|
61
72
|
this.stopped = false;
|
|
73
|
+
this.mirrors?.resume();
|
|
62
74
|
this.failures = 0;
|
|
63
75
|
this.log.info(`Navi conversations advertised on the ${this.options.config.agentType} adapter (${this.options.config.naviWorkspaceChat ? "read-only + workspace" : "read-only"}).`);
|
|
64
76
|
this.schedule(0);
|
|
@@ -68,12 +80,14 @@ class ConversationLoop {
|
|
|
68
80
|
if (this.timer)
|
|
69
81
|
clearTimeout(this.timer);
|
|
70
82
|
this.timer = null;
|
|
83
|
+
this.mirrors?.stop();
|
|
71
84
|
}
|
|
72
85
|
/** Stops new polls and waits for one already on the wire. Running turns finish on their own slots. */
|
|
73
86
|
async drain() {
|
|
74
87
|
this.stop();
|
|
75
88
|
await this.polling;
|
|
76
89
|
await this.housekeeping_;
|
|
90
|
+
await this.mirrors?.drain();
|
|
77
91
|
}
|
|
78
92
|
schedule(delayMs) {
|
|
79
93
|
if (this.stopped)
|
|
@@ -173,6 +187,7 @@ class ConversationLoop {
|
|
|
173
187
|
if (at - this.lastPruneAt >= PRUNE_INTERVAL_MS) {
|
|
174
188
|
this.lastPruneAt = at;
|
|
175
189
|
await this.journal.prune(JOURNAL_RETENTION_MS, this.options.config.workspaceRoot).catch(() => undefined);
|
|
190
|
+
await this.mirrors?.prune(MIRROR_RETENTION_MS, at).catch(() => undefined);
|
|
176
191
|
}
|
|
177
192
|
}
|
|
178
193
|
async settle(journal) {
|
|
@@ -213,6 +228,7 @@ class ConversationLoop {
|
|
|
213
228
|
const workspace = await (0, workspace_cjs_1.prepareConversationWorkspace)({
|
|
214
229
|
workspaceRoot: config.workspaceRoot, conversationId, repositoryUrl, workingBranch, env, signal, mode,
|
|
215
230
|
...(this.options.gitRunner ? { runner: this.options.gitRunner } : {}),
|
|
231
|
+
...(this.mirrors ? { mirrors: this.mirrors } : {}),
|
|
216
232
|
});
|
|
217
233
|
const guard = await (0, worktree_guard_cjs_1.prepareWorktreeGuard)({
|
|
218
234
|
workDir: workspace.metaDir,
|
|
@@ -0,0 +1,225 @@
|
|
|
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.ConversationMirrors = void 0;
|
|
7
|
+
const node_crypto_1 = require("node:crypto");
|
|
8
|
+
const node_fs_1 = require("node:fs");
|
|
9
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
10
|
+
const sandbox_cjs_1 = require("../sandbox.cjs");
|
|
11
|
+
/** Incremental fetch of an existing mirror; a full first clone runs in the background instead. */
|
|
12
|
+
const MIRROR_FETCH_TIMEOUT_MS = 60_000;
|
|
13
|
+
const MIRROR_CLONE_TIMEOUT_MS = 15 * 60_000;
|
|
14
|
+
const LOCAL_CLONE_TIMEOUT_MS = 3 * 60_000;
|
|
15
|
+
const MIRRORS_DIR = "conversation-mirrors";
|
|
16
|
+
/**
|
|
17
|
+
* Machine-level bare mirrors that seed Navi conversation checkouts.
|
|
18
|
+
*
|
|
19
|
+
* A fresh network clone of a large repository took ~35 s per new conversation,
|
|
20
|
+
* which was nearly all of the first answer's latency. With a mirror, a new
|
|
21
|
+
* conversation costs one incremental `git fetch` from GitHub plus a local,
|
|
22
|
+
* local `git clone` without a second network transfer.
|
|
23
|
+
*
|
|
24
|
+
* - The fetch uses the machine's own git credentials on every new
|
|
25
|
+
* conversation, so a machine that lost access still fails the turn instead
|
|
26
|
+
* of answering from a stale copy.
|
|
27
|
+
* - Checkouts copy their objects, without hardlinks or alternates. Writable
|
|
28
|
+
* chat must not be able to change another conversation's object inodes.
|
|
29
|
+
* - The first conversation of a repository on a machine keeps the direct
|
|
30
|
+
* clone; the mirror is built afterwards in the background and published by
|
|
31
|
+
* an atomic rename, so an interrupted build is never mistaken for a mirror.
|
|
32
|
+
*/
|
|
33
|
+
class ConversationMirrors {
|
|
34
|
+
options;
|
|
35
|
+
root;
|
|
36
|
+
runner;
|
|
37
|
+
/** Serializes git operations per mirror within this process. */
|
|
38
|
+
locks = new Map();
|
|
39
|
+
building = new Map();
|
|
40
|
+
warming = new AbortController();
|
|
41
|
+
paused = false;
|
|
42
|
+
constructor(workspaceRoot, options = {}) {
|
|
43
|
+
this.options = options;
|
|
44
|
+
this.root = node_path_1.default.join(workspaceRoot, MIRRORS_DIR);
|
|
45
|
+
this.runner = options.runner ?? sandbox_cjs_1.nodeCommandRunner;
|
|
46
|
+
}
|
|
47
|
+
/** Mirror location for a validated https repository URL. */
|
|
48
|
+
mirrorPath(url) {
|
|
49
|
+
return node_path_1.default.join(this.root, `${(0, node_crypto_1.createHash)("sha256").update(url).digest("hex").slice(0, 32)}.git`);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Refreshes the repository's mirror and clones `checkout` from it. Returns
|
|
53
|
+
* false (leaving no checkout behind) when there is no usable mirror, so the
|
|
54
|
+
* caller clones from the network instead.
|
|
55
|
+
*/
|
|
56
|
+
async seedCheckout(options) {
|
|
57
|
+
const mirror = this.mirrorPath(options.url);
|
|
58
|
+
if (!(await this.usableMirror(mirror)))
|
|
59
|
+
return false;
|
|
60
|
+
return this.locked(mirror, () => this.withDiskLock(mirror, false, async () => {
|
|
61
|
+
if (!(await this.usableMirror(mirror)))
|
|
62
|
+
return false;
|
|
63
|
+
const fetched = await this.runner.run("git", ["--git-dir", mirror, "fetch", "--quiet", "--prune", "--no-tags", "--", options.url, "+refs/heads/*:refs/heads/*"], { cwd: this.root, env: options.env, timeoutMs: MIRROR_FETCH_TIMEOUT_MS, signal: options.signal });
|
|
64
|
+
if (fetched.code !== 0) {
|
|
65
|
+
if (options.signal?.aborted)
|
|
66
|
+
return false;
|
|
67
|
+
// Lost access, or a damaged mirror. Either way it must not seed this
|
|
68
|
+
// conversation; the direct clone decides whether the turn can run and
|
|
69
|
+
// a successful one rebuilds the mirror.
|
|
70
|
+
this.options.log?.warn(`conversation mirror fetch failed with exit ${fetched.code}; cloning directly`);
|
|
71
|
+
await node_fs_1.promises.rm(mirror, { recursive: true, force: true }).catch(() => undefined);
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
const now = new Date();
|
|
75
|
+
await node_fs_1.promises.utimes(mirror, now, now).catch(() => undefined);
|
|
76
|
+
let branch = options.branch;
|
|
77
|
+
if (!branch) {
|
|
78
|
+
// Fetch updates refs, not the bare repository's symbolic HEAD. Query
|
|
79
|
+
// the current remote default instead of silently using its old value.
|
|
80
|
+
const remoteHead = await this.runner.run("git", ["ls-remote", "--symref", "--exit-code", "--", options.url, "HEAD"], { cwd: this.root, env: options.env, timeoutMs: MIRROR_FETCH_TIMEOUT_MS, signal: options.signal });
|
|
81
|
+
const ref = /^ref: (refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*)\tHEAD$/m.exec(remoteHead.stdout)?.[1];
|
|
82
|
+
if (remoteHead.code !== 0 || !ref || ref.includes(".."))
|
|
83
|
+
return false;
|
|
84
|
+
branch = ref.slice("refs/heads/".length);
|
|
85
|
+
const updated = await this.runner.run("git", ["--git-dir", mirror, "symbolic-ref", "HEAD", ref], { cwd: this.root, env: options.env, timeoutMs: 30_000, signal: options.signal });
|
|
86
|
+
if (updated.code !== 0)
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
const cloned = await this.runner.run("git", ["clone", "--quiet", "--local", "--no-hardlinks", "--no-tags", "--branch", branch, "--", mirror, options.checkout],
|
|
90
|
+
// Only this clone reads a local path, and only the mirror this runtime built.
|
|
91
|
+
{ cwd: node_path_1.default.dirname(options.checkout), env: { ...options.env, GIT_ALLOW_PROTOCOL: "file" }, timeoutMs: LOCAL_CLONE_TIMEOUT_MS, signal: options.signal });
|
|
92
|
+
if (cloned.code === 0) {
|
|
93
|
+
// The agent's `git fetch`/`git log origin/...` must see GitHub, not this machine's mirror path.
|
|
94
|
+
const repointed = await this.runner.run("git", ["-C", options.checkout, "remote", "set-url", "origin", options.url], { cwd: options.checkout, env: options.env, timeoutMs: 30_000, signal: options.signal });
|
|
95
|
+
if (repointed.code === 0)
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
this.options.log?.warn(`conversation checkout from mirror failed with exit ${cloned.code}; cloning directly`);
|
|
99
|
+
await node_fs_1.promises.rm(options.checkout, { recursive: true, force: true }).catch(() => undefined);
|
|
100
|
+
return false;
|
|
101
|
+
}));
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Builds the repository's mirror in the background if it does not exist.
|
|
105
|
+
* Called after a direct clone succeeded with the same credentials, so it
|
|
106
|
+
* never delays a turn and never runs for a repository this machine cannot read.
|
|
107
|
+
*/
|
|
108
|
+
warm(url, env, signal) {
|
|
109
|
+
if (this.paused || signal?.aborted || this.options.signal?.aborted)
|
|
110
|
+
return Promise.resolve();
|
|
111
|
+
const mirror = this.mirrorPath(url);
|
|
112
|
+
const pending = this.building.get(mirror);
|
|
113
|
+
if (pending)
|
|
114
|
+
return pending;
|
|
115
|
+
const controller = new AbortController();
|
|
116
|
+
const signals = [this.warming.signal, signal, this.options.signal].filter((s) => !!s);
|
|
117
|
+
const abort = () => controller.abort();
|
|
118
|
+
for (const source of signals) {
|
|
119
|
+
source.addEventListener("abort", abort, { once: true });
|
|
120
|
+
if (source.aborted)
|
|
121
|
+
abort();
|
|
122
|
+
}
|
|
123
|
+
const build = this.locked(mirror, () => this.withDiskLock(mirror, undefined, async () => {
|
|
124
|
+
if (controller.signal.aborted || await this.usableMirror(mirror))
|
|
125
|
+
return;
|
|
126
|
+
const partial = await node_fs_1.promises.mkdtemp(`${mirror}.partial-`);
|
|
127
|
+
try {
|
|
128
|
+
const result = await this.runner.run("git", ["clone", "--quiet", "--bare", "--no-tags", "--", url, partial], { cwd: this.root, env, timeoutMs: MIRROR_CLONE_TIMEOUT_MS, signal: controller.signal });
|
|
129
|
+
if (controller.signal.aborted)
|
|
130
|
+
return;
|
|
131
|
+
if (result.code !== 0 || !(await regularFile(node_path_1.default.join(partial, "HEAD")))) {
|
|
132
|
+
throw new Error(`git clone --bare failed with exit ${result.code}`);
|
|
133
|
+
}
|
|
134
|
+
await node_fs_1.promises.rename(partial, mirror);
|
|
135
|
+
}
|
|
136
|
+
finally {
|
|
137
|
+
await node_fs_1.promises.rm(partial, { recursive: true, force: true }).catch(() => undefined);
|
|
138
|
+
}
|
|
139
|
+
}))
|
|
140
|
+
// A credential helper can put secrets in stderr or spawn errors. Cache
|
|
141
|
+
// diagnostics must not copy those values into persistent runtime logs.
|
|
142
|
+
.catch(() => this.options.log?.warn("conversation mirror build failed; direct cloning remains available"))
|
|
143
|
+
.finally(() => {
|
|
144
|
+
for (const source of signals)
|
|
145
|
+
source.removeEventListener("abort", abort);
|
|
146
|
+
this.building.delete(mirror);
|
|
147
|
+
});
|
|
148
|
+
this.building.set(mirror, build);
|
|
149
|
+
return build;
|
|
150
|
+
}
|
|
151
|
+
resume() {
|
|
152
|
+
this.paused = false;
|
|
153
|
+
if (this.warming.signal.aborted)
|
|
154
|
+
this.warming = new AbortController();
|
|
155
|
+
}
|
|
156
|
+
stop() {
|
|
157
|
+
this.paused = true;
|
|
158
|
+
this.warming.abort();
|
|
159
|
+
}
|
|
160
|
+
async drain() {
|
|
161
|
+
this.stop();
|
|
162
|
+
await Promise.allSettled([...this.building.values()]);
|
|
163
|
+
}
|
|
164
|
+
/** Removes mirrors (and abandoned partial builds) no conversation has used for `maxAgeMs`. */
|
|
165
|
+
async prune(maxAgeMs, now = Date.now()) {
|
|
166
|
+
if (!(await directory(this.root)))
|
|
167
|
+
return;
|
|
168
|
+
const entries = await node_fs_1.promises.readdir(this.root, { withFileTypes: true }).catch(() => []);
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
if (!entry.isDirectory() || !/^[0-9a-f]{32}\.git(\.partial(?:-[A-Za-z0-9]+)?)?$/.test(entry.name))
|
|
171
|
+
continue;
|
|
172
|
+
const mirror = node_path_1.default.join(this.root, entry.name).replace(/\.partial(?:-[A-Za-z0-9]+)?$/, "");
|
|
173
|
+
if (this.locks.has(mirror))
|
|
174
|
+
continue;
|
|
175
|
+
await this.locked(mirror, () => this.withDiskLock(mirror, undefined, async () => {
|
|
176
|
+
const target = node_path_1.default.join(this.root, entry.name);
|
|
177
|
+
const stat = await node_fs_1.promises.lstat(target).catch(() => null);
|
|
178
|
+
if (!stat?.isDirectory() || now - stat.mtimeMs < maxAgeMs)
|
|
179
|
+
return;
|
|
180
|
+
await node_fs_1.promises.rm(target, { recursive: true, force: true }).catch(() => undefined);
|
|
181
|
+
}));
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
async usableMirror(mirror) {
|
|
185
|
+
return await directory(this.root) && await directory(mirror) && await regularFile(node_path_1.default.join(mirror, "HEAD"));
|
|
186
|
+
}
|
|
187
|
+
/** Other runtime processes fall back to direct clones while this cache is busy.
|
|
188
|
+
* A crashed holder leaves a stale lock; never steal it based only on age. */
|
|
189
|
+
async withDiskLock(mirror, unavailable, work) {
|
|
190
|
+
await node_fs_1.promises.mkdir(this.root, { recursive: true, mode: 0o700 });
|
|
191
|
+
if (!(await directory(this.root)))
|
|
192
|
+
return unavailable;
|
|
193
|
+
const lock = `${mirror}.lock`;
|
|
194
|
+
try {
|
|
195
|
+
await node_fs_1.promises.mkdir(lock, { mode: 0o700 });
|
|
196
|
+
}
|
|
197
|
+
catch (error) {
|
|
198
|
+
if (error.code === "EEXIST")
|
|
199
|
+
return unavailable;
|
|
200
|
+
throw error;
|
|
201
|
+
}
|
|
202
|
+
try {
|
|
203
|
+
return await work();
|
|
204
|
+
}
|
|
205
|
+
finally {
|
|
206
|
+
await node_fs_1.promises.rm(lock, { recursive: true, force: true });
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
locked(mirror, work) {
|
|
210
|
+
const previous = this.locks.get(mirror) ?? Promise.resolve();
|
|
211
|
+
const next = previous.catch(() => undefined).then(work);
|
|
212
|
+
const tail = next.catch(() => undefined);
|
|
213
|
+
this.locks.set(mirror, tail);
|
|
214
|
+
void tail.then(() => { if (this.locks.get(mirror) === tail)
|
|
215
|
+
this.locks.delete(mirror); });
|
|
216
|
+
return next;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
exports.ConversationMirrors = ConversationMirrors;
|
|
220
|
+
async function directory(file) {
|
|
221
|
+
return node_fs_1.promises.lstat(file).then((stat) => stat.isDirectory(), () => false);
|
|
222
|
+
}
|
|
223
|
+
async function regularFile(file) {
|
|
224
|
+
return node_fs_1.promises.lstat(file).then((stat) => stat.isFile(), () => false);
|
|
225
|
+
}
|
|
@@ -79,6 +79,8 @@ function conversationRoot(workspaceRoot, conversationId) {
|
|
|
79
79
|
* The clone uses the machine's own git credentials (the same ones a local
|
|
80
80
|
* `git clone` would use), never a Navarch server-side provider credential.
|
|
81
81
|
* A repository this machine cannot read fails the turn instead of falling back.
|
|
82
|
+
* With `mirrors`, a new checkout is seeded from this machine's refreshed
|
|
83
|
+
* mirror of the repository instead of a full network clone (see mirror.cts).
|
|
82
84
|
*/
|
|
83
85
|
async function prepareConversationWorkspace(options) {
|
|
84
86
|
const url = validRepositoryUrl(options.repositoryUrl);
|
|
@@ -126,10 +128,15 @@ async function prepareConversationWorkspace(options) {
|
|
|
126
128
|
GIT_CONFIG_KEY_0: "core.hooksPath",
|
|
127
129
|
GIT_CONFIG_VALUE_0: process.platform === "win32" ? "NUL" : "/dev/null",
|
|
128
130
|
};
|
|
129
|
-
const
|
|
130
|
-
if (
|
|
131
|
-
await
|
|
132
|
-
|
|
131
|
+
const seeded = await options.mirrors?.seedCheckout({ url, branch, checkout: workspace.checkout, env, signal: options.signal }) ?? false;
|
|
132
|
+
if (!seeded) {
|
|
133
|
+
const result = await runner.run("git", ["clone", "--quiet", "--filter=blob:none", "--no-tags", ...(branch ? ["--branch", branch] : []), "--", url, workspace.checkout], { cwd: root, env, timeoutMs: CLONE_TIMEOUT_MS, signal: options.signal });
|
|
134
|
+
if (result.code !== 0) {
|
|
135
|
+
await node_fs_1.promises.rm(workspace.checkout, { recursive: true, force: true }).catch(() => undefined);
|
|
136
|
+
throw new Error(`git clone failed with exit ${result.code}: ${result.stderr.slice(-500)}`);
|
|
137
|
+
}
|
|
138
|
+
// This machine can read the repository: build the mirror for the next conversation.
|
|
139
|
+
void options.mirrors?.warm(url, env, options.signal);
|
|
133
140
|
}
|
|
134
141
|
await node_fs_1.promises.writeFile(marker, identity, { encoding: "utf8", mode: 0o600 });
|
|
135
142
|
return workspace;
|
package/dist/exit-conditions.cjs
CHANGED
|
@@ -22,7 +22,7 @@ exports.mapExitCondition = mapExitCondition;
|
|
|
22
22
|
exports.AGENT_AUTH_FAILURE_PREFIX = "Coding agent authentication failed";
|
|
23
23
|
function agentAuthFailureReport(detail) {
|
|
24
24
|
return `${exports.AGENT_AUTH_FAILURE_PREFIX}: ${detail} ` +
|
|
25
|
-
"Log the coding agent in again on this machine (`claude /login`, `codex login`,
|
|
25
|
+
"Log the coding agent in again on this machine (`claude /login`, `codex login`, an interactive `agy` session, or open `muse` and run `/login`), then resume the agent.";
|
|
26
26
|
}
|
|
27
27
|
/**
|
|
28
28
|
* Best-effort parse of `claude -p --output-format json` (or `stream-json`,
|
|
@@ -0,0 +1,167 @@
|
|
|
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.bridgeMuseMcp = bridgeMuseMcp;
|
|
7
|
+
exports.main = main;
|
|
8
|
+
const promises_1 = require("node:fs/promises");
|
|
9
|
+
const node_fs_1 = require("node:fs");
|
|
10
|
+
const node_crypto_1 = require("node:crypto");
|
|
11
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
12
|
+
function object(value) {
|
|
13
|
+
return value !== null && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* A tools-only MCP transport bridge. Muse receives only this executable and
|
|
17
|
+
* an expiring task-owned file path. Credentials never enter MSP commands or
|
|
18
|
+
* Muse's durable journal. No new listener, OAuth flow or persistent grant.
|
|
19
|
+
*/
|
|
20
|
+
async function bridgeMuseMcp(downstream, upstream) {
|
|
21
|
+
let initializeId;
|
|
22
|
+
let ending = false;
|
|
23
|
+
let failed = false;
|
|
24
|
+
let finish;
|
|
25
|
+
const done = new Promise((resolve) => { finish = resolve; });
|
|
26
|
+
const close = async () => {
|
|
27
|
+
if (ending)
|
|
28
|
+
return;
|
|
29
|
+
ending = true;
|
|
30
|
+
await Promise.allSettled([upstream.close(), downstream.close()]);
|
|
31
|
+
finish();
|
|
32
|
+
};
|
|
33
|
+
const fail = () => {
|
|
34
|
+
if (ending)
|
|
35
|
+
return;
|
|
36
|
+
failed = true;
|
|
37
|
+
// Upstream errors can contain endpoints, headers or provider response
|
|
38
|
+
// bodies. Never forward/log them to Muse or its retained transcript.
|
|
39
|
+
process.stderr.write("Navarch MCP bridge connection failed.\n");
|
|
40
|
+
void close();
|
|
41
|
+
};
|
|
42
|
+
const reject = (message, target) => {
|
|
43
|
+
if ("id" in message)
|
|
44
|
+
void target.send({ jsonrpc: "2.0", id: message.id, error: { code: -32601, message: "This Navarch bridge supports tools only" } }).catch(fail);
|
|
45
|
+
};
|
|
46
|
+
downstream.onmessage = (message) => {
|
|
47
|
+
if (!("method" in message)) {
|
|
48
|
+
reject(message, downstream);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
if (!["initialize", "notifications/initialized", "notifications/cancelled", "ping", "tools/list", "tools/call"].includes(message.method)) {
|
|
52
|
+
reject(message, downstream);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
if (message.method === "initialize" && "id" in message) {
|
|
56
|
+
initializeId = message.id;
|
|
57
|
+
// Do not advertise sampling, elicitation, roots, or any other route for
|
|
58
|
+
// an upstream server to ask the unattended model to approve something.
|
|
59
|
+
message = { ...message, params: { ...message.params, capabilities: {} } };
|
|
60
|
+
}
|
|
61
|
+
void upstream.send(message).catch(fail);
|
|
62
|
+
};
|
|
63
|
+
upstream.onmessage = (message) => {
|
|
64
|
+
let outgoing = message;
|
|
65
|
+
if ("method" in message && "id" in message) {
|
|
66
|
+
reject(message, upstream);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
if ("method" in message && !["notifications/tools/list_changed", "notifications/progress", "notifications/cancelled"].includes(message.method))
|
|
70
|
+
return;
|
|
71
|
+
if ("error" in message) {
|
|
72
|
+
outgoing = { jsonrpc: "2.0", id: message.id, error: { code: message.error.code, message: "Navarch MCP upstream request failed" } };
|
|
73
|
+
}
|
|
74
|
+
if ("result" in message && message.id === initializeId) {
|
|
75
|
+
const result = object(message.result);
|
|
76
|
+
if (!result || typeof result.protocolVersion !== "string") {
|
|
77
|
+
fail();
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const http = upstream;
|
|
81
|
+
http.setProtocolVersion?.(result.protocolVersion);
|
|
82
|
+
outgoing = { ...message, result: { ...result, capabilities: { ...(object(result.capabilities)?.tools ? { tools: object(result.capabilities).tools } : {}) } } };
|
|
83
|
+
}
|
|
84
|
+
void downstream.send(outgoing).catch(fail);
|
|
85
|
+
};
|
|
86
|
+
upstream.onerror = fail;
|
|
87
|
+
downstream.onerror = fail;
|
|
88
|
+
upstream.onclose = () => { void close(); };
|
|
89
|
+
downstream.onclose = () => { void close(); };
|
|
90
|
+
const onEnd = () => { void close(); };
|
|
91
|
+
process.stdin.once("end", onEnd);
|
|
92
|
+
process.once("SIGTERM", onEnd);
|
|
93
|
+
process.once("SIGINT", onEnd);
|
|
94
|
+
try {
|
|
95
|
+
await upstream.start();
|
|
96
|
+
await downstream.start();
|
|
97
|
+
await done;
|
|
98
|
+
if (failed)
|
|
99
|
+
throw new Error("Navarch MCP bridge connection failed");
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
await close();
|
|
103
|
+
process.stdin.removeListener("end", onEnd);
|
|
104
|
+
process.removeListener("SIGTERM", onEnd);
|
|
105
|
+
process.removeListener("SIGINT", onEnd);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function main(args = process.argv.slice(2)) {
|
|
109
|
+
try {
|
|
110
|
+
const [configPath, name, digest] = args;
|
|
111
|
+
if (!configPath || !node_path_1.default.isAbsolute(configPath) || !name || !digest || !/^[a-f0-9]{64}$/.test(digest) || args.length !== 3)
|
|
112
|
+
throw new Error("Invalid bridge arguments");
|
|
113
|
+
const directory = await (0, promises_1.stat)(node_path_1.default.dirname(configPath));
|
|
114
|
+
if (!directory.isDirectory() || (directory.mode & 0o077) !== 0)
|
|
115
|
+
throw new Error("Non-private bridge directory");
|
|
116
|
+
const file = await (0, promises_1.open)(configPath, node_fs_1.constants.O_RDONLY | (node_fs_1.constants.O_NOFOLLOW ?? 0));
|
|
117
|
+
let content;
|
|
118
|
+
try {
|
|
119
|
+
const metadata = await file.stat();
|
|
120
|
+
if (!metadata.isFile() || (metadata.mode & 0o077) !== 0 || metadata.size > 1024 * 1024)
|
|
121
|
+
throw new Error("Non-private bridge file");
|
|
122
|
+
content = await file.readFile("utf8");
|
|
123
|
+
}
|
|
124
|
+
finally {
|
|
125
|
+
await file.close();
|
|
126
|
+
}
|
|
127
|
+
// Bind the transport/endpoint/headers to the exact configuration chosen
|
|
128
|
+
// by Navarch. A later file replacement cannot redirect a credential.
|
|
129
|
+
if ((0, node_crypto_1.createHash)("sha256").update(content).digest("hex") !== digest)
|
|
130
|
+
throw new Error("Bridge configuration changed");
|
|
131
|
+
const source = object(JSON.parse(content));
|
|
132
|
+
const server = object(source?.[name]);
|
|
133
|
+
if (!server)
|
|
134
|
+
throw new Error("Missing MCP server");
|
|
135
|
+
const { StdioServerTransport } = await import("@modelcontextprotocol/sdk/server/stdio.js");
|
|
136
|
+
let upstream;
|
|
137
|
+
if (server.transport === "streamableHttp" && typeof server.url === "string") {
|
|
138
|
+
const { StreamableHTTPClientTransport } = await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
|
|
139
|
+
const endpoint = new URL(server.url);
|
|
140
|
+
if (!["http:", "https:"].includes(endpoint.protocol) || endpoint.username || endpoint.password)
|
|
141
|
+
throw new Error("Invalid MCP URL");
|
|
142
|
+
upstream = new StreamableHTTPClientTransport(endpoint, {
|
|
143
|
+
requestInit: { headers: server.headers },
|
|
144
|
+
// The SDK's SSE GET path does not inherit requestInit.redirect.
|
|
145
|
+
// Enforce the exact authorized endpoint for every HTTP method.
|
|
146
|
+
fetch: (input, init) => {
|
|
147
|
+
const url = new URL(input);
|
|
148
|
+
if (url.href !== endpoint.href)
|
|
149
|
+
return Promise.reject(new Error("MCP endpoint changed"));
|
|
150
|
+
return fetch(input, { ...init, redirect: "error" });
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
else if (server.transport === "stdio" && typeof server.command === "string") {
|
|
155
|
+
const { StdioClientTransport, getDefaultEnvironment } = await import("@modelcontextprotocol/sdk/client/stdio.js");
|
|
156
|
+
upstream = new StdioClientTransport({ command: server.command, args: server.args,
|
|
157
|
+
env: { ...getDefaultEnvironment(), ...server.env }, stderr: "ignore" });
|
|
158
|
+
}
|
|
159
|
+
else
|
|
160
|
+
throw new Error("Unsupported MCP transport");
|
|
161
|
+
await bridgeMuseMcp(new StdioServerTransport(), upstream);
|
|
162
|
+
}
|
|
163
|
+
catch {
|
|
164
|
+
process.stderr.write("Navarch MCP bridge could not start; check its task-owned configuration and installed runtime.\n");
|
|
165
|
+
process.exitCode = 1;
|
|
166
|
+
}
|
|
167
|
+
}
|