@adhdev/daemon-core 1.0.28-rc.10 → 1.0.28-rc.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -786,10 +786,10 @@ function readInjected(value) {
786
786
  }
787
787
  function getDaemonBuildInfo() {
788
788
  if (cached) return cached;
789
- const commit = readInjected(true ? "e4b8cfd97ab9fbe458b61198b091a9051da87a6e" : void 0) ?? "unknown";
790
- const commitShort = readInjected(true ? "e4b8cfd9" : void 0) ?? (commit !== "unknown" ? commit.slice(0, 7) : "unknown");
791
- const version = readInjected(true ? "1.0.28-rc.10" : void 0) ?? readInjected(typeof process !== "undefined" ? process.env?.ADHDEV_PKG_VERSION : void 0) ?? "unknown";
792
- const builtAt = readInjected(true ? "2026-07-26T03:08:22.566Z" : void 0);
789
+ const commit = readInjected(true ? "5cd04eec1e043188c3ef97821372ad5ff615cd04" : void 0) ?? "unknown";
790
+ const commitShort = readInjected(true ? "5cd04eec" : void 0) ?? (commit !== "unknown" ? commit.slice(0, 7) : "unknown");
791
+ const version = readInjected(true ? "1.0.28-rc.11" : void 0) ?? readInjected(typeof process !== "undefined" ? process.env?.ADHDEV_PKG_VERSION : void 0) ?? "unknown";
792
+ const builtAt = readInjected(true ? "2026-07-26T07:08:19.328Z" : void 0);
793
793
  cached = builtAt ? { commit, commitShort, version, builtAt } : { commit, commitShort, version };
794
794
  return cached;
795
795
  }
@@ -27269,6 +27269,11 @@ function validateCondition(c, sectionIds, path46) {
27269
27269
  return errs;
27270
27270
  }
27271
27271
  if ("cursor_above" in w && "changed" in w) return errs;
27272
+ if ("signal" in w) {
27273
+ if (typeof w.signal !== "string" || !w.signal.trim()) errs.push(`${path46}.signal must be a non-empty string`);
27274
+ if (w.equals !== void 0 && typeof w.equals !== "boolean") errs.push(`${path46}.equals must be a boolean`);
27275
+ return errs;
27276
+ }
27272
27277
  if ("elapsed_ms" in w) {
27273
27278
  if (typeof w.elapsed_ms !== "number") errs.push(`${path46}.elapsed_ms must be a number`);
27274
27279
  return errs;
@@ -27583,6 +27588,51 @@ var init_evaluator = __esm({
27583
27588
  }
27584
27589
  });
27585
27590
 
27591
+ // src/providers/spec/signal-envelope.ts
27592
+ function unavailableSignalSnapshot(now, reason, profile) {
27593
+ return {
27594
+ kind: SIGNAL_SNAPSHOT_KIND,
27595
+ sampledAt: now,
27596
+ available: false,
27597
+ unavailableReason: reason,
27598
+ ...profile ? { profile } : {},
27599
+ signals: { final_assistant_present: null, in_turn_progress: null, transcript_growing: null },
27600
+ detail: { msgCount: 0, sourceMtimeMs: 0, ageMs: null }
27601
+ };
27602
+ }
27603
+ function evaluateSignalLeaf(cond, snapshot) {
27604
+ const expected = cond.equals ?? true;
27605
+ if (!snapshot || !snapshot.available) {
27606
+ const why = !snapshot ? "no snapshot" : `unavailable(${snapshot.unavailableReason ?? "unknown"})`;
27607
+ return {
27608
+ value: null,
27609
+ shadowResult: null,
27610
+ detail: `signal ${cond.signal} ${why} \u2192 fail-open`
27611
+ };
27612
+ }
27613
+ const raw = Object.prototype.hasOwnProperty.call(snapshot.signals, cond.signal) ? snapshot.signals[cond.signal] : null;
27614
+ if (raw === null || raw === void 0) {
27615
+ return {
27616
+ value: null,
27617
+ shadowResult: null,
27618
+ detail: `signal ${cond.signal} unknown \u2192 fail-open`
27619
+ };
27620
+ }
27621
+ const shadowResult = raw === expected;
27622
+ return {
27623
+ value: raw,
27624
+ shadowResult,
27625
+ detail: `signal ${cond.signal}=${raw} expected=${expected} \u2192 ${shadowResult}`
27626
+ };
27627
+ }
27628
+ var SIGNAL_SNAPSHOT_KIND;
27629
+ var init_signal_envelope = __esm({
27630
+ "src/providers/spec/signal-envelope.ts"() {
27631
+ "use strict";
27632
+ SIGNAL_SNAPSHOT_KIND = "adhdev:fsm/signal-snapshot@0";
27633
+ }
27634
+ });
27635
+
27586
27636
  // src/providers/spec/fsm-evaluator.ts
27587
27637
  var fsm_evaluator_exports = {};
