@sagentlab/navarch-runtime 0.1.90 → 0.1.92

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
@@ -460,6 +460,9 @@ unchanged across the deployment.
460
460
  | `NAVARCH_QODER_EXTRA_ARGS` | — | Comma list of extra CLI args appended after the unattended defaults (`--setting-sources`, `--permission-mode auto`, `--output-format json`). An explicit `--output-format`, `--permission-mode`, `--settings`, `--model`, or `--reasoning-effort` here replaces the per-session default. Qoder selects its own account models; pin one via `--model` here or `connect --model` to override. |
461
461
  | `NAVARCH_MCP_CONFIG_PATH` | — | Path to the platform MCP config passed as `--mcp-config`. |
462
462
  | `NAVARCH_WORKTREE_GUARD` | on | Host-mode sessions get an adapter-native per-session worktree boundary guard (see below). Set `off` to disable it for every runtime on the machine — required to run OpenCode on the host. |
463
+ | `NAVARCH_NAVI_CONVERSATIONS` | on | Serve read-only Navi repository conversations on this machine. Only the `claude-code` adapter on the host sandbox with the worktree guard on is supported; `navarch-runtime doctor` prints the `navi_chat:` state and reason. Set `off` to stop polling for conversations. |
464
+ | `NAVARCH_CONVERSATION_POLL_MS` | `5000` | Idle poll interval for queued conversation turns (1000–30000). |
465
+ | `NAVARCH_CONVERSATION_TURN_TIMEOUT_MS` | `540000` | Time limit for one conversation turn's agent process (at most nine minutes). |
463
466
  | `NAVARCH_GUARD_EXTRA_ROOTS` | — | `path.delimiter`-separated (`:` on POSIX) extra directories the worktree guard allows beyond the session worktree, shared bare repo, and temp dirs. |
464
467
 
465
468
  ## Worktree boundary guard (host mode)
