open-walnut 0.4.0 → 0.4.1

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.
Files changed (29) hide show
  1. package/README.md +13 -1
  2. package/bin/open-walnut.js +14 -0
  3. package/dist/cli.js +582 -102
  4. package/dist/daemon-binaries/acp-worker.js.version +1 -1
  5. package/dist/web/server.js +555 -102
  6. package/dist/web/static/assets/{OfficePreview-DUTDx4ef.js → OfficePreview-C8OMbtiq.js} +2 -2
  7. package/dist/web/static/assets/{docx-preview-Qj-aUywE.js → docx-preview-hDgtrOpv.js} +1 -1
  8. package/dist/web/static/assets/{index-D6haQ3tX.js → index-B2-N8jTH.js} +1 -1
  9. package/dist/web/static/assets/{index-sF1ObMS0.js → index-B35fidy6.js} +1 -1
  10. package/dist/web/static/assets/{index-BvxwKQUT.js → index-BAb6UVet.js} +1 -1
  11. package/dist/web/static/assets/{index-BmDSVLwR.js → index-C5-vT4kT.js} +1 -1
  12. package/dist/web/static/assets/{index-fk2aCKPB.js → index-CBHRNnCk.js} +1 -1
  13. package/dist/web/static/assets/{index-CEVKci-j.js → index-CPHXL-wy.js} +1 -1
  14. package/dist/web/static/assets/{index-xu9Gy-xP.js → index-CWIydpPr.js} +1 -1
  15. package/dist/web/static/assets/{index-CfC-vHYG.js → index-Caj09uf7.js} +1 -1
  16. package/dist/web/static/assets/{index-oIyzyrLY.js → index-D1uUk0AQ.js} +273 -273
  17. package/dist/web/static/assets/{index-CzP6rbk7.js → index-D81YKipC.js} +1 -1
  18. package/dist/web/static/assets/{index-Bke7ZOIJ.js → index-DtRt21Zi.js} +1 -1
  19. package/dist/web/static/assets/{index-CSssA5eI.js → index-EowLf_Xf.js} +1 -1
  20. package/dist/web/static/assets/{index-y7f5PVUr.js → index-KY36bfpA.js} +1 -1
  21. package/dist/web/static/assets/{index-CeR0xElr.js → index-VcSnwuHG.js} +1 -1
  22. package/dist/web/static/assets/{index-CCa56u0e.js → index-bfZj1PAc.js} +1 -1
  23. package/dist/web/static/assets/{index-DqY9Z9j5.js → index-gv-TJwHT.js} +1 -1
  24. package/dist/web/static/assets/{index-Dv42N8tm.js → index-nRBC-NJN.js} +1 -1
  25. package/dist/web/static/assets/{index-BV9e5-KR.js → index-ph8Hxff-.js} +1 -1
  26. package/dist/web/static/assets/{jszip.min-C3K7iJBk.js → jszip.min-DyrlNEg4.js} +1 -1
  27. package/dist/web/static/assets/{pptx-preview.es-UhsuLLNP.js → pptx-preview.es-DjND6y6O.js} +1 -1
  28. package/dist/web/static/index.html +1 -1
  29. package/package.json +5 -4
