@adhdev/daemon-core 0.9.82-rc.318 → 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.318",
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.318",
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
  },
@@ -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';
@@ -52,6 +52,7 @@ import { assertProviderSupportsDeclaredInput, getEffectiveMessageInputSupport }
52
52
  import type { ProviderInstance, ProviderState, AcpProviderState, ProviderErrorReason, ProviderEvent, InstanceContext, SessionModalState } from './provider-instance.js';
53
53
  import { StatusMonitor } from './status-monitor.js';
54
54
  import { buildLegacyModelModeSummaryMetadata } from './summary-metadata.js';
55
+ import { workingDirBasename } from './working-dir.js';
55
56
  import {
56
57
  buildAssistantChatMessage,
57
58
  buildChatMessage,
@@ -345,7 +346,7 @@ export class AcpProviderInstance implements ProviderInstance {
345
346
  }
346
347
 
347
348
  getSessionModalState(): SessionModalState {
348
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
349
+ const dirName = workingDirBasename(this.workingDir);
349
350
  return {
350
351
  id: this.instanceId,
351
352
  status: this.currentStatus,
@@ -358,7 +359,7 @@ export class AcpProviderInstance implements ProviderInstance {
358
359
  }
359
360
 
360
361
  getState(): AcpProviderState {
361
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
362
+ const dirName = workingDirBasename(this.workingDir);
362
363
 
363
364
  const recentMessages = normalizeChatMessages(this.messages.map(m => {
364
365
  const content = m.content;
@@ -1504,7 +1505,7 @@ export class AcpProviderInstance implements ProviderInstance {
1504
1505
  private detectStatusTransition(): void {
1505
1506
  const now = Date.now();
1506
1507
  const newStatus = this.currentStatus;
1507
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
1508
+ const dirName = workingDirBasename(this.workingDir);
1508
1509
  const chatTitle = `${this.provider.name} · ${dirName}`;
1509
1510
  const progressFingerprint = newStatus === 'generating'
1510
1511
  ? `${this.partialContent}::${JSON.stringify(this.partialBlocks)}::${JSON.stringify(this.activeToolCalls.map(t => ({ name: t.name, status: t.status })))}`.slice(-2000)
@@ -28,6 +28,7 @@ import { getCliScriptCommand, parseCliScriptResult } from './cli-script-results.
28
28
  import { mergeProviderPatchState, resolveProviderStateSurface } from './provider-patch-state.js';
29
29
  import { normalizeProviderSessionId } from './provider-session-id.js';
30
30
  import { buildChatMessage, buildRuntimeSystemChatMessage, isUserFacingChatMessage, normalizeChatMessages, resolveChatMessageKind, extractFinalSummaryFromMessages } from './chat-message-normalization.js';
31
+ import { workingDirBasename } from './working-dir.js';
31
32
 
32
33
  type PersistableCliHistoryMessage = {
33
34
  role: string;
@@ -685,7 +686,7 @@ export class CliProviderInstance implements ProviderInstance {
685
686
  }))
686
687
  : mergedMessages;
687
688
 
688
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
689
+ const dirName = workingDirBasename(this.workingDir);
689
690
  const parsedChatStatus = typeof parsedStatus?.status === 'string' && parsedStatus.status.trim()
690
691
  ? parsedStatus.status.trim()
691
692
  : undefined;
@@ -827,7 +828,7 @@ export class CliProviderInstance implements ProviderInstance {
827
828
  const autoApproveActive = adapterStatus.status === 'waiting_approval' && this.shouldAutoApprove();
828
829
  const autoApproveHoldIdle = this.autoApproveBusy && adapterStatus.status === 'idle';
829
830
  const visibleStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : adapterStatus.status;
830
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
831
+ const dirName = workingDirBasename(this.workingDir);
831
832
  return {
832
833
  // Honor the caller-supplied sessionId — InstanceMgr rejects the
833
834
  // projection when projected.id !== requested sessionId, and
@@ -1541,7 +1542,7 @@ export class CliProviderInstance implements ProviderInstance {
1541
1542
  // transcript shape does NOT override the FSM's busy/idle decision.
1542
1543
  const autoApproveHoldIdle = this.autoApproveBusy && rawStatus === 'idle';
1543
1544
  const newStatus = autoApproveActive || autoApproveHoldIdle ? 'generating' : rawStatus;
1544
- const dirName = this.workingDir.split('/').filter(Boolean).pop() || 'session';
1545
+ const dirName = workingDirBasename(this.workingDir);
1545
1546
  const chatTitle = `${this.provider.name} · ${dirName}`;
1546
1547
  const partial = this.adapter.getPartialResponse();
1547
1548
  const progressFingerprint = newStatus === 'generating'
@@ -2053,7 +2054,7 @@ export class CliProviderInstance implements ProviderInstance {
2053
2054
  receivedAt: normalizedMessage.receivedAt || normalizedMessage.timestamp,
2054
2055
  historyDedupKey: dedupKey,
2055
2056
  }],
2056
- this.adapter.getScriptParsedStatus?.()?.title || this.workingDir.split('/').filter(Boolean).pop() || 'session',
2057
+ this.adapter.getScriptParsedStatus?.()?.title || workingDirBasename(this.workingDir),
2057
2058
  this.instanceId,
2058
2059
  this.providerSessionId,
2059
2060
  );
@@ -25,6 +25,16 @@ export interface TerminalAdapterOpts {
25
25
  args?: string[];
26
26
  cwd?: string;
27
27
  env?: Record<string, string>;
28
+ /**
29
+ * When true, `env` is already a COMPLETE, sanitized environment (the spawn
30
+ * planner merged + stripped process.env already) and must be passed to the
31
+ * PTY verbatim — NOT overlaid on top of process.env. Overlaying would
32
+ * re-introduce the npm_/PNPM_/parent-session keys the planner explicitly
33
+ * stripped, so the spec path's spawn env would diverge from the legacy
34
+ * path's. Defaults to false (legacy overlay behaviour) for any caller that
35
+ * still passes a partial env.
36
+ */
37
+ envIsComplete?: boolean;
28
38
  cols?: number;
29
39
  rows?: number;
30
40
  /** Coalesce screen snapshots: emit on_screen_changed at most this often. */
@@ -76,9 +86,12 @@ export class TerminalAdapter {
76
86
  }
77
87
 
78
88
  start(): void {
89
+ const env = this.opts.envIsComplete
90
+ ? ((this.opts.env ?? {}) as Record<string, string>)
91
+ : ({ ...process.env, ...(this.opts.env ?? {}) } as Record<string, string>);
79
92
  this.pty = this.factory.spawn(this.opts.binary, this.opts.args ?? [], {
80
93
  cwd: this.opts.cwd ?? process.cwd(),
81
- env: { ...process.env, ...(this.opts.env ?? {}) } as Record<string, string>,
94
+ env,
82
95
  cols: this.cols,
83
96
  rows: this.rows,
84
97
  });