@@ -0,0 +1,269 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.claudeConversationRunner = exports.ClaudeStreamParser = exports.CLAUDE_CONVERSATION_TOOLS = void 0;
4
+ exports.claudeConversationArgs = claudeConversationArgs;
5
+ exports.runClaudeConversationTurn = runClaudeConversationTurn;
6
+ const node_child_process_1 = require("node:child_process");
7
+ const node_perf_hooks_1 = require("node:perf_hooks");
8
+ const kill_agent_cjs_1 = require("../kill-agent.cjs");
9
+ /**
10
+ * Read-only tools a Navi repository question may use. Anything not listed
11
+ * (Bash, Edit, Write, WebFetch, Task, MCP tools) is absent from the model's
12
+ * tool list, so a question cannot itself authorize a code change, PR,
13
+ * deployment, or new task. Machine-wide NAVARCH_CLAUDE_EXTRA_ARGS are
14
+ * deliberately not applied here: they could add --dangerously-skip-permissions
15
+ * or --allowedTools and widen this boundary.
16
+ */
17
+ exports.CLAUDE_CONVERSATION_TOOLS = "Read,Grep,Glob";
18
+ const STDERR_TAIL_BYTES = 8 * 1024;
19
+ /**
20
+ * Incremental parser for `claude -p --output-format stream-json --verbose
21
+ * --include-partial-messages`. Chunks may split a record anywhere. Text is
22
+ * emitted from `text_delta` records the moment they are parsed. A complete
23
+ * `assistant` message is used only for a message that streamed no deltas
24
+ * (a CLI without partial-message support), so text is never emitted twice.
25
+ */
26
+ class ClaudeStreamParser {
27
+ emit;
28
+ onRecord;
29
+ buffer = "";
30
+ currentMessageId = null;
31
+ streamedMessages = new Set();
32
+ emittedText = false;
33
+ separatorPending = false;
34
+ result = null;
35
+ /** `onRecord` fires for each complete record before any event it produces is emitted. */
36
+ constructor(emit, onRecord = () => undefined) {
37
+ this.emit = emit;
38
+ this.onRecord = onRecord;
39
+ }
40
+ /** Returns the number of complete records parsed from this chunk. */
41
+ push(chunk) {
42
+ this.buffer += chunk;
43
+ let records = 0;
44
+ for (;;) {
45
+ const newline = this.buffer.indexOf("\n");
46
+ if (newline < 0)
47
+ break;
48
+ const line = this.buffer.slice(0, newline).trim();
49
+ this.buffer = this.buffer.slice(newline + 1);
50
+ if (!line)
51
+ continue;
52
+ records += 1;
53
+ this.onRecord();
54
+ this.record(line);
55
+ }
56
+ return records;
57
+ }
58
+ /** Parses a final record that lacks a trailing newline. */
59
+ flush() {
60
+ const line = this.buffer.trim();
61
+ this.buffer = "";
62
+ if (line) {
63
+ this.onRecord();
64
+ this.record(line);
65
+ }
66
+ }
67
+ /** Used when the agent finished without any streamed text. */
68
+ get hasEmittedText() {
69
+ return this.emittedText;
70
+ }
71
+ text(text) {
72
+ if (!text)
73
+ return;
74
+ this.emit({ type: "text", text: this.separatorPending ? `\n\n${text}` : text });
75
+ this.separatorPending = false;
76
+ this.emittedText = true;
77
+ }
78
+ record(line) {
79
+ let parsed;
80
+ try {
81
+ parsed = JSON.parse(line);
82
+ }
83
+ catch {
84
+ return;
85
+ }
86
+ if (!parsed || typeof parsed !== "object")
87
+ return;
88
+ const record = parsed;
89
+ switch (record.type) {
90
+ case "stream_event":
91
+ this.streamEvent(record.event, record.parent_tool_use_id);
92
+ break;
93
+ case "assistant":
94
+ this.assistant(record);
95
+ break;
96
+ case "user":
97
+ this.toolResults(record);
98
+ break;
99
+ case "result":
100
+ this.result = {
101
+ isError: record.is_error === true || (typeof record.subtype === "string" && record.subtype !== "success"),
102
+ text: typeof record.result === "string" ? record.result : null,
103
+ };
104
+ if (this.result.isError)
105
+ this.emit({ type: "failure", reason: String(record.subtype ?? "error") });
106
+ break;
107
+ }
108
+ }
109
+ streamEvent(event, parentToolUseId) {
110
+ // Sub-agent output is not part of the user's answer.
111
+ if (!event || typeof event !== "object" || parentToolUseId)
112
+ return;
113
+ const e = event;
114
+ const message = asRec(e.message);
115
+ const delta = asRec(e.delta);
116
+ if (e.type === "message_start") {
117
+ this.currentMessageId = typeof message?.id === "string" ? message.id : null;
118
+ }
119
+ else if (e.type === "content_block_start") {
120
+ const block = asRec(e.content_block);
121
+ if (block?.type === "tool_use" && typeof block.name === "string") {
122
+ this.emit({ type: "tool_status", tool: block.name, phase: "started" });
123
+ }
124
+ else if (block?.type === "text" && this.emittedText) {
125
+ this.separatorPending = true;
126
+ }
127
+ }
128
+ else if (e.type === "content_block_delta" && delta?.type === "text_delta" && typeof delta.text === "string") {
129
+ if (this.currentMessageId)
130
+ this.streamedMessages.add(this.currentMessageId);
131
+ this.text(delta.text);
132
+ }
133
+ }
134
+ assistant(record) {
135
+ const message = asRec(record.message);
136
+ if (record.parent_tool_use_id || !message || !Array.isArray(message.content))
137
+ return;
138
+ if (typeof message.id === "string" && this.streamedMessages.has(message.id))
139
+ return;
140
+ for (const item of message.content) {
141
+ const block = asRec(item);
142
+ if (block?.type === "text" && typeof block.text === "string") {
143
+ if (this.emittedText)
144
+ this.separatorPending = true;
145
+ this.text(block.text);
146
+ }
147
+ else if (block?.type === "tool_use" && typeof block.name === "string" && !(typeof message.id === "string" && this.streamedMessages.has(message.id))) {
148
+ this.emit({ type: "tool_status", tool: block.name, phase: "started" });
149
+ }
150
+ }
151
+ }
152
+ toolResults(record) {
153
+ const content = asRec(record.message)?.content;
154
+ if (!Array.isArray(content))
155
+ return;
156
+ for (const item of content) {
157
+ const block = asRec(item);
158
+ if (block?.type === "tool_result" && block.is_error === true) {
159
+ this.emit({ type: "tool_status", tool: "tool", phase: "failed" });
160
+ }
161
+ }
162
+ }
163
+ }
164
+ exports.ClaudeStreamParser = ClaudeStreamParser;
165
+ function asRec(value) {
166
+ return value && typeof value === "object" ? value : null;
167
+ }
168
+ /** Fixed, read-only argument list for one conversation turn. */
169
+ function claudeConversationArgs(options) {
170
+ const args = [
171
+ "-p",
172
+ "--output-format", "stream-json",
173
+ "--verbose",
174
+ "--include-partial-messages",
175
+ // The server holds the bounded history. A local Claude transcript would
176
+ // be a second, unbounded private copy of the conversation.
177
+ "--no-session-persistence",
178
+ // No operator hooks, project settings or MCP servers (including the
179
+ // lease-scoped Navarch task tools) in a conversation.
180
+ "--setting-sources", "",
181
+ "--strict-mcp-config",
182
+ "--tools", exports.CLAUDE_CONVERSATION_TOOLS,
183
+ "--permission-mode", "default",
184
+ ];
185
+ if (options.settingsPath)
186
+ args.push("--settings", options.settingsPath);
187
+ if (options.model)
188
+ args.push("--model", options.model);
189
+ return args;
190
+ }
191
+ async function runClaudeConversationTurn(options) {
192
+ const idle = (aborted) => ({
193
+ exitCode: null, timedOut: false, aborted, succeeded: false, firstEventMs: null, firstTextMs: null, stderr: "",
194
+ });
195
+ if (options.signal.aborted)
196
+ return idle(true);
197
+ const startedAt = node_perf_hooks_1.performance.now();
198
+ let firstEventMs = null;
199
+ let firstTextMs = null;
200
+ let timedOut = false;
201
+ let aborted = false;
202
+ let closed = false;
203
+ let stderr = "";
204
+ const deliver = (event) => {
205
+ // After stop, timeout or exit, late output must not reach the caller.
206
+ if (closed || aborted || timedOut)
207
+ return;
208
+ if (event.type === "text" && firstTextMs === null)
209
+ firstTextMs = node_perf_hooks_1.performance.now() - startedAt;
210
+ options.onEvent(event);
211
+ };
212
+ const parser = new ClaudeStreamParser(deliver, () => {
213
+ if (firstEventMs === null)
214
+ firstEventMs = node_perf_hooks_1.performance.now() - startedAt;
215
+ });
216
+ return new Promise((resolve) => {
217
+ const child = (0, node_child_process_1.spawn)(options.bin, claudeConversationArgs(options), {
218
+ detached: process.platform !== "win32",
219
+ cwd: options.cwd,
220
+ env: options.env,
221
+ stdio: ["pipe", "pipe", "pipe"],
222
+ });
223
+ child.stdin?.on("error", () => undefined);
224
+ child.stdin?.end(options.prompt);
225
+ options.onProcessStarted?.(child.pid);
226
+ const timer = options.timeoutMs > 0 ? setTimeout(() => {
227
+ timedOut = true;
228
+ (0, kill_agent_cjs_1.killAgent)(child);
229
+ }, options.timeoutMs) : undefined;
230
+ const onAbort = () => {
231
+ aborted = true;
232
+ (0, kill_agent_cjs_1.killAgent)(child);
233
+ };
234
+ options.signal.addEventListener("abort", onAbort, { once: true });
235
+ child.stdout.setEncoding("utf8");
236
+ child.stdout.on("data", (chunk) => {
237
+ parser.push(chunk);
238
+ });
239
+ child.stderr.setEncoding("utf8");
240
+ child.stderr.on("data", (chunk) => {
241
+ stderr = (stderr + chunk).slice(-STDERR_TAIL_BYTES);
242
+ });
243
+ const finish = (exitCode) => {
244
+ if (closed)
245
+ return;
246
+ parser.flush();
247
+ // An answer delivered only in the final result record (no stream deltas).
248
+ if (!parser.hasEmittedText && parser.result && !parser.result.isError && parser.result.text) {
249
+ deliver({ type: "text", text: parser.result.text });
250
+ }
251
+ closed = true;
252
+ clearTimeout(timer);
253
+ options.signal.removeEventListener("abort", onAbort);
254
+ resolve({
255
+ exitCode, timedOut, aborted,
256
+ succeeded: exitCode === 0 && !aborted && !timedOut && !!parser.result && !parser.result.isError,
257
+ firstEventMs, firstTextMs, stderr,
258
+ });
259
+ };
260
+ child.on("error", (err) => {
261
+ stderr = (stderr + `\n${String(err)}`).slice(-STDERR_TAIL_BYTES);
262
+ finish(null);
263
+ });
264
+ child.on("close", (code) => finish(code));
265
+ if (options.signal.aborted)
266
+ onAbort();
267
+ });
268
+ }
269
+ exports.claudeConversationRunner = { run: runClaudeConversationTurn };
@@ -5,6 +5,7 @@ exports.runClaudeCodeAdapter = runClaudeCodeAdapter;
5
5
  const node_child_process_1 = require("node:child_process");