@@ -10199,7 +10199,25 @@ function reapSession(sid, code, reason) {
10199
10199
  let jsonlAgeMs = null;
10200
10200
  try { jsonlAgeMs = Date.now() - fs.statSync(session.jsonlPath).mtimeMs; } catch {}
10201
10201
  const cleanExit = isTurnCompleteExit(session.jsonlPath);
10202
- if (code !== 0 && cleanExit) {
10202
+ // An INTENTIONAL daemon-initiated reclamation is not a failure, and the JSONL
10203
+ // tail cannot tell us it happened. The idle scanner stamps idleReclaimAt
10204
+ // before it signals; the death then lands here through whichever path notices
10205
+ // it (usually orphan-poll ESRCH \u2192 code -1). Verified production case: the last
10206
+ // line was a control_response (the reply to a Walnut-issued control request \u2014
10207
+ // a routine shape), so isTurnCompleteExit said false, exitCode stayed -1, and
10208
+ // the projection painted a red "Error" over a healthy, fully resumable session
10209
+ // we reclaimed on purpose. Checked FIRST and unconditionally: the flag carries
10210
+ // information the tail does not contain, which is why isTurnCompleteExit is
10211
+ // left alone. Keep in sync with daemon-core.ts reapSession.
10212
+ const intentionalReclaim = session.idleReclaimAt != null;
10213
+ if (intentionalReclaim && code !== 0) {
10214
+ logMsg('info', 'reapSession: intentional idle reclamation, normalizing exit code', {
10215
+ sid, pid: session.pid, originalCode: code, originalReason: reason,
10216
+ jsonlAgeMs, cleanExit, idleReclaimAt: session.idleReclaimAt,
10217
+ });
10218
+ code = 0;
10219
+ reason = reason + '+intentional-idle-reclaim';
10220
+ } else if (code !== 0 && cleanExit) {
10203
10221
  logMsg('info', 'reapSession: turn-complete detected, normalizing exit code', {
10204
10222
  sid, pid: session.pid, originalCode: code, originalReason: reason, jsonlAgeMs,
10205
10223
  });
@@ -15964,6 +15982,14 @@ function scanIdleSessions() {
15964
15982
  }
15965
15983
  const idleMinutes = Math.round(idleMs / 60000);
15966
15984
  logMsg('warn', 'idle scan: killing idle session (no subscribers, no output)', { sid, pid, idleMinutes, protectedBy: prot.source, thresholdMs: prot.killMs, detail: prot.detail });
15985
+ // Record the intent BEFORE signalling: the reap arrives asynchronously
15986
+ // (the orphan poll sees ESRCH and reports 'orphan-poll-dead', code -1), so
15987
+ // by then nothing remembers that WE asked for this. Without the stamp
15988
+ // reapSession can only consult the JSONL tail, which says "not a clean turn
15989
+ // end" for any session whose last line isn't a type:result \u2014 and the
15990
+ // projection then shows a red Error for our own routine reclamation.
15991
+ // Keep in sync with daemon-standalone.ts scanIdleSessions.
15992
+ session.idleReclaimAt = now;
15967
15993
  killSessionProcessGroup(pid, sid);
15968
15994
  }
15969
15995
  }
@@ -16647,7 +16673,20 @@ function createDaemonCore(deps) {
16647
16673
  jsonlAgeMs = clock() - fs114.statSync(session2.jsonlPath).mtimeMs;
16648
16674
  } catch {
16649
16675
  }
16650
- if (cleanExit && code !== 0) {
16676
+ const intentionalReclaim = session2.idleReclaimAt != null;
16677
+ if (intentionalReclaim && code !== 0) {
16678
+ logger2("info", "reapSession: intentional idle reclamation, normalizing exit code", {
16679
+ sid,
16680
+ pid: session2.pid,
16681
+ originalCode: code,
16682
+ originalReason: reason,
16683
+ jsonlAgeMs,
16684
+ cleanExit,
16685
+ idleReclaimAt: session2.idleReclaimAt
16686
+ });
16687
+ code = 0;
16688
+ reason = reason + "+intentional-idle-reclaim";
16689
+ } else if (cleanExit && code !== 0) {
16651
16690
  logger2("info", "reapSession: turn-complete detected, normalizing exit code", {
16652
16691
  sid,
16653
16692
  pid: session2.pid,
@@ -18118,6 +18157,22 @@ var init_local_daemon = __esm({
18118
18157
  });
18119
18158
 
18120
18159
  // src/core/session-error-kind.ts
18160
+ var session_error_kind_exports = {};
18161
+ __export(session_error_kind_exports, {
18162
+ INFRA_STATUS_REASONS: () => INFRA_STATUS_REASONS,
18163
+ RESCUABLE_STOPPED_WINDOW_MS: () => RESCUABLE_STOPPED_WINDOW_MS,
18164
+ TERMINAL_STATUS_REASONS: () => TERMINAL_STATUS_REASONS,
18165
+ assertsHostUnreachable: () => assertsHostUnreachable,
18166
+ classifySessionError: () => classifySessionError,
18167
+ classifyStatusReasonKind: () => classifyStatusReasonKind,
18168
+ isInfraSessionError: () => isInfraSessionError,
18169
+ isRecoverableSessionError: () => isRecoverableSessionError,
18170
+ isRescuableStoppedRecord: () => isRescuableStoppedRecord
18171
+ });
18172
+ function assertsHostUnreachable(record) {
18173
+ if (!record.errorMessage) return false;
18174
+ return typeof record.status_reason === "string" && REACHABILITY_CLAIM_REASONS.has(record.status_reason);
18175
+ }
18121
18176
  function classifyStatusReasonKind(reason) {
18122
18177
  if (typeof reason !== "string") return void 0;
18123
18178
  if (INFRA_STATUS_REASONS.has(reason)) return "infra";
@@ -18146,7 +18201,7 @@ function isRescuableStoppedRecord(record, nowMs = Date.now()) {
18146
18201
  if (!Number.isFinite(changedAt)) return false;
18147
18202
  return nowMs - changedAt <= RESCUABLE_STOPPED_WINDOW_MS;
18148
18203
  }
18149
- var INFRA_STATUS_REASONS, TERMINAL_STATUS_REASONS, INFRA_TEXT_SIGNATURES, RESCUABLE_STOPPED_WINDOW_MS;
18204
+ var INFRA_STATUS_REASONS, TERMINAL_STATUS_REASONS, INFRA_TEXT_SIGNATURES, REACHABILITY_CLAIM_REASONS, RESCUABLE_STOPPED_WINDOW_MS;
18150
18205
  var init_session_error_kind = __esm({
18151
18206
  "src/core/session-error-kind.ts"() {
18152
18207
  "use strict";
@@ -18190,6 +18245,9 @@ var init_session_error_kind = __esm({
18190
18245
  "ssh tunnel",
18191
18246
  "broken pipe"
18192
18247
  ];
18248
+ REACHABILITY_CLAIM_REASONS = /* @__PURE__ */ new Set([
18249
+ "remote_unreachable"
18250
+ ]);
18193
18251
  RESCUABLE_STOPPED_WINDOW_MS = 24 * 60 * 60 * 1e3;
18194
18252
  }
18195
18253
  });
@@ -68462,8 +68520,22 @@ var init_token_truth = __esm({
68462
68520
  });
68463
68521
 
68464
68522
  // src/utils/image-compress.ts
68465
- import sharp from "sharp";
68523
+ async function loadSharp() {
68524
+ sharpLoad ??= import("sharp").then(
68525
+ (m6) => m6.default ?? m6,
68526
+ (err2) => {
68527
+ log.agent.warn("sharp is not installed; images are sent uncompressed", {
68528
+ error: err2 instanceof Error ? err2.message : String(err2),
68529
+ fix: "a machine with glibc 2.28+ installs sharp prebuilt; otherwise install libvips and re-run npm install"
68530
+ });
68531
+ return null;
68532
+ }
68533
+ );
68534
+ return sharpLoad;
68535
+ }
68466
68536
  async function clampImageDimensions(buffer2, mimeType) {
68537
+ const sharp = await loadSharp();
68538
+ if (!sharp) return { buffer: buffer2, mimeType };
68467
68539
  try {
68468
68540
  const meta = await sharp(buffer2).metadata();
68469
68541
  const longest = Math.max(meta.width ?? 0, meta.height ?? 0);
@@ -68485,6 +68557,8 @@ async function compressForApi(inputBuffer, inputMimeType) {
68485
68557
  if (buffer2.toString("base64").length <= MAX_BASE64_BYTES) {
68486
68558
  return { buffer: buffer2, mimeType };
68487
68559
  }
68560
+ const sharp = await loadSharp();
68561
+ if (!sharp) return { buffer: buffer2, mimeType };
68488
68562
  try {
68489
68563
  if (mimeType === "image/gif") {
68490
68564
  const webp = await sharp(buffer2, { animated: true }).webp({ quality: 80 }).toBuffer();
@@ -68522,10 +68596,11 @@ async function compressForApi(inputBuffer, inputMimeType) {
68522
68596
  return { buffer: buffer2, mimeType };
68523
68597
  }
68524
68598
  }
68525
- var MAX_BASE64_BYTES, MAX_IMAGE_DIMENSION;
68599
+ var sharpLoad, MAX_BASE64_BYTES, MAX_IMAGE_DIMENSION;
68526
68600
  var init_image_compress = __esm({
68527
68601
  "src/utils/image-compress.ts"() {
68528
68602
  "use strict";
68603
+ init_logging();
68529
68604
  MAX_BASE64_BYTES = 5e6;
68530
68605
  MAX_IMAGE_DIMENSION = 1568;
68531
68606
  }
@@ -71516,9 +71591,20 @@ async function applySnapshot(sessionId, snapshot2, source) {
71516
71591
  updates.errorKind = void 0;
71517
71592
  clearSuppressedErrorReason(sessionId);
71518
71593
  } else {
71594
+ let droppedDisproven = false;
71595
+ if (assertsHostUnreachable(record)) {
71596
+ updates.errorMessage = void 0;
71597
+ droppedDisproven = true;
71598
+ log.session.info("snapshot projection dropped disproven unreachability diagnosis", {
71599
+ sessionId,
71600
+ priorReason: record.status_reason ?? null,
71601
+ projected,
71602
+ source
71603
+ });
71604
+ }
71519
71605
  const stashed = takeSuppressedErrorReason(sessionId);
71520
71606
  if (stashed) {
71521
- if (!record.errorMessage && stashed.message) updates.errorMessage = stashed.message;
71607
+ if ((!record.errorMessage || droppedDisproven) && stashed.message) updates.errorMessage = stashed.message;
71522
71608
  if (!record.errorKind && stashed.kind) updates.errorKind = stashed.kind;
71523
71609
  log.session.info("snapshot projection adopted suppressed error reason", {
71524
71610
  sessionId,
@@ -71571,6 +71657,7 @@ var init_session_snapshot_apply = __esm({
71571
71657
  "use strict";
71572
71658
  init_logging();
71573
71659
  init_session_snapshot_gate();
71660
+ init_session_error_kind();
71574
71661
  init_engine_registry();
71575
71662
  init_session_snapshot_gate();
71576
71663
  TERMINAL = /* @__PURE__ */ new Set(["stopped", "error"]);
@@ -74185,9 +74272,15 @@ var init_acp_session = __esm({
74185
74272
  }
74186
74273
  async resolveArtifacts() {
74187
74274
  if (this.cfg.artifacts) return this.cfg.artifacts;
74188
- return resolveAcpArtifacts(this.engineId, {
74275
+ const artifacts = resolveAcpArtifacts(this.engineId, {
74189
74276
  configuredAdapterCmd: await this.resolveConfiguredAdapterCmd()
74190
74277
  });
74278
+ if (!fsModule.existsSync(artifacts.workerCmd[1])) {
74279
+ throw new Error(
74280
+ `Engine '${this.engineId}' runs on the ACP worker, which is not built (${artifacts.workerCmd[1]}). Install Bun (https://bun.sh) and run \`npm run build:daemon\`, or install Walnut from npm, which ships it.`
74281
+ );
74282
+ }
74283
+ return artifacts;
74191
74284
  }
74192
74285
  async establishProviderSession() {
74193
74286
  await this.seedReplayCursor();
@@ -77724,6 +77817,7 @@ __export(session_lifecycle_exports, {
77724
77817
  persistSessionModeChange: () => persistSessionModeChange,
77725
77818
  readProviderSessionHistory: () => readProviderSessionHistory,
77726
77819
  readSessionRichHistory: () => readSessionRichHistory,
77820
+ recheckSession: () => recheckSession,
77727
77821
  respondSessionPermission: () => respondSessionPermission,
77728
77822
  restartSession: () => restartSession,
77729
77823
  retrySession: () => retrySession,
@@ -78061,7 +78155,7 @@ async function retrySession(sessionId) {
78061
78155
  return { status: "reconnected", sessionId };
78062
78156
  }
78063
78157
  if (await resumeConversationExists(record)) {
78064
- const { sendMessageToSession: sendMessageToSession2, getQueue: getQueue2, unparkMessage: unparkMessage2 } = await Promise.resolve().then(() => (init_session_message_queue(), session_message_queue_exports));
78158
+ const { getQueue: getQueue2, unparkMessage: unparkMessage2 } = await Promise.resolve().then(() => (init_session_message_queue(), session_message_queue_exports));
78065
78159
  const pendingMsgs = await getQueue2(sessionId);
78066
78160
  for (const parked of pendingMsgs.filter((m6) => m6.status === "parked")) {
78067
78161
  await unparkMessage2(sessionId, parked.id);
@@ -78071,9 +78165,22 @@ async function retrySession(sessionId) {
78071
78165
  log.session.info("session retry: re-processing pending queue messages", { sessionId, taskId: record.taskId, count: pendingMsgs.length });
78072
78166
  return { status: "resuming", sessionId, restoredMessages: pendingMsgs.length };
78073
78167
  }
78074
- await sendMessageToSession2(sessionId, "continue", { source: "retry", taskId: record.taskId });
78075
- log.session.info("session retry: resuming via --resume (no pending messages)", { sessionId, taskId: record.taskId });
78076
- return { status: "resuming", sessionId };
78168
+ const relabelled = await updateSessionRecord2(sessionId, {
78169
+ process_status: "stopped",
78170
+ errorMessage: void 0,
78171
+ errorKind: void 0,
78172
+ activity: void 0,
78173
+ last_status_change: (/* @__PURE__ */ new Date()).toISOString(),
78174
+ status_reason: "retry_reconnect",
78175
+ status_changed_by: "user"
78176
+ });
78177
+ emitSessionStatusChanged2(relabelled, {}, ["*"]);
78178
+ log.session.info("session retry: reconnected \u2014 conversation preserved, no message sent", {
78179
+ sessionId,
78180
+ taskId: record.taskId,
78181
+ host: record.host ?? "__local__"
78182
+ });
78183
+ return { status: "resumable", sessionId };
78077
78184
  }
78078
78185
  log.session.warn("session retry: conversation never persisted \u2014 starting fresh instead of --resume", {
78079
78186
  sessionId,
@@ -78131,6 +78238,91 @@ async function retrySession(sessionId) {
78131
78238
  });
78132
78239
  return { status: "pending", taskId: task.id, oldSessionId: sessionId };
78133
78240
  }
78241
+ async function withDeadline(p6, ms) {
78242
+ let timer3;
78243
+ try {
78244
+ return await Promise.race([
78245
+ p6,
78246
+ new Promise((resolve) => {
78247
+ timer3 = setTimeout(() => resolve(RECHECK_TIMED_OUT), ms);
78248
+ if (timer3 && typeof timer3 === "object" && "unref" in timer3) timer3.unref();
78249
+ })
78250
+ ]);
78251
+ } finally {
78252
+ if (timer3) clearTimeout(timer3);
78253
+ }
78254
+ }
78255
+ async function recheckSession(sessionId) {
78256
+ const { getSessionByClaudeId: getSessionByClaudeId2 } = await Promise.resolve().then(() => (init_session_tracker(), session_tracker_exports));
78257
+ const record = await getSessionByClaudeId2(sessionId);
78258
+ if (!record) throw new SessionControlError("Session not found", 404);
78259
+ const hostKey2 = record.host ?? "__local__";
78260
+ const { classifySessionError: classifySessionError2 } = await Promise.resolve().then(() => (init_session_error_kind(), session_error_kind_exports));
78261
+ const kind = classifySessionError2(record);
78262
+ const base = { sessionId, processStatus: record.process_status, infraClaim: kind === "infra" };
78263
+ const { isDaemonConnected: isDaemonConnected2, getPooledSnapshotConnection: getPooledSnapshotConnection2 } = await Promise.resolve().then(() => (init_daemon_connection(), daemon_connection_exports));
78264
+ const reachable = isDaemonConnected2(hostKey2);
78265
+ if (kind === "terminal") return { ...base, checked: false, reachable, reason: "terminal_error" };
78266
+ if (record.archived) return { ...base, checked: false, reachable, reason: "archived" };
78267
+ const conn = getPooledSnapshotConnection2(record.host);
78268
+ if (!conn) {
78269
+ log.session.info("session recheck: no pooled snapshot connection \u2014 nothing to ask", {
78270
+ sessionId,
78271
+ host: hostKey2,
78272
+ reachable
78273
+ });
78274
+ return { ...base, checked: false, reachable, reason: "no_pooled_connection" };
78275
+ }
78276
+ let raced;
78277
+ try {
78278
+ raced = await withDeadline(
78279
+ conn.send("getState", { sid: sessionId }, RECHECK_RPC_TIMEOUT_MS),
78280
+ RECHECK_RPC_TIMEOUT_MS
78281
+ );
78282
+ } catch (err2) {
78283
+ log.session.info("session recheck: getState failed", {
78284
+ sessionId,
78285
+ host: hostKey2,
78286
+ error: err2 instanceof Error ? err2.message : String(err2)
78287
+ });
78288
+ return { ...base, checked: false, reachable, reason: "rpc_failed" };
78289
+ }
78290
+ if (raced === RECHECK_TIMED_OUT) {
78291
+ log.session.warn("session recheck: getState timed out", {
78292
+ sessionId,
78293
+ host: hostKey2,
78294
+ timeoutMs: RECHECK_RPC_TIMEOUT_MS
78295
+ });
78296
+ return { ...base, checked: false, reachable, reason: "timeout" };
78297
+ }
78298
+ const snapshot2 = raced?.ok ? raced.snapshot : void 0;
78299
+ if (!snapshot2) {
78300
+ return { ...base, checked: false, reachable: true, reason: "no_snapshot" };
78301
+ }
78302
+ const { applySnapshot: applySnapshot2 } = await Promise.resolve().then(() => (init_session_snapshot_apply(), session_snapshot_apply_exports));
78303
+ const outcome = await applySnapshot2(sessionId, snapshot2, "recheck-open");
78304
+ const after = await getSessionByClaudeId2(sessionId);
78305
+ const processStatus = after?.process_status ?? record.process_status;
78306
+ log.session.info("session recheck: reconciled against host", {
78307
+ sessionId,
78308
+ host: hostKey2,
78309
+ previous: record.process_status,
78310
+ processStatus,
78311
+ cliState: snapshot2.cliState,
78312
+ applyOutcome: outcome.outcome
78313
+ });
78314
+ return {
78315
+ sessionId,
78316
+ checked: true,
78317
+ reachable: true,
78318
+ alive: snapshot2.cliState !== "dead",
78319
+ processStatus,
78320
+ // Re-classify against the CURRENT record: a converged row may no longer
78321
+ // carry the claim, and the client uses this to decide whether the sentence
78322
+ // on screen is still true.
78323
+ infraClaim: classifySessionError2(after ?? record) === "infra"
78324
+ };
78325
+ }
78134
78326
  async function respondSessionPermission(sessionId, requestId, allow, denyMessage, optionId, answers) {
78135
78327
  if (typeof requestId !== "string" || !requestId || typeof allow !== "boolean") {
78136
78328
  throw new SessionControlError("requestId (string) and allow (boolean) are required", 400);
@@ -78424,7 +78616,7 @@ async function readSessionRichHistory(sessionId, tail) {
78424
78616
  ...historyWindowed ? { windowed: true } : {}
78425
78617
  };
78426
78618
  }
78427
- var CLAUDE_SESSION_MODES, MAX_PINNED_MESSAGES, MAX_PIN_LABEL_CHARS, MAX_PIN_ID_CHARS, GIT_BASES, MAX_FORK_DEPTH, HISTORY_STARTUP_GRACE_MS;
78619
+ var CLAUDE_SESSION_MODES, MAX_PINNED_MESSAGES, MAX_PIN_LABEL_CHARS, MAX_PIN_ID_CHARS, RECHECK_RPC_TIMEOUT_MS, RECHECK_TIMED_OUT, GIT_BASES, MAX_FORK_DEPTH, HISTORY_STARTUP_GRACE_MS;
78428
78620
  var init_session_lifecycle = __esm({
78429
78621
  "src/core/sessions/session-lifecycle.ts"() {
78430
78622
  "use strict";
@@ -78438,6 +78630,8 @@ var init_session_lifecycle = __esm({
78438
78630
  MAX_PINNED_MESSAGES = 200;
78439
78631
  MAX_PIN_LABEL_CHARS = 300;
78440
78632
  MAX_PIN_ID_CHARS = 200;
78633
+ RECHECK_RPC_TIMEOUT_MS = 5e3;
78634
+ RECHECK_TIMED_OUT = /* @__PURE__ */ Symbol("recheck-timeout");
78441
78635
  GIT_BASES = /* @__PURE__ */ new Set(["uncommitted", "previous", "remote"]);
78442
78636
  MAX_FORK_DEPTH = 5;
78443
78637
  HISTORY_STARTUP_GRACE_MS = 2 * 60 * 1e3;
@@ -87820,7 +88014,7 @@ function isRevealSecretPath(resolved) {
87820
88014
  ];
87821
88015
  return denied.some((d8) => resolved === d8 || resolved.startsWith(d8 + path70.sep)) || /(^|\/)config\.ya?ml$/.test(resolved);
87822
88016
  }
87823
- var filesRouter, MAX_ENTRIES2, REMOTE_TIMEOUT_MS3, REMOTE_RESOLVE_BUDGET_MS, FilesOpError, MAX_UPWARD_LEVELS2, MAX_DOWNWARD_DEPTH, REFERENCE_SEARCH_BUDGET_MS, SYMBOL_RE2, REVEAL_APP_DENY_EXTS;
88017
+ var filesRouter, MAX_ENTRIES2, REMOTE_TIMEOUT_MS3, REMOTE_RESOLVE_BUDGET_MS, FilesOpError, MAX_UPWARD_LEVELS2, MAX_DOWNWARD_DEPTH, REFERENCE_SEARCH_BUDGET_MS, SYMBOL_RE2, LIST_MANY_MAX_PATHS, LIST_MANY_CONCURRENCY, LIST_MANY_DEADLINE_MS, REVEAL_APP_DENY_EXTS;
87824
88018
  var init_files2 = __esm({
87825
88019
  "src/web/routes/files.ts"() {
87826
88020
  "use strict";
@@ -87953,6 +88147,54 @@ var init_files2 = __esm({
87953
88147
  next(err2);
87954
88148
  }
87955
88149
  });
88150
+ LIST_MANY_MAX_PATHS = 64;
88151
+ LIST_MANY_CONCURRENCY = 8;
88152
+ LIST_MANY_DEADLINE_MS = 8e3;
88153
+ filesRouter.post("/list-many", async (req, res, next) => {
88154
+ try {
88155
+ const body3 = req.body ?? {};
88156
+ const raw = Array.isArray(body3.paths) ? body3.paths : null;
88157
+ if (!raw) {
88158
+ res.status(400).json({ error: "paths (string[]) is required" });
88159
+ return;
88160
+ }
88161
+ const host = typeof body3.host === "string" && body3.host ? body3.host : void 0;
88162
+ const showHidden = body3.showHidden === true || body3.showHidden === "1";
88163
+ const paths = [...new Set(
88164
+ raw.filter((p6) => typeof p6 === "string" && !!p6)
88165
+ )];
88166
+ const truncated = paths.length > LIST_MANY_MAX_PATHS;
88167
+ const wanted = paths.slice(0, LIST_MANY_MAX_PATHS);
88168
+ const deadline4 = Date.now() + LIST_MANY_DEADLINE_MS;
88169
+ const listings = [];
88170
+ let timedOut = false;
88171
+ let next_ = 0;
88172
+ const worker = async () => {
88173
+ for (; ; ) {
88174
+ const i8 = next_++;
88175
+ if (i8 >= wanted.length) return;
88176
+ if (Date.now() >= deadline4) {
88177
+ timedOut = true;
88178
+ return;
88179
+ }
88180
+ const dirPath = wanted[i8];
88181
+ try {
88182
+ listings.push(await listSessionFiles(dirPath, host, showHidden));
88183
+ } catch (err2) {
88184
+ listings.push({ path: dirPath, error: err2 instanceof Error ? err2.message : String(err2) });
88185
+ }
88186
+ }
88187
+ };
88188
+ await Promise.all(Array.from({ length: Math.min(LIST_MANY_CONCURRENCY, wanted.length) }, worker));
88189
+ res.json({ listings, truncated, timedOut });
88190
+ } catch (err2) {
88191
+ if (err2 instanceof FilesOpError) {
88192
+ res.status(err2.statusCode).json({ error: err2.message });
88193
+ return;
88194
+ }
88195
+ next(err2);
88196
+ }
88197
+ });
87956
88198
  REVEAL_APP_DENY_EXTS = /* @__PURE__ */ new Set([
87957
88199
  "command",
87958
88200
  "sh",
@@ -115976,6 +116218,11 @@ async function handleSessionControlRelay(action, sessionId, params) {
115976
116218
  result = await retrySession2(sessionId);
115977
116219
  break;
115978
116220
  }
116221
+ case "recheck": {
116222
+ const { recheckSession: recheckSession2 } = await Promise.resolve().then(() => (init_session_lifecycle(), session_lifecycle_exports));
116223
+ result = await recheckSession2(sessionId);
116224
+ break;
116225
+ }
115979
116226
  case "permission": {
115980
116227
  const { respondSessionPermission: respondSessionPermission2 } = await Promise.resolve().then(() => (init_session_lifecycle(), session_lifecycle_exports));
115981
116228
  result = await respondSessionPermission2(sessionId, p6.requestId, p6.allow, p6.message, p6.optionId, p6.answers);
@@ -153537,6 +153784,19 @@ var init_sessions = __esm({
153537
153784
  next(err2);
153538
153785
  }
153539
153786
  });
153787
+ sessionsRouter.post("/:sessionId/recheck", async (req, res, next) => {
153788
+ try {
153789
+ const sessionId = req.params.sessionId;
153790
+ const result = await recheckSession(sessionId);
153791
+ res.json(result);
153792
+ } catch (err2) {
153793
+ if (err2 instanceof SessionControlError) {
153794
+ res.status(err2.statusCode).json({ error: err2.message });
153795
+ return;
153796
+ }
153797
+ next(err2);
153798
+ }
153799
+ });
153540
153800
  sessionsRouter.post("/:sessionId/restart", async (req, res, next) => {
153541
153801
  const sessionId = req.params.sessionId;
153542
153802
  const startedAt = Date.now();
@@ -155260,9 +155520,13 @@ var init_credential_verify = __esm({
155260
155520
  var config_exports = {};
155261
155521
  __export(config_exports, {
155262
155522
  buildProvidersPayload: () => buildProvidersPayload,
155263
- configRouter: () => configRouter
155523
+ configRouter: () => configRouter,
155524
+ setStaticRootReporter: () => setStaticRootReporter
155264
155525
  });
155265
155526
  import { Router as Router20 } from "express";
155527
+ function setStaticRootReporter(fn) {
155528
+ staticRootReporter = fn;
155529
+ }
155266
155530
  async function fetchOllamaModels(baseUrl) {
155267
155531
  if (Date.now() - _ollamaCache.ts < OLLAMA_CACHE_TTL) return _ollamaCache.models;
155268
155532
  const url = (baseUrl ?? DEFAULT_BASE_URLS["ollama"] ?? "http://localhost:11434") + "/api/tags";
@@ -155395,7 +155659,7 @@ async function buildProvidersPayload() {
155395
155659
  }
155396
155660
  return { providers };
155397
155661
  }
155398
- var configRouter, _ollamaCache, OLLAMA_CACHE_TTL;
155662
+ var configRouter, staticRootReporter, _ollamaCache, OLLAMA_CACHE_TTL;
155399
155663
  var init_config11 = __esm({
155400
155664
  "src/web/routes/config.ts"() {
155401
155665
  "use strict";
@@ -155410,6 +155674,7 @@ var init_config11 = __esm({
155410
155674
  init_defaults();
155411
155675
  init_config_redact();
155412
155676
  configRouter = Router20();
155677
+ staticRootReporter = null;
155413
155678
  configRouter.get("/", async (_req, res, next) => {
155414
155679
  try {
155415
155680
  const config = await getConfig();
@@ -155436,7 +155701,8 @@ var init_config11 = __esm({
155436
155701
  remoteIdUniquenessGaps = getExtIndexUniquenessGaps2();
155437
155702
  } catch {
155438
155703
  }
155439
- res.json({ config: CLOUD_MODE ? redactConfig(config) : config, envTokenHint, installDir: CLOUD_MODE ? null : WALNUT_INSTALL_DIR, notesDir: CLOUD_MODE ? null : NOTES_DIR, processNice, memory, canRevealLocalFiles, remoteIdUniquenessGaps, cloud: CLOUD_MODE });
155704
+ const webAssets = staticRootReporter?.() ?? null;
155705
+ res.json({ config: CLOUD_MODE ? redactConfig(config) : config, envTokenHint, installDir: CLOUD_MODE ? null : WALNUT_INSTALL_DIR, notesDir: CLOUD_MODE ? null : NOTES_DIR, processNice, memory, canRevealLocalFiles, remoteIdUniquenessGaps, cloud: CLOUD_MODE, webAssets });
155440
155706
  } catch (err2) {
155441
155707
  next(err2);
155442
155708
  }
@@ -159226,6 +159492,9 @@ var init_session_chat = __esm({
159226
159492
 
159227
159493
  // src/web/routes/browser-logs.ts
159228
159494
  import { Router as Router31 } from "express";
159495
+ function entrySubsystem(entry) {
159496
+ return entry.subsystem && ENTRY_SUBSYSTEMS.has(entry.subsystem) ? entry.subsystem : "browser";
159497
+ }
159229
159498
  function checkRate(client, entryCount) {
159230
159499
  const now = Date.now();
159231
159500
  let state9 = clientRates.get(client);
@@ -159267,10 +159536,11 @@ function writeEntries(entries) {
159267
159536
  if (entry.args) meta.args = truncate(entry.args, 1e3);
159268
159537
  if (entry.url) meta.url = entry.url;
159269
159538
  if (entry.count && entry.count > 1) meta.count = entry.count;
159539
+ if (entry.client === "mac-app") meta.client = entry.client;
159270
159540
  writeLogEntry({
159271
159541
  time: entry.time,
159272
159542
  level: mapLevel(entry.level),
159273
- subsystem: "browser",
159543
+ subsystem: entrySubsystem(entry),
159274
159544
  message: truncate(entry.message, 2e3),
159275
159545
  browserLevel: entry.level,
159276
159546
  // preserve original level (e.g. 'log' vs 'info')
@@ -159293,13 +159563,14 @@ function registerBrowserLogsRpc() {
159293
159563
  writeEntries(entries);
159294
159564
  });
159295
159565
  }
159296
- var RATE_WINDOW_MS, RATE_LIMIT, clientRates, REST_MAX_ENTRIES, browserLogsRouter;
159566
+ var ENTRY_SUBSYSTEMS, RATE_WINDOW_MS, RATE_LIMIT, clientRates, REST_MAX_ENTRIES, browserLogsRouter;
159297
159567
  var init_browser_logs = __esm({
159298
159568
  "src/web/routes/browser-logs.ts"() {
159299
159569
  "use strict";
159300
159570
  init_handler();
159301
159571
  init_logging();
159302
159572
  init_logger();
159573
+ ENTRY_SUBSYSTEMS = /* @__PURE__ */ new Set(["browser", "desktop"]);
159303
159574
  RATE_WINDOW_MS = 1e4;
159304
159575
  RATE_LIMIT = 500;
159305
159576
  clientRates = /* @__PURE__ */ new WeakMap();
@@ -163643,6 +163914,7 @@ var init_session_health_monitor = __esm({
163643
163914
  log.session.info("health monitor: drained orphan dead-pool", { orphanCount: orphanIds.length, written: written.length });
163644
163915
  }
163645
163916
  if (sessions3.length === 0) {
163917
+ if (!ctx.overBudget()) await this.checkSnapshotPull(sessions3, ctx, allSessions);
163646
163918
  const total = Date.now() - checkT0;
163647
163919
  if (total > 500) log.session.warn("health monitor: check() slow (no active sessions)", { totalMs: total, orphanMs: tOrphan - checkT0, recoverMs: tRecover - tOrphan });
163648
163920
  return;
@@ -163672,7 +163944,7 @@ var init_session_health_monitor = __esm({
163672
163944
  await this.reconcilePendingBackgroundTasks(sessions3);
163673
163945
  endPhase("pendingBgTasks");
163674
163946
  if (ctx.overBudget()) return;
163675
- await this.checkSnapshotPull(sessions3, ctx);
163947
+ await this.checkSnapshotPull(sessions3, ctx, allSessions);
163676
163948
  endPhase("snapshotPull");
163677
163949
  if (ctx.overBudget()) return;
163678
163950
  const reconciledIds = await this.reconcileStuckRunningSessions(sessions3, taskMap);
@@ -164166,15 +164438,39 @@ var init_session_health_monitor = __esm({
164166
164438
  bus.emit(EventNames.SESSION_WILL_REAP, payload, ["*"], { source: "health-monitor" });
164167
164439
  }
164168
164440
  /**
164169
- * C2 snapshot pull channel (contract §5): every 30s tick, for records in
164170
- * {running, idle} on a native (non-codex, non-embedded/sdk) engine whose
164171
- * host has a POOLED CONNECTED DaemonConnection advertising 'snapshot-v1',
164172
- * PULL getState and feed the snapshot to applySnapshot. NEVER dials a new
164173
- * connection. Sequential, capped at 10 sids/tick, per-sid 25s spacing.
164441
+ * C2 snapshot pull channel (contract §5): every 30s tick, PULL getState from
164442
+ * the host's POOLED CONNECTED DaemonConnection advertising 'snapshot-v1' and
164443
+ * feed the answer to applySnapshot. NEVER dials a new connection, always
164444
+ * sequential. TWO candidate classes with SEPARATE budgets and cadences:
164445
+ *
164446
+ * - LIVE — {running, idle}: 10 sids/tick, 25s per-sid spacing. The steady
164447
+ * state channel.
164448
+ * - RE-EXAMINATION — {error, stopped} whose cause is NOT positively terminal
164449
+ * (classifySessionError) and whose last_status_change is inside
164450
+ * RESCUABLE_STOPPED_WINDOW_MS: 5 sids/tick, 5 min per-sid spacing. Same
164451
+ * engine/provider/awaiting_spawn/archived exclusions as the live class.
164174
164452
  *
164175
- * TWO fairness/budget properties this loop must keep (C8/C9/C12/C29):
164453
+ * WHY RE-EXAMINATION EXISTS — do not "optimize" the error class back out. A
164454
+ * wrongly-'error' record was a two-sided deadlock: this lane is the ONE writer
164455
+ * the enforce-mode gate sanctions for a snapshot-covered session, and while it
164456
+ * looked at live records only it never re-derived status for an 'error' one.
164457
+ * recoverInfraFailedSessions DOES look, but the fix it writes
164458
+ * (('health-monitor','auto_recovered_dead')) is itself category-①, so the gate
164459
+ * drops the whole patch — the lane allowed to write refused to look, and the
164460
+ * lane that looked was not allowed to write. Records froze in 'error' with
164461
+ * "Connection lost — unable to reach remote host" for hours after their host
164462
+ * came back (2026-09-03, a remote dev host whose tunnel flapped: prose still on
164463
+ * the record 2h after the reconnect), burning a 30s log-churn loop the whole
164464
+ * time. Terminality is decided STRUCTURALLY, never by matching errorMessage
164465
+ * prose — see the header of session-error-kind.ts for why that ban exists.
164176
164466
  *
164177
- * - It honors the TickContext budget INSIDE the loop (same discipline as
164467
+ * Re-examination candidates come from a WIDER pool than the live ones: the
164468
+ * tick's own working set drops every 'error' row (isTerminalSession), so the
164469
+ * caller passes the unfiltered health-scan set for this class only.
164470
+ *
164471
+ * THREE fairness/budget properties both classes must keep (C8/C9/C12/C29):
164472
+ *
164473
+ * - The TickContext budget is honored INSIDE the loop (same discipline as
164178
164474
  * checkHungSessions). Each iteration is a real daemon RPC with a
164179
164475
  * probe-timeout ceiling, so 10 slow hosts could burn 10 × the probe
164180
164476
  * timeout sequentially BEFORE the authoritative reconcile phases that run
@@ -164183,70 +164479,101 @@ var init_session_health_monitor = __esm({
164183
164479
  * next tick (see the rotation below).
164184
164480
  *
164185
164481
  * - Candidates are ordered by lastPullAt ASCENDING (never-pulled first),
164186
- * which turns the 10/tick cap into a round-robin. In list order the SAME
164187
- * first ten sids were pulled every tick and — because the 25s spacing is
164188
- * shorter than the 30s cadence — sids 11+ were never pulled at all: the
164189
- * pull channel silently did not exist for them.
164482
+ * which turns each cap into a round-robin. In list order the SAME first ten
164483
+ * sids were pulled every tick and — because the 25s spacing is shorter than
164484
+ * the 30s cadence — sids 11+ were never pulled at all: the pull channel
164485
+ * silently did not exist for them. Each class rotates on its OWN timestamps.
164486
+ *
164487
+ * - The classes cannot spend each other's budget. Re-examination is
164488
+ * speculative ("is this record still true?") and a settled-death cohort
164489
+ * dwarfs the live set (the live DB measured 100 stopped candidates against
164490
+ * 4 error rows), so it must cost a handful of cheap RPCs per tick and
164491
+ * nothing more — the same reasoning as MAX_STOPPED_PROBES_PER_TICK below.
164190
164492
  */
164191
164493
  snapshotPullAt = /* @__PURE__ */ new Map();
164192
- async checkSnapshotPull(sessions3, ctx) {
164494
+ /** Separate from snapshotPullAt on purpose: the two classes rotate
164495
+ * independently, so a live pull can never reset the slow re-examination
164496
+ * cadence (or vice versa) when a record moves between them. */
164497
+ snapshotReexamPullAt = /* @__PURE__ */ new Map();
164498
+ async checkSnapshotPull(sessions3, ctx, reexamPool = sessions3) {
164193
164499
  const PULL_MIN_GAP_MS = 25e3;
164194
164500
  const MAX_PULLS_PER_TICK = 10;
164501
+ const REEXAM_MIN_GAP_MS = 5 * 6e4;
164502
+ const MAX_REEXAM_PULLS_PER_TICK = 5;
164195
164503
  try {
164196
164504
  const { getSnapshotStatusMode: getSnapshotStatusMode2 } = await Promise.resolve().then(() => (init_session_snapshot_gate(), session_snapshot_gate_exports));
164197
164505
  if (getSnapshotStatusMode2() === "off") return;
164198
164506
  const { getPooledSnapshotConnection: getPooledSnapshotConnection2 } = await Promise.resolve().then(() => (init_daemon_connection(), daemon_connection_exports));
164199
164507
  const { applySnapshot: applySnapshot2 } = await Promise.resolve().then(() => (init_session_snapshot_apply(), session_snapshot_apply_exports));
164200
164508
  const now = Date.now();
164201
- const candidates = sessions3.filter((s4) => {
164202
- if (s4.process_status !== "running" && s4.process_status !== "idle") return false;
164509
+ const pullable = (s4) => {
164510
+ if (s4.archived) return false;
164203
164511
  if (!engineCaps(s4.engine).snapshotPull) return false;
164204
164512
  if (s4.provider === "embedded" || s4.provider === "sdk") return false;
164205
- if (s4.status_reason === "awaiting_spawn") return false;
164206
- const lastPull = this.snapshotPullAt.get(s4.claudeSessionId) ?? 0;
164207
- return now - lastPull >= PULL_MIN_GAP_MS;
164208
- }).sort((a8, b8) => (this.snapshotPullAt.get(a8.claudeSessionId) ?? 0) - (this.snapshotPullAt.get(b8.claudeSessionId) ?? 0));
164209
- let pulled = 0;
164210
- let capped = false;
164211
- for (const s4 of candidates) {
164212
- if (ctx?.overBudget()) {
164213
- log.session.warn("health monitor: checkSnapshotPull abandoned mid-loop (over budget)", {
164214
- pulled,
164215
- candidateCount: candidates.length
164216
- });
164217
- break;
164218
- }
164219
- const conn = getPooledSnapshotConnection2(s4.host);
164220
- if (!conn) continue;
164221
- if (pulled >= MAX_PULLS_PER_TICK) {
164222
- capped = true;
164223
- break;
164224
- }
164225
- pulled++;
164226
- this.snapshotPullAt.set(s4.claudeSessionId, now);
164227
- try {
164228
- const resp = await probeWithTimeout(
164229
- conn.send("getState", { sid: s4.claudeSessionId }),
164230
- null,
164231
- "snapshot-pull-getState",
164232
- s4.claudeSessionId
164233
- );
164234
- const snapshot2 = resp?.ok ? resp.snapshot : void 0;
164235
- if (snapshot2) await applySnapshot2(s4.claudeSessionId, snapshot2, "pull-30s");
164236
- } catch (err2) {
164237
- log.session.debug("health monitor: snapshot pull failed", {
164238
- sessionId: s4.claudeSessionId,
164239
- host: s4.host ?? "__local__",
164240
- error: err2 instanceof Error ? err2.message : String(err2)
164241
- });
164513
+ return s4.status_reason !== "awaiting_spawn";
164514
+ };
164515
+ const byPullAge = (at) => (a8, b8) => (at.get(a8.claudeSessionId) ?? 0) - (at.get(b8.claudeSessionId) ?? 0);
164516
+ const live = sessions3.filter((s4) => {
164517
+ if (s4.process_status !== "running" && s4.process_status !== "idle") return false;
164518
+ if (!pullable(s4)) return false;
164519
+ return now - (this.snapshotPullAt.get(s4.claudeSessionId) ?? 0) >= PULL_MIN_GAP_MS;
164520
+ }).sort(byPullAge(this.snapshotPullAt));
164521
+ const reexam = reexamPool.filter((s4) => {
164522
+ if (s4.process_status !== "error" && s4.process_status !== "stopped") return false;
164523
+ if (!pullable(s4)) return false;
164524
+ if (classifySessionError(s4) === "terminal") return false;
164525
+ const changedAt = s4.last_status_change ? Date.parse(s4.last_status_change) : NaN;
164526
+ if (!Number.isFinite(changedAt)) return false;
164527
+ if (now - changedAt > RESCUABLE_STOPPED_WINDOW_MS) return false;
164528
+ return now - (this.snapshotReexamPullAt.get(s4.claudeSessionId) ?? 0) >= REEXAM_MIN_GAP_MS;
164529
+ }).sort(byPullAge(this.snapshotReexamPullAt));
164530
+ let abandoned = false;
164531
+ const runClass = async (candidates, cap2, at, candidateClass) => {
164532
+ let pulled = 0;
164533
+ for (const s4 of candidates) {
164534
+ if (ctx?.overBudget()) {
164535
+ abandoned = true;
164536
+ log.session.warn("health monitor: checkSnapshotPull abandoned mid-loop (over budget)", {
164537
+ pulled,
164538
+ candidateCount: candidates.length,
164539
+ candidateClass
164540
+ });
164541
+ break;
164542
+ }
164543
+ const conn = getPooledSnapshotConnection2(s4.host);
164544
+ if (!conn) continue;
164545
+ if (pulled >= cap2) {
164546
+ log.session.info("health monitor: snapshot pull capped this tick", {
164547
+ cap: cap2,
164548
+ sessionCount: sessions3.length,
164549
+ candidateCount: candidates.length,
164550
+ candidateClass
164551
+ });
164552
+ break;
164553
+ }
164554
+ pulled++;
164555
+ at.set(s4.claudeSessionId, now);
164556
+ try {
164557
+ const resp = await probeWithTimeout(
164558
+ conn.send("getState", { sid: s4.claudeSessionId }),
164559
+ null,
164560
+ "snapshot-pull-getState",
164561
+ s4.claudeSessionId
164562
+ );
164563
+ const snapshot2 = resp?.ok ? resp.snapshot : void 0;
164564
+ if (snapshot2) await applySnapshot2(s4.claudeSessionId, snapshot2, "pull-30s");
164565
+ } catch (err2) {
164566
+ log.session.debug("health monitor: snapshot pull failed", {
164567
+ sessionId: s4.claudeSessionId,
164568
+ host: s4.host ?? "__local__",
164569
+ error: err2 instanceof Error ? err2.message : String(err2)
164570
+ });
164571
+ }
164242
164572
  }
164243
- }
164244
- if (capped) {
164245
- log.session.info("health monitor: snapshot pull capped this tick", {
164246
- cap: MAX_PULLS_PER_TICK,
164247
- sessionCount: sessions3.length,
164248
- candidateCount: candidates.length
164249
- });
164573
+ };
164574
+ await runClass(live, MAX_PULLS_PER_TICK, this.snapshotPullAt, "live");
164575
+ if (!abandoned) {
164576
+ await runClass(reexam, MAX_REEXAM_PULLS_PER_TICK, this.snapshotReexamPullAt, "reexam");
164250
164577
  }
164251
164578
  if (this.snapshotPullAt.size > 200) {
164252
164579
  const liveIds = new Set(sessions3.map((s4) => s4.claudeSessionId));
@@ -164254,6 +164581,12 @@ var init_session_health_monitor = __esm({
164254
164581
  if (!liveIds.has(sid)) this.snapshotPullAt.delete(sid);
164255
164582
  }
164256
164583
  }
164584
+ if (this.snapshotReexamPullAt.size > 200) {
164585
+ const poolIds = new Set(reexamPool.map((s4) => s4.claudeSessionId));
164586
+ for (const sid of this.snapshotReexamPullAt.keys()) {
164587
+ if (!poolIds.has(sid)) this.snapshotReexamPullAt.delete(sid);
164588
+ }
164589
+ }
164257
164590
  } catch (err2) {
164258
164591
  log.session.warn("health monitor: checkSnapshotPull failed", {
164259
164592
  error: err2 instanceof Error ? err2.message : String(err2)
@@ -164416,6 +164749,31 @@ var init_session_health_monitor = __esm({
164416
164749
  });
164417
164750
  }
164418
164751
  }
164752
+ /**
164753
+ * Can a scheduled auto-recovery actually FIRE for this record?
164754
+ *
164755
+ * fire() (session-auto-recover) hard-requires the linked task to still be
164756
+ * IN_PROGRESS — AGENT_COMPLETE means the work was already handed back and
164757
+ * resuming would talk over the human. Arming a recovery that cannot pass that
164758
+ * check is not free: the 30s tick re-armed it forever and each arming logged
164759
+ * an abort 20s later (measured running for 2+ hours on one session), while
164760
+ * `if (armed) continue` also skipped the phase sync that IS appropriate there.
164761
+ *
164762
+ * A lookup that cannot answer (no task id, deleted task, DB error) returns
164763
+ * true: only a KNOWN non-IN_PROGRESS phase blocks arming, so a transient read
164764
+ * failure can never disable recovery. schedule()'s own guards still apply.
164765
+ */
164766
+ async autoRecoverCanFire(s4) {
164767
+ if (!s4.taskId) return true;
164768
+ try {
164769
+ const { getTask: getTask2 } = await Promise.resolve().then(() => (init_task_manager(), task_manager_exports));
164770
+ const task = await getTask2(s4.taskId);
164771
+ if (!task) return true;
164772
+ return task.phase === "IN_PROGRESS";
164773
+ } catch {
164774
+ return true;
164775
+ }
164776
+ }
164419
164777
  /**
164420
164778
  * Auto-recover remote sessions stuck in 'error' with "Connection lost" message.
164421
164779
  *
@@ -164502,28 +164860,31 @@ var init_session_health_monitor = __esm({
164502
164860
  } else if (wasStopped) {
164503
164861
  continue;
164504
164862
  } else {
164505
- const updated = await updateSessionRecord2(s4.claudeSessionId, {
164506
- process_status: "stopped",
164507
- errorMessage: void 0,
164508
- errorKind: "infra",
164509
- activity: void 0,
164510
- last_status_change: now,
164511
- status_reason: "auto_recovered_dead",
164512
- status_changed_by: "health-monitor"
164513
- });
164514
- emitSessionStatusChanged(
164515
- updated,
164516
- {},
164517
- ["*"],
164518
- { source: "health-monitor", urgency: "urgent" }
164519
- );
164520
- log.session.info("health monitor: auto-recovered connection-lost session (process dead)", {
164521
- sessionId: s4.claudeSessionId,
164522
- host: s4.host,
164523
- alive: false
164524
- });
164863
+ const { isSnapshotCovered: isSnapshotCovered2 } = await Promise.resolve().then(() => (init_session_snapshot_gate(), session_snapshot_gate_exports));
164864
+ if (!isSnapshotCovered2(s4.claudeSessionId)) {
164865
+ const updated = await updateSessionRecord2(s4.claudeSessionId, {
164866
+ process_status: "stopped",
164867
+ errorMessage: void 0,
164868
+ errorKind: "infra",
164869
+ activity: void 0,
164870
+ last_status_change: now,
164871
+ status_reason: "auto_recovered_dead",
164872
+ status_changed_by: "health-monitor"
164873
+ });
164874
+ emitSessionStatusChanged(
164875
+ updated,
164876
+ {},
164877
+ ["*"],
164878
+ { source: "health-monitor", urgency: "urgent" }
164879
+ );
164880
+ log.session.info("health monitor: auto-recovered connection-lost session (process dead)", {
164881
+ sessionId: s4.claudeSessionId,
164882
+ host: s4.host,
164883
+ alive: false
164884
+ });
164885
+ }
164525
164886
  const { scheduleSessionAutoRecover: scheduleSessionAutoRecover2 } = await Promise.resolve().then(() => (init_session_auto_recover(), session_auto_recover_exports));
164526
- const armed = scheduleSessionAutoRecover2(s4, s4.status_reason ?? "daemon_reported_exit");
164887
+ const armed = await this.autoRecoverCanFire(s4) && scheduleSessionAutoRecover2(s4, s4.status_reason ?? "daemon_reported_exit");
164527
164888
  if (armed) continue;
164528
164889
  if (s4.taskId) {
164529
164890
  try {
@@ -172673,6 +173034,20 @@ var init_session_lifecycle_v1 = __esm({
172673
173034
  next(err2);
172674
173035
  }
172675
173036
  });
173037
+ sessionLifecycleV1Router.post("/sessions/:id/recheck", async (req, res, next) => {
173038
+ try {
173039
+ const sessionId = validSid2(req, res);
173040
+ if (!sessionId) return;
173041
+ if (CLOUD_MODE) {
173042
+ await relayControlAction(res, "recheck", sessionId, void 0, 200);
173043
+ return;
173044
+ }
173045
+ const { recheckSession: recheckSession2 } = await Promise.resolve().then(() => (init_session_lifecycle(), session_lifecycle_exports));
173046
+ await runLocal(res, next, 200, () => recheckSession2(sessionId));
173047
+ } catch (err2) {
173048
+ next(err2);
173049
+ }
173050
+ });
172676
173051
  sessionLifecycleV1Router.post("/sessions/:id/permission", async (req, res, next) => {
172677
173052
  try {
172678
173053
  const sessionId = validSid2(req, res);
@@ -184587,6 +184962,16 @@ async function resolveCredentialHealth() {
184587
184962
  return { hasReadyProvider: false, source: "none" };
184588
184963
  }
184589
184964
  }
184965
+ async function isWalnutAnsweringOn(port) {
184966
+ try {
184967
+ const res = await fetch(`http://127.0.0.1:${port}/api/system/health`, { signal: AbortSignal.timeout(1500) });
184968
+ if (!res.ok) return false;
184969
+ const body3 = await res.json();
184970
+ return !!body3 && typeof body3 === "object" && "hasReadyProvider" in body3;
184971
+ } catch {
184972
+ return false;
184973
+ }
184974
+ }
184590
184975
  async function startServer(options = {}) {
184591
184976
  if (httpServer) throw new Error("Server already running. Call stopServer() first.");
184592
184977
  bootCompleted = false;
@@ -185113,6 +185498,7 @@ async function startServer(options = {}) {
185113
185498
  });
185114
185499
  if (!dev) {
185115
185500
  const staticDir = (() => {
185501
+ if (process.env.WALNUT_WEB_STATIC_DIR) return process.env.WALNUT_WEB_STATIC_DIR;
185116
185502
  let dir2 = path184.dirname(fileURLToPath13(import.meta.url));
185117
185503
  for (let i8 = 0; i8 < 5; i8++) {
185118
185504
  const candidate = path184.join(dir2, "web", "static", "index.html");
@@ -185129,11 +185515,65 @@ async function startServer(options = {}) {
185129
185515
  }
185130
185516
  return path184.join(process.cwd(), "dist", "web", "static");
185131
185517
  })();
185518
+ const mirrorDir = process.env.WALNUT_WEB_STATIC_MIRROR || path184.join(WALNUT_HOME, "cache", "web-static");
185519
+ let mirrorReady = false;
185520
+ try {
185521
+ const want = fs113.readFileSync(path184.join(staticDir, "index.html"), "utf-8");
185522
+ let have = null;
185523
+ try {
185524
+ have = fs113.readFileSync(path184.join(mirrorDir, "index.html"), "utf-8");
185525
+ } catch {
185526
+ }
185527
+ if (have !== want) {
185528
+ fs113.rmSync(mirrorDir, { recursive: true, force: true });
185529
+ fs113.mkdirSync(path184.dirname(mirrorDir), { recursive: true });
185530
+ fs113.cpSync(staticDir, mirrorDir, { recursive: true });
185531
+ }
185532
+ } catch (err2) {
185533
+ log.web.warn("could not refresh the web-asset mirror", {
185534
+ mirrorDir,
185535
+ error: err2 instanceof Error ? err2.message : String(err2)
185536
+ });
185537
+ }
185538
+ try {
185539
+ mirrorReady = fs113.statSync(path184.join(mirrorDir, "index.html")).isFile();
185540
+ } catch {
185541
+ mirrorReady = false;
185542
+ }
185132
185543
  app.use(express3.static(staticDir));
185544
+ if (mirrorReady) app.use(express3.static(mirrorDir));
185545
+ const checkStaticRoot = () => {
185546
+ try {
185547
+ return fs113.statSync(path184.join(staticDir, "index.html")).isFile();
185548
+ } catch {
185549
+ return false;
185550
+ }
185551
+ };
185552
+ let staticRootOk = checkStaticRoot();
185553
+ if (!staticRootOk) {
185554
+ log.web.error("web assets are NOT servable at startup", { staticDir, mirrorReady });
185555
+ }
185556
+ const staticRootTimer = setInterval(() => {
185557
+ const ok = checkStaticRoot();
185558
+ if (ok === staticRootOk) return;
185559
+ staticRootOk = ok;
185560
+ if (ok) log.web.info("web assets are servable again", { staticDir });
185561
+ else log.web.error("web assets VANISHED from under the running server", { staticDir, mirrorReady });
185562
+ }, 6e4);
185563
+ staticRootTimer.unref();
185564
+ setStaticRootReporter(() => ({ staticDir, ok: staticRootOk, mirrorDir, mirrorReady }));
185133
185565
  app.use((req, res, next) => {
185134
185566
  if (req.method !== "GET" || req.path.startsWith("/api/")) return next();
185135
185567
  if (isStaticAssetPath(req.path)) return next();
185136
- res.sendFile("index.html", { root: staticDir });
185568
+ res.sendFile("index.html", { root: staticDir }, (err2) => {
185569
+ if (!err2) return;
185570
+ if (mirrorReady) {
185571
+ res.sendFile("index.html", { root: mirrorDir }, () => {
185572
+ });
185573
+ return;
185574
+ }
185575
+ next(err2);
185576
+ });
185137
185577
  });
185138
185578
  }
185139
185579
  app.use("/api", notFoundHandler);
@@ -185153,9 +185593,21 @@ async function startServer(options = {}) {
185153
185593
  }
185154
185594
  httpServer = createServer(app);
185155
185595
  attachWss(httpServer);
185156
- await new Promise((resolve, reject) => {
185157
- httpServer.listen(port, () => resolve());
185158
- httpServer.once("error", reject);
185596
+ try {
185597
+ await new Promise((resolve, reject) => {
185598
+ httpServer.once("error", reject);
185599
+ httpServer.listen(port, () => resolve());
185600
+ });
185601
+ } catch (err2) {
185602
+ if (err2?.code !== "EADDRINUSE") throw err2;
185603
+ const already = await isWalnutAnsweringOn(port);
185604
+ throw new Error(
185605
+ already ? `Walnut is already running at http://localhost:${port} (EADDRINUSE). Open that address; there is nothing to start.` : `Port ${port} is in use by another program (EADDRINUSE). Start Walnut on a free one instead, for example:
185606
+ open-walnut web --port ${port + 1}`
185607
+ );
185608
+ }
185609
+ httpServer.on("error", (err2) => {
185610
+ log.web.error("http server error after listen", { error: err2 instanceof Error ? err2.message : String(err2) });
185159
185611
  });
185160
185612
  const label = dev ? "dev" : "production";
185161
185613
  log.web.info(`server listening on http://localhost:${port}`, { mode: label, port });
@@ -185165,6 +185617,7 @@ async function startServer(options = {}) {
185165
185617
  const { updateInstanceLockPort: updateInstanceLockPort2 } = await Promise.resolve().then(() => (init_instance_lock(), instance_lock_exports));
185166
185618
  updateInstanceLockPort2(bound.port);
185167
185619
  setPluginApiBase(`http://127.0.0.1:${bound.port}`);
185620
+ process.env.WALNUT_SERVER_URL = `http://localhost:${bound.port}`;
185168
185621
  }
185169
185622
  }
185170
185623
  if (CLOUD_MODE) {