27588
27638
  __export(fsm_evaluator_exports, {
@@ -27616,24 +27666,74 @@ function isAny(c) {
27616
27666
  function isNot(c) {
27617
27667
  return "not" in c;
27618
27668
  }
27619
- function evalCond(cond, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId) {
27669
+ function isSignal(c) {
27670
+ return "signal" in c;
27671
+ }
27672
+ function containsSignal(c) {
27673
+ if (isSignal(c)) return true;
27674
+ if (isAll(c)) return c.all.some(containsSignal);
27675
+ if (isAny(c)) return c.any.some(containsSignal);
27676
+ if (isNot(c)) return containsSignal(c.not);
27677
+ return false;
27678
+ }
27679
+ function foldShadow(cond) {
27680
+ if (cond.kind === "signal") {
27681
+ const s2 = cond.signal;
27682
+ if (s2 && s2.shadowResult !== null && s2.shadowResult !== void 0) {
27683
+ return { value: s2.shadowResult, unknown: false };
27684
+ }
27685
+ return { value: true, unknown: true };
27686
+ }
27687
+ if (cond.kind === "not" && cond.children?.length) {
27688
+ const c = foldShadow(cond.children[0]);
27689
+ return { value: !c.value, unknown: c.unknown };
27690
+ }
27691
+ if (cond.kind === "all" && cond.children) {
27692
+ const kids = cond.children.map(foldShadow);
27693
+ if (kids.some((k) => !k.unknown && !k.value)) return { value: false, unknown: false };
27694
+ if (kids.some((k) => k.unknown)) return { value: true, unknown: true };
27695
+ return { value: true, unknown: false };
27696
+ }
27697
+ if (cond.kind === "any" && cond.children) {
27698
+ const kids = cond.children.map(foldShadow);
27699
+ if (kids.some((k) => !k.unknown && k.value)) return { value: true, unknown: false };
27700
+ if (kids.some((k) => k.unknown)) return { value: false, unknown: true };
27701
+ return { value: false, unknown: false };
27702
+ }
27703
+ return { value: cond.result, unknown: false };
27704
+ }
27705
+ function evalCond(cond, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId, signalSnapshot) {
27620
27706
  if (isAll(cond)) {
27621
- const children = cond.all.map((c) => evalCond(c, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId));
27707
+ const children = cond.all.map((c) => evalCond(c, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId, signalSnapshot));
27622
27708
  const result = children.every((c) => c.result);
27623
27709
  const remainingMs = result ? 0 : Math.max(0, ...children.filter((c) => !c.result).map((c) => c.remainingMs ?? 0));
27624
27710
  return { kind: "all", result, detail: `all(${children.length})`, remainingMs, children };
27625
27711
  }
27626
27712
  if (isAny(cond)) {
27627
- const children = cond.any.map((c) => evalCond(c, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId));
27713
+ const children = cond.any.map((c) => evalCond(c, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId, signalSnapshot));
27628
27714
  const result = children.some((c) => c.result);
27629
27715
  const pending = children.filter((c) => !c.result).map((c) => c.remainingMs ?? Infinity);
27630
27716
  const remainingMs = result ? 0 : pending.length ? Math.min(...pending) : 0;
27631
27717
  return { kind: "any", result, detail: `any(${children.length})`, remainingMs: Number.isFinite(remainingMs) ? remainingMs : 0, children };
27632
27718
  }
27633
27719
  if (isNot(cond)) {
27634
- const child = evalCond(cond.not, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId);
27720
+ const child = evalCond(cond.not, sections, fullScreen, cursor, prevLines, clock, legacyTrace, stateId, signalSnapshot);
27635
27721
  return { kind: "not", result: !child.result, detail: `not`, remainingMs: 0, children: [child] };
27636
27722
  }
27723
+ if (isSignal(cond)) {
27724
+ const leaf = evaluateSignalLeaf(cond, signalSnapshot);
27725
+ return {
27726
+ kind: "signal",
27727
+ result: true,
27728
+ detail: `${leaf.detail} (stage0 shadow \u2014 pass-through)`,
27729
+ signal: {
27730
+ name: cond.signal,
27731
+ available: !!signalSnapshot?.available,
27732
+ value: leaf.value,
27733
+ shadowResult: leaf.shadowResult
27734
+ }
27735
+ };
27736
+ }
27637
27737
  if (isElapsed(cond)) {
27638
27738
  const age = clock.now - clock.stateEnteredAt;
27639
27739
  const result = age >= cond.elapsed_ms;
@@ -27672,7 +27772,7 @@ function fromLabel(t) {
27672
27772
  const from = Array.isArray(t.from) ? t.from.join("|") : t.from;
27673
27773
  return t.label ?? `${from}\u2192${t.to}`;
27674
27774
  }
27675
- function evaluateFsm(spec, currentStateId, screenText, cursor, prevLines, clock) {
27775
+ function evaluateFsm(spec, currentStateId, screenText, cursor, prevLines, clock, signalSnapshot) {
27676
27776
  const legacyTrace = [];
27677
27777
  const lines = screenText.split("\n").map((l) => l.endsWith("\r") ? l.slice(0, -1) : l);
27678
27778
  const cleanScreen = lines.join("\n");
@@ -27688,7 +27788,7 @@ function evaluateFsm(spec, currentStateId, screenText, cursor, prevLines, clock)
27688
27788
  let cond;
27689
27789
  let condResult = true;
27690
27790
  if (t.when) {
27691
- cond = evalCond(t.when, sections, cleanScreen, cursor, prevLines, clock, legacyTrace, `${currentStateId}\u2192${t.to}`);
27791
+ cond = evalCond(t.when, sections, cleanScreen, cursor, prevLines, clock, legacyTrace, `${currentStateId}\u2192${t.to}`, signalSnapshot);
27692
27792
  condResult = cond.result;
27693
27793
  }
27694
27794
  const fires = holdSatisfied && condResult;
@@ -27703,6 +27803,14 @@ function evaluateFsm(spec, currentStateId, screenText, cursor, prevLines, clock)
27703
27803
  fires,
27704
27804
  priority: t.priority ?? 0
27705
27805
  };
27806
+ if (t.when && containsSignal(t.when) && cond) {
27807
+ const folded = foldShadow(cond);
27808
+ te.shadow = {
27809
+ condResult: folded.value,
27810
+ fires: holdSatisfied && folded.value,
27811
+ unknown: folded.unknown
27812
+ };
27813
+ }
27706
27814
  transitions.push(te);
27707
27815
  if (fires && !fired) fired = te;
27708
27816
  }
@@ -27725,6 +27833,7 @@ var init_fsm_evaluator = __esm({
27725
27833
  "use strict";
27726
27834
  init_evaluator();
27727
27835
  init_fsm_types();
27836
+ init_signal_envelope();
27728
27837
  WHOLE_SCREEN = -1;
27729
27838
  }
27730
27839
  });
@@ -45073,12 +45182,21 @@ async function runDaemonUpgradeHelper(payload) {
45073
45182
  appendUpgradeLog(`Waiting for parent pid ${payload.parentPid} to exit`);
45074
45183
  await waitForPidExit(payload.parentPid, 15e3);
45075
45184
  }
45076
- if (process.platform === "win32") {
45185
+ if (process.platform === "win32" || payload.killSessionHost === true) {
45077
45186
  await stopSessionHostProcesses(sessionHostAppName);
45078
45187
  } else {
45079
45188
  appendUpgradeLog("POSIX \u2014 session-host left running (survives upgrade; sessions rebind on next boot)");
45080
45189
  }
45081
45190
  removeDaemonPidFile();
45191
+ if (payload.skipInstall) {
45192
+ appendUpgradeLog("Restart-only mode \u2014 package install skipped, re-spawning daemon");
45193
+ spawnDetachedDaemonRestart(restartArgv, payload.cwd);
45194
+ try {
45195
+ fs15.unlinkSync(getUpgradeFailureNoticePath());
45196
+ } catch {
45197
+ }
45198
+ return;
45199
+ }
45082
45200
  const instanceDir = resolveInstanceDir();
45083
45201
  const windowsInstallerLayout = resolveWindowsInstallerLayout({
45084
45202
  homeDir: os14.homedir(),
@@ -45197,6 +45315,13 @@ async function runDaemonUpgradeHelper(payload) {
45197
45315
  cleanupStaleGlobalInstallDirs(payload.packageName, installCommand.surface);
45198
45316
  appendUpgradeLog("Post-install staging cleanup complete");
45199
45317
  }
45318
+ spawnDetachedDaemonRestart(restartArgv, payload.cwd);
45319
+ try {
45320
+ fs15.unlinkSync(getUpgradeFailureNoticePath());
45321
+ } catch {
45322
+ }
45323
+ }
45324
+ function spawnDetachedDaemonRestart(restartArgv, cwd) {
45200
45325
  if (restartArgv.length > 0) {
45201
45326
  const env = { ...process.env };
45202
45327
  delete env[UPGRADE_HELPER_ENV];
@@ -45205,17 +45330,13 @@ async function runDaemonUpgradeHelper(payload) {
45205
45330
  detached: true,
45206
45331
  stdio: "ignore",
45207
45332
  windowsHide: true,
45208
- cwd: payload.cwd || process.cwd(),
45333
+ cwd: cwd || process.cwd(),
45209
45334
  env
45210
45335
  });
45211
45336
  child.unref();
45212
45337
  } else {
45213
45338
  appendUpgradeLog("No restart argv provided; upgrade completed without restart");
45214
45339
  }
45215
- try {
45216
- fs15.unlinkSync(getUpgradeFailureNoticePath());
45217
- } catch {
45218
- }
45219
45340
  }
45220
45341
  async function maybeRunDaemonUpgradeHelperFromEnv() {
45221
45342
  const raw = process.env[UPGRADE_HELPER_ENV];
@@ -45308,6 +45429,41 @@ var daemonLifecycleHandlers = {
45308
45429
  return { success: false, error: e.message };
45309
45430
  }
45310
45431
  },
45432
+ // Restart-only path (mesh restart_daemon_node mode="restart"): re-spawn the
45433
+ // daemon WITHOUT the npm reinstall daemon_upgrade performs. Used to reset
45434
+ // daemon state (memory leaks, zombie sessions, wedged internals) when the
45435
+ // version is already correct — downtime drops to the detached re-spawn.
45436
+ // killSessionHost is an explicit opt-in hard refresh (see upgrade-helper).
45437
+ daemon_restart: async (ctx, args) => {
45438
+ LOG.info("Restart", "Restart-only requested (no package reinstall)");
45439
+ try {
45440
+ const isStandalone = ctx.deps.packageName === "@adhdev/daemon-standalone" || process.argv[1]?.includes("daemon-standalone");
45441
+ const pkgName = isStandalone ? "@adhdev/daemon-standalone" : "adhdev";
45442
+ const killSessionHost = args?.killSessionHost === true;
45443
+ if (killSessionHost) {
45444
+ LOG.warn("Restart", "killSessionHost requested \u2014 session-host will be stopped and ALL hosted sessions destroyed");
45445
+ }
45446
+ spawnDetachedDaemonUpgradeHelper({
45447
+ packageName: pkgName,
45448
+ targetVersion: "",
45449
+ parentPid: process.pid,
45450
+ restartArgv: process.argv.slice(1),
45451
+ cwd: process.cwd(),
45452
+ sessionHostAppName: process.env.ADHDEV_SESSION_HOST_NAME || "adhdev",
45453
+ skipInstall: true,
45454
+ killSessionHost
45455
+ });
45456
+ LOG.info("Restart", "Scheduled detached restart-only helper");
45457
+ setTimeout(() => {
45458
+ LOG.info("Restart", "Exiting daemon so detached helper can re-spawn it...");
45459
+ process.exit(0);
45460
+ }, 3e3);
45461
+ return { success: true, restarted: true, restarting: true, mode: "restart", killSessionHost };
45462
+ } catch (e) {
45463
+ LOG.error("Restart", `Failed: ${e.message}`);
45464
+ return { success: false, error: e.message };
45465
+ }
45466
+ },
45311
45467
  set_machine_nickname: async (_ctx, args) => {
45312
45468
  const nickname = args?.nickname;
45313
45469
  updateConfig({ machineNickname: nickname || null });
@@ -45804,6 +45960,93 @@ import * as fs24 from "fs";
45804
45960
  init_hash();
45805
45961
  init_transcript_evidence();
45806
45962
 
45963
+ // src/providers/transcript-signal-source.ts
45964
+ init_logger();
45965
+ init_signal_envelope();
45966
+ var TranscriptSignalSource = class {
45967
+ constructor(opts) {
45968
+ this.opts = opts;
45969
+ }
45970
+ /** msgCount seen at the previous update — drives in_turn_progress. */
45971
+ prevMsgCount = -1;
45972
+ /** Fingerprint of the last LOGGED snapshot, so the shadow log fires on
45973
+ * change only (a quiet session must not spam one line per read). */
45974
+ lastLoggedFingerprint = "";
45975
+ /**
45976
+ * Normalize one daemon read into a SignalSnapshot and emit the Stage-0
45977
+ * shadow log when the observation changed. Pure w.r.t. the outside world:
45978
+ * the only side effect is the (change-gated) log line. Returns the
45979
+ * snapshot the caller should inject into the FSM driver.
45980
+ */
45981
+ update(sample, now = Date.now()) {
45982
+ const snapshot = this.buildSnapshot(sample, now);
45983
+ this.logOnChange(snapshot);
45984
+ return snapshot;
45985
+ }
45986
+ /** Pure normalization — separated from update() so tests can assert the
45987
+ * envelope without touching the log path. */
45988
+ buildSnapshot(sample, now) {
45989
+ const profileRef = { class: this.opts.profile.class, timing: this.opts.profile.timing };
45990
+ if (this.opts.profile.class !== "native-source") {
45991
+ return unavailableSignalSnapshot(now, "no_native_source", profileRef);
45992
+ }
45993
+ if (sample.error) {
45994
+ return unavailableSignalSnapshot(now, "error", profileRef);
45995
+ }
45996
+ const probe = sample.probe;
45997
+ if (!probe || !Array.isArray(sample.messages)) {
45998
+ return unavailableSignalSnapshot(now, "unresolved", profileRef);
45999
+ }
46000
+ const msgCount = typeof probe.msgCount === "number" && Number.isFinite(probe.msgCount) ? probe.msgCount : sample.messages.length;
46001
+ const sourceMtimeMs = typeof probe.sourceMtimeMs === "number" && Number.isFinite(probe.sourceMtimeMs) ? probe.sourceMtimeMs : 0;
46002
+ const ageMs = sourceMtimeMs > 0 ? Math.max(0, now - sourceMtimeMs) : null;
46003
+ let turnStartedAt;
46004
+ try {
46005
+ turnStartedAt = this.opts.turnStartedAt?.();
46006
+ } catch {
46007
+ turnStartedAt = void 0;
46008
+ }
46009
+ let finalAssistant = null;
46010
+ try {
46011
+ finalAssistant = this.opts.finalAssistantPresent(sample.messages, turnStartedAt);
46012
+ } catch {
46013
+ finalAssistant = null;
46014
+ }
46015
+ const countAdvanced = this.prevMsgCount >= 0 && msgCount > this.prevMsgCount;
46016
+ const fresh = ageMs !== null && ageMs < this.opts.growthQuietMs;
46017
+ const inTurnProgress = countAdvanced || fresh;
46018
+ const transcriptGrowing = ageMs === null ? null : fresh;
46019
+ this.prevMsgCount = msgCount;
46020
+ return {
46021
+ kind: SIGNAL_SNAPSHOT_KIND,
46022
+ sampledAt: now,
46023
+ available: true,
46024
+ profile: profileRef,
46025
+ signals: {
46026
+ final_assistant_present: finalAssistant,
46027
+ in_turn_progress: inTurnProgress,
46028
+ transcript_growing: transcriptGrowing
46029
+ },
46030
+ detail: { msgCount, sourceMtimeMs, ageMs }
46031
+ };
46032
+ }
46033
+ /** Stage-0 shadow log: emit one line when the normalized observation
46034
+ * CHANGES. This log is the Stage 1-3 judgment input ("which signals were
46035
+ * actually observable, and when"), so it carries the full signal set —
46036
+ * but only on change, so a steady-state session stays quiet. */
46037
+ logOnChange(snapshot) {
46038
+ const s2 = snapshot.signals;
46039
+ const fingerprint = snapshot.available ? `1|fa=${s2.final_assistant_present}|tp=${s2.in_turn_progress}|tg=${s2.transcript_growing}|n=${snapshot.detail.msgCount}` : `0|${snapshot.unavailableReason ?? "unknown"}`;
46040
+ if (fingerprint === this.lastLoggedFingerprint) return;
46041
+ this.lastLoggedFingerprint = fingerprint;
46042
+ const age = snapshot.detail.ageMs === null ? "n/a" : `${snapshot.detail.ageMs}ms`;
46043
+ LOG.info(
46044
+ "TranscriptSignalSource",
46045
+ `[${this.opts.label}] [shadow] signals available=${snapshot.available}` + (snapshot.available ? ` final_assistant_present=${s2.final_assistant_present} in_turn_progress=${s2.in_turn_progress} transcript_growing=${s2.transcript_growing} msgCount=${snapshot.detail.msgCount} age=${age}` : ` reason=${snapshot.unavailableReason ?? "unknown"}`)
46046
+ );
46047
+ }
46048
+ };
46049
+
45807
46050
  // src/providers/spec/route.ts
45808
46051
  init_provider_cli_adapter();
45809
46052
  import * as fs22 from "fs";
@@ -46168,6 +46411,15 @@ var FsmDriver = class {
46168
46411
  * transition — the rich pre-transition table that lastFsmEval only keeps
46169
46412
  * for the single most recent evaluation. Separate from stateHistory. */
46170
46413
  fsmSnapshotHistory = [];
46414
+ /** TX-FSM Stage 0 (shadow): the latest daemon-injected signal observation.
46415
+ * Read by evalFsmNow for the shadow verdict of `signal` conditions ONLY —
46416
+ * the Stage-0 pass-through in the evaluator means it can never alter
46417
+ * which transition fires. */
46418
+ signalObservation = null;
46419
+ /** Per-transition last-logged shadow divergence (`${from}→${to}` → whether
46420
+ * the shadow verdict currently disagrees with the real one), so the
46421
+ * shadow log emits on FLIP only, not every frame. */
46422
+ shadowDivergenceLast = /* @__PURE__ */ new Map();
46171
46423
  subscribe(listener) {
46172
46424
  this.listeners.add(listener);
46173
46425
  return () => {
@@ -46240,6 +46492,16 @@ var FsmDriver = class {
46240
46492
  updateMeta(meta, replace = false) {
46241
46493
  this.adapter.updateMeta(meta, replace);
46242
46494
  }
46495
+ /**
46496
+ * TX-FSM Stage 0 (shadow): receive the daemon-normalized signal
46497
+ * observation. The driver stores it verbatim — it does NOT poll, parse,
46498
+ * or read anything itself (generic-PTY-engine boundary). The observation
46499
+ * only feeds the shadow verdict of `signal` conditions; the evaluator's
46500
+ * Stage-0 pass-through guarantees it cannot change a transition.
46501
+ */
46502
+ setSignalObservation(snapshot) {
46503
+ this.signalObservation = snapshot ?? null;
46504
+ }
46243
46505
  snapshot() {
46244
46506
  return this.adapter.snapshot();
46245
46507
  }
@@ -46430,7 +46692,35 @@ var FsmDriver = class {
46430
46692
  }
46431
46693
  evalFsmNow(screen, cursor, now) {
46432
46694
  const prev = this.prevScreenLines.length > 0 ? this.prevScreenLines : void 0;
46433
- return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now));
46695
+ return evaluateFsm(this.spec, this.currentStateId, screen, cursor, prev, this.buildClock(now), this.signalObservation);
46696
+ }
46697
+ /**
46698
+ * TX-FSM Stage 0 (shadow): compare each signal-guarded transition's
46699
+ * counterfactual verdict against the real (PTY-only) one and log on FLIP.
46700
+ * This is the "만약 적용했다면" half of the shadow log — the Stage 1-3
46701
+ * evidence for whether signal gating would have changed any verdict, and
46702
+ * in which direction. Read-only: runs after the evaluation is complete
46703
+ * and never feeds back into it.
46704
+ */
46705
+ logShadowDivergence(ev) {
46706
+ for (const t of ev.transitions) {
46707
+ if (!t.shadow) continue;
46708
+ const key2 = `${this.currentStateId}\u2192${t.to}`;
46709
+ const diverges = t.shadow.fires !== t.fires;
46710
+ if ((this.shadowDivergenceLast.get(key2) ?? false) === diverges) continue;
46711
+ this.shadowDivergenceLast.set(key2, diverges);
46712
+ if (diverges) {
46713
+ LOG.info(
46714
+ "FsmDriver",
46715
+ `[${this.specTag()}] [shadow] ${key2} (${t.label}): real fires=${t.fires} but signal-gated verdict would be fires=${t.shadow.fires} (shadowCond=${t.shadow.condResult}${t.shadow.unknown ? ", signal unknown/fail-open" : ""})`
46716
+ );
46717
+ } else {
46718
+ LOG.info(
46719
+ "FsmDriver",
46720
+ `[${this.specTag()}] [shadow] ${key2} (${t.label}): shadow verdict realigned with real fires=${t.fires}`
46721
+ );
46722
+ }
46723
+ }
46434
46724
  }
46435
46725
  reevaluate(forceEmit = false) {
46436
46726
  const now = Date.now();
@@ -46441,6 +46731,7 @@ var FsmDriver = class {
46441
46731
  const ev = this.evalFsmNow(screen, cursor, now);
46442
46732
  this.lastFsmEval = ev;
46443
46733
  this.prevScreenLines = currentLines;
46734
+ this.logShadowDivergence(ev);
46444
46735
  if (ev.fired) {
46445
46736
  this.commitTransition(ev.fired, now, ev);
46446
46737
  this.emitStateChanged(forceEmit);
@@ -49089,6 +49380,18 @@ var SpecCliAdapter = class _SpecCliAdapter {
49089
49380
  }
49090
49381
  refreshProviderDefinition() {
49091
49382
  }
49383
+ /**
49384
+ * TX-FSM Stage 0 (shadow): forward the daemon's normalized signal
49385
+ * observation into the FSM driver. Observation-only — failures here must
49386
+ * never break the adapter, and the driver treats the envelope as a pure
49387
+ * injected value (no reads cross the engine boundary).
49388
+ */
49389
+ setSignalObservation(snapshot) {
49390
+ try {
49391
+ this.driver.setSignalObservation?.(snapshot);
49392
+ } catch {
49393
+ }
49394
+ }
49092
49395
  handleEvent(ev) {
49093
49396
  this.lastEvent = ev;
49094
49397
  switch (ev.kind) {
@@ -50943,6 +51246,10 @@ var CliProviderInstance = class _CliProviderInstance {
50943
51246
  completedDebounceTimer = null;
50944
51247
  completedDebouncePending = null;
50945
51248
  lastExternalCompletionProbe = null;
51249
+ /** TX-FSM Stage 0 (shadow): lazily-created transcript signal normalizer.
51250
+ * Fed ONLY by transcript reads this instance already performs — it adds
51251
+ * zero I/O and its output never feeds back into any verdict. */
51252
+ transcriptSignalSource = null;
50946
51253
  /**
50947
51254
  * The final assistant summary of the last completed turn, cached at
50948
51255
  * completion-emit time. For a native-source provider (antigravity) whose
@@ -51108,6 +51415,7 @@ var CliProviderInstance = class _CliProviderInstance {
51108
51415
  });
51109
51416
  if (restoredHistory.source !== "provider-native") {
51110
51417
  this.lastExternalCompletionProbe = null;
51418
+ this.publishTranscriptSignalObservation(null);
51111
51419
  return null;
51112
51420
  }
51113
51421
  this.lastExternalCompletionProbe = buildExternalTranscriptProbe(
@@ -51115,8 +51423,48 @@ var CliProviderInstance = class _CliProviderInstance {
51115
51423
  restoredHistory.sourcePath,
51116
51424
  restoredHistory.sourceMtimeMs
51117
51425
  );
51426
+ this.publishTranscriptSignalObservation(restoredHistory.messages);
51118
51427
  return restoredHistory.messages;
51119
51428
  }
51429
+ /**
51430
+ * TX-FSM Stage 0 (shadow): normalize the transcript read that JUST
51431
+ * happened into a SignalSnapshot and inject it into the FSM driver as a
51432
+ * pure observation (daemon → SpecCliAdapter → FsmDriver). Fed ONLY by
51433
+ * reads this method's caller already performs — it adds zero I/O, so the
51434
+ * getState() zero-native-read invariant and the stall-path read cadence
51435
+ * are untouched. The snapshot is shadow data: nothing on this path feeds
51436
+ * back into completion/stall/redrive verdicts. Fail-open end to end — a
51437
+ * missing adapter hook (non-spec providers), an unresolved transcript, or
51438
+ * any throw degrades to "no observation", never to a wedge.
51439
+ */
51440
+ publishTranscriptSignalObservation(messages, error = false) {
51441
+ const adapter = this.adapter;
51442
+ if (typeof adapter?.setSignalObservation !== "function") return;
51443
+ try {
51444
+ if (!this.transcriptSignalSource) {
51445
+ this.transcriptSignalSource = new TranscriptSignalSource({
51446
+ label: this.type,
51447
+ // Choke point: class/timing come from the P0 profile
51448
+ // resolver, never from raw predicates or provider names.
51449
+ profile: resolveTranscriptAuthorityProfile(this.provider),
51450
+ turnStartedAt: () => {
51451
+ const t = this.adapter?.currentTurnStartedAt;
51452
+ return typeof t === "number" && Number.isFinite(t) ? t : void 0;
51453
+ },
51454
+ // Reuse the exact completion machinery (I1) for the
51455
+ // final_assistant_present signal rather than duplicating
51456
+ // the message scan.
51457
+ finalAssistantPresent: (msgs, ts2) => this.completionHasFinalAssistantMessage(msgs, ts2),
51458
+ growthQuietMs: MISSING_ASSISTANT_TRANSCRIPT_GROWTH_QUIET_MS
51459
+ });
51460
+ }
51461
+ const snapshot = this.transcriptSignalSource.update(
51462
+ { messages, probe: this.lastExternalCompletionProbe, error }
51463
+ );
51464
+ adapter.setSignalObservation(snapshot);
51465
+ } catch {
51466
+ }
51467
+ }
51120
51468
  /**
51121
51469
  * The content of the LAST visible assistant bubble in a message list, or ''
51122
51470
  * when the tail is not an assistant reply. Skips trailing system/tool/activity
@@ -62591,17 +62939,134 @@ var fastForwardHandlers = {
62591
62939
 
62592
62940
  // src/commands/med-family/mesh-restart.ts
62593
62941
  init_dist();
62942
+ init_logger();
62594
62943
  var RESTART_BLOCKING_STATES = /* @__PURE__ */ new Set(["generating", "waiting_approval", "starting"]);
62595
- function hasBlockingSessions(ctx) {
62944
+ var DEFERRED_RESTART_DEFAULT_TIMEOUT_MS = 30 * 60 * 1e3;
62945
+ var DEFERRED_RESTART_MAX_TIMEOUT_MS = 6 * 60 * 60 * 1e3;
62946
+ var DEFERRED_RESTART_POLL_MS = 1e4;
62947
+ function collectBlockingSessions(ctx, meshId) {
62948
+ const blocking = [];
62596
62949
  const states = ctx.deps.instanceManager.collectAllStates();
62950
+ const consider = (state) => {
62951
+ const status = String(state?.status || "");
62952
+ if (!RESTART_BLOCKING_STATES.has(status)) return;
62953
+ blocking.push({
62954
+ instanceId: typeof state?.instanceId === "string" ? state.instanceId : null,
62955
+ status,
62956
+ // The coordinator marker is stamped by mesh_coordinator_launch and
62957
+ // re-stamped on restore (cli-manager.restoreHostedSessions). Ad-hoc
62958
+ // coordinator sessions have no marker and are NOT waived by selfOnly.
62959
+ selfCoordinator: !!meshId && state?.settings?.meshCoordinatorFor === meshId
62960
+ });
62961
+ };
62597
62962
  for (const state of states) {
62598
- if (RESTART_BLOCKING_STATES.has(String(state.status || ""))) return true;
62963
+ consider(state);
62599
62964
  const childStates = "extensions" in state && Array.isArray(state.extensions) ? state.extensions : [];
62600
- for (const child of childStates) {
62601
- if (RESTART_BLOCKING_STATES.has(String(child?.status || ""))) return true;
62602
- }
62965
+ for (const child of childStates) consider(child);
62966
+ }
62967
+ return blocking;
62968
+ }
62969
+ var pendingDeferredRestart = null;
62970
+ function deferredRestartInfo() {
62971
+ if (!pendingDeferredRestart) return null;
62972
+ return {
62973
+ meshId: pendingDeferredRestart.meshId,
62974
+ nodeId: pendingDeferredRestart.nodeId,
62975
+ mode: pendingDeferredRestart.mode,
62976
+ killSessionHost: pendingDeferredRestart.killSessionHost,
62977
+ scheduledAt: new Date(pendingDeferredRestart.scheduledAt).toISOString(),
62978
+ expiresAt: new Date(pendingDeferredRestart.expiresAt).toISOString(),
62979
+ runCondition: "executes automatically once no session is generating / waiting_approval / starting"
62980
+ };
62981
+ }
62982
+ function clearPendingDeferredRestart() {
62983
+ if (pendingDeferredRestart) clearInterval(pendingDeferredRestart.timer);
62984
+ pendingDeferredRestart = null;
62985
+ }
62986
+ function normalizeRestartMode(value) {
62987
+ return value === "restart" ? "restart" : "upgrade";
62988
+ }
62989
+ function restartWarnings(args) {
62990
+ const warnings = [];
62991
+ if (args.forced) {
62992
+ warnings.push("forced restart over active sessions \u2014 in-flight turns were interrupted and the in-memory pendingOutboundQueue is permanently lost (no persistence/restore path).");
62993
+ }
62994
+ if (args.killSessionHost) {
62995
+ warnings.push("killSessionHost: the session-host process was stopped \u2014 ALL hosted CLI sessions on this machine are destroyed (hard refresh).");
62996
+ }
62997
+ if (process.platform === "win32") {
62998
+ warnings.push("Windows: the daemon restart/upgrade path stops the session-host regardless of options \u2014 all hosted sessions terminate.");
62999
+ } else {
63000
+ warnings.push("POSIX: hosted PTY sessions survive a plain daemon restart and rebind on next boot (unless killSessionHost was set).");
63001
+ }
63002
+ return warnings;
63003
+ }
63004
+ async function executeRestart(ctx, args, opts) {
63005
+ const mode = normalizeRestartMode(args?.mode);
63006
+ const killSessionHost = args?.killSessionHost === true;
63007
+ const result = mode === "restart" ? await daemonLifecycleHandlers.daemon_restart({ deps: ctx.deps }, { killSessionHost }) : await daemonLifecycleHandlers.daemon_upgrade({ deps: ctx.deps }, args);
63008
+ const restarting = result?.restarting === true;
63009
+ return {
63010
+ ...result,
63011
+ mode,
63012
+ restarted: restarting,
63013
+ ...restarting ? {
63014
+ warnings: restartWarnings({ killSessionHost, forced: opts.forced }),
63015
+ // MCP IPC has no automatic reconnect/retry — tell the caller when to
63016
+ // talk to this daemon again.
63017
+ clientHint: "daemon restarting \u2014 retry your next call in ~10s"
63018
+ } : {}
63019
+ };
63020
+ }
63021
+ function scheduleDeferredRestart(ctx, args, meshId, nodeId) {
63022
+ clearPendingDeferredRestart();
63023
+ const requestedTimeout = typeof args?.timeoutMs === "number" && Number.isFinite(args.timeoutMs) ? args.timeoutMs : DEFERRED_RESTART_DEFAULT_TIMEOUT_MS;
63024
+ const timeoutMs = Math.min(Math.max(requestedTimeout, DEFERRED_RESTART_POLL_MS), DEFERRED_RESTART_MAX_TIMEOUT_MS);
63025
+ const record = {
63026
+ meshId,
63027
+ nodeId,
63028
+ mode: normalizeRestartMode(args?.mode),
63029
+ killSessionHost: args?.killSessionHost === true,
63030
+ scheduledAt: Date.now(),
63031
+ expiresAt: Date.now() + timeoutMs,
63032
+ timer: setInterval(() => {
63033
+ void deferredRestartTick(ctx);
63034
+ }, DEFERRED_RESTART_POLL_MS)
63035
+ };
63036
+ record.timer.unref?.();
63037
+ pendingDeferredRestart = record;
63038
+ LOG.info("MeshRestart", `Deferred restart scheduled for node ${nodeId} (mode=${record.mode}, expires in ${Math.round(timeoutMs / 6e4)}min)`);
63039
+ return {
63040
+ success: true,
63041
+ restarted: false,
63042
+ scheduled: true,
63043
+ code: "restart_scheduled_when_idle",
63044
+ reason: "Restart scheduled \u2014 it will execute automatically as soon as no session on this daemon is generating / waiting_approval / starting. Running at an idle point also avoids pendingOutboundQueue loss, making this the safest restart path.",
63045
+ deferredRestart: deferredRestartInfo()
63046
+ };
63047
+ }
63048
+ async function deferredRestartTick(ctx) {
63049
+ const record = pendingDeferredRestart;
63050
+ if (!record) return;
63051
+ if (Date.now() >= record.expiresAt) {
63052
+ LOG.warn("MeshRestart", `Deferred restart for node ${record.nodeId} expired without reaching an idle point; dropping the schedule`);
63053
+ clearPendingDeferredRestart();
63054
+ return;
63055
+ }
63056
+ const blocking = collectBlockingSessions(ctx, record.meshId);
63057
+ if (blocking.length > 0) return;
63058
+ LOG.info("MeshRestart", `Deferred restart for node ${record.nodeId} executing \u2014 daemon is idle`);
63059
+ clearPendingDeferredRestart();
63060
+ try {
63061
+ await executeRestart(ctx, {
63062
+ meshId: record.meshId,
63063
+ nodeId: record.nodeId,
63064
+ mode: record.mode,
63065
+ killSessionHost: record.killSessionHost
63066
+ }, { forced: false });
63067
+ } catch (e) {
63068
+ LOG.error("MeshRestart", `Deferred restart execution failed: ${e?.message || String(e)}`);
62603
63069
  }
62604
- return false;
62605
63070
  }
62606
63071
  var meshRestartHandlers = {
62607
63072
  restart_daemon_node: async (ctx, args) => {
@@ -62622,16 +63087,43 @@ var meshRestartHandlers = {
62622
63087
  });
62623
63088
  return forwarded ?? { success: false, error: "no response from remote node" };
62624
63089
  }
62625
- if (hasBlockingSessions(ctx)) {
63090
+ if (args?.cancelWhenIdle === true) {
63091
+ const had = pendingDeferredRestart !== null;
63092
+ clearPendingDeferredRestart();
63093
+ return { success: true, restarted: false, cancelled: had, deferredRestart: null };
63094
+ }
63095
+ if (args?.whenIdleStatus === true) {
63096
+ return { success: true, restarted: false, deferredRestart: deferredRestartInfo() };
63097
+ }
63098
+ const blocking = collectBlockingSessions(ctx, meshId);
63099
+ if (blocking.length > 0) {
63100
+ if (args?.force === true) {
63101
+ LOG.warn("MeshRestart", `force restart over ${blocking.length} blocking session(s): ${blocking.map((b) => `${b.instanceId || "?"}(${b.status})`).join(", ")} \u2014 pendingOutboundQueue will be lost`);
63102
+ return executeRestart(ctx, args, { forced: true });
63103
+ }
63104
+ const foreignBlocking = blocking.filter((b) => !b.selfCoordinator);
63105
+ if (args?.selfOnly === true && foreignBlocking.length === 0) {
63106
+ LOG.info("MeshRestart", `selfOnly restart: waiving ${blocking.length} self-coordinator session(s) for mesh ${meshId}`);
63107
+ return executeRestart(ctx, args, { forced: false });
63108
+ }
63109
+ if (args?.whenIdle === true) {
63110
+ return scheduleDeferredRestart(ctx, args, meshId, nodeId);
63111
+ }
62626
63112
  return {
62627
63113
  success: false,
62628
63114
  restarted: false,
62629
63115
  code: "blocking_sessions",
62630
- reason: "Daemon has an active session (generating / waiting_approval / starting); restart refused to avoid interrupting in-flight work. Retry when the node is idle."
63116
+ reason: "Daemon has an active session (generating / waiting_approval / starting); restart refused to avoid interrupting in-flight work. Retry when the node is idle.",
63117
+ blockingSessions: blocking,
63118
+ options: {
63119
+ selfOnly: "waive only this mesh's own coordinator session (settings.meshCoordinatorFor === meshId)",
63120
+ force: "bypass the idle-gate entirely \u2014 kills in-flight turns and loses the unpersisted pendingOutboundQueue",
63121
+ whenIdle: "schedule the restart to run automatically once the daemon goes idle (safest)"
63122
+ },
63123
+ deferredRestart: deferredRestartInfo()
62631
63124
  };
62632
63125
  }
62633
- const result = await daemonLifecycleHandlers.daemon_upgrade({ deps: ctx.deps }, args);
62634
- return { ...result, restarted: result?.restarting === true };
63126
+ return executeRestart(ctx, args, { forced: false });
62635
63127
  }
62636
63128
  };
62637
63129