6
6
  const exit_conditions_cjs_1 = require("../exit-conditions.cjs");
7
7
  const kill_agent_cjs_1 = require("../kill-agent.cjs");
8
+ const claude_conversation_cjs_1 = require("./claude-conversation.cjs");
8
9
  const adapter_capacity_cjs_1 = require("../adapter-capacity.cjs");
9
10
  const DEFAULT_CLAUDE_MODEL = "claude-opus-5-5";
10
11
  const FALLBACK_CLAUDE_MODEL = "claude-opus-5-5";
@@ -281,4 +282,5 @@ function shellQuote(value) {
281
282
  exports.claudeCodeAdapter = {
282
283
  agentType: "claude-code",
283
284
  run: runClaudeCodeAdapter,
285
+ conversation: claude_conversation_cjs_1.claudeConversationRunner,
284
286
  };
package/dist/api.cjs CHANGED
@@ -158,6 +158,20 @@ class NavarchApiClient {
158
158
  throw new Error("issueSecrets: empty response from control plane.");
159
159
  return result;
160
160
  }
161
+ /** Navi runtime v1: advertises conversation support for 90 s and returns queued turns for this machine. */
162
+ async pollConversations(machineId) {
163
+ const result = await this.request("POST", `/api/machines/${encodeURIComponent(machineId)}/conversations`, { protocol: 1 });
164
+ if (!result)
165
+ throw new Error("pollConversations: empty response from control plane.");
166
+ return result;
167
+ }
168
+ /** Navi runtime v1 claim / renew / append for one turn. */
169
+ async conversationCommand(machineId, conversationId, command) {
170
+ const result = await this.request("POST", `/api/machines/${encodeURIComponent(machineId)}/conversations/${encodeURIComponent(conversationId)}`, command);
171
+ if (!result)
172
+ throw new Error("conversationCommand: empty response from control plane.");
173
+ return result;
174
+ }
161
175
  /** Create a signed upload target for this lease's redacted transcript. */
162
176
  async getTranscriptUploadUrl(leaseId) {
163
177
  const result = await this.request("POST", `/api/dispatch/${encodeURIComponent(leaseId)}/transcript-upload-url`, {});
@@ -6,6 +6,7 @@ const api_cjs_1 = require("./api.cjs");
6
6
  const logger_cjs_1 = require("./logger.cjs");
7
7
  const log = (0, logger_cjs_1.createLogger)("claim");
8
8
  const MAX_CLAIM_BACKOFF_MS = 60_000;
9
+ const pendingClaimSlot = "pending-claim-request";
9
10
  /**
10
11
  * Polls dispatch/claim on an interval, gated by available capacity
11
12
  * (implementation-plan.md WP-07 "Claim loop: poll dispatch, receive context
@@ -69,13 +70,24 @@ class ClaimLoop {
69
70
  // the dispatcher records it on the lease and excludes it from review
70
71
  // tasks of PRs this session authors.
71
72
  const sessionId = (0, node_crypto_1.randomUUID)();
72
- const claimed = await this.api.claim({
73
- available_capacity: this.capacity.available(),
74
- capabilities: this.config.capabilities,
75
- agent_type: this.config.agentType,
76
- runtimes: this.config.runtimes,
77
- session_id: sessionId,
78
- });
73
+ // Hold the slot for the duration of the claim request. Navi conversation
74
+ // turns also take slots; without this reservation one could take the last
75
+ // slot while the control plane is granting a lease, stranding that lease.
76
+ const availableCapacity = this.capacity.available();
77
+ this.capacity.acquire(pendingClaimSlot);
78
+ let claimed;
79
+ try {
80
+ claimed = await this.api.claim({
81
+ available_capacity: availableCapacity,
82
+ capabilities: this.config.capabilities,
83
+ agent_type: this.config.agentType,
84
+ runtimes: this.config.runtimes,
85
+ session_id: sessionId,
86
+ });
87
+ }
88
+ finally {
89
+ this.capacity.release(pendingClaimSlot);
90
+ }
79
91
  if (this.consecutiveFailures > 0) {
80
92
  log.info(`claim polling recovered after ${this.consecutiveFailures} failed attempt(s)`);
81
93
  }
package/dist/cli.cjs CHANGED
@@ -12,6 +12,8 @@ const api_cjs_1 = require("./api.cjs");
12
12
  const capacity_cjs_1 = require("./capacity.cjs");
13
13
  const heartbeat_loop_cjs_1 = require("./heartbeat-loop.cjs");
14
14
  const claim_loop_cjs_1 = require("./claim-loop.cjs");
15
+ const loop_cjs_1 = require("./conversation/loop.cjs");
16
+ const capability_cjs_1 = require("./conversation/capability.cjs");
15
17
  const session_cjs_1 = require("./session.cjs");
16
18
  const sandbox_cjs_1 = require("./sandbox.cjs");
17
19
  const logger_cjs_1 = require("./logger.cjs");
@@ -267,6 +269,13 @@ async function startCommand(flags) {
267
269
  return (0, session_cjs_1.runSession)({ api, config, shutdownSignal: runtimeShutdown.signal }, claimed, sessionId)
268
270
  .finally(() => activeSessionIds.delete(sessionId));
269
271
  });
272
+ const conversationLoop = new loop_cjs_1.ConversationLoop({
273
+ api,
274
+ machineId: identity.machine_id,
275
+ config,
276
+ capacity,
277
+ shutdownSignal: runtimeShutdown.signal,
278
+ });
270
279
  const bootId = (0, node_crypto_1.randomUUID)();
271
280
  const updateCoordinatorRef = {};
272
281
  let readySent = false;
@@ -282,6 +291,7 @@ async function startCommand(flags) {
282
291
  }
283
292
  }, (status) => {
284
293
  claimLoop.stop();
294
+ conversationLoop.stop();
285
295
  worktreeJanitor.stop();
286
296
  log.error(status === 410
287
297
  ? `agent ${identity.name} has been removed from the fleet; it can no longer claim tasks.`
@@ -298,12 +308,14 @@ async function startCommand(flags) {
298
308
  updateCoordinatorRef.current = new update_coordinator_cjs_1.RuntimeUpdateCoordinator({
299
309
  config,
300
310
  claimLoop,
311
+ conversationLoop,
301
312
  capacity,
302
313
  heartbeat,
303
314
  });
304
315
  heartbeat.start();
305
316
  worktreeJanitor.start();
306
317
  claimLoop.start();
318
+ conversationLoop.start();
307
319
  log.info(`navarch-runtime started: machine=${identity.name} agent=${config.agentType} max_sessions=${config.maxSessions} api_base=${identity.api_base}`);
308
320
  let shuttingDown = false;
309
321
  const shutdown = () => {
@@ -316,6 +328,7 @@ async function startCommand(flags) {
316
328
  log.info("shutting down: draining active sessions...");
317
329
  heartbeat.setUpdateState("draining");
318
330
  await claimLoop.drain();
331
+ await conversationLoop.drain();
319
332
  await capacity.waitForIdle();
320
333
  worktreeJanitor.stop();
321
334
  heartbeat.stop();
@@ -391,6 +404,8 @@ async function doctorCommand(flags) {
391
404
  console.log(`sandbox_mode: ${config.sandboxMode}`);
392
405
  console.log(`worktree_guard: ${config.worktreeGuard ? "on" : "off"}`);
393
406
  console.log(`runtimes: ${config.runtimes?.join(", ") ?? config.agentType}`);
407
+ const conversation = (0, capability_cjs_1.conversationCapability)(config);
408
+ console.log(`navi_chat: ${conversation.state.toUpperCase()}${conversation.state === "unsupported" ? ` — ${conversation.reason}` : ` (${config.agentType}, read-only)`}`);
394
409
  console.log(`docker: ${dockerOk ? "available" : "NOT AVAILABLE (docker-backed sessions will fail)"}`);
395
410
  const browserProbe = await (0, browser_probe_cjs_1.probeBrowserCapability)(config);
396
411
  console.log(`browser: ${browserProbe.status.toUpperCase()}${browserProbe.reason ? ` — ${browserProbe.reason}` : ""}`);
package/dist/config.cjs CHANGED
@@ -178,6 +178,11 @@ function loadRuntimeConfig(env = process.env) {
178
178
  // by default; operators who would rather share nothing can opt out.
179
179
  reportUsageLimits: !["off", "false", "0"].includes(env.NAVARCH_USAGE_LIMITS ?? ""),
180
180
  browserProbe: !["off", "false", "0"].includes(env.NAVARCH_BROWSER_PROBE ?? ""),
181
+ naviConversations: !["off", "false", "0"].includes(env.NAVARCH_NAVI_CONVERSATIONS ?? ""),
182
+ // The server stops waiting for a capability advertisement after 90 s and
183
+ // asks for a poll at least every 30 s; the floor only bounds control-plane load.
184
+ conversationPollIntervalMs: Math.min(30_000, Math.max(1_000, envInt(env, "NAVARCH_CONVERSATION_POLL_MS", 5_000))),
185
+ conversationTurnTimeoutMs: Math.min(9 * 60_000, envInt(env, "NAVARCH_CONVERSATION_TURN_TIMEOUT_MS", 9 * 60_000)),
181
186
  codexHome: defaultCodexHome(env),
182
187
  claudeConfigPath: defaultClaudeConfigPath(env),
183
188
  };
@@ -0,0 +1,53 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.conversationCapability = conversationCapability;
4
+ exports.conversationSupportMatrix = conversationSupportMatrix;
5
+ const index_cjs_1 = require("../adapters/index.cjs");
6
+ /**
7
+ * Whether this runtime can execute a Navi conversation turn, with the reason
8
+ * when it cannot. The runtime polls (and so advertises conversation support
9
+ * to the control plane) only when this returns `supported`.
10
+ *
11
+ * A conversation always runs on the machine's configured primary adapter
12
+ * (`agentType`). An unsupported primary adapter is reported, never replaced
13
+ * by another installed agent: the user selected this runtime for the agent it
14
+ * runs.
15
+ */
16
+ function conversationCapability(config) {
17
+ if (!config.naviConversations) {
18
+ return { state: "unsupported", reason: "Disabled by NAVARCH_NAVI_CONVERSATIONS=off." };
19
+ }
20
+ const adapter = (0, index_cjs_1.selectAdapter)(config.agentType);
21
+ if (!adapter.conversation) {
22
+ return {
23
+ state: "unsupported",
24
+ reason: `The ${config.agentType} adapter has no streamed read-only conversation path.`,
25
+ };
26
+ }
27
+ if (config.sandboxMode !== "host") {
28
+ return {
29
+ state: "unsupported",
30
+ reason: "Conversations run on the host; NAVARCH_SANDBOX_MODE=docker is not supported yet.",
31
+ };
32
+ }
33
+ if (!config.worktreeGuard) {
34
+ return {
35
+ state: "unsupported",
36
+ reason: "Conversations need the worktree read boundary; NAVARCH_WORKTREE_GUARD is off.",
37
+ };
38
+ }
39
+ return { state: "supported" };
40
+ }
41
+ /** Per-adapter support table for docs and diagnostics (host sandbox, guard on, feature enabled). */
42
+ function conversationSupportMatrix() {
43
+ const agents = ["claude-code", "codex", "gemini", "opencode", "acp", "qoder", "pi-local", "pi"];
44
+ return agents.map((agentType) => ({
45
+ agentType,
46
+ capability: conversationCapability({
47
+ agentType,
48
+ sandboxMode: "host",
49
+ worktreeGuard: true,
50
+ naviConversations: true,
51
+ }),
52
+ }));
53
+ }
@@ -0,0 +1,153 @@
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.ConversationJournal = void 0;
7
+ exports.assertUuid = assertUuid;
8
+ const node_fs_1 = require("node:fs");
9
+ const node_path_1 = __importDefault(require("node:path"));
10
+ const UUID = /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/i;
11
+ function assertUuid(value, label) {
12
+ if (!UUID.test(value))
13
+ throw new Error(`${label} is not a UUID.`);
14
+ return value.toLowerCase();
15
+ }
16
+ /** File-backed journal under `<workspaceRoot>/conversations/journal`. One file per turn. */
17
+ class ConversationJournal {
18
+ now;
19
+ root;
20
+ constructor(workspaceRoot, now = Date.now) {
21
+ this.now = now;
22
+ this.root = node_path_1.default.join(workspaceRoot, "conversations", "journal");
23
+ }
24
+ file(conversationId, turnId) {
25
+ return node_path_1.default.join(this.root, assertUuid(conversationId, "conversation id"), `${assertUuid(turnId, "turn id")}.json`);
26
+ }
27
+ async read(conversationId, turnId) {
28
+ const file = this.file(conversationId, turnId);
29
+ let raw;
30
+ try {
31
+ raw = await node_fs_1.promises.readFile(file, "utf8");
32
+ }
33
+ catch (error) {
34
+ if (error.code === "ENOENT")
35
+ return null;
36
+ throw error;
37
+ }
38
+ try {
39
+ const parsed = JSON.parse(raw);
40
+ if (parsed?.v === 1 && parsed.turnId === turnId.toLowerCase() && typeof parsed.token === "string")
41
+ return parsed;
42
+ }
43
+ catch {
44
+ // fall through
45
+ }
46
+ // An unreadable record means we cannot prove the turn never ran. Treat it
47
+ // as executed: the server's lease expiry settles it.
48
+ return {
49
+ v: 1, conversationId: conversationId.toLowerCase(), turnId: turnId.toLowerCase(), token: "",
50
+ state: "done", outcome: "corrupt", acked: 0, createdAt: this.now(), updatedAt: this.now(),
51
+ };
52
+ }
53
+ /** Creates the record with `token` if absent. The caller must pass a fresh random UUID. */
54
+ async open(conversationId, turnId, token) {
55
+ const existing = await this.read(conversationId, turnId);
56
+ if (existing)
57
+ return { journal: existing, created: false };
58
+ const journal = {
59
+ v: 1,
60
+ conversationId: assertUuid(conversationId, "conversation id"),
61
+ turnId: assertUuid(turnId, "turn id"),
62
+ token: assertUuid(token, "claim token"),
63
+ state: "claiming",
64
+ acked: 0,
65
+ createdAt: this.now(),
66
+ updatedAt: this.now(),
67
+ };
68
+ await this.write(journal);
69
+ return { journal, created: true };
70
+ }
71
+ /** Atomic replace so a crash never leaves a half-written record. */
72
+ async write(journal) {
73
+ journal.updatedAt = this.now();
74
+ const file = this.file(journal.conversationId, journal.turnId);
75
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(file), { recursive: true, mode: 0o700 });
76
+ const temp = `${file}.${process.pid}.tmp`;
77
+ await node_fs_1.promises.writeFile(temp, `${JSON.stringify(journal)}\n`, { mode: 0o600 });
78
+ await node_fs_1.promises.rename(temp, file);
79
+ }
80
+ /** Marks the turn terminal and drops retained text. */
81
+ async finish(journal, outcome) {
82
+ journal.state = "done";
83
+ journal.outcome = outcome;
84
+ delete journal.pending;
85
+ await this.write(journal);
86
+ }
87
+ /** Records that were left in a non-terminal state, for crash recovery. */
88
+ async unfinished() {
89
+ const found = [];
90
+ let conversations;
91
+ try {
92
+ conversations = await node_fs_1.promises.readdir(this.root);
93
+ }
94
+ catch {
95
+ return found;
96
+ }
97
+ for (const conversationId of conversations) {
98
+ if (!UUID.test(conversationId))
99
+ continue;
100
+ let files;
101
+ try {
102
+ files = await node_fs_1.promises.readdir(node_path_1.default.join(this.root, conversationId));
103
+ }
104
+ catch {
105
+ continue;
106
+ }
107
+ for (const file of files) {
108
+ if (!file.endsWith(".json"))
109
+ continue;
110
+ const journal = await this.read(conversationId, file.slice(0, -".json".length)).catch(() => null);
111
+ if (journal && journal.state !== "done")
112
+ found.push(journal);
113
+ }
114
+ }
115
+ return found;
116
+ }
117
+ /**
118
+ * Removes journals and conversation checkouts older than `maxAgeMs`. The
119
+ * server keeps idempotency records for 24 hours, so callers pass longer.
120
+ */
121
+ async prune(maxAgeMs, workspaceRoot) {
122
+ const cutoff = this.now() - maxAgeMs;
123
+ const conversationsRoot = node_path_1.default.join(workspaceRoot, "conversations");
124
+ let ids;
125
+ try {
126
+ ids = await node_fs_1.promises.readdir(this.root);
127
+ }
128
+ catch {
129
+ return;
130
+ }
131
+ for (const conversationId of ids) {
132
+ if (!UUID.test(conversationId))
133
+ continue;
134
+ const dir = node_path_1.default.join(this.root, conversationId);
135
+ let newest = 0;
136
+ let unfinished = false;
137
+ for (const file of await node_fs_1.promises.readdir(dir).catch(() => [])) {
138
+ const journal = await this.read(conversationId, file.replace(/\.json$/, "")).catch(() => null);
139
+ if (!journal)
140
+ continue;
141
+ newest = Math.max(newest, journal.updatedAt);
142
+ if (journal.state !== "done")
143
+ unfinished = true;
144
+ }
145
+ if (unfinished || newest > cutoff)
146
+ continue;
147
+ await node_fs_1.promises.rm(dir, { recursive: true, force: true }).catch(() => undefined);
148
+ await node_fs_1.promises.rm(node_path_1.default.join(conversationsRoot, conversationId.toLowerCase()), { recursive: true, force: true })
149
+ .catch(() => undefined);
150
+ }
151
+ }
152
+ }
153
+ exports.ConversationJournal = ConversationJournal;