@adhdev/daemon-core 0.9.82-rc.317 → 0.9.82-rc.319
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-runtime.d.ts +34 -0
- package/dist/detection/ide-detector.d.ts +13 -0
- package/dist/detection/win32-ide-version.d.ts +37 -0
- package/dist/index.js +1547 -1375
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1570 -1398
- package/dist/index.mjs.map +1 -1
- package/dist/providers/spec/adapter.d.ts +10 -0
- package/dist/providers/working-dir.d.ts +17 -0
- package/package.json +2 -2
- package/src/cli-adapters/provider-cli-runtime.ts +54 -7
- package/src/cli-adapters/resolve-executable.ts +46 -1
- package/src/detection/cli-detector.ts +28 -9
- package/src/detection/ide-detector.ts +31 -2
- package/src/detection/win32-ide-version.ts +106 -0
- package/src/mesh/mesh-events-coordinator.ts +124 -22
- package/src/providers/acp-provider-instance.ts +4 -3
- package/src/providers/cli-provider-instance.ts +5 -4
- package/src/providers/spec/adapter.ts +14 -1
- package/src/providers/spec/fsm-driver.ts +29 -8
- package/src/providers/version-archive.ts +28 -6
- package/src/providers/working-dir.ts +23 -0
|
@@ -5,6 +5,16 @@ export interface TerminalAdapterOpts {
|
|
|
5
5
|
args?: string[];
|
|
6
6
|
cwd?: string;
|
|
7
7
|
env?: Record<string, string>;
|
|
8
|
+
/**
|
|
9
|
+
* When true, `env` is already a COMPLETE, sanitized environment (the spawn
|
|
10
|
+
* planner merged + stripped process.env already) and must be passed to the
|
|
11
|
+
* PTY verbatim — NOT overlaid on top of process.env. Overlaying would
|
|
12
|
+
* re-introduce the npm_/PNPM_/parent-session keys the planner explicitly
|
|
13
|
+
* stripped, so the spec path's spawn env would diverge from the legacy
|
|
14
|
+
* path's. Defaults to false (legacy overlay behaviour) for any caller that
|
|
15
|
+
* still passes a partial env.
|
|
16
|
+
*/
|
|
17
|
+
envIsComplete?: boolean;
|
|
8
18
|
cols?: number;
|
|
9
19
|
rows?: number;
|
|
10
20
|
/** Coalesce screen snapshots: emit on_screen_changed at most this often. */
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* working-dir — shared helpers for deriving display names from a session's
|
|
3
|
+
* working directory. Used by CLI/ACP provider instances to build session/tab
|
|
4
|
+
* titles.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* OS-aware basename of a working directory path.
|
|
8
|
+
*
|
|
9
|
+
* Splits on BOTH POSIX (`/`) and Windows (`\`) separators so a win32 path like
|
|
10
|
+
* `D:\gh\adhdev-cloud` resolves to `adhdev-cloud` even when the daemon's own
|
|
11
|
+
* `path.basename` is POSIX-only — and a path that mixes separators still works.
|
|
12
|
+
* Trailing separators and root-only paths fall back to `'session'`, matching
|
|
13
|
+
* the historical `.split('/').filter(Boolean).pop() || 'session'` behavior.
|
|
14
|
+
*
|
|
15
|
+
* Mirrors the web dashboard's `getWorkspaceName` (`ws.split(/[/\\]/)`).
|
|
16
|
+
*/
|
|
17
|
+
export declare function workingDirBasename(p: string): string;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
3
|
+
"version": "0.9.82-rc.319",
|
|
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",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"author": "vilmire",
|
|
47
47
|
"license": "AGPL-3.0-or-later",
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@adhdev/mesh-shared": "0.9.82-rc.
|
|
49
|
+
"@adhdev/mesh-shared": "0.9.82-rc.319",
|
|
50
50
|
"@adhdev/session-host-core": "*",
|
|
51
51
|
"@agentclientprotocol/sdk": "^0.16.1",
|
|
52
52
|
"ajv": "^8.20.0",
|
|
@@ -34,22 +34,69 @@ export function resolveCliSpawnPlan(options: {
|
|
|
34
34
|
const configuredCommand = typeof runtimeSettings.executablePath === 'string' && runtimeSettings.executablePath.trim()
|
|
35
35
|
? runtimeSettings.executablePath.trim()
|
|
36
36
|
: spawnConfig.command;
|
|
37
|
-
|
|
37
|
+
return resolveCliSpawnPlanFromParts({
|
|
38
|
+
command: configuredCommand,
|
|
39
|
+
baseArgs: spawnConfig.args,
|
|
40
|
+
shell: spawnConfig.shell,
|
|
41
|
+
baseEnv: spawnConfig.env,
|
|
42
|
+
workingDir,
|
|
43
|
+
extraArgs,
|
|
44
|
+
extraEnv,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Cols/rows for a PTY spawn. Defaults to the session-host terminal size; the
|
|
50
|
+
* spec driver may override per-instance.
|
|
51
|
+
*/
|
|
52
|
+
export interface CliSpawnGeometry {
|
|
53
|
+
cols?: number;
|
|
54
|
+
rows?: number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Core spawn-plan resolution from already-flattened spawn parts — the single
|
|
59
|
+
* source of truth for binary resolution (findBinary: PATH + npm-global / Node
|
|
60
|
+
* dir fallback), `{{workingDir}}` token substitution, shell wrapping
|
|
61
|
+
* (script-shims / non-absolute / non-native binaries), and env sanitization +
|
|
62
|
+
* TERMINAL_CWD. Both the legacy provider-module path (resolveCliSpawnPlan) and
|
|
63
|
+
* the spec/FSM path (FsmDriver.buildAdapterOpts) feed into this so the two
|
|
64
|
+
* spawn paths can never diverge.
|
|
65
|
+
*/
|
|
66
|
+
export function resolveCliSpawnPlanFromParts(options: {
|
|
67
|
+
/** Raw command from the spec/provider (`binary` / `spawn.command`), or an
|
|
68
|
+
* operator-configured executable path override. */
|
|
69
|
+
command: string;
|
|
70
|
+
/** Base launch args declared by the spec/provider (`spawn_args` /
|
|
71
|
+
* `spawn.args`). */
|
|
72
|
+
baseArgs?: string[];
|
|
73
|
+
/** When true, always wrap the launch in a login shell. */
|
|
74
|
+
shell?: boolean;
|
|
75
|
+
/** Base env declared by the spec/provider (`env` / `spawn.env`). */
|
|
76
|
+
baseEnv?: Record<string, string>;
|
|
77
|
+
workingDir: string;
|
|
78
|
+
/** Per-launch extra args (e.g. resume session id) appended after baseArgs. */
|
|
79
|
+
extraArgs?: string[];
|
|
80
|
+
extraEnv?: Record<string, string>;
|
|
81
|
+
geometry?: CliSpawnGeometry;
|
|
82
|
+
}): CliSpawnPlan {
|
|
83
|
+
const { command, baseArgs, shell, baseEnv, workingDir, extraArgs, extraEnv, geometry } = options;
|
|
84
|
+
const binaryPath = findBinary(command);
|
|
38
85
|
const isWin = os.platform() === 'win32';
|
|
39
|
-
const allArgs = [...
|
|
86
|
+
const allArgs = [...(baseArgs ?? []), ...(extraArgs ?? [])].map((arg) =>
|
|
40
87
|
typeof arg === 'string' ? arg.replace(/\{\{workingDir\}\}/g, workingDir) : arg,
|
|
41
88
|
);
|
|
42
89
|
|
|
43
90
|
let shellCmd: string;
|
|
44
91
|
let shellArgs: string[];
|
|
45
92
|
const useShellUnix = !isWin && (
|
|
46
|
-
!!
|
|
93
|
+
!!shell
|
|
47
94
|
|| !path.isAbsolute(binaryPath)
|
|
48
95
|
|| isScriptBinary(binaryPath)
|
|
49
96
|
|| !looksLikeMachOOrElf(binaryPath)
|
|
50
97
|
);
|
|
51
98
|
const isCmdShim = isWin && /\.(cmd|bat)$/i.test(binaryPath);
|
|
52
|
-
const useShellWin = !!
|
|
99
|
+
const useShellWin = !!shell
|
|
53
100
|
|| isCmdShim
|
|
54
101
|
|| !path.isAbsolute(binaryPath)
|
|
55
102
|
|| isScriptBinary(binaryPath);
|
|
@@ -68,7 +115,7 @@ export function resolveCliSpawnPlan(options: {
|
|
|
68
115
|
shellArgs = allArgs;
|
|
69
116
|
}
|
|
70
117
|
|
|
71
|
-
const env = buildCliSpawnEnv(process.env, { ...(
|
|
118
|
+
const env = buildCliSpawnEnv(process.env, { ...(baseEnv || {}), ...(extraEnv || {}) });
|
|
72
119
|
// Some CLI agents, notably Hermes, route their tools through TERMINAL_CWD
|
|
73
120
|
// rather than process.cwd(). Keep the generic ADHDev launch workspace as
|
|
74
121
|
// the single source of truth so PTY cwd and tool cwd cannot diverge.
|
|
@@ -82,8 +129,8 @@ export function resolveCliSpawnPlan(options: {
|
|
|
82
129
|
isWin,
|
|
83
130
|
useShell,
|
|
84
131
|
ptyOptions: {
|
|
85
|
-
cols: DEFAULT_SESSION_HOST_COLS,
|
|
86
|
-
rows: DEFAULT_SESSION_HOST_ROWS,
|
|
132
|
+
cols: geometry?.cols ?? DEFAULT_SESSION_HOST_COLS,
|
|
133
|
+
rows: geometry?.rows ?? DEFAULT_SESSION_HOST_ROWS,
|
|
87
134
|
cwd: workingDir,
|
|
88
135
|
env,
|
|
89
136
|
},
|
|
@@ -6,6 +6,42 @@ import * as path from 'path';
|
|
|
6
6
|
// need a cmd.exe wrapper).
|
|
7
7
|
const DIRECT_EXEC_EXT = new Set(['.exe', '.com']);
|
|
8
8
|
|
|
9
|
+
// Executable extensions to probe when scanning a directory ourselves, ordered
|
|
10
|
+
// most-directly-launchable first. node-pty's ConPTY backend launches an
|
|
11
|
+
// absolute `.cmd`/`.bat` shim fine (verified) — it only fails to *resolve* a
|
|
12
|
+
// bare command against an incomplete PATH — so once we hand it an absolute
|
|
13
|
+
// path, a `.cmd` shim works just as well as a real `.exe`.
|
|
14
|
+
const WIN_EXEC_EXT = ['.exe', '.com', '.cmd', '.bat'];
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Resolve a bare command against well-known global-bin directories that are
|
|
18
|
+
* frequently NOT on the daemon's inherited PATH, so `where` (which only
|
|
19
|
+
* searches PATH) misses them. A daemon running under one Node install (e.g.
|
|
20
|
+
* nvm) never sees another npm prefix's bin dir — notably npm's Windows default
|
|
21
|
+
* prefix at %APPDATA%\npm, where `npm i -g @openai/codex` lands. Returns an
|
|
22
|
+
* absolute path on the first hit, or null. Mirrors findBinary()'s extraDirs in
|
|
23
|
+
* provider-cli-shared.ts; kept inline here so this lightweight module (loaded
|
|
24
|
+
* by pty-transport) need not pull in the heavier shared module.
|
|
25
|
+
*/
|
|
26
|
+
function resolveWin32GlobalBin(trimmed: string): string | null {
|
|
27
|
+
// Only resolve a bare command name — anything with a path separator is the
|
|
28
|
+
// caller's explicit location and must not be re-pointed at a global bin dir.
|
|
29
|
+
if (path.isAbsolute(trimmed) || trimmed.includes('/') || trimmed.includes('\\')) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
const extraDirs: string[] = [];
|
|
33
|
+
if (process.env.APPDATA) extraDirs.push(path.join(process.env.APPDATA, 'npm'));
|
|
34
|
+
try { extraDirs.push(path.dirname(process.execPath)); } catch { /* best-effort */ }
|
|
35
|
+
for (const dir of extraDirs) {
|
|
36
|
+
if (!dir) continue;
|
|
37
|
+
for (const ext of WIN_EXEC_EXT) {
|
|
38
|
+
const full = path.join(dir, trimmed + ext);
|
|
39
|
+
if (existsSync(full)) return full;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
9
45
|
/**
|
|
10
46
|
* Resolve a launch command to an absolute executable path on Windows.
|
|
11
47
|
*
|
|
@@ -39,7 +75,16 @@ export function resolveWin32Executable(command: string): string {
|
|
|
39
75
|
return direct || matches[0] || command;
|
|
40
76
|
}
|
|
41
77
|
} catch {
|
|
42
|
-
// `where` not found / non-zero exit — fall through to
|
|
78
|
+
// `where` not found / non-zero exit — fall through to the global-bin scan.
|
|
43
79
|
}
|
|
80
|
+
|
|
81
|
+
// `where` found nothing on PATH. Before giving up (and letting node-pty crash
|
|
82
|
+
// with "File not found:" on the bare command), search npm's off-PATH global
|
|
83
|
+
// bin dir(s). This is the codex case: `npm i -g @openai/codex` installs to
|
|
84
|
+
// %APPDATA%\npm, which is absent from a nvm-launched daemon's PATH, so a spec
|
|
85
|
+
// binary of "codex" never resolved and the spawn ENOENT'd.
|
|
86
|
+
const globalBin = resolveWin32GlobalBin(trimmed);
|
|
87
|
+
if (globalBin) return globalBin;
|
|
88
|
+
|
|
44
89
|
return command;
|
|
45
90
|
}
|
|
@@ -12,6 +12,7 @@ import * as os from 'os';
|
|
|
12
12
|
import * as path from 'path';
|
|
13
13
|
import { existsSync } from 'fs';
|
|
14
14
|
import type { ProviderLoader } from '../providers/provider-loader.js';
|
|
15
|
+
import { findBinary } from '../cli-adapters/provider-cli-shared.js';
|
|
15
16
|
|
|
16
17
|
export interface CLIInfo {
|
|
17
18
|
id: string;
|
|
@@ -57,6 +58,29 @@ function resolveCommandPath(command: string): string | null {
|
|
|
57
58
|
return null;
|
|
58
59
|
}
|
|
59
60
|
|
|
61
|
+
/**
|
|
62
|
+
* Resolve a CLI command to an absolute path for install detection.
|
|
63
|
+
* Order: explicit path → PATH (`where`/`which`) → well-known global-bin dirs
|
|
64
|
+
* (e.g. %APPDATA%\npm) via the shared spawn-layer findBinary.
|
|
65
|
+
*
|
|
66
|
+
* The final step keeps detection consistent with the spawn layer: findBinary
|
|
67
|
+
* already searches npm's default Windows prefix at %APPDATA%\npm (where
|
|
68
|
+
* `npm i -g @openai/codex` lands), so a CLI installed under an npm prefix that
|
|
69
|
+
* is NOT on the daemon's inherited PATH is detected as installed instead of
|
|
70
|
+
* being blocked at the launch gate. findBinary returns a bare "<name>.cmd"
|
|
71
|
+
* (non-absolute) when nothing is found, so we only accept an absolute path
|
|
72
|
+
* that actually exists.
|
|
73
|
+
*/
|
|
74
|
+
async function resolveDetectionPath(command: string, whichCmd: string): Promise<string | null> {
|
|
75
|
+
const explicitPath = resolveCommandPath(command);
|
|
76
|
+
if (explicitPath) return explicitPath;
|
|
77
|
+
const whichResult = await execAsync(`${whichCmd} ${shellQuote(command)}`);
|
|
78
|
+
if (whichResult) return whichResult.split('\n')[0];
|
|
79
|
+
const resolved = findBinary(command);
|
|
80
|
+
if (path.isAbsolute(resolved) && existsSync(resolved)) return resolved;
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
|
|
60
84
|
/** Run a shell command with timeout, returning stdout or null on failure */
|
|
61
85
|
function execAsync(cmd: string, timeoutMs = 5000): Promise<string | null> {
|
|
62
86
|
return new Promise((resolve) => {
|
|
@@ -97,11 +121,8 @@ export async function detectCLIs(
|
|
|
97
121
|
const results = await Promise.all(
|
|
98
122
|
cliList.map(async (cli): Promise<CLIInfo> => {
|
|
99
123
|
try {
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
if (!pathResult) return { ...cli, installed: false };
|
|
103
|
-
|
|
104
|
-
const firstPath = explicitPath || pathResult.split('\n')[0];
|
|
124
|
+
const firstPath = await resolveDetectionPath(cli.command, whichCmd);
|
|
125
|
+
if (!firstPath) return { ...cli, installed: false };
|
|
105
126
|
|
|
106
127
|
// Get version (parallel with other checks)
|
|
107
128
|
let version: string | undefined;
|
|
@@ -148,10 +169,8 @@ export async function detectCLI(
|
|
|
148
169
|
const platform = os.platform();
|
|
149
170
|
const whichCmd = platform === 'win32' ? 'where' : 'which';
|
|
150
171
|
try {
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
if (!pathResult) return null;
|
|
154
|
-
const firstPath = explicitPath || pathResult.split('\n')[0];
|
|
172
|
+
const firstPath = await resolveDetectionPath(target.command, whichCmd);
|
|
173
|
+
if (!firstPath) return null;
|
|
155
174
|
let version: string | undefined;
|
|
156
175
|
if (options?.includeVersion !== false) {
|
|
157
176
|
const versionCommands = [
|
|
@@ -14,6 +14,7 @@ import { existsSync, statSync } from 'fs';
|
|
|
14
14
|
import { platform, homedir } from 'os';
|
|
15
15
|
import * as path from 'path';
|
|
16
16
|
import type { ProviderLoader } from '../providers/provider-loader.js';
|
|
17
|
+
import { isKnownWin32GuiExe, readWin32IdeVersionFromDisk } from './win32-ide-version.js';
|
|
17
18
|
|
|
18
19
|
// ─── Types ──────────────────────────────────────
|
|
19
20
|
|
|
@@ -95,7 +96,28 @@ function findCliCommand(command: string): string | null {
|
|
|
95
96
|
return null;
|
|
96
97
|
}
|
|
97
98
|
|
|
98
|
-
|
|
99
|
+
/**
|
|
100
|
+
* Resolve an IDE version on demand (NOT at boot).
|
|
101
|
+
*
|
|
102
|
+
* Order of preference, all spawn-free where possible:
|
|
103
|
+
* 1. win32: read the bundled product.json/package.json next to the exe.
|
|
104
|
+
* 2. Otherwise spawn `<cli> --version` — but ONLY when the binary is not a
|
|
105
|
+
* known GUI executable (the #4 guard), so we never boot an IDE window.
|
|
106
|
+
*
|
|
107
|
+
* `win32ProcessNames` is provider.json `processNames.win32` (type → exe names),
|
|
108
|
+
* used to recognise GUI executables. Callers that have a ProviderLoader can
|
|
109
|
+
* pass `providerLoader.getWinProcessNames()`.
|
|
110
|
+
*/
|
|
111
|
+
export async function getIdeVersion(
|
|
112
|
+
cliCommand: string,
|
|
113
|
+
win32ProcessNames: Record<string, string[]> = {},
|
|
114
|
+
): Promise<string | null> {
|
|
115
|
+
if (platform() === 'win32') {
|
|
116
|
+
const fromDisk = readWin32IdeVersionFromDisk(cliCommand);
|
|
117
|
+
if (fromDisk) return fromDisk;
|
|
118
|
+
// Refuse to spawn a known GUI exe — it would launch the IDE window.
|
|
119
|
+
if (isKnownWin32GuiExe(cliCommand, win32ProcessNames)) return null;
|
|
120
|
+
}
|
|
99
121
|
try {
|
|
100
122
|
const { stdout } = await execAsync(`"${cliCommand}" --version`, {
|
|
101
123
|
encoding: 'utf-8',
|
|
@@ -162,7 +184,14 @@ export async function detectIDEs(providerLoader?: ProviderLoader): Promise<IDEIn
|
|
|
162
184
|
const installed = os === 'darwin'
|
|
163
185
|
? !!(resolvedCli || appPath)
|
|
164
186
|
: !!resolvedCli;
|
|
165
|
-
|
|
187
|
+
// Boot-time IDE detection must NOT spawn `<cli> --version`. On Windows
|
|
188
|
+
// the resolved "CLI" is frequently the GUI Electron exe (case-insensitive
|
|
189
|
+
// FS matches `...\cursor\cursor.exe` against `Cursor.exe`), and running it
|
|
190
|
+
// with `--version` boots the IDE window. `installed` is decided purely by
|
|
191
|
+
// existsSync above, and the dashboard drops `version` anyway — the only
|
|
192
|
+
// real version consumer is provider-loader.resolve() at CDP attach time,
|
|
193
|
+
// which fetches the version lazily. So leave it null here.
|
|
194
|
+
const version: string | null = null;
|
|
166
195
|
|
|
167
196
|
results.push({
|
|
168
197
|
id: def.id,
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADHDev — Windows IDE version detection (execution-free)
|
|
3
|
+
*
|
|
4
|
+
* On Windows the resolved "CLI" for a VS Code fork (Cursor, Antigravity, …)
|
|
5
|
+
* frequently resolves to the GUI Electron executable itself, because the
|
|
6
|
+
* case-insensitive filesystem matches `...\cursor\cursor.exe` against the
|
|
7
|
+
* real `Cursor.exe`. Running `<that exe> --version` boots the GUI instead of
|
|
8
|
+
* printing a version (Electron ignores the unknown flag and opens a window).
|
|
9
|
+
*
|
|
10
|
+
* To learn the version WITHOUT spawning anything, we read the bundled
|
|
11
|
+
* `product.json` / `package.json` that ship next to the executable. VS Code
|
|
12
|
+
* forks embed their version there. This is a pure filesystem read.
|
|
13
|
+
*
|
|
14
|
+
* It also exposes a guard so any remaining `--version` exec path can refuse to
|
|
15
|
+
* spawn a binary that is a known GUI executable for an IDE provider.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import * as fs from 'fs';
|
|
19
|
+
import * as path from 'path';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Candidate locations for the version manifest relative to the GUI exe dir.
|
|
23
|
+
* VS Code forks place `resources/app/{product,package}.json` next to the exe.
|
|
24
|
+
*/
|
|
25
|
+
function manifestCandidates(exeDir: string): string[] {
|
|
26
|
+
return [
|
|
27
|
+
path.join(exeDir, 'resources', 'app', 'product.json'),
|
|
28
|
+
path.join(exeDir, 'resources', 'app', 'package.json'),
|
|
29
|
+
// Some packagings keep product.json one level up.
|
|
30
|
+
path.join(exeDir, 'product.json'),
|
|
31
|
+
];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function parseVersionFromManifest(raw: string): string | null {
|
|
35
|
+
try {
|
|
36
|
+
const json = JSON.parse(raw) as Record<string, unknown>;
|
|
37
|
+
// product.json forks expose the IDE's own version here; package.json
|
|
38
|
+
// exposes the upstream VS Code engine version. Prefer the former.
|
|
39
|
+
const candidate = json.version;
|
|
40
|
+
if (typeof candidate === 'string' && candidate.trim()) {
|
|
41
|
+
return candidate.trim();
|
|
42
|
+
}
|
|
43
|
+
} catch {
|
|
44
|
+
/* not valid JSON — ignore */
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Read an IDE version from disk (no process spawn).
|
|
51
|
+
*
|
|
52
|
+
* @param exePath Absolute path to the GUI executable (or any path inside the
|
|
53
|
+
* IDE install dir — we read the manifest relative to its directory).
|
|
54
|
+
* @returns The version string, or null if it could not be determined.
|
|
55
|
+
*/
|
|
56
|
+
export function readWin32IdeVersionFromDisk(exePath: string): string | null {
|
|
57
|
+
if (!exePath) return null;
|
|
58
|
+
let exeDir: string;
|
|
59
|
+
try {
|
|
60
|
+
exeDir = fs.statSync(exePath).isDirectory() ? exePath : path.dirname(exePath);
|
|
61
|
+
} catch {
|
|
62
|
+
exeDir = path.dirname(exePath);
|
|
63
|
+
}
|
|
64
|
+
for (const candidate of manifestCandidates(exeDir)) {
|
|
65
|
+
try {
|
|
66
|
+
if (!fs.existsSync(candidate)) continue;
|
|
67
|
+
const version = parseVersionFromManifest(fs.readFileSync(candidate, 'utf-8'));
|
|
68
|
+
if (version) return version;
|
|
69
|
+
} catch {
|
|
70
|
+
/* ignore unreadable manifest */
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return null;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* True when `binPath` resolves to a known GUI executable for some IDE.
|
|
78
|
+
*
|
|
79
|
+
* Used as a safety net: any code path about to run `<bin> --version` must skip
|
|
80
|
+
* the spawn when the binary is actually the GUI Electron exe, since that would
|
|
81
|
+
* launch the IDE window. Comparison is case-insensitive on the basename
|
|
82
|
+
* (Windows filesystem semantics) against every provider's `processNames.win32`.
|
|
83
|
+
*
|
|
84
|
+
* @param binPath Resolved binary path that would be exec'd.
|
|
85
|
+
* @param win32ProcessNames Map of provider type → list of GUI exe names
|
|
86
|
+
* (from provider.json `processNames.win32`, e.g. `["Cursor.exe"]`).
|
|
87
|
+
*/
|
|
88
|
+
export function isKnownWin32GuiExe(
|
|
89
|
+
binPath: string | null | undefined,
|
|
90
|
+
win32ProcessNames: Record<string, string[]>,
|
|
91
|
+
): boolean {
|
|
92
|
+
if (!binPath) return false;
|
|
93
|
+
// Use win32 basename semantics regardless of host OS: this matches Windows
|
|
94
|
+
// paths even when the daemon-core test/CI host is POSIX (where `/` is the
|
|
95
|
+
// only separator). On the real win32 target this is identical to basename().
|
|
96
|
+
const base = path.win32.basename(binPath).toLowerCase();
|
|
97
|
+
if (!base.endsWith('.exe')) return false;
|
|
98
|
+
for (const names of Object.values(win32ProcessNames)) {
|
|
99
|
+
for (const name of names) {
|
|
100
|
+
if (typeof name === 'string' && name.toLowerCase() === base) {
|
|
101
|
+
return true;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return false;
|
|
106
|
+
}
|
|
@@ -380,6 +380,16 @@ const autoLaunchInProgress = new Set<string>();
|
|
|
380
380
|
const autoLaunchCooldownUntil = new Map<string, number>();
|
|
381
381
|
const AUTO_LAUNCH_COOLDOWN_MS = 5_000;
|
|
382
382
|
|
|
383
|
+
// De-dup for repeated `skipped` ledger noise: the reconcile loop re-runs the queue
|
|
384
|
+
// trigger every 4s, so a task that can't be claimed (e.g. a remote node with no
|
|
385
|
+
// transport, or a node under cooldown) would otherwise append an identical
|
|
386
|
+
// session_auto_launch{phase:'skipped'} entry on every tick — flooding the ledger.
|
|
387
|
+
// We suppress a `skipped` ledger append when the immediately-prior recorded event
|
|
388
|
+
// for that task was the SAME (phase, reason). Any non-skip phase (started/failed/
|
|
389
|
+
// completed) or a changed reason resets the de-dup so real transitions still record.
|
|
390
|
+
const lastAutoLaunchLedgerKey = new Map<string, string>();
|
|
391
|
+
const AUTO_LAUNCH_LEDGER_DEDUP_MAX = 2000;
|
|
392
|
+
|
|
383
393
|
function sweepExpiredCooldowns(): void {
|
|
384
394
|
const now = Date.now();
|
|
385
395
|
for (const [key, until] of autoLaunchCooldownUntil) {
|
|
@@ -457,7 +467,9 @@ function isLaunchableNode(node: any): boolean {
|
|
|
457
467
|
return health === 'online' || health === 'unknown';
|
|
458
468
|
}
|
|
459
469
|
|
|
460
|
-
|
|
470
|
+
/** Whether a mesh node's daemon/machine identity resolves to THIS coordinator daemon
|
|
471
|
+
* (i.e. the queue session can be spawned by a direct local `launch_cli`). */
|
|
472
|
+
function isLocalAutoLaunchNode(node: any): boolean {
|
|
461
473
|
const daemonId = readNonEmptyString(node?.daemonId);
|
|
462
474
|
const machineId = readNonEmptyString(node?.machineId);
|
|
463
475
|
const appConfig = loadConfig();
|
|
@@ -466,17 +478,44 @@ function localAutoLaunchSkipReason(node: any): string | null {
|
|
|
466
478
|
const standaloneDaemonId = localMachineId ? `standalone_${localMachineId}` : '';
|
|
467
479
|
|
|
468
480
|
const daemonMatchesLocal = !daemonId || daemonId === cloudDaemonId || daemonId === standaloneDaemonId;
|
|
469
|
-
const machineMatchesLocal = !machineId || (localMachineId && machineId === localMachineId);
|
|
481
|
+
const machineMatchesLocal = !machineId || (!!localMachineId && machineId === localMachineId);
|
|
470
482
|
|
|
471
483
|
if (node?.isLocalWorktree === true) {
|
|
472
|
-
return daemonMatchesLocal && machineMatchesLocal
|
|
484
|
+
return daemonMatchesLocal && machineMatchesLocal;
|
|
473
485
|
}
|
|
474
|
-
|
|
475
486
|
if (daemonId || machineId) {
|
|
476
|
-
return daemonMatchesLocal && machineMatchesLocal
|
|
487
|
+
return daemonMatchesLocal && machineMatchesLocal;
|
|
477
488
|
}
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
478
491
|
|
|
479
|
-
|
|
492
|
+
/**
|
|
493
|
+
* Resolve how a pending queue task should be auto-launched onto a node.
|
|
494
|
+
*
|
|
495
|
+
* - `local`: spawn directly on this daemon via cliManager.handleCliCommand('launch_cli').
|
|
496
|
+
* - `remote`: forward `launch_cli` to the node's daemon via dispatchMeshCommand
|
|
497
|
+
* (mirrors what mesh_launch_session does). Requires dispatchMeshCommand AND a
|
|
498
|
+
* resolvable coordinator daemonId for relay-safe completion routing.
|
|
499
|
+
* - `skip`: not launchable from here — carries the reason (e.g. a remote node with
|
|
500
|
+
* no dispatch transport, or no coordinator daemonId to stamp).
|
|
501
|
+
*/
|
|
502
|
+
function resolveAutoLaunchTarget(components: DaemonComponents, node: any): {
|
|
503
|
+
mode: 'local' | 'remote' | 'skip';
|
|
504
|
+
reason?: string;
|
|
505
|
+
daemonId?: string;
|
|
506
|
+
coordinatorDaemonId?: string;
|
|
507
|
+
} {
|
|
508
|
+
if (isLocalAutoLaunchNode(node)) return { mode: 'local' };
|
|
509
|
+
|
|
510
|
+
// Remote node. Forwarding the launch is possible only with a dispatch transport
|
|
511
|
+
// (cloud mode) plus a coordinator daemonId to stamp into the worker so completion
|
|
512
|
+
// events route back here. Without either, fall back to a graceful skip.
|
|
513
|
+
const daemonId = readNonEmptyString(node?.daemonId);
|
|
514
|
+
if (!daemonId) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
|
|
515
|
+
if (!components.dispatchMeshCommand) return { mode: 'skip', reason: 'remote_auto_launch_unsupported' };
|
|
516
|
+
const coordinatorDaemonId = readNonEmptyString(loadConfig().machineId);
|
|
517
|
+
if (!coordinatorDaemonId) return { mode: 'skip', reason: 'remote_auto_launch_no_coordinator_daemon_id' };
|
|
518
|
+
return { mode: 'remote', daemonId, coordinatorDaemonId };
|
|
480
519
|
}
|
|
481
520
|
|
|
482
521
|
function activeAssignedCount(meshId: string): number {
|
|
@@ -632,6 +671,20 @@ function recordAutoLaunchEvent(meshId: string, args: {
|
|
|
632
671
|
reason?: string;
|
|
633
672
|
error?: string;
|
|
634
673
|
}) {
|
|
674
|
+
// Suppress consecutive identical `skipped` entries for the same task (4s reconcile
|
|
675
|
+
// re-trigger noise). Non-skip phases and changed reasons always record and reset
|
|
676
|
+
// the de-dup so genuine state transitions remain visible in the ledger.
|
|
677
|
+
const dedupKey = `${meshId}:${args.taskId}`;
|
|
678
|
+
const currentSig = `${args.phase}|${args.reason || ''}`;
|
|
679
|
+
if (args.phase === 'skipped' && lastAutoLaunchLedgerKey.get(dedupKey) === currentSig) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
lastAutoLaunchLedgerKey.set(dedupKey, currentSig);
|
|
683
|
+
if (lastAutoLaunchLedgerKey.size > AUTO_LAUNCH_LEDGER_DEDUP_MAX) {
|
|
684
|
+
// Bound memory: drop the oldest insertion (Map preserves insertion order).
|
|
685
|
+
const oldest = lastAutoLaunchLedgerKey.keys().next().value;
|
|
686
|
+
if (oldest !== undefined) lastAutoLaunchLedgerKey.delete(oldest);
|
|
687
|
+
}
|
|
635
688
|
try {
|
|
636
689
|
appendLedgerEntry(meshId, {
|
|
637
690
|
kind: 'session_auto_launch',
|
|
@@ -827,9 +880,13 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
827
880
|
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: 'node_not_launch_ready', nodeId });
|
|
828
881
|
continue;
|
|
829
882
|
}
|
|
830
|
-
const
|
|
831
|
-
if (
|
|
832
|
-
|
|
883
|
+
const launchTarget = resolveAutoLaunchTarget(components, node);
|
|
884
|
+
if (launchTarget.mode === 'skip') {
|
|
885
|
+
// Remote node we can't reach (no transport / no coordinator daemonId).
|
|
886
|
+
// Set a cooldown so the 4s reconcile loop doesn't re-attempt this node
|
|
887
|
+
// every tick; the de-dup'd skip ledger keeps it diagnosable without flood.
|
|
888
|
+
markAutoLaunch(meshId, task.id, { status: 'skipped', reason: launchTarget.reason || 'auto_launch_unavailable', nodeId });
|
|
889
|
+
autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
|
|
833
890
|
continue;
|
|
834
891
|
}
|
|
835
892
|
// Write tasks keep the one-active-per-node invariant (worktree isolation);
|
|
@@ -865,23 +922,68 @@ async function maybeAutoLaunchOneQueueSession(components: DaemonComponents, mesh
|
|
|
865
922
|
continue;
|
|
866
923
|
}
|
|
867
924
|
|
|
925
|
+
// Shared worker-launch envelope. For a local node it spawns directly on this
|
|
926
|
+
// daemon; for a remote node the identical command is forwarded to the node's
|
|
927
|
+
// daemon (mirrors mesh_launch_session), with the coordinator daemonId stamped
|
|
928
|
+
// so the worker's completion events route back to this coordinator.
|
|
929
|
+
const launchSettings: Record<string, unknown> = {
|
|
930
|
+
// Worker launch envelope: role + mesh context so worker can route completion events.
|
|
931
|
+
role: 'worker',
|
|
932
|
+
meshNodeFor: meshId,
|
|
933
|
+
meshNodeId: nodeId,
|
|
934
|
+
spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
|
|
935
|
+
// Coordinator-dispatched worker: auto-approve unless mesh/node policy
|
|
936
|
+
// opts out (default true). Lands in settingsOverride and beats the
|
|
937
|
+
// global per-provider-type autoApprove config (see shouldAutoApprove).
|
|
938
|
+
autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
|
|
939
|
+
launchedByCoordinator: true,
|
|
940
|
+
autoLaunchedForQueueTaskId: task.id,
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
if (launchTarget.mode === 'remote') {
|
|
944
|
+
// Relay-safe completion routing: stamp the coordinator anchor the same way
|
|
945
|
+
// mesh_launch_session does so the worker forwards events back to this daemon.
|
|
946
|
+
const remoteSettings: Record<string, unknown> = {
|
|
947
|
+
...launchSettings,
|
|
948
|
+
meshCoordinatorDaemonId: launchTarget.coordinatorDaemonId,
|
|
949
|
+
meshCoordinatorNodeId: nodeId,
|
|
950
|
+
};
|
|
951
|
+
markAutoLaunch(meshId, task.id, { status: 'started', nodeId, providerType: resolved.providerType });
|
|
952
|
+
let launchResult: any;
|
|
953
|
+
try {
|
|
954
|
+
launchResult = await components.dispatchMeshCommand!(launchTarget.daemonId!, 'launch_cli', {
|
|
955
|
+
cliType: resolved.providerType,
|
|
956
|
+
dir: node.workspace,
|
|
957
|
+
settings: remoteSettings,
|
|
958
|
+
});
|
|
959
|
+
} catch (e: any) {
|
|
960
|
+
markAutoLaunch(meshId, task.id, { status: 'failed', reason: `remote_launch_dispatch_failed: ${e?.message || String(e)}`, nodeId, providerType: resolved.providerType });
|
|
961
|
+
autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
|
|
962
|
+
return false;
|
|
963
|
+
}
|
|
964
|
+
const payload = (launchResult && typeof launchResult === 'object' && 'payload' in launchResult && launchResult.payload && typeof launchResult.payload === 'object')
|
|
965
|
+
? launchResult.payload
|
|
966
|
+
: launchResult;
|
|
967
|
+
if (!payload?.success) {
|
|
968
|
+
const reason = readNonEmptyString(payload?.error) || 'remote_launch_cli_failed';
|
|
969
|
+
markAutoLaunch(meshId, task.id, { status: 'failed', reason, nodeId, providerType: resolved.providerType });
|
|
970
|
+
autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
|
|
971
|
+
return false;
|
|
972
|
+
}
|
|
973
|
+
// Remote launch is async: the worker session will register and emit agent:ready,
|
|
974
|
+
// which (forwarded back here) drives the claim via the normal event path / PHASE 1
|
|
975
|
+
// reconcile. Set a cooldown so the 4s loop doesn't re-launch before that lands.
|
|
976
|
+
const remoteSessionId = readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.id) || readNonEmptyString(payload.runtimeSessionId);
|
|
977
|
+
markAutoLaunch(meshId, task.id, { status: 'completed', nodeId, providerType: resolved.providerType, sessionId: remoteSessionId || undefined });
|
|
978
|
+
autoLaunchCooldownUntil.set(launchKey, Date.now() + AUTO_LAUNCH_COOLDOWN_MS); sweepExpiredCooldowns();
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
|
|
868
982
|
markAutoLaunch(meshId, task.id, { status: 'started', nodeId, providerType: resolved.providerType });
|
|
869
983
|
const launchResult: any = await components.cliManager.handleCliCommand('launch_cli', {
|
|
870
984
|
cliType: resolved.providerType,
|
|
871
985
|
dir: node.workspace,
|
|
872
|
-
settings:
|
|
873
|
-
// Worker launch envelope: role + mesh context so worker can route completion events.
|
|
874
|
-
role: 'worker',
|
|
875
|
-
meshNodeFor: meshId,
|
|
876
|
-
meshNodeId: nodeId,
|
|
877
|
-
spawnedSessionVisibility: mesh?.policy?.spawnedSessionVisibility || 'hidden',
|
|
878
|
-
// Coordinator-dispatched worker: auto-approve unless mesh/node policy
|
|
879
|
-
// opts out (default true). Lands in settingsOverride and beats the
|
|
880
|
-
// global per-provider-type autoApprove config (see shouldAutoApprove).
|
|
881
|
-
autoApprove: resolveDelegatedWorkerAutoApprove(mesh?.policy, node?.policy),
|
|
882
|
-
launchedByCoordinator: true,
|
|
883
|
-
autoLaunchedForQueueTaskId: task.id,
|
|
884
|
-
},
|
|
986
|
+
settings: launchSettings,
|
|
885
987
|
});
|
|
886
988
|
if (!launchResult?.success) {
|
|
887
989
|
const reason = launchResult?.error || 'launch_cli_failed';
|