@adhdev/daemon-core 1.0.23 → 1.0.24
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/cli-adapters/provider-cli-shared.d.ts +2 -0
- package/dist/commands/upgrade-helper.d.ts +8 -0
- package/dist/commands/windows-atomic-upgrade.d.ts +8 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +229 -79
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +185 -37
- package/dist/index.mjs.map +1 -1
- package/dist/providers/cli-provider-instance.d.ts +24 -0
- package/package.json +1 -1
- package/src/cli-adapters/provider-cli-shared.ts +109 -6
- package/src/commands/upgrade-helper.d.ts +3 -0
- package/src/commands/upgrade-helper.ts +35 -8
- package/src/commands/windows-atomic-upgrade.ts +26 -4
- package/src/index.ts +2 -1
- package/src/providers/cli-provider-instance.ts +86 -14
- package/src/session-host/managed-host.ts +47 -1
|
@@ -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
|
@@ -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
|
-
|
|
813
|
-
try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
|
|
918
|
+
extraDirs.push(...windowsExtraBinDirs());
|
|
814
919
|
} else {
|
|
815
|
-
extraDirs.push(
|
|
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 ?
|
|
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,
|
|
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,
|
|
173
|
-
const pointerPath = path.join(homeDir,
|
|
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
|
|
113
|
-
const
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
|
2662
|
-
|
|
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
|
|
2741
|
-
|
|
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
|
|
2818
|
-
|
|
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
|
-
|
|
3405
|
-
|
|
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(
|
|
198
|
+
const child = spawn(nodeExecutable, [entry], {
|
|
153
199
|
detached: true,
|
|
154
200
|
stdio,
|
|
155
201
|
windowsHide: true,
|