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
package/dist/cli.js CHANGED
@@ -7366,6 +7366,23 @@ function findRepoRoot() {
7366
7366
  }
7367
7367
  return null;
7368
7368
  }
7369
+ function bunAvailable() {
7370
+ const home = process.env.HOME ?? "";
7371
+ const candidates = [
7372
+ ...(process.env.PATH ?? "").split(path14.delimiter).filter(Boolean).map((d8) => path14.join(d8, "bun")),
7373
+ path14.join(home, ".bun", "bin", "bun"),
7374
+ "/opt/homebrew/bin/bun",
7375
+ "/usr/local/bin/bun"
7376
+ ];
7377
+ return candidates.some((p6) => {
7378
+ try {
7379
+ fs12.accessSync(p6, fs12.constants.X_OK);
7380
+ return true;
7381
+ } catch {
7382
+ return false;
7383
+ }
7384
+ });
7385
+ }
7369
7386
  function readBuiltVersions() {
7370
7387
  const out = /* @__PURE__ */ new Map();
7371
7388
  try {
@@ -7389,6 +7406,10 @@ function verifyDaemonBinaryVersion() {
7389
7406
  return true;
7390
7407
  }
7391
7408
  const built = readBuiltVersions();
7409
+ if (built.size === 0 && !bunAvailable()) {
7410
+ log.session.info("no daemon binaries and no Bun on this machine; sessions use the source daemon", { expected });
7411
+ return true;
7412
+ }
7392
7413
  if (built.size === 0) {
7393
7414
  return handleMismatch({
7394
7415
  reason: "no daemon binaries built",
@@ -7447,6 +7468,12 @@ function handleMismatch(ctx) {
7447
7468
  return false;
7448
7469
  }
7449
7470
  const builtAfter = readBuiltVersions();
7471
+ if (builtAfter.size === 0) {
7472
+ log.session.warn("daemon rebuild produced no binaries (no Bun?); continuing with the source daemon", { expected: ctx.expected });
7473
+ console.error(` Daemon binaries were not built (Bun is not installed). Sessions use the source daemon. Continuing startup.
7474
+ `);
7475
+ return true;
7476
+ }
7450
7477
  const stillOff = [...builtAfter].filter(([, v3]) => v3 !== ctx.expected);
7451
7478
  if (stillOff.length > 0) {
7452
7479
  log.session.error(
@@ -8788,7 +8815,25 @@ function reapSession(sid, code, reason) {
8788
8815
  let jsonlAgeMs = null;
8789
8816
  try { jsonlAgeMs = Date.now() - fs.statSync(session.jsonlPath).mtimeMs; } catch {}
8790
8817
  const cleanExit = isTurnCompleteExit(session.jsonlPath);
8791
- if (code !== 0 && cleanExit) {
8818
+ // An INTENTIONAL daemon-initiated reclamation is not a failure, and the JSONL
8819
+ // tail cannot tell us it happened. The idle scanner stamps idleReclaimAt
8820
+ // before it signals; the death then lands here through whichever path notices
8821
+ // it (usually orphan-poll ESRCH \u2192 code -1). Verified production case: the last
8822
+ // line was a control_response (the reply to a Walnut-issued control request \u2014
8823
+ // a routine shape), so isTurnCompleteExit said false, exitCode stayed -1, and
8824
+ // the projection painted a red "Error" over a healthy, fully resumable session
8825
+ // we reclaimed on purpose. Checked FIRST and unconditionally: the flag carries
8826
+ // information the tail does not contain, which is why isTurnCompleteExit is
8827
+ // left alone. Keep in sync with daemon-core.ts reapSession.
8828
+ const intentionalReclaim = session.idleReclaimAt != null;
8829
+ if (intentionalReclaim && code !== 0) {
8830
+ logMsg('info', 'reapSession: intentional idle reclamation, normalizing exit code', {
8831
+ sid, pid: session.pid, originalCode: code, originalReason: reason,
8832
+ jsonlAgeMs, cleanExit, idleReclaimAt: session.idleReclaimAt,
8833
+ });
8834
+ code = 0;
8835
+ reason = reason + '+intentional-idle-reclaim';
8836
+ } else if (code !== 0 && cleanExit) {
8792
8837
  logMsg('info', 'reapSession: turn-complete detected, normalizing exit code', {
8793
8838
  sid, pid: session.pid, originalCode: code, originalReason: reason, jsonlAgeMs,
8794
8839
  });
@@ -14553,6 +14598,14 @@ function scanIdleSessions() {
14553
14598
  }
14554
14599
  const idleMinutes = Math.round(idleMs / 60000);
14555
14600
  logMsg('warn', 'idle scan: killing idle session (no subscribers, no output)', { sid, pid, idleMinutes, protectedBy: prot.source, thresholdMs: prot.killMs, detail: prot.detail });
14601
+ // Record the intent BEFORE signalling: the reap arrives asynchronously
14602
+ // (the orphan poll sees ESRCH and reports 'orphan-poll-dead', code -1), so
14603
+ // by then nothing remembers that WE asked for this. Without the stamp
14604
+ // reapSession can only consult the JSONL tail, which says "not a clean turn
14605
+ // end" for any session whose last line isn't a type:result \u2014 and the
14606
+ // projection then shows a red Error for our own routine reclamation.
14607
+ // Keep in sync with daemon-standalone.ts scanIdleSessions.
14608
+ session.idleReclaimAt = now;
14556
14609
  killSessionProcessGroup(pid, sid);
14557
14610
  }
14558
14611
  }
@@ -15236,7 +15289,20 @@ function createDaemonCore(deps) {
15236
15289
  jsonlAgeMs = clock() - fs121.statSync(session2.jsonlPath).mtimeMs;
15237
15290
  } catch {
15238
15291
  }
15239
- if (cleanExit && code !== 0) {
15292
+ const intentionalReclaim = session2.idleReclaimAt != null;
15293
+ if (intentionalReclaim && code !== 0) {
15294
+ logger2("info", "reapSession: intentional idle reclamation, normalizing exit code", {
15295
+ sid,
15296
+ pid: session2.pid,
15297
+ originalCode: code,
15298
+ originalReason: reason,
15299
+ jsonlAgeMs,
15300
+ cleanExit,
15301
+ idleReclaimAt: session2.idleReclaimAt
15302
+ });
15303
+ code = 0;
15304
+ reason = reason + "+intentional-idle-reclaim";
15305
+ } else if (cleanExit && code !== 0) {
15240
15306
  logger2("info", "reapSession: turn-complete detected, normalizing exit code", {
15241
15307
  sid,
15242
15308
  pid: session2.pid,
@@ -16707,6 +16773,22 @@ var init_local_daemon = __esm({
16707
16773
  });
16708
16774
 
16709
16775
  // src/core/session-error-kind.ts
16776
+ var session_error_kind_exports = {};
16777
+ __export(session_error_kind_exports, {
16778
+ INFRA_STATUS_REASONS: () => INFRA_STATUS_REASONS,
16779
+ RESCUABLE_STOPPED_WINDOW_MS: () => RESCUABLE_STOPPED_WINDOW_MS,
16780
+ TERMINAL_STATUS_REASONS: () => TERMINAL_STATUS_REASONS,
16781
+ assertsHostUnreachable: () => assertsHostUnreachable,
16782
+ classifySessionError: () => classifySessionError,
16783
+ classifyStatusReasonKind: () => classifyStatusReasonKind,
16784
+ isInfraSessionError: () => isInfraSessionError,
16785
+ isRecoverableSessionError: () => isRecoverableSessionError,
16786
+ isRescuableStoppedRecord: () => isRescuableStoppedRecord
16787
+ });
16788
+ function assertsHostUnreachable(record) {
16789
+ if (!record.errorMessage) return false;
16790
+ return typeof record.status_reason === "string" && REACHABILITY_CLAIM_REASONS.has(record.status_reason);
16791
+ }
16710
16792
  function classifyStatusReasonKind(reason) {
16711
16793
  if (typeof reason !== "string") return void 0;
16712
16794
  if (INFRA_STATUS_REASONS.has(reason)) return "infra";
@@ -16735,7 +16817,7 @@ function isRescuableStoppedRecord(record, nowMs = Date.now()) {
16735
16817
  if (!Number.isFinite(changedAt)) return false;
16736
16818
  return nowMs - changedAt <= RESCUABLE_STOPPED_WINDOW_MS;
16737
16819
  }
16738
- var INFRA_STATUS_REASONS, TERMINAL_STATUS_REASONS, INFRA_TEXT_SIGNATURES, RESCUABLE_STOPPED_WINDOW_MS;
16820
+ var INFRA_STATUS_REASONS, TERMINAL_STATUS_REASONS, INFRA_TEXT_SIGNATURES, REACHABILITY_CLAIM_REASONS, RESCUABLE_STOPPED_WINDOW_MS;
16739
16821
  var init_session_error_kind = __esm({
16740
16822
  "src/core/session-error-kind.ts"() {
16741
16823
  "use strict";
@@ -16779,6 +16861,9 @@ var init_session_error_kind = __esm({
16779
16861
  "ssh tunnel",
16780
16862
  "broken pipe"
16781
16863
  ];
16864
+ REACHABILITY_CLAIM_REASONS = /* @__PURE__ */ new Set([
16865
+ "remote_unreachable"
16866
+ ]);
16782
16867
  RESCUABLE_STOPPED_WINDOW_MS = 24 * 60 * 60 * 1e3;
16783
16868
  }
16784
16869
  });
@@ -67231,8 +67316,22 @@ var init_token_truth = __esm({
67231
67316
  });
67232
67317
 
67233
67318
  // src/utils/image-compress.ts
67234
- import sharp from "sharp";
67319
+ async function loadSharp() {
67320
+ sharpLoad ??= import("sharp").then(
67321
+ (m6) => m6.default ?? m6,
67322
+ (err2) => {
67323
+ log.agent.warn("sharp is not installed; images are sent uncompressed", {
67324
+ error: err2 instanceof Error ? err2.message : String(err2),
67325
+ fix: "a machine with glibc 2.28+ installs sharp prebuilt; otherwise install libvips and re-run npm install"
67326
+ });
67327
+ return null;
67328
+ }
67329
+ );
67330
+ return sharpLoad;
67331
+ }
67235
67332
  async function clampImageDimensions(buffer2, mimeType) {
67333
+ const sharp = await loadSharp();
67334
+ if (!sharp) return { buffer: buffer2, mimeType };
67236
67335
  try {
67237
67336
  const meta = await sharp(buffer2).metadata();
67238
67337
  const longest = Math.max(meta.width ?? 0, meta.height ?? 0);
@@ -67254,6 +67353,8 @@ async function compressForApi(inputBuffer, inputMimeType) {
67254
67353
  if (buffer2.toString("base64").length <= MAX_BASE64_BYTES) {
67255
67354
  return { buffer: buffer2, mimeType };
67256
67355
  }
67356
+ const sharp = await loadSharp();
67357
+ if (!sharp) return { buffer: buffer2, mimeType };
67257
67358
  try {
67258
67359
  if (mimeType === "image/gif") {
67259
67360
  const webp = await sharp(buffer2, { animated: true }).webp({ quality: 80 }).toBuffer();
@@ -67291,10 +67392,11 @@ async function compressForApi(inputBuffer, inputMimeType) {
67291
67392
  return { buffer: buffer2, mimeType };
67292
67393
  }
67293
67394
  }
67294
- var MAX_BASE64_BYTES, MAX_IMAGE_DIMENSION;
67395
+ var sharpLoad, MAX_BASE64_BYTES, MAX_IMAGE_DIMENSION;
67295
67396
  var init_image_compress = __esm({
67296
67397
  "src/utils/image-compress.ts"() {
67297
67398
  "use strict";
67399
+ init_logging();
67298
67400
  MAX_BASE64_BYTES = 5e6;
67299
67401
  MAX_IMAGE_DIMENSION = 1568;
67300
67402
  }
@@ -70285,9 +70387,20 @@ async function applySnapshot(sessionId, snapshot2, source) {
70285
70387
  updates.errorKind = void 0;
70286
70388
  clearSuppressedErrorReason(sessionId);
70287
70389
  } else {
70390
+ let droppedDisproven = false;
70391
+ if (assertsHostUnreachable(record)) {
70392
+ updates.errorMessage = void 0;
70393
+ droppedDisproven = true;
70394
+ log.session.info("snapshot projection dropped disproven unreachability diagnosis", {
70395
+ sessionId,
70396
+ priorReason: record.status_reason ?? null,
70397
+ projected,
70398
+ source
70399
+ });
70400
+ }
70288
70401
  const stashed = takeSuppressedErrorReason(sessionId);
70289
70402
  if (stashed) {
70290
- if (!record.errorMessage && stashed.message) updates.errorMessage = stashed.message;
70403
+ if ((!record.errorMessage || droppedDisproven) && stashed.message) updates.errorMessage = stashed.message;
70291
70404
  if (!record.errorKind && stashed.kind) updates.errorKind = stashed.kind;
70292
70405
  log.session.info("snapshot projection adopted suppressed error reason", {
70293
70406
  sessionId,
@@ -70340,6 +70453,7 @@ var init_session_snapshot_apply = __esm({
70340
70453
  "use strict";
70341
70454
  init_logging();
70342
70455
  init_session_snapshot_gate();
70456
+ init_session_error_kind();
70343
70457
  init_engine_registry();
70344
70458
  init_session_snapshot_gate();
70345
70459
  TERMINAL = /* @__PURE__ */ new Set(["stopped", "error"]);
@@ -72954,9 +73068,15 @@ var init_acp_session = __esm({
72954
73068
  }
72955
73069
  async resolveArtifacts() {
72956
73070
  if (this.cfg.artifacts) return this.cfg.artifacts;
72957
- return resolveAcpArtifacts(this.engineId, {
73071
+ const artifacts = resolveAcpArtifacts(this.engineId, {
72958
73072
  configuredAdapterCmd: await this.resolveConfiguredAdapterCmd()
72959
73073
  });
73074
+ if (!fsModule.existsSync(artifacts.workerCmd[1])) {
73075
+ throw new Error(
73076
+ `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.`
73077
+ );
73078
+ }
73079
+ return artifacts;
72960
73080
  }
72961
73081
  async establishProviderSession() {
72962
73082
  await this.seedReplayCursor();
@@ -76493,6 +76613,7 @@ __export(session_lifecycle_exports, {
76493
76613
  persistSessionModeChange: () => persistSessionModeChange,
76494
76614
  readProviderSessionHistory: () => readProviderSessionHistory,
76495
76615
  readSessionRichHistory: () => readSessionRichHistory,
76616
+ recheckSession: () => recheckSession,
76496
76617
  respondSessionPermission: () => respondSessionPermission,
76497
76618
  restartSession: () => restartSession,
76498
76619
  retrySession: () => retrySession,
@@ -76830,7 +76951,7 @@ async function retrySession(sessionId) {
76830
76951
  return { status: "reconnected", sessionId };
76831
76952
  }
76832
76953
  if (await resumeConversationExists(record)) {
76833
- const { sendMessageToSession: sendMessageToSession2, getQueue: getQueue2, unparkMessage: unparkMessage2 } = await Promise.resolve().then(() => (init_session_message_queue(), session_message_queue_exports));
76954
+ const { getQueue: getQueue2, unparkMessage: unparkMessage2 } = await Promise.resolve().then(() => (init_session_message_queue(), session_message_queue_exports));
76834
76955
  const pendingMsgs = await getQueue2(sessionId);
76835
76956
  for (const parked of pendingMsgs.filter((m6) => m6.status === "parked")) {
76836
76957
  await unparkMessage2(sessionId, parked.id);
@@ -76840,9 +76961,22 @@ async function retrySession(sessionId) {
76840
76961
  log.session.info("session retry: re-processing pending queue messages", { sessionId, taskId: record.taskId, count: pendingMsgs.length });
76841
76962
  return { status: "resuming", sessionId, restoredMessages: pendingMsgs.length };
76842
76963
  }
76843
- await sendMessageToSession2(sessionId, "continue", { source: "retry", taskId: record.taskId });
76844
- log.session.info("session retry: resuming via --resume (no pending messages)", { sessionId, taskId: record.taskId });
76845
- return { status: "resuming", sessionId };
76964
+ const relabelled = await updateSessionRecord2(sessionId, {
76965
+ process_status: "stopped",
76966
+ errorMessage: void 0,
76967
+ errorKind: void 0,
76968
+ activity: void 0,
76969
+ last_status_change: (/* @__PURE__ */ new Date()).toISOString(),
76970
+ status_reason: "retry_reconnect",
76971
+ status_changed_by: "user"
76972
+ });
76973
+ emitSessionStatusChanged2(relabelled, {}, ["*"]);
76974
+ log.session.info("session retry: reconnected \u2014 conversation preserved, no message sent", {
76975
+ sessionId,
76976
+ taskId: record.taskId,
76977
+ host: record.host ?? "__local__"
76978
+ });
76979
+ return { status: "resumable", sessionId };
76846
76980
  }
76847
76981
  log.session.warn("session retry: conversation never persisted \u2014 starting fresh instead of --resume", {
76848
76982
  sessionId,
@@ -76900,6 +77034,91 @@ async function retrySession(sessionId) {
76900
77034
  });
76901
77035
  return { status: "pending", taskId: task.id, oldSessionId: sessionId };
76902
77036
  }
77037
+ async function withDeadline(p6, ms) {
77038
+ let timer3;
77039
+ try {
77040
+ return await Promise.race([
77041
+ p6,
77042
+ new Promise((resolve) => {
77043
+ timer3 = setTimeout(() => resolve(RECHECK_TIMED_OUT), ms);
77044
+ if (timer3 && typeof timer3 === "object" && "unref" in timer3) timer3.unref();
77045
+ })
77046
+ ]);
77047
+ } finally {
77048
+ if (timer3) clearTimeout(timer3);
77049
+ }
77050
+ }
77051
+ async function recheckSession(sessionId) {
77052
+ const { getSessionByClaudeId: getSessionByClaudeId2 } = await Promise.resolve().then(() => (init_session_tracker(), session_tracker_exports));
77053
+ const record = await getSessionByClaudeId2(sessionId);
77054
+ if (!record) throw new SessionControlError("Session not found", 404);
77055
+ const hostKey2 = record.host ?? "__local__";
77056
+ const { classifySessionError: classifySessionError2 } = await Promise.resolve().then(() => (init_session_error_kind(), session_error_kind_exports));
77057
+ const kind = classifySessionError2(record);
77058
+ const base = { sessionId, processStatus: record.process_status, infraClaim: kind === "infra" };
77059
+ const { isDaemonConnected: isDaemonConnected2, getPooledSnapshotConnection: getPooledSnapshotConnection2 } = await Promise.resolve().then(() => (init_daemon_connection(), daemon_connection_exports));
77060
+ const reachable = isDaemonConnected2(hostKey2);
77061
+ if (kind === "terminal") return { ...base, checked: false, reachable, reason: "terminal_error" };
77062
+ if (record.archived) return { ...base, checked: false, reachable, reason: "archived" };
77063
+ const conn = getPooledSnapshotConnection2(record.host);
77064
+ if (!conn) {
77065
+ log.session.info("session recheck: no pooled snapshot connection \u2014 nothing to ask", {
77066
+ sessionId,
77067
+ host: hostKey2,
77068
+ reachable
77069
+ });
77070
+ return { ...base, checked: false, reachable, reason: "no_pooled_connection" };
77071
+ }
77072
+ let raced;
77073
+ try {
77074
+ raced = await withDeadline(
77075
+ conn.send("getState", { sid: sessionId }, RECHECK_RPC_TIMEOUT_MS),
77076
+ RECHECK_RPC_TIMEOUT_MS
77077
+ );
77078
+ } catch (err2) {
77079
+ log.session.info("session recheck: getState failed", {
77080
+ sessionId,
77081
+ host: hostKey2,
77082
+ error: err2 instanceof Error ? err2.message : String(err2)
77083
+ });
77084
+ return { ...base, checked: false, reachable, reason: "rpc_failed" };
77085
+ }
77086
+ if (raced === RECHECK_TIMED_OUT) {
77087
+ log.session.warn("session recheck: getState timed out", {
77088
+ sessionId,
77089
+ host: hostKey2,
77090
+ timeoutMs: RECHECK_RPC_TIMEOUT_MS
77091
+ });
77092
+ return { ...base, checked: false, reachable, reason: "timeout" };
77093
+ }
77094
+ const snapshot2 = raced?.ok ? raced.snapshot : void 0;
77095
+ if (!snapshot2) {
77096
+ return { ...base, checked: false, reachable: true, reason: "no_snapshot" };
77097
+ }
77098
+ const { applySnapshot: applySnapshot2 } = await Promise.resolve().then(() => (init_session_snapshot_apply(), session_snapshot_apply_exports));
77099
+ const outcome = await applySnapshot2(sessionId, snapshot2, "recheck-open");
77100
+ const after = await getSessionByClaudeId2(sessionId);
77101
+ const processStatus = after?.process_status ?? record.process_status;
77102
+ log.session.info("session recheck: reconciled against host", {
77103
+ sessionId,
77104
+ host: hostKey2,
77105
+ previous: record.process_status,
77106
+ processStatus,
77107
+ cliState: snapshot2.cliState,
77108
+ applyOutcome: outcome.outcome
77109
+ });
77110
+ return {
77111
+ sessionId,
77112
+ checked: true,
77113
+ reachable: true,
77114
+ alive: snapshot2.cliState !== "dead",
77115
+ processStatus,
77116
+ // Re-classify against the CURRENT record: a converged row may no longer
77117
+ // carry the claim, and the client uses this to decide whether the sentence
77118
+ // on screen is still true.
77119
+ infraClaim: classifySessionError2(after ?? record) === "infra"
77120
+ };
77121
+ }
76903
77122
  async function respondSessionPermission(sessionId, requestId, allow, denyMessage, optionId, answers) {
76904
77123
  if (typeof requestId !== "string" || !requestId || typeof allow !== "boolean") {
76905
77124
  throw new SessionControlError("requestId (string) and allow (boolean) are required", 400);
@@ -77193,7 +77412,7 @@ async function readSessionRichHistory(sessionId, tail) {
77193
77412
  ...historyWindowed ? { windowed: true } : {}
77194
77413
  };
77195
77414
  }
77196
- var CLAUDE_SESSION_MODES, MAX_PINNED_MESSAGES, MAX_PIN_LABEL_CHARS, MAX_PIN_ID_CHARS, GIT_BASES, MAX_FORK_DEPTH, HISTORY_STARTUP_GRACE_MS;
77415
+ 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;
77197
77416
  var init_session_lifecycle = __esm({
77198
77417
  "src/core/sessions/session-lifecycle.ts"() {
77199
77418
  "use strict";
@@ -77207,6 +77426,8 @@ var init_session_lifecycle = __esm({
77207
77426
  MAX_PINNED_MESSAGES = 200;
77208
77427
  MAX_PIN_LABEL_CHARS = 300;
77209
77428
  MAX_PIN_ID_CHARS = 200;
77429
+ RECHECK_RPC_TIMEOUT_MS = 5e3;
77430
+ RECHECK_TIMED_OUT = /* @__PURE__ */ Symbol("recheck-timeout");
77210
77431
  GIT_BASES = /* @__PURE__ */ new Set(["uncommitted", "previous", "remote"]);
77211
77432
  MAX_FORK_DEPTH = 5;
77212
77433
  HISTORY_STARTUP_GRACE_MS = 2 * 60 * 1e3;
@@ -86589,7 +86810,7 @@ function isRevealSecretPath(resolved) {
86589
86810
  ];
86590
86811
  return denied.some((d8) => resolved === d8 || resolved.startsWith(d8 + path69.sep)) || /(^|\/)config\.ya?ml$/.test(resolved);
86591
86812
  }
86592
- 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;
86813
+ 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;
86593
86814
  var init_files2 = __esm({
86594
86815
  "src/web/routes/files.ts"() {
86595
86816
  "use strict";
@@ -86722,6 +86943,54 @@ var init_files2 = __esm({
86722
86943
  next(err2);
86723
86944
  }
86724
86945
  });
86946
+ LIST_MANY_MAX_PATHS = 64;
86947
+ LIST_MANY_CONCURRENCY = 8;
86948
+ LIST_MANY_DEADLINE_MS = 8e3;
86949
+ filesRouter.post("/list-many", async (req, res, next) => {
86950
+ try {
86951
+ const body3 = req.body ?? {};
86952
+ const raw = Array.isArray(body3.paths) ? body3.paths : null;
86953
+ if (!raw) {
86954
+ res.status(400).json({ error: "paths (string[]) is required" });
86955
+ return;
86956
+ }
86957
+ const host = typeof body3.host === "string" && body3.host ? body3.host : void 0;
86958
+ const showHidden = body3.showHidden === true || body3.showHidden === "1";
86959
+ const paths = [...new Set(
86960
+ raw.filter((p6) => typeof p6 === "string" && !!p6)
86961
+ )];
86962
+ const truncated = paths.length > LIST_MANY_MAX_PATHS;
86963
+ const wanted = paths.slice(0, LIST_MANY_MAX_PATHS);
86964
+ const deadline4 = Date.now() + LIST_MANY_DEADLINE_MS;
86965
+ const listings = [];
86966
+ let timedOut = false;
86967
+ let next_ = 0;
86968
+ const worker = async () => {
86969
+ for (; ; ) {
86970
+ const i8 = next_++;
86971
+ if (i8 >= wanted.length) return;
86972
+ if (Date.now() >= deadline4) {
86973
+ timedOut = true;
86974
+ return;
86975
+ }
86976
+ const dirPath = wanted[i8];
86977
+ try {
86978
+ listings.push(await listSessionFiles(dirPath, host, showHidden));
86979
+ } catch (err2) {
86980
+ listings.push({ path: dirPath, error: err2 instanceof Error ? err2.message : String(err2) });
86981
+ }
86982
+ }
86983
+ };
86984
+ await Promise.all(Array.from({ length: Math.min(LIST_MANY_CONCURRENCY, wanted.length) }, worker));
86985
+ res.json({ listings, truncated, timedOut });
86986
+ } catch (err2) {
86987
+ if (err2 instanceof FilesOpError) {
86988
+ res.status(err2.statusCode).json({ error: err2.message });
86989
+ return;
86990
+ }
86991
+ next(err2);
86992
+ }
86993
+ });
86725
86994
  REVEAL_APP_DENY_EXTS = /* @__PURE__ */ new Set([
86726
86995
  "command",
86727
86996
  "sh",
@@ -102616,6 +102885,19 @@ var init_sessions2 = __esm({
102616
102885
  next(err2);
102617
102886
  }
102618
102887
  });
102888
+ sessionsRouter.post("/:sessionId/recheck", async (req, res, next) => {
102889
+ try {
102890
+ const sessionId = req.params.sessionId;
102891
+ const result = await recheckSession(sessionId);
102892
+ res.json(result);
102893
+ } catch (err2) {
102894
+ if (err2 instanceof SessionControlError) {
102895
+ res.status(err2.statusCode).json({ error: err2.message });
102896
+ return;
102897
+ }
102898
+ next(err2);
102899
+ }
102900
+ });
102619
102901
  sessionsRouter.post("/:sessionId/restart", async (req, res, next) => {
102620
102902
  const sessionId = req.params.sessionId;
102621
102903
  const startedAt = Date.now();
@@ -105319,9 +105601,13 @@ var init_credential_verify = __esm({
105319
105601
  var config_exports = {};
105320
105602
  __export(config_exports, {
105321
105603
  buildProvidersPayload: () => buildProvidersPayload,
105322
- configRouter: () => configRouter
105604
+ configRouter: () => configRouter,
105605
+ setStaticRootReporter: () => setStaticRootReporter
105323
105606
  });
105324
105607
  import { Router as Router14 } from "express";
105608
+ function setStaticRootReporter(fn) {
105609
+ staticRootReporter = fn;
105610
+ }
105325
105611
  async function fetchOllamaModels(baseUrl) {
105326
105612
  if (Date.now() - _ollamaCache.ts < OLLAMA_CACHE_TTL) return _ollamaCache.models;
105327
105613
  const url = (baseUrl ?? DEFAULT_BASE_URLS["ollama"] ?? "http://localhost:11434") + "/api/tags";
@@ -105454,7 +105740,7 @@ async function buildProvidersPayload() {
105454
105740
  }
105455
105741
  return { providers };
105456
105742
  }
105457
- var configRouter, _ollamaCache, OLLAMA_CACHE_TTL;
105743
+ var configRouter, staticRootReporter, _ollamaCache, OLLAMA_CACHE_TTL;
105458
105744
  var init_config11 = __esm({
105459
105745
  "src/web/routes/config.ts"() {
105460
105746
  "use strict";
@@ -105469,6 +105755,7 @@ var init_config11 = __esm({
105469
105755
  init_defaults();
105470
105756
  init_config_redact();
105471
105757
  configRouter = Router14();
105758
+ staticRootReporter = null;
105472
105759
  configRouter.get("/", async (_req, res, next) => {
105473
105760
  try {
105474
105761
  const config = await getConfig();
@@ -105495,7 +105782,8 @@ var init_config11 = __esm({
105495
105782
  remoteIdUniquenessGaps = getExtIndexUniquenessGaps2();
105496
105783
  } catch {
105497
105784
  }
105498
- 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 });
105785
+ const webAssets = staticRootReporter?.() ?? null;
105786
+ 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 });
105499
105787
  } catch (err2) {
105500
105788
  next(err2);
105501
105789
  }
@@ -110364,6 +110652,9 @@ var init_session_chat = __esm({
110364
110652
 
110365
110653
  // src/web/routes/browser-logs.ts
110366
110654
  import { Router as Router25 } from "express";
110655
+ function entrySubsystem(entry) {
110656
+ return entry.subsystem && ENTRY_SUBSYSTEMS.has(entry.subsystem) ? entry.subsystem : "browser";
110657
+ }
110367
110658
  function checkRate(client, entryCount) {
110368
110659
  const now = Date.now();
110369
110660
  let state9 = clientRates.get(client);
@@ -110405,10 +110696,11 @@ function writeEntries(entries) {
110405
110696
  if (entry.args) meta.args = truncate(entry.args, 1e3);
110406
110697
  if (entry.url) meta.url = entry.url;
110407
110698
  if (entry.count && entry.count > 1) meta.count = entry.count;
110699
+ if (entry.client === "mac-app") meta.client = entry.client;
110408
110700
  writeLogEntry({
110409
110701
  time: entry.time,
110410
110702
  level: mapLevel(entry.level),
110411
- subsystem: "browser",
110703
+ subsystem: entrySubsystem(entry),
110412
110704
  message: truncate(entry.message, 2e3),
110413
110705
  browserLevel: entry.level,
110414
110706
  // preserve original level (e.g. 'log' vs 'info')
@@ -110431,13 +110723,14 @@ function registerBrowserLogsRpc() {
110431
110723
  writeEntries(entries);
110432
110724
  });
110433
110725
  }
110434
- var RATE_WINDOW_MS, RATE_LIMIT, clientRates, REST_MAX_ENTRIES, browserLogsRouter;
110726
+ var ENTRY_SUBSYSTEMS, RATE_WINDOW_MS, RATE_LIMIT, clientRates, REST_MAX_ENTRIES, browserLogsRouter;
110435
110727
  var init_browser_logs = __esm({
110436
110728
  "src/web/routes/browser-logs.ts"() {
110437
110729
  "use strict";
110438
110730
  init_handler();
110439
110731
  init_logging();
110440
110732
  init_logger();
110733
+ ENTRY_SUBSYSTEMS = /* @__PURE__ */ new Set(["browser", "desktop"]);
110441
110734
  RATE_WINDOW_MS = 1e4;
110442
110735
  RATE_LIMIT = 500;
110443
110736
  clientRates = /* @__PURE__ */ new WeakMap();
@@ -118518,6 +118811,7 @@ var init_session_health_monitor = __esm({
118518
118811
  log.session.info("health monitor: drained orphan dead-pool", { orphanCount: orphanIds.length, written: written.length });
118519
118812
  }
118520
118813
  if (sessions3.length === 0) {
118814
+ if (!ctx.overBudget()) await this.checkSnapshotPull(sessions3, ctx, allSessions);
118521
118815
  const total = Date.now() - checkT0;
118522
118816
  if (total > 500) log.session.warn("health monitor: check() slow (no active sessions)", { totalMs: total, orphanMs: tOrphan - checkT0, recoverMs: tRecover - tOrphan });
118523
118817
  return;
@@ -118547,7 +118841,7 @@ var init_session_health_monitor = __esm({
118547
118841
  await this.reconcilePendingBackgroundTasks(sessions3);
118548
118842
  endPhase("pendingBgTasks");
118549
118843
  if (ctx.overBudget()) return;
118550
- await this.checkSnapshotPull(sessions3, ctx);
118844
+ await this.checkSnapshotPull(sessions3, ctx, allSessions);
118551
118845
  endPhase("snapshotPull");
118552
118846
  if (ctx.overBudget()) return;
118553
118847
  const reconciledIds = await this.reconcileStuckRunningSessions(sessions3, taskMap);
@@ -119041,15 +119335,39 @@ var init_session_health_monitor = __esm({
119041
119335
  bus.emit(EventNames.SESSION_WILL_REAP, payload, ["*"], { source: "health-monitor" });
119042
119336
  }
119043
119337
  /**
119044
- * C2 snapshot pull channel (contract §5): every 30s tick, for records in
119045
- * {running, idle} on a native (non-codex, non-embedded/sdk) engine whose
119046
- * host has a POOLED CONNECTED DaemonConnection advertising 'snapshot-v1',
119047
- * PULL getState and feed the snapshot to applySnapshot. NEVER dials a new
119048
- * connection. Sequential, capped at 10 sids/tick, per-sid 25s spacing.
119338
+ * C2 snapshot pull channel (contract §5): every 30s tick, PULL getState from
119339
+ * the host's POOLED CONNECTED DaemonConnection advertising 'snapshot-v1' and
119340
+ * feed the answer to applySnapshot. NEVER dials a new connection, always
119341
+ * sequential. TWO candidate classes with SEPARATE budgets and cadences:
119342
+ *
119343
+ * - LIVE — {running, idle}: 10 sids/tick, 25s per-sid spacing. The steady
119344
+ * state channel.
119345
+ * - RE-EXAMINATION — {error, stopped} whose cause is NOT positively terminal
119346
+ * (classifySessionError) and whose last_status_change is inside
119347
+ * RESCUABLE_STOPPED_WINDOW_MS: 5 sids/tick, 5 min per-sid spacing. Same
119348
+ * engine/provider/awaiting_spawn/archived exclusions as the live class.
119349
+ *
119350
+ * WHY RE-EXAMINATION EXISTS — do not "optimize" the error class back out. A
119351
+ * wrongly-'error' record was a two-sided deadlock: this lane is the ONE writer
119352
+ * the enforce-mode gate sanctions for a snapshot-covered session, and while it
119353
+ * looked at live records only it never re-derived status for an 'error' one.
119354
+ * recoverInfraFailedSessions DOES look, but the fix it writes
119355
+ * (('health-monitor','auto_recovered_dead')) is itself category-①, so the gate
119356
+ * drops the whole patch — the lane allowed to write refused to look, and the
119357
+ * lane that looked was not allowed to write. Records froze in 'error' with
119358
+ * "Connection lost — unable to reach remote host" for hours after their host
119359
+ * came back (2026-09-03, a remote dev host whose tunnel flapped: prose still on
119360
+ * the record 2h after the reconnect), burning a 30s log-churn loop the whole
119361
+ * time. Terminality is decided STRUCTURALLY, never by matching errorMessage
119362
+ * prose — see the header of session-error-kind.ts for why that ban exists.
119363
+ *
119364
+ * Re-examination candidates come from a WIDER pool than the live ones: the
119365
+ * tick's own working set drops every 'error' row (isTerminalSession), so the
119366
+ * caller passes the unfiltered health-scan set for this class only.
119049
119367
  *
119050
- * TWO fairness/budget properties this loop must keep (C8/C9/C12/C29):
119368
+ * THREE fairness/budget properties both classes must keep (C8/C9/C12/C29):
119051
119369
  *
119052
- * - It honors the TickContext budget INSIDE the loop (same discipline as
119370
+ * - The TickContext budget is honored INSIDE the loop (same discipline as
119053
119371
  * checkHungSessions). Each iteration is a real daemon RPC with a
119054
119372
  * probe-timeout ceiling, so 10 slow hosts could burn 10 × the probe
119055
119373
  * timeout sequentially BEFORE the authoritative reconcile phases that run
@@ -119058,70 +119376,101 @@ var init_session_health_monitor = __esm({
119058
119376
  * next tick (see the rotation below).
119059
119377
  *
119060
119378
  * - Candidates are ordered by lastPullAt ASCENDING (never-pulled first),
119061
- * which turns the 10/tick cap into a round-robin. In list order the SAME
119062
- * first ten sids were pulled every tick and — because the 25s spacing is
119063
- * shorter than the 30s cadence — sids 11+ were never pulled at all: the
119064
- * pull channel silently did not exist for them.
119379
+ * which turns each cap into a round-robin. In list order the SAME first ten
119380
+ * sids were pulled every tick and — because the 25s spacing is shorter than
119381
+ * the 30s cadence — sids 11+ were never pulled at all: the pull channel
119382
+ * silently did not exist for them. Each class rotates on its OWN timestamps.
119383
+ *
119384
+ * - The classes cannot spend each other's budget. Re-examination is
119385
+ * speculative ("is this record still true?") and a settled-death cohort
119386
+ * dwarfs the live set (the live DB measured 100 stopped candidates against
119387
+ * 4 error rows), so it must cost a handful of cheap RPCs per tick and
119388
+ * nothing more — the same reasoning as MAX_STOPPED_PROBES_PER_TICK below.
119065
119389
  */
119066
119390
  snapshotPullAt = /* @__PURE__ */ new Map();
119067
- async checkSnapshotPull(sessions3, ctx) {
119391
+ /** Separate from snapshotPullAt on purpose: the two classes rotate
119392
+ * independently, so a live pull can never reset the slow re-examination
119393
+ * cadence (or vice versa) when a record moves between them. */
119394
+ snapshotReexamPullAt = /* @__PURE__ */ new Map();
119395
+ async checkSnapshotPull(sessions3, ctx, reexamPool = sessions3) {
119068
119396
  const PULL_MIN_GAP_MS = 25e3;
119069
119397
  const MAX_PULLS_PER_TICK = 10;
119398
+ const REEXAM_MIN_GAP_MS = 5 * 6e4;
119399
+ const MAX_REEXAM_PULLS_PER_TICK = 5;
119070
119400
  try {
119071
119401
  const { getSnapshotStatusMode: getSnapshotStatusMode2 } = await Promise.resolve().then(() => (init_session_snapshot_gate(), session_snapshot_gate_exports));
119072
119402
  if (getSnapshotStatusMode2() === "off") return;
119073
119403
  const { getPooledSnapshotConnection: getPooledSnapshotConnection2 } = await Promise.resolve().then(() => (init_daemon_connection(), daemon_connection_exports));
119074
119404
  const { applySnapshot: applySnapshot2 } = await Promise.resolve().then(() => (init_session_snapshot_apply(), session_snapshot_apply_exports));
119075
119405
  const now = Date.now();
119076
- const candidates = sessions3.filter((s4) => {
119077
- if (s4.process_status !== "running" && s4.process_status !== "idle") return false;
119406
+ const pullable = (s4) => {
119407
+ if (s4.archived) return false;
119078
119408
  if (!engineCaps(s4.engine).snapshotPull) return false;
119079
119409
  if (s4.provider === "embedded" || s4.provider === "sdk") return false;
119080
- if (s4.status_reason === "awaiting_spawn") return false;
119081
- const lastPull = this.snapshotPullAt.get(s4.claudeSessionId) ?? 0;
119082
- return now - lastPull >= PULL_MIN_GAP_MS;
119083
- }).sort((a8, b8) => (this.snapshotPullAt.get(a8.claudeSessionId) ?? 0) - (this.snapshotPullAt.get(b8.claudeSessionId) ?? 0));
119084
- let pulled = 0;
119085
- let capped = false;
119086
- for (const s4 of candidates) {
119087
- if (ctx?.overBudget()) {
119088
- log.session.warn("health monitor: checkSnapshotPull abandoned mid-loop (over budget)", {
119089
- pulled,
119090
- candidateCount: candidates.length
119091
- });
119092
- break;
119093
- }
119094
- const conn = getPooledSnapshotConnection2(s4.host);
119095
- if (!conn) continue;
119096
- if (pulled >= MAX_PULLS_PER_TICK) {
119097
- capped = true;
119098
- break;
119099
- }
119100
- pulled++;
119101
- this.snapshotPullAt.set(s4.claudeSessionId, now);
119102
- try {
119103
- const resp = await probeWithTimeout(
119104
- conn.send("getState", { sid: s4.claudeSessionId }),
119105
- null,
119106
- "snapshot-pull-getState",
119107
- s4.claudeSessionId
119108
- );
119109
- const snapshot2 = resp?.ok ? resp.snapshot : void 0;
119110
- if (snapshot2) await applySnapshot2(s4.claudeSessionId, snapshot2, "pull-30s");
119111
- } catch (err2) {
119112
- log.session.debug("health monitor: snapshot pull failed", {
119113
- sessionId: s4.claudeSessionId,
119114
- host: s4.host ?? "__local__",
119115
- error: err2 instanceof Error ? err2.message : String(err2)
119116
- });
119410
+ return s4.status_reason !== "awaiting_spawn";
119411
+ };
119412
+ const byPullAge = (at) => (a8, b8) => (at.get(a8.claudeSessionId) ?? 0) - (at.get(b8.claudeSessionId) ?? 0);
119413
+ const live = sessions3.filter((s4) => {
119414
+ if (s4.process_status !== "running" && s4.process_status !== "idle") return false;
119415
+ if (!pullable(s4)) return false;
119416
+ return now - (this.snapshotPullAt.get(s4.claudeSessionId) ?? 0) >= PULL_MIN_GAP_MS;
119417
+ }).sort(byPullAge(this.snapshotPullAt));
119418
+ const reexam = reexamPool.filter((s4) => {
119419
+ if (s4.process_status !== "error" && s4.process_status !== "stopped") return false;
119420
+ if (!pullable(s4)) return false;
119421
+ if (classifySessionError(s4) === "terminal") return false;
119422
+ const changedAt = s4.last_status_change ? Date.parse(s4.last_status_change) : NaN;
119423
+ if (!Number.isFinite(changedAt)) return false;
119424
+ if (now - changedAt > RESCUABLE_STOPPED_WINDOW_MS) return false;
119425
+ return now - (this.snapshotReexamPullAt.get(s4.claudeSessionId) ?? 0) >= REEXAM_MIN_GAP_MS;
119426
+ }).sort(byPullAge(this.snapshotReexamPullAt));
119427
+ let abandoned = false;
119428
+ const runClass = async (candidates, cap2, at, candidateClass) => {
119429
+ let pulled = 0;
119430
+ for (const s4 of candidates) {
119431
+ if (ctx?.overBudget()) {
119432
+ abandoned = true;
119433
+ log.session.warn("health monitor: checkSnapshotPull abandoned mid-loop (over budget)", {
119434
+ pulled,
119435
+ candidateCount: candidates.length,
119436
+ candidateClass
119437
+ });
119438
+ break;
119439
+ }
119440
+ const conn = getPooledSnapshotConnection2(s4.host);
119441
+ if (!conn) continue;
119442
+ if (pulled >= cap2) {
119443
+ log.session.info("health monitor: snapshot pull capped this tick", {
119444
+ cap: cap2,
119445
+ sessionCount: sessions3.length,
119446
+ candidateCount: candidates.length,
119447
+ candidateClass
119448
+ });
119449
+ break;
119450
+ }
119451
+ pulled++;
119452
+ at.set(s4.claudeSessionId, now);
119453
+ try {
119454
+ const resp = await probeWithTimeout(
119455
+ conn.send("getState", { sid: s4.claudeSessionId }),
119456
+ null,
119457
+ "snapshot-pull-getState",
119458
+ s4.claudeSessionId
119459
+ );
119460
+ const snapshot2 = resp?.ok ? resp.snapshot : void 0;
119461
+ if (snapshot2) await applySnapshot2(s4.claudeSessionId, snapshot2, "pull-30s");
119462
+ } catch (err2) {
119463
+ log.session.debug("health monitor: snapshot pull failed", {
119464
+ sessionId: s4.claudeSessionId,
119465
+ host: s4.host ?? "__local__",
119466
+ error: err2 instanceof Error ? err2.message : String(err2)
119467
+ });
119468
+ }
119117
119469
  }
119118
- }
119119
- if (capped) {
119120
- log.session.info("health monitor: snapshot pull capped this tick", {
119121
- cap: MAX_PULLS_PER_TICK,
119122
- sessionCount: sessions3.length,
119123
- candidateCount: candidates.length
119124
- });
119470
+ };
119471
+ await runClass(live, MAX_PULLS_PER_TICK, this.snapshotPullAt, "live");
119472
+ if (!abandoned) {
119473
+ await runClass(reexam, MAX_REEXAM_PULLS_PER_TICK, this.snapshotReexamPullAt, "reexam");
119125
119474
  }
119126
119475
  if (this.snapshotPullAt.size > 200) {
119127
119476
  const liveIds = new Set(sessions3.map((s4) => s4.claudeSessionId));
@@ -119129,6 +119478,12 @@ var init_session_health_monitor = __esm({
119129
119478
  if (!liveIds.has(sid)) this.snapshotPullAt.delete(sid);
119130
119479
  }
119131
119480
  }
119481
+ if (this.snapshotReexamPullAt.size > 200) {
119482
+ const poolIds = new Set(reexamPool.map((s4) => s4.claudeSessionId));
119483
+ for (const sid of this.snapshotReexamPullAt.keys()) {
119484
+ if (!poolIds.has(sid)) this.snapshotReexamPullAt.delete(sid);
119485
+ }
119486
+ }
119132
119487
  } catch (err2) {
119133
119488
  log.session.warn("health monitor: checkSnapshotPull failed", {
119134
119489
  error: err2 instanceof Error ? err2.message : String(err2)
@@ -119291,6 +119646,31 @@ var init_session_health_monitor = __esm({
119291
119646
  });
119292
119647
  }
119293
119648
  }
119649
+ /**
119650
+ * Can a scheduled auto-recovery actually FIRE for this record?
119651
+ *
119652
+ * fire() (session-auto-recover) hard-requires the linked task to still be
119653
+ * IN_PROGRESS — AGENT_COMPLETE means the work was already handed back and
119654
+ * resuming would talk over the human. Arming a recovery that cannot pass that
119655
+ * check is not free: the 30s tick re-armed it forever and each arming logged
119656
+ * an abort 20s later (measured running for 2+ hours on one session), while
119657
+ * `if (armed) continue` also skipped the phase sync that IS appropriate there.
119658
+ *
119659
+ * A lookup that cannot answer (no task id, deleted task, DB error) returns
119660
+ * true: only a KNOWN non-IN_PROGRESS phase blocks arming, so a transient read
119661
+ * failure can never disable recovery. schedule()'s own guards still apply.
119662
+ */
119663
+ async autoRecoverCanFire(s4) {
119664
+ if (!s4.taskId) return true;
119665
+ try {
119666
+ const { getTask: getTask2 } = await Promise.resolve().then(() => (init_task_manager(), task_manager_exports));
119667
+ const task = await getTask2(s4.taskId);
119668
+ if (!task) return true;
119669
+ return task.phase === "IN_PROGRESS";
119670
+ } catch {
119671
+ return true;
119672
+ }
119673
+ }
119294
119674
  /**
119295
119675
  * Auto-recover remote sessions stuck in 'error' with "Connection lost" message.
119296
119676
  *
@@ -119377,28 +119757,31 @@ var init_session_health_monitor = __esm({
119377
119757
  } else if (wasStopped) {
119378
119758
  continue;
119379
119759
  } else {
119380
- const updated = await updateSessionRecord2(s4.claudeSessionId, {
119381
- process_status: "stopped",
119382
- errorMessage: void 0,
119383
- errorKind: "infra",
119384
- activity: void 0,
119385
- last_status_change: now,
119386
- status_reason: "auto_recovered_dead",
119387
- status_changed_by: "health-monitor"
119388
- });
119389
- emitSessionStatusChanged(
119390
- updated,
119391
- {},
119392
- ["*"],
119393
- { source: "health-monitor", urgency: "urgent" }
119394
- );
119395
- log.session.info("health monitor: auto-recovered connection-lost session (process dead)", {
119396
- sessionId: s4.claudeSessionId,
119397
- host: s4.host,
119398
- alive: false
119399
- });
119760
+ const { isSnapshotCovered: isSnapshotCovered2 } = await Promise.resolve().then(() => (init_session_snapshot_gate(), session_snapshot_gate_exports));
119761
+ if (!isSnapshotCovered2(s4.claudeSessionId)) {
119762
+ const updated = await updateSessionRecord2(s4.claudeSessionId, {
119763
+ process_status: "stopped",
119764
+ errorMessage: void 0,
119765
+ errorKind: "infra",
119766
+ activity: void 0,
119767
+ last_status_change: now,
119768
+ status_reason: "auto_recovered_dead",
119769
+ status_changed_by: "health-monitor"
119770
+ });
119771
+ emitSessionStatusChanged(
119772
+ updated,
119773
+ {},
119774
+ ["*"],
119775
+ { source: "health-monitor", urgency: "urgent" }
119776
+ );
119777
+ log.session.info("health monitor: auto-recovered connection-lost session (process dead)", {
119778
+ sessionId: s4.claudeSessionId,
119779
+ host: s4.host,
119780
+ alive: false
119781
+ });
119782
+ }
119400
119783
  const { scheduleSessionAutoRecover: scheduleSessionAutoRecover2 } = await Promise.resolve().then(() => (init_session_auto_recover(), session_auto_recover_exports));
119401
- const armed = scheduleSessionAutoRecover2(s4, s4.status_reason ?? "daemon_reported_exit");
119784
+ const armed = await this.autoRecoverCanFire(s4) && scheduleSessionAutoRecover2(s4, s4.status_reason ?? "daemon_reported_exit");
119402
119785
  if (armed) continue;
119403
119786
  if (s4.taskId) {
119404
119787
  try {
@@ -132723,6 +133106,20 @@ var init_session_lifecycle_v1 = __esm({
132723
133106
  next(err2);
132724
133107
  }
132725
133108
  });
133109
+ sessionLifecycleV1Router.post("/sessions/:id/recheck", async (req, res, next) => {
133110
+ try {
133111
+ const sessionId = validSid2(req, res);
133112
+ if (!sessionId) return;
133113
+ if (CLOUD_MODE) {
133114
+ await relayControlAction(res, "recheck", sessionId, void 0, 200);
133115
+ return;
133116
+ }
133117
+ const { recheckSession: recheckSession2 } = await Promise.resolve().then(() => (init_session_lifecycle(), session_lifecycle_exports));
133118
+ await runLocal(res, next, 200, () => recheckSession2(sessionId));
133119
+ } catch (err2) {
133120
+ next(err2);
133121
+ }
133122
+ });
132726
133123
  sessionLifecycleV1Router.post("/sessions/:id/permission", async (req, res, next) => {
132727
133124
  try {
132728
133125
  const sessionId = validSid2(req, res);
@@ -149378,6 +149775,16 @@ async function resolveCredentialHealth() {
149378
149775
  return { hasReadyProvider: false, source: "none" };
149379
149776
  }
149380
149777
  }
149778
+ async function isWalnutAnsweringOn(port) {
149779
+ try {
149780
+ const res = await fetch(`http://127.0.0.1:${port}/api/system/health`, { signal: AbortSignal.timeout(1500) });
149781
+ if (!res.ok) return false;
149782
+ const body3 = await res.json();
149783
+ return !!body3 && typeof body3 === "object" && "hasReadyProvider" in body3;
149784
+ } catch {
149785
+ return false;
149786
+ }
149787
+ }
149381
149788
  async function startServer(options = {}) {
149382
149789
  if (httpServer) throw new Error("Server already running. Call stopServer() first.");
149383
149790
  bootCompleted = false;
@@ -149904,6 +150311,7 @@ async function startServer(options = {}) {
149904
150311
  });
149905
150312
  if (!dev) {
149906
150313
  const staticDir = (() => {
150314
+ if (process.env.WALNUT_WEB_STATIC_DIR) return process.env.WALNUT_WEB_STATIC_DIR;
149907
150315
  let dir2 = path157.dirname(fileURLToPath13(import.meta.url));
149908
150316
  for (let i8 = 0; i8 < 5; i8++) {
149909
150317
  const candidate = path157.join(dir2, "web", "static", "index.html");
@@ -149920,11 +150328,65 @@ async function startServer(options = {}) {
149920
150328
  }
149921
150329
  return path157.join(process.cwd(), "dist", "web", "static");
149922
150330
  })();
150331
+ const mirrorDir = process.env.WALNUT_WEB_STATIC_MIRROR || path157.join(WALNUT_HOME, "cache", "web-static");
150332
+ let mirrorReady = false;
150333
+ try {
150334
+ const want = fs98.readFileSync(path157.join(staticDir, "index.html"), "utf-8");
150335
+ let have = null;
150336
+ try {
150337
+ have = fs98.readFileSync(path157.join(mirrorDir, "index.html"), "utf-8");
150338
+ } catch {
150339
+ }
150340
+ if (have !== want) {
150341
+ fs98.rmSync(mirrorDir, { recursive: true, force: true });
150342
+ fs98.mkdirSync(path157.dirname(mirrorDir), { recursive: true });
150343
+ fs98.cpSync(staticDir, mirrorDir, { recursive: true });
150344
+ }
150345
+ } catch (err2) {
150346
+ log.web.warn("could not refresh the web-asset mirror", {
150347
+ mirrorDir,
150348
+ error: err2 instanceof Error ? err2.message : String(err2)
150349
+ });
150350
+ }
150351
+ try {
150352
+ mirrorReady = fs98.statSync(path157.join(mirrorDir, "index.html")).isFile();
150353
+ } catch {
150354
+ mirrorReady = false;
150355
+ }
149923
150356
  app.use(express3.static(staticDir));
150357
+ if (mirrorReady) app.use(express3.static(mirrorDir));
150358
+ const checkStaticRoot = () => {
150359
+ try {
150360
+ return fs98.statSync(path157.join(staticDir, "index.html")).isFile();
150361
+ } catch {
150362
+ return false;
150363
+ }
150364
+ };
150365
+ let staticRootOk = checkStaticRoot();
150366
+ if (!staticRootOk) {
150367
+ log.web.error("web assets are NOT servable at startup", { staticDir, mirrorReady });
150368
+ }
150369
+ const staticRootTimer = setInterval(() => {
150370
+ const ok2 = checkStaticRoot();
150371
+ if (ok2 === staticRootOk) return;
150372
+ staticRootOk = ok2;
150373
+ if (ok2) log.web.info("web assets are servable again", { staticDir });
150374
+ else log.web.error("web assets VANISHED from under the running server", { staticDir, mirrorReady });
150375
+ }, 6e4);
150376
+ staticRootTimer.unref();
150377
+ setStaticRootReporter(() => ({ staticDir, ok: staticRootOk, mirrorDir, mirrorReady }));
149924
150378
  app.use((req, res, next) => {
149925
150379
  if (req.method !== "GET" || req.path.startsWith("/api/")) return next();
149926
150380
  if (isStaticAssetPath(req.path)) return next();
149927
- res.sendFile("index.html", { root: staticDir });
150381
+ res.sendFile("index.html", { root: staticDir }, (err2) => {
150382
+ if (!err2) return;
150383
+ if (mirrorReady) {
150384
+ res.sendFile("index.html", { root: mirrorDir }, () => {
150385
+ });
150386
+ return;
150387
+ }
150388
+ next(err2);
150389
+ });
149928
150390
  });
149929
150391
  }
149930
150392
  app.use("/api", notFoundHandler);
@@ -149944,9 +150406,21 @@ async function startServer(options = {}) {
149944
150406
  }
149945
150407
  httpServer = createServer(app);
149946
150408
  attachWss(httpServer);
149947
- await new Promise((resolve, reject) => {
149948
- httpServer.listen(port, () => resolve());
149949
- httpServer.once("error", reject);
150409
+ try {
150410
+ await new Promise((resolve, reject) => {
150411
+ httpServer.once("error", reject);
150412
+ httpServer.listen(port, () => resolve());
150413
+ });
150414
+ } catch (err2) {
150415
+ if (err2?.code !== "EADDRINUSE") throw err2;
150416
+ const already = await isWalnutAnsweringOn(port);
150417
+ throw new Error(
150418
+ 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:
150419
+ open-walnut web --port ${port + 1}`
150420
+ );
150421
+ }
150422
+ httpServer.on("error", (err2) => {
150423
+ log.web.error("http server error after listen", { error: err2 instanceof Error ? err2.message : String(err2) });
149950
150424
  });
149951
150425
  const label = dev ? "dev" : "production";
149952
150426
  log.web.info(`server listening on http://localhost:${port}`, { mode: label, port });
@@ -149956,6 +150430,7 @@ async function startServer(options = {}) {
149956
150430
  const { updateInstanceLockPort: updateInstanceLockPort2 } = await Promise.resolve().then(() => (init_instance_lock(), instance_lock_exports));
149957
150431
  updateInstanceLockPort2(bound.port);
149958
150432
  setPluginApiBase(`http://127.0.0.1:${bound.port}`);
150433
+ process.env.WALNUT_SERVER_URL = `http://localhost:${bound.port}`;
149959
150434
  }
149960
150435
  }
149961
150436
  if (CLOUD_MODE) {
@@ -158532,6 +159007,11 @@ async function handleSessionControlRelay(action, sessionId, params) {
158532
159007
  result = await retrySession2(sessionId);
158533
159008
  break;
158534
159009
  }
159010
+ case "recheck": {
159011
+ const { recheckSession: recheckSession2 } = await Promise.resolve().then(() => (init_session_lifecycle(), session_lifecycle_exports));
159012
+ result = await recheckSession2(sessionId);
159013
+ break;
159014
+ }
158535
159015
  case "permission": {
158536
159016
  const { respondSessionPermission: respondSessionPermission2 } = await Promise.resolve().then(() => (init_session_lifecycle(), session_lifecycle_exports));
158537
159017
  result = await respondSessionPermission2(sessionId, p6.requestId, p6.allow, p6.message, p6.optionId, p6.answers);