@adhdev/daemon-core 1.0.23 → 1.0.24-rc.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.
@@ -768,6 +768,30 @@ export declare class CliProviderInstance implements ProviderInstance {
768
768
  * the emitted event, exactly as each inline builder produced before.
769
769
  */
770
770
  private emitGeneratingCompleted;
771
+ /**
772
+ * COMPLETION-WEAK-REARM (fix1): the double-emit guard shared by the three transcript
773
+ * re-emit paths (flushMeshCompletionBeforeCleanup, tryReconcilePurePtyCompletionForStall,
774
+ * tryReconcileNativeSourceCompletionForStall). Returns true when a re-emit for `taskId`
775
+ * must be SUPPRESSED because this turn's completion already fired with strong evidence.
776
+ *
777
+ * The defect this replaces: the old guard short-circuited on ANY prior emit for the
778
+ * taskId, regardless of its evidence. After a WEAK completion (CANON-C decoupled-immediate
779
+ * missing_final_assistant, or a startup-grace fast-collapse synth), the same session
780
+ * reaching a GENUINE idle later (final assistant present) was silently swallowed — the
781
+ * worker never emitted the genuine completion and the coordinator held on the acked-death
782
+ * deadline (8 min).
783
+ *
784
+ * New behavior:
785
+ * • no latch / taskId mismatch → NOT suppressed (the caller's own evidence gate runs).
786
+ * • prior emit was GENUINE (not weak) → SUPPRESSED (single-shot; a clean completion is
787
+ * never re-emitted).
788
+ * • prior emit was WEAK → re-arm ONE-SHOT, but only across a real generating→idle
789
+ * transition: require busyEpoch to have advanced past the weak emit's epoch, so a
790
+ * static idle screen cannot re-fire the same weak frame. The genuine re-emit passes
791
+ * evidenceLevel:'transcript' (non-weak), overwriting the latch → any subsequent idle
792
+ * tick hits the now-genuine latch and is suppressed. Never a third emit.
793
+ */
794
+ private shouldSuppressCompletionReEmit;
771
795
  /**
772
796
  * AUTOAPPROVE-FLAP-INBOX-MISSING sticky-approval overlay. Returns the adapterStatus a
773
797
  * flap-prone claude-cli approval SHOULD present this frame — either the raw status
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "1.0.23",
3
+ "version": "1.0.24-rc.1",
4
4
  "description": "ADHDev daemon core — CDP, IDE detection, providers, command execution",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "*",
51
- "@adhdev/session-host-core": "*",
50
+ "@adhdev/mesh-shared": "1.0.24-rc.1",
51
+ "@adhdev/session-host-core": "1.0.24-rc.1",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -789,6 +789,112 @@ export function computeTerminalQueryTail(buffer: string): string {
789
789
  return '';
790
790
  }
791
791
 
792
+ /**
793
+ * Windows executable extensions to probe, derived from `%PATHEXT%` so we honor
794
+ * whatever the shell would actually resolve (`.exe;.bat;.cmd;.ps1;…`). We add
795
+ * `.ps1` explicitly (npm now ships PowerShell shims and it is not always in the
796
+ * default PATHEXT) and always include `''` so an already-extensioned name
797
+ * (`codex.cmd`) still matches. Falls back to a sane default list when PATHEXT
798
+ * is unset. Non-win32 callers never reach this.
799
+ */
800
+ function windowsExecutableExtensions(): string[] {
801
+ const raw = process.env.PATHEXT;
802
+ const fromEnv = raw
803
+ ? raw.split(';').map((e) => e.trim().toLowerCase()).filter(Boolean)
804
+ : ['.exe', '.cmd', '.bat'];
805
+ const merged = [...fromEnv];
806
+ if (!merged.includes('.ps1')) merged.push('.ps1');
807
+ // Bare name last so extensioned matches win first.
808
+ if (!merged.includes('')) merged.push('');
809
+ // De-dupe while preserving order.
810
+ return Array.from(new Set(merged));
811
+ }
812
+
813
+ /**
814
+ * Best-effort resolution of the active npm global prefix via
815
+ * `npm config get prefix`. This is the authoritative non-default prefix that a
816
+ * daemon's inherited PATH cannot infer — nvm / nvm-windows, `npm config set
817
+ * prefix`, `~/.npm-global`, custom `--prefix`. Consulted with a SHORT timeout so
818
+ * a slow/absent npm never hangs detection, and memoized per-process so repeated
819
+ * detections don't re-spawn the (relatively slow) npm process.
820
+ * Returns the raw prefix dir, or undefined when npm is unavailable.
821
+ */
822
+ let cachedNpmPrefix: string | undefined | null = undefined; // null = resolved-to-nothing
823
+ function npmGlobalPrefix(): string | undefined {
824
+ if (cachedNpmPrefix !== undefined) return cachedNpmPrefix ?? undefined;
825
+ try {
826
+ const prefix = execSync('npm config get prefix', {
827
+ encoding: 'utf-8',
828
+ timeout: 2000,
829
+ windowsHide: true,
830
+ stdio: ['ignore', 'pipe', 'ignore'],
831
+ }).trim();
832
+ cachedNpmPrefix = prefix && prefix !== 'undefined' ? prefix : null;
833
+ } catch {
834
+ cachedNpmPrefix = null; // npm not on PATH or slow — remember and skip
835
+ }
836
+ return cachedNpmPrefix ?? undefined;
837
+ }
838
+
839
+ /** Test-only: reset the memoized npm-prefix so a test can re-stub `execSync`. */
840
+ export function __resetNpmPrefixCacheForTests(): void {
841
+ cachedNpmPrefix = undefined;
842
+ }
843
+
844
+ /**
845
+ * Common Windows npm-global / package-manager bin dirs that a daemon's inherited
846
+ * PATH frequently misses. Additive and existence-guarded — we only return dirs
847
+ * that exist, so the common path stats nothing extra beyond a handful of paths.
848
+ * `npm config get prefix` is consulted best-effort (short timeout) because it is
849
+ * the authoritative non-default prefix (nvm-windows, custom `--prefix`).
850
+ */
851
+ function windowsExtraBinDirs(): string[] {
852
+ const dirs: string[] = [];
853
+ const fs = require('fs');
854
+ const push = (dir: string | undefined | null) => {
855
+ if (!dir) return;
856
+ try { if (fs.existsSync(dir)) dirs.push(dir); } catch { /* best-effort */ }
857
+ };
858
+ if (process.env.APPDATA) push(path.join(process.env.APPDATA, 'npm'));
859
+ if (process.env.LOCALAPPDATA) push(path.join(process.env.LOCALAPPDATA, 'npm'));
860
+ if (process.env.USERPROFILE) push(path.join(process.env.USERPROFILE, 'scoop', 'shims'));
861
+ // nvm-windows / custom prefix: `npm config get prefix` points at the active
862
+ // global bin dir, which on Windows is the prefix dir itself (shims live there).
863
+ push(npmGlobalPrefix());
864
+ try { push(path.dirname(process.execPath)); } catch { /* best-effort */ }
865
+ return dirs;
866
+ }
867
+
868
+ /**
869
+ * Common Unix (Linux/macOS) global-bin dirs that a daemon's minimal non-login
870
+ * PATH frequently misses. The systemd unit ships no PATH pin, so a daemon started
871
+ * outside the user's interactive shell never sees these. Additive and
872
+ * existence-guarded. Notably includes `~/.local/bin`, the Claude Code native
873
+ * installer default (`curl … install.sh | bash`), plus the npm global prefix bin
874
+ * (nvm / `npm config set prefix` / `~/.npm-global`).
875
+ */
876
+ function unixExtraBinDirs(): string[] {
877
+ const dirs: string[] = [];
878
+ const fs = require('fs');
879
+ const home = os.homedir();
880
+ const push = (dir: string | undefined | null) => {
881
+ if (!dir) return;
882
+ try { if (fs.existsSync(dir)) dirs.push(dir); } catch { /* best-effort */ }
883
+ };
884
+ // Claude Code native-installer default + alternate native location.
885
+ push(path.join(home, '.local', 'bin'));
886
+ push(path.join(home, '.claude', 'local', 'bin'));
887
+ // Historical / explicit npm-global default.
888
+ push(path.join(home, '.npm-global', 'bin'));
889
+ push('/usr/local/bin');
890
+ push('/opt/homebrew/bin');
891
+ // Active npm prefix bin — covers nvm / custom `npm config set prefix`.
892
+ const prefix = npmGlobalPrefix();
893
+ if (prefix) push(path.join(prefix, 'bin'));
894
+ try { push(path.dirname(process.execPath)); } catch { /* best-effort */ }
895
+ return dirs;
896
+ }
897
+
792
898
  export function findBinary(name: string): string {
793
899
  const trimmed = String(name || '').trim();
794
900
  if (!trimmed) return trimmed;
@@ -809,15 +915,12 @@ export function findBinary(name: string): string {
809
915
  // handling then launches it correctly regardless of PATH.
810
916
  const extraDirs: string[] = [];
811
917
  if (isWin) {
812
- if (process.env.APPDATA) extraDirs.push(path.join(process.env.APPDATA, 'npm'));
813
- try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
918
+ extraDirs.push(...windowsExtraBinDirs());
814
919
  } else {
815
- extraDirs.push(path.join(os.homedir(), '.npm-global', 'bin'));
816
- extraDirs.push('/usr/local/bin', '/opt/homebrew/bin');
817
- try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
920
+ extraDirs.push(...unixExtraBinDirs());
818
921
  }
819
922
  const searchDirs = [...paths, ...extraDirs];
820
- const exes = isWin ? ['.exe', '.cmd', '.bat', ''] : [''];
923
+ const exes = isWin ? windowsExecutableExtensions() : [''];
821
924
 
822
925
  for (const p of searchDirs) {
823
926
  if (!p) continue;
@@ -19,11 +19,14 @@ export interface PinnedGlobalInstallCommand {
19
19
  surface: CurrentGlobalInstallSurface;
20
20
  execOptions: { shell: boolean };
21
21
  }
22
+ export declare function resolveInstanceDir(configDir?: string): string;
22
23
  export declare function resolveCurrentGlobalInstallSurface(options: {
23
24
  packageName: string;
24
25
  currentCliPath?: string;
25
26
  nodeExecutable?: string;
26
27
  platform?: NodeJS.Platform;
28
+ homeDir?: string;
29
+ instanceDir?: string;
27
30
  }): CurrentGlobalInstallSurface;
28
31
  export declare function buildPinnedGlobalInstallCommand(options: {
29
32
  packageName: string;
@@ -16,9 +16,22 @@ import {
16
16
  stopOwnedProcessesForPrefixes,
17
17
  waitForPidExit,
18
18
  } from './process-lifecycle.js';
19
+ import { getConfigDir } from '../config/config.js';
19
20
 
20
21
  const UPGRADE_HELPER_ENV = 'ADHDEV_DAEMON_UPGRADE_HELPER';
21
22
 
23
+ // Canonical per-instance base dir name (e.g. `.adhdev` for stable,
24
+ // `.adhdev-preview` for the coexisting preview install). Derived from the
25
+ // running daemon's config dir basename so it stays consistent with Phase 0/1:
26
+ // the installer pins ADHDEV_CONFIG_DIR=~/.adhdev-preview for the preview
27
+ // instance, getConfigDir() honors it, and its basename is the instance dir. A
28
+ // stable / no-override daemon yields `.adhdev`, so every Windows-layout path is
29
+ // byte-for-byte identical to before the instance axis existed.
30
+ export function resolveInstanceDir(configDir: string = getConfigDir()): string {
31
+ const base = path.basename(configDir).trim();
32
+ return base || '.adhdev';
33
+ }
34
+
22
35
  export interface DaemonUpgradeHelperPayload {
23
36
  packageName: string;
24
37
  targetVersion: string;
@@ -152,9 +165,9 @@ function resolveInstallPrefixFromPackageRoot(packageRoot: string, packageName: s
152
165
  // dispatcher shims in ~/.adhdev/npm-global. Because self-upgrade reuses the
153
166
  // running prefix, that install then re-installs into the same node22 dir forever
154
167
  // and never converts to the dispatcher. Detecting it lets us force convergence.
155
- function isPortableNode22Prefix(prefix: string | null, homeDir: string): boolean {
168
+ function isPortableNode22Prefix(prefix: string | null, homeDir: string, instanceDir: string = '.adhdev'): boolean {
156
169
  if (!prefix) return false;
157
- const portableRoot = path.join(homeDir, '.adhdev', 'tools', 'node22');
170
+ const portableRoot = path.join(homeDir, instanceDir, 'tools', 'node22');
158
171
  const normalizedPrefix = path.resolve(prefix).replace(/[\\/]+$/, '').toLowerCase();
159
172
  const normalizedRoot = path.resolve(portableRoot).replace(/[\\/]+$/, '').toLowerCase();
160
173
  return normalizedPrefix === normalizedRoot || normalizedPrefix.startsWith(`${normalizedRoot}${path.sep.toLowerCase()}`);
@@ -168,9 +181,9 @@ function isPortableNode22Prefix(prefix: string | null, homeDir: string): boolean
168
181
  // resolveWindowsInstallerLayout only requires a `npm-installs/version-*` path —
169
182
  // performWindowsAtomicUpgrade stages a fresh version- prefix of its own and uses
170
183
  // this only as the "old prefix" to stop/clean, so a non-existent path is a no-op.
171
- function canonicalDispatcherInstallPrefix(homeDir: string): string {
172
- const installRoot = path.join(homeDir, '.adhdev', 'npm-installs');
173
- const pointerPath = path.join(homeDir, '.adhdev', 'npm-global', '.adhdev-current');
184
+ function canonicalDispatcherInstallPrefix(homeDir: string, instanceDir: string = '.adhdev'): string {
185
+ const installRoot = path.join(homeDir, instanceDir, 'npm-installs');
186
+ const pointerPath = path.join(homeDir, instanceDir, 'npm-global', '.adhdev-current');
174
187
  try {
175
188
  const activeVersion = fs.readFileSync(pointerPath, 'utf8').trim();
176
189
  if (activeVersion.startsWith('version-')) return path.join(installRoot, activeVersion);
@@ -186,11 +199,19 @@ export function resolveCurrentGlobalInstallSurface(options: {
186
199
  nodeExecutable?: string;
187
200
  platform?: NodeJS.Platform;
188
201
  homeDir?: string;
202
+ /**
203
+ * Per-instance base dir name under homeDir (`.adhdev` stable /
204
+ * `.adhdev-preview` preview). Defaults to the running daemon's config-dir
205
+ * basename via resolveInstanceDir(), so the legacy-prefix convergence checks
206
+ * scope to the correct instance's tools/node22 + npm-installs tree.
207
+ */
208
+ instanceDir?: string;
189
209
  }): CurrentGlobalInstallSurface {
190
210
  const packageRoot = findCurrentPackageRoot(options.currentCliPath || process.argv[1], options.packageName);
191
211
  const npmInvocation = resolveSiblingNpmInvocation(options.nodeExecutable || process.execPath, options.platform);
192
212
  const platform = options.platform || process.platform;
193
213
  const homeDir = options.homeDir || os.homedir();
214
+ const instanceDir = options.instanceDir || resolveInstanceDir();
194
215
  let installPrefix = packageRoot ? resolveInstallPrefixFromPackageRoot(packageRoot, options.packageName) : null;
195
216
  // FIX C: on Windows, never let a self-upgrade perpetuate the legacy
196
217
  // node22-prefix install. If the running adhdev lives under ~/.adhdev/tools/
@@ -198,8 +219,8 @@ export function resolveCurrentGlobalInstallSurface(options: {
198
219
  // converges to the ~/.adhdev/npm-global pointer + shims. Scoped to win32 AND a
199
220
  // tools/node22 prefix so npm-linked dev / standalone / real dispatcher installs
200
221
  // are untouched.
201
- if (platform === 'win32' && isPortableNode22Prefix(installPrefix, homeDir)) {
202
- installPrefix = canonicalDispatcherInstallPrefix(homeDir);
222
+ if (platform === 'win32' && isPortableNode22Prefix(installPrefix, homeDir, instanceDir)) {
223
+ installPrefix = canonicalDispatcherInstallPrefix(homeDir, instanceDir);
203
224
  }
204
225
  return {
205
226
  npmExecutable: npmInvocation.executable,
@@ -576,12 +597,18 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
576
597
 
577
598
  await stopSessionHostProcesses(sessionHostAppName);
578
599
  removeDaemonPidFile();
600
+ // Scope the Windows atomic-upgrade layout to THIS daemon's instance so
601
+ // `adhdev-preview update` rotates only the preview prefix/pointer/tools tree
602
+ // and never touches the stable install (and vice-versa). Stable / no-override
603
+ // daemons resolve `.adhdev`, keeping the historical layout byte-identical.
604
+ const instanceDir = resolveInstanceDir();
579
605
  const windowsInstallerLayout = resolveWindowsInstallerLayout({
580
606
  homeDir: os.homedir(),
581
607
  installPrefix: installCommand.surface.installPrefix,
608
+ instanceDir,
582
609
  });
583
610
  if (windowsInstallerLayout) {
584
- const portableNode = findPortableNode22(os.homedir());
611
+ const portableNode = findPortableNode22(os.homedir(), process.execPath, instanceDir);
585
612
  if (!portableNode) {
586
613
  throw new Error('installer-managed Windows update requires the portable Node.js 22 runtime');
587
614
  }
@@ -103,14 +103,32 @@ function normalizeForCompare(value: string): string {
103
103
  return path.resolve(value).replace(/[\\/]+$/, '').toLowerCase();
104
104
  }
105
105
 
106
+ // The per-instance base directory name under homeDir. Stable installs live under
107
+ // `.adhdev` (the historical literal); the coexisting preview install lives under
108
+ // `.adhdev-preview`. When unset it falls back to `.adhdev`, so every stable
109
+ // call site resolves to byte-identical paths to before this option existed.
110
+ export const DEFAULT_INSTANCE_DIR = '.adhdev';
111
+
112
+ function normalizeInstanceDir(instanceDir?: string | null): string {
113
+ const trimmed = instanceDir?.trim();
114
+ return trimmed ? trimmed : DEFAULT_INSTANCE_DIR;
115
+ }
116
+
106
117
  export function resolveWindowsInstallerLayout(options: {
107
118
  homeDir: string;
108
119
  installPrefix: string | null;
109
120
  platform?: NodeJS.Platform;
121
+ /**
122
+ * Per-instance base dir name under homeDir (e.g. `.adhdev` for stable,
123
+ * `.adhdev-preview` for the coexisting preview install). Defaults to
124
+ * `.adhdev`, keeping the stable layout byte-for-byte identical to before.
125
+ */
126
+ instanceDir?: string;
110
127
  }): WindowsInstallerLayout | null {
111
128
  if ((options.platform || process.platform) !== 'win32' || !options.installPrefix) return null;
112
- const installRoot = path.join(options.homeDir, '.adhdev', 'npm-installs');
113
- const stablePrefix = path.join(options.homeDir, '.adhdev', 'npm-global');
129
+ const instanceDir = normalizeInstanceDir(options.instanceDir);
130
+ const installRoot = path.join(options.homeDir, instanceDir, 'npm-installs');
131
+ const stablePrefix = path.join(options.homeDir, instanceDir, 'npm-global');
114
132
  const pointerPath = path.join(stablePrefix, POINTER_NAME);
115
133
  const activeVersionName = path.basename(options.installPrefix);
116
134
  if (!activeVersionName.startsWith('version-')) return null;
@@ -140,9 +158,13 @@ function nodeMajor(nodeExecutable: string): number | null {
140
158
  }
141
159
  }
142
160
 
143
- export function findPortableNode22(homeDir: string, currentNode: string = process.execPath): string | null {
161
+ export function findPortableNode22(
162
+ homeDir: string,
163
+ currentNode: string = process.execPath,
164
+ instanceDir: string = DEFAULT_INSTANCE_DIR,
165
+ ): string | null {
144
166
  const candidates: string[] = [currentNode];
145
- const portableRoot = path.join(homeDir, '.adhdev', 'tools', 'node22');
167
+ const portableRoot = path.join(homeDir, normalizeInstanceDir(instanceDir), 'tools', 'node22');
146
168
  try {
147
169
  const dirs = fs.readdirSync(portableRoot, { withFileTypes: true })
148
170
  .filter((entry) => entry.isDirectory())
package/src/index.ts CHANGED
@@ -205,7 +205,7 @@ export type RecentSessionBucket = 'needs_attention' | 'working' | 'task_complete
205
205
  export type { IDaemonCore, DaemonCoreOptions } from './daemon-core.js';
206
206
 
207
207
  // ── Config ──
208
- export { loadConfig, saveConfig, resetConfig, isSetupComplete, markSetupComplete, updateConfig, getDaemonDataDir } from './config/config.js';
208
+ export { loadConfig, saveConfig, resetConfig, isSetupComplete, markSetupComplete, updateConfig, getConfigDir, getDaemonDataDir } from './config/config.js';
209
209
  export { getWorkspaceState } from './config/workspaces.js';
210
210
  export { appendRecentActivity, getRecentActivity } from './config/recent-activity.js';
211
211
  export type { RecentActivityEntry } from './config/recent-activity.js';
@@ -392,6 +392,7 @@ export {
392
392
  maybeRunDaemonUpgradeHelperFromEnv,
393
393
  spawnDetachedDaemonUpgradeHelper,
394
394
  resolveCurrentGlobalInstallSurface,
395
+ resolveInstanceDir,
395
396
  buildPinnedGlobalInstallCommand,
396
397
  execNpmCommandSync,
397
398
  getNpmExecOptions,
@@ -27,6 +27,7 @@ import { LOG } from '../logging/logger.js';
27
27
  import { recordDebugTrace } from '../logging/debug-trace.js';
28
28
  import { shouldCollectTraceCategory } from '../logging/debug-config.js';
29
29
  import { traceMeshEventStage, traceMeshEventDrop } from '../mesh/mesh-event-trace.js';
30
+ import { isWeakCompletionEvidence } from '../mesh/mesh-events-utils.js';
30
31
  import type { ChatMessage } from '../types.js';
31
32
  import { buildPersistedProviderEffectMessage, normalizeProviderEffects } from './control-effects.js';
32
33
  import { formatAutoApprovalMessage, pickApprovalButton, hasNegativeApprovalOption, hasReliableApprovalAffirmative, looksLikeActiveApprovalPromptText, normalizeApprovalLabel } from './approval-utils.js';
@@ -1439,7 +1440,20 @@ export class CliProviderInstance implements ProviderInstance {
1439
1440
  // this to refuse a SECOND completion for a turn whose completion already fired —
1440
1441
  // so a worker that finished cleanly and is simply being auto-cleaned never emits a
1441
1442
  // duplicate. taskId '' covers an ad-hoc (no-task) turn. null = none emitted yet.
1442
- private lastEmittedCompletion: { taskId: string; at: number } | null = null;
1443
+ //
1444
+ // COMPLETION-WEAK-REARM (fix1): the latch now carries the EVIDENCE STRENGTH of the
1445
+ // recorded emit. `weak` mirrors isWeakCompletionEvidence() over the exact event that
1446
+ // was pushed (evidenceLevel ∈ {weak,insufficient}, reviewRecommended, or a
1447
+ // missing_final_assistant diagnostic — the CANON-C decoupled-immediate emit and the
1448
+ // startup-grace fast-collapse synth are the two weak producers). `emittedAtEpoch`
1449
+ // snapshots busyEpoch at emit time so the transcript re-emit paths can require a real
1450
+ // generating→idle transition (busyEpoch advanced past this) before re-arming — a
1451
+ // static idle screen can never re-fire the same weak frame. A weak latch is a
1452
+ // ONE-SHOT re-arm: the genuine re-emit overwrites this with weak=false, so a
1453
+ // subsequent idle tick hits the non-weak latch and stops (never a third emit).
1454
+ private lastEmittedCompletion:
1455
+ | { taskId: string; at: number; evidenceLevel?: string; weak: boolean; emittedAtEpoch: number }
1456
+ | null = null;
1443
1457
 
1444
1458
  private async enforceFreshSessionLaunchIfNeeded(): Promise<void> {
1445
1459
  const scriptName = getForcedNewSessionScriptName(this.provider, this.launchMode);
@@ -2658,8 +2672,10 @@ export class CliProviderInstance implements ProviderInstance {
2658
2672
  if (!this.isMeshWorkerSession()) return false;
2659
2673
  const taskId = this.completingTurnTaskId();
2660
2674
 
2661
- // DOUBLE-EMIT guard: this turn's completion already fired never re-emit.
2662
- if (this.lastEmittedCompletion && this.lastEmittedCompletion.taskId === (taskId ?? '')) {
2675
+ // DOUBLE-EMIT guard (COMPLETION-WEAK-REARM fix1): suppress a re-emit only when this
2676
+ // turn's completion already fired with GENUINE evidence. A prior WEAK emit is
2677
+ // re-armable once a real generating→idle transition intervened (one-shot).
2678
+ if (this.shouldSuppressCompletionReEmit(taskId)) {
2663
2679
  return false;
2664
2680
  }
2665
2681
 
@@ -2737,8 +2753,10 @@ export class CliProviderInstance implements ProviderInstance {
2737
2753
  if (this.hasAdapterPendingResponse()) return false;
2738
2754
 
2739
2755
  const taskId = this.completingTurnTaskId();
2740
- // DOUBLE-EMIT guard: this turn's completion already fired never re-emit.
2741
- if (this.lastEmittedCompletion && this.lastEmittedCompletion.taskId === (taskId ?? '')) {
2756
+ // DOUBLE-EMIT guard (COMPLETION-WEAK-REARM fix1): suppress a re-emit only when this
2757
+ // turn's completion already fired with GENUINE evidence. A prior WEAK emit is
2758
+ // re-armable once a real generating→idle transition intervened (one-shot).
2759
+ if (this.shouldSuppressCompletionReEmit(taskId)) {
2742
2760
  return false;
2743
2761
  }
2744
2762
  // The injected task's own turn must have genuinely started (guards against a
@@ -2814,8 +2832,10 @@ export class CliProviderInstance implements ProviderInstance {
2814
2832
  if (this.hasAdapterPendingResponse()) return false;
2815
2833
 
2816
2834
  const taskId = this.completingTurnTaskId();
2817
- // DOUBLE-EMIT guard: this turn's completion already fired never re-emit.
2818
- if (this.lastEmittedCompletion && this.lastEmittedCompletion.taskId === (taskId ?? '')) {
2835
+ // DOUBLE-EMIT guard (COMPLETION-WEAK-REARM fix1): suppress a re-emit only when this
2836
+ // turn's completion already fired with GENUINE evidence. A prior WEAK emit is
2837
+ // re-armable once a real generating→idle transition intervened (one-shot).
2838
+ if (this.shouldSuppressCompletionReEmit(taskId)) {
2819
2839
  return false;
2820
2840
  }
2821
2841
  // The injected task's own turn must have genuinely started (guards against a
@@ -3401,12 +3421,8 @@ export class CliProviderInstance implements ProviderInstance {
3401
3421
  if (summary) {
3402
3422
  this.lastCompletionSummary = { content: summary, receivedAt: opts.timestamp };
3403
3423
  }
3404
- // KIMI-MESH-COMPLETION-EMIT (axis 2, double-emit guard): record that THIS turn's
3405
- // completion has now been emitted, keyed by its taskId, so the pre-cleanup
3406
- // completion flush never fires a duplicate for the same turn.
3407
- this.lastEmittedCompletion = { taskId: typeof opts.taskId === 'string' ? opts.taskId : '', at: Date.now() };
3408
- this.pushEvent({
3409
- event: 'agent:generating_completed',
3424
+ const completionEvent = {
3425
+ event: 'agent:generating_completed' as const,
3410
3426
  chatTitle: opts.chatTitle,
3411
3427
  duration: opts.duration,
3412
3428
  timestamp: opts.timestamp,
@@ -3417,7 +3433,27 @@ export class CliProviderInstance implements ProviderInstance {
3417
3433
  finalSummary: opts.finalSummary,
3418
3434
  ...(opts.evidenceLevel !== undefined ? { evidenceLevel: opts.evidenceLevel } : {}),
3419
3435
  ...(opts.completionDiagnostic !== undefined ? { completionDiagnostic: opts.completionDiagnostic } : {}),
3420
- });
3436
+ };
3437
+ // KIMI-MESH-COMPLETION-EMIT (axis 2, double-emit guard): record that THIS turn's
3438
+ // completion has now been emitted, keyed by its taskId, so the pre-cleanup
3439
+ // completion flush never fires a duplicate for the same turn.
3440
+ //
3441
+ // COMPLETION-WEAK-REARM (fix1): stamp the emit's evidence STRENGTH so the three
3442
+ // transcript re-emit guards can distinguish a weak first emit (which must be
3443
+ // re-armable once a genuine idle lands) from a genuine one (single-shot). The
3444
+ // weakness is read from the exact event being pushed — evidenceLevel plus the
3445
+ // completionDiagnostic (missing_final_assistant blockReason) — via the same
3446
+ // isWeakCompletionEvidence() the coordinator/ledger paths share, so the worker's
3447
+ // notion of "weak" cannot drift from theirs. emittedAtEpoch snapshots busyEpoch so
3448
+ // a re-arm requires a real generating→idle transition after this emit.
3449
+ this.lastEmittedCompletion = {
3450
+ taskId: typeof opts.taskId === 'string' ? opts.taskId : '',
3451
+ at: Date.now(),
3452
+ evidenceLevel: opts.evidenceLevel,
3453
+ weak: isWeakCompletionEvidence(completionEvent as Record<string, unknown>),
3454
+ emittedAtEpoch: this.busyEpoch,
3455
+ };
3456
+ this.pushEvent(completionEvent);
3421
3457
  // COORDINATOR-SILENT-IDLE one-shot consume: this completion's snapshot rides the
3422
3458
  // armed mute (resolveMuted honors settings.silentNextIdlePush for the idle status
3423
3459
  // above), so the routine idle push is suppressed for THIS completion only. Clear
@@ -3429,6 +3465,42 @@ export class CliProviderInstance implements ProviderInstance {
3429
3465
  }
3430
3466
  }
3431
3467
 
3468
+ /**
3469
+ * COMPLETION-WEAK-REARM (fix1): the double-emit guard shared by the three transcript
3470
+ * re-emit paths (flushMeshCompletionBeforeCleanup, tryReconcilePurePtyCompletionForStall,
3471
+ * tryReconcileNativeSourceCompletionForStall). Returns true when a re-emit for `taskId`
3472
+ * must be SUPPRESSED because this turn's completion already fired with strong evidence.
3473
+ *
3474
+ * The defect this replaces: the old guard short-circuited on ANY prior emit for the
3475
+ * taskId, regardless of its evidence. After a WEAK completion (CANON-C decoupled-immediate
3476
+ * missing_final_assistant, or a startup-grace fast-collapse synth), the same session
3477
+ * reaching a GENUINE idle later (final assistant present) was silently swallowed — the
3478
+ * worker never emitted the genuine completion and the coordinator held on the acked-death
3479
+ * deadline (8 min).
3480
+ *
3481
+ * New behavior:
3482
+ * • no latch / taskId mismatch → NOT suppressed (the caller's own evidence gate runs).
3483
+ * • prior emit was GENUINE (not weak) → SUPPRESSED (single-shot; a clean completion is
3484
+ * never re-emitted).
3485
+ * • prior emit was WEAK → re-arm ONE-SHOT, but only across a real generating→idle
3486
+ * transition: require busyEpoch to have advanced past the weak emit's epoch, so a
3487
+ * static idle screen cannot re-fire the same weak frame. The genuine re-emit passes
3488
+ * evidenceLevel:'transcript' (non-weak), overwriting the latch → any subsequent idle
3489
+ * tick hits the now-genuine latch and is suppressed. Never a third emit.
3490
+ */
3491
+ private shouldSuppressCompletionReEmit(taskId: string | undefined): boolean {
3492
+ const latch = this.lastEmittedCompletion;
3493
+ if (!latch || latch.taskId !== (taskId ?? '')) return false;
3494
+ // Prior emit was genuine → single-shot, never re-emit.
3495
+ if (!latch.weak) return true;
3496
+ // Prior emit was weak → allow the genuine re-emit ONLY once a real generating phase
3497
+ // opened after the weak emit (busyEpoch advanced). Otherwise a static idle frame would
3498
+ // re-fire the same weak completion. Bounded to a single re-arm by the latch overwrite
3499
+ // the genuine re-emit performs (weak=false), so the next tick is suppressed above.
3500
+ if (this.busyEpoch <= latch.emittedAtEpoch) return true;
3501
+ return false;
3502
+ }
3503
+
3432
3504
  /**
3433
3505
  * AUTOAPPROVE-FLAP-INBOX-MISSING sticky-approval overlay. Returns the adapterStatus a
3434
3506
  * flap-prone claude-cli approval SHOULD present this frame — either the raw status
@@ -9,6 +9,8 @@ import {
9
9
  type SessionHostRequestType,
10
10
  } from '@adhdev/session-host-core';
11
11
  import { getProcessCommandLine, parseNodeScriptPath } from '../commands/process-lifecycle.js';
12
+ import { findPortableNode22 } from '../commands/windows-atomic-upgrade.js';
13
+ import { resolveInstanceDir } from '../commands/upgrade-helper.js';
12
14
  import { LOG } from '../logging/logger.js';
13
15
  import { ensureSessionHostReady as ensureSharedSessionHostReady } from './runtime-support.js';
14
16
  import { DEFAULT_SESSION_HOST_READY_TIMEOUT_MS } from '../runtime-defaults.js';
@@ -139,8 +141,52 @@ export function createManagedSessionHost(options: ManagedSessionHostOptions): Ma
139
141
  }
140
142
  }
141
143
 
144
+ /**
145
+ * Resolve the node binary used to launch the session-host-daemon child.
146
+ *
147
+ * The session-host process `require()`s node-pty, whose Windows conpty.node
148
+ * prebuild only ships for the bundled portable Node 22 (matching node-pty's
149
+ * shipped win32-x64 prebuild). If the parent daemon happens to run under a
150
+ * different node (e.g. the box's SYSTEM node 24 on a fresh install), spawning
151
+ * the session-host with raw `process.execPath` would load node-pty under that
152
+ * node — and if the prebuild is missing there, every session start crashes
153
+ * with `Cannot find module ./prebuilds/win32-x64/conpty.node`.
154
+ *
155
+ * On win32 we therefore resolve the portable Node 22 the atomic-upgrade path
156
+ * already uses (`findPortableNode22`) and spawn the child with it. If no
157
+ * portable Node 22 is staged we fall back to `process.execPath` (never crash
158
+ * a working setup) but warn loudly so the misconfiguration is diagnosable.
159
+ *
160
+ * On every other platform this returns `process.execPath` unchanged — the
161
+ * session-host runtime selection is win32-only.
162
+ */
163
+ function resolveSessionHostNode(): string {
164
+ if (process.platform !== 'win32') {
165
+ return process.execPath;
166
+ }
167
+ let portableNode: string | null = null;
168
+ try {
169
+ portableNode = findPortableNode22(os.homedir(), process.execPath, resolveInstanceDir());
170
+ } catch (error) {
171
+ LOG.warn(
172
+ 'SessionHost',
173
+ `Failed to resolve portable Node 22 for the session-host spawn: ${error instanceof Error ? error.message : String(error)}`,
174
+ );
175
+ }
176
+ if (portableNode) {
177
+ return portableNode;
178
+ }
179
+ LOG.warn(
180
+ 'SessionHost',
181
+ `Portable Node 22 not found; spawning the session-host with ${process.execPath}. ` +
182
+ 'node-pty may fail to load its conpty.node prebuild under a non-22 Node on win32.',
183
+ );
184
+ return process.execPath;
185
+ }
186
+
142
187
  function spawnHost(): void {
143
188
  const entry = resolveEntry();
189
+ const nodeExecutable = resolveSessionHostNode();
144
190
  let stdio: StdioOptions = 'ignore';
145
191
  let logFd: number | null = null;
146
192
  if (options.spawnStdio === 'logfile') {
@@ -149,7 +195,7 @@ export function createManagedSessionHost(options: ManagedSessionHostOptions): Ma
149
195
  logFd = fs.openSync(path.join(logDir, 'session-host.log'), 'a');
150
196
  stdio = ['ignore', logFd, logFd];
151
197
  }
152
- const child = spawn(process.execPath, [entry], {
198
+ const child = spawn(nodeExecutable, [entry], {
153
199
  detached: true,
154
200
  stdio,
155
201
  windowsHide: true,