@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.
@@ -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.317",
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.317",
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
- const binaryPath = findBinary(configuredCommand);
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 = [...spawnConfig.args, ...extraArgs].map((arg) =>
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
- !!spawnConfig.shell
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 = !!spawnConfig.shell
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, { ...(spawnConfig.env || {}), ...(extraEnv || {}) });
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 original command.
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 explicitPath = resolveCommandPath(cli.command);
101
- const pathResult = explicitPath || await execAsync(`${whichCmd} ${shellQuote(cli.command)}`);
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 explicitPath = resolveCommandPath(target.command);
152
- const pathResult = explicitPath || await execAsync(`${whichCmd} ${shellQuote(target.command)}`);
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
- async function getIdeVersion(cliCommand: string): Promise<string | null> {
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
- const version = resolvedCli ? await getIdeVersion(resolvedCli) : null;
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
- function localAutoLaunchSkipReason(node: any): string | null {
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 ? null : 'remote_auto_launch_unsupported';
484
+ return daemonMatchesLocal && machineMatchesLocal;
473
485
  }
474
-
475
486
  if (daemonId || machineId) {
476
- return daemonMatchesLocal && machineMatchesLocal ? null : 'remote_auto_launch_unsupported';
487
+ return daemonMatchesLocal && machineMatchesLocal;
477
488
  }
489
+ return true;
490
+ }
478
491
 
479
- return null;
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 localSkipReason = localAutoLaunchSkipReason(node);
831
- if (localSkipReason) {
832
- markAutoLaunch(meshId, task.id, { status: 'skipped', reason: localSkipReason, nodeId });
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';