@adhdev/daemon-core 0.9.82-rc.352 → 0.9.82-rc.353

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.352",
3
+ "version": "0.9.82-rc.353",
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.352",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.353",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -1097,10 +1097,19 @@ export class DaemonCliManager {
1097
1097
  }
1098
1098
  }
1099
1099
  }
1100
- // 2. Fuzzy match (returns first of multiple sessions — may be inaccurate)
1101
- for (const [k, a] of this.adapters) {
1102
- if (a.cliType === agentType) {
1103
- return { adapter: a, key: k };
1100
+ // 2. Fuzzy match (returns first of multiple sessions — may be inaccurate).
1101
+ // FAIL-CLOSED: only when NO explicit instanceKey/targetSessionId was requested.
1102
+ // When a specific session WAS named (step 0) but is not hosted on this daemon,
1103
+ // falling back to the first same-cliType adapter silently redirects the command
1104
+ // into an UNRELATED session — e.g. a relayed/misrouted mesh send_chat lands in the
1105
+ // coordinator's own CLI session, echoing the dispatched task body back to the
1106
+ // coordinator (TASKECHO self-inject). Returning null instead makes the caller
1107
+ // surface an explicit "not running" error rather than mis-delivering the message.
1108
+ if (!opts?.instanceKey) {
1109
+ for (const [k, a] of this.adapters) {
1110
+ if (a.cliType === agentType) {
1111
+ return { adapter: a, key: k };
1112
+ }
1104
1113
  }
1105
1114
  }
1106
1115
  return null;
@@ -40,6 +40,7 @@ import {
40
40
  summarizeGitShape as sharedSummarizeGitShape,
41
41
  normalizeMeshNodeId,
42
42
  meshNodeIdMatches,
43
+ daemonIdsEquivalent,
43
44
  } from '@adhdev/mesh-shared';
44
45
  import { SessionRegistry } from '../sessions/registry.js';
45
46
  import { LOG } from '../logging/logger.js';
@@ -3462,6 +3463,13 @@ const MESH_FORWARDABLE_SESSION_COMMANDS = new Set([
3462
3463
  'set_mode',
3463
3464
  'change_model',
3464
3465
  'set_thought_level',
3466
+ // agent_command (send_chat / clear_history / stop) is session-scoped too: a command
3467
+ // explicitly naming a targetSessionId MUST reach that session wherever it lives, never a
3468
+ // different local session. Without forwarding, a misrouted/relayed send_chat for a REMOTE
3469
+ // worker session that reaches the wrong daemon used to fuzzy-inject the task body into that
3470
+ // daemon's own CLI session (TASKECHO coordinator self-echo). Forwarding to the owning daemon
3471
+ // delivers it to the real worker instead. (findAdapter is also fail-closed as the backstop.)
3472
+ 'agent_command',
3465
3473
  ]);
3466
3474
  const READ_DEBUG_ENABLED = process.argv.includes('--dev') || process.env.ADHDEV_READ_DEBUG === '1';
3467
3475
 
@@ -3901,7 +3909,10 @@ export class DaemonCommandRouter {
3901
3909
  if (!nodeDaemonId) continue;
3902
3910
  // Only forward to a genuinely remote daemon. When the owning node is this
3903
3911
  // coordinator itself (locally hosted worker), fall through to local handling.
3904
- if (selfDaemonId && nodeDaemonId === selfDaemonId) return undefined;
3912
+ // id-form robust: the node daemonId and selfDaemonId may be stored in different
3913
+ // forms of the same machine — a strict `===` would miss the self-match and forward
3914
+ // a local session to a remote form of THIS daemon (loopback).
3915
+ if (selfDaemonId && daemonIdsEquivalent(nodeDaemonId, selfDaemonId)) return undefined;
3905
3916
  return nodeDaemonId;
3906
3917
  }
3907
3918
  return undefined;
@@ -4090,12 +4101,52 @@ export class DaemonCommandRouter {
4090
4101
  return false;
4091
4102
  }
4092
4103
 
4104
+ /**
4105
+ * Best-effort recursive removal of a managed worktree directory.
4106
+ *
4107
+ * The git-registry de-registration is the safety-critical step of worktree
4108
+ * teardown; a leftover directory must never gate dropping the node from the
4109
+ * mesh. On Windows, `fs.rmSync` can throw EINVAL/EPERM/EBUSY on submodule
4110
+ * gitlink (`.git`) files, long paths, junctions, or while a just-stopped
4111
+ * delegate session is still releasing a handle/cwd on the directory. This
4112
+ * helper absorbs those errors (never throws), with bounded retries + backoff
4113
+ * to give handles time to release, and reports whether residue remains.
4114
+ */
4115
+ private async bestEffortRemoveWorktreeDir(dir: string): Promise<{ removed: boolean; residue: boolean; error?: string }> {
4116
+ if (!dir || !fs.existsSync(dir)) return { removed: true, residue: false };
4117
+ const sleep = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms));
4118
+ // EINVAL is the Windows symptom for submodule gitlink residue; the rest are
4119
+ // transient lock/permission classes. None should escape as a throw here.
4120
+ const ABSORB = new Set(['EINVAL', 'EPERM', 'EBUSY', 'ENOTEMPTY', 'EACCES', 'EMFILE', 'ENFILE']);
4121
+ let lastErr: any;
4122
+ for (let attempt = 0; attempt < 4; attempt++) {
4123
+ try {
4124
+ // maxRetries/retryDelay give fs.rmSync its own internal backoff for
4125
+ // EBUSY/EPERM/ENOTEMPTY; the outer loop extends tolerance to EINVAL.
4126
+ fs.rmSync(dir, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
4127
+ if (!fs.existsSync(dir)) return { removed: true, residue: false };
4128
+ lastErr = new Error('directory still present after rmSync');
4129
+ } catch (e: any) {
4130
+ lastErr = e;
4131
+ const code = typeof e?.code === 'string' ? e.code : '';
4132
+ if (code && !ABSORB.has(code)) {
4133
+ // Unexpected error class — stay best-effort (no throw) but stop retrying.
4134
+ break;
4135
+ }
4136
+ }
4137
+ await sleep(150 * (attempt + 1));
4138
+ }
4139
+ return fs.existsSync(dir)
4140
+ ? { removed: false, residue: true, error: String(lastErr?.message || lastErr || 'unknown rm error') }
4141
+ : { removed: true, residue: false };
4142
+ }
4143
+
4093
4144
  private async cleanupLocalWorktreeNode(args: {
4094
4145
  mesh: any;
4095
4146
  node: any;
4096
4147
  nodeId: string;
4097
4148
  force?: boolean;
4098
- }): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string; fallback?: string; forced?: boolean; convergence?: Record<string, unknown> } | { success: false; code: string; error: string; recoveryHint: string; convergence?: Record<string, unknown> }> {
4149
+ }): Promise<{ success: true; skipped?: boolean; removedPath?: string; repoRoot?: string; reason?: string; fallback?: string; forced?: boolean; convergence?: Record<string, unknown>; recovered?: boolean; residue?: boolean; residueWarning?: string; residueError?: string } | { success: false; code: string; error: string; recoveryHint: string; convergence?: Record<string, unknown> }> {
4099
4150
  const workspace = typeof args.node?.workspace === 'string' ? args.node.workspace.trim() : '';
4100
4151
  if (!workspace) {
4101
4152
  return {
@@ -4155,11 +4206,35 @@ export class DaemonCommandRouter {
4155
4206
  const entries = await listWorktrees(repoRoot);
4156
4207
  const managedEntry = entries.find(entry => normalizePath(entry.path) === actualPath);
4157
4208
  if (!managedEntry) {
4209
+ // Idempotent residue recovery (NOT a refusal). By this point the path is
4210
+ // already proven ADHDev-managed: worktreeBranch metadata is present and
4211
+ // actualPath === expectedPath. Git nonetheless no longer lists it as a
4212
+ // worktree. This is the post-force-fallback re-entry state — an earlier
4213
+ // removal de-registered the worktree from git but left the directory
4214
+ // behind (commonly Windows EINVAL on submodule gitlink files). Refusing
4215
+ // here would strand the node in mesh membership forever, so prune any
4216
+ // stale registration, best-effort remove the leftover directory, and
4217
+ // report success so the caller drops the node from the mesh registry.
4218
+ try {
4219
+ const { execFile } = await import('node:child_process');
4220
+ const { promisify } = await import('node:util');
4221
+ const execFileAsync = promisify(execFile);
4222
+ await execFileAsync('git', ['worktree', 'prune'], {
4223
+ cwd: repoRoot, encoding: 'utf8', timeout: 30_000, maxBuffer: 4 * 1024 * 1024, windowsHide: true,
4224
+ });
4225
+ } catch { /* prune is best-effort */ }
4226
+ const rm = await this.bestEffortRemoveWorktreeDir(workspace);
4158
4227
  return {
4159
- success: false,
4160
- code: 'mesh_worktree_cleanup_not_registered',
4161
- error: `Refusing to remove '${workspace}' because it is not registered in git worktree list for '${repoRoot}'`,
4162
- recoveryHint: 'Inspect git worktree list --porcelain from the source repo. If the path was already removed, prune git worktrees before retrying.',
4228
+ success: true,
4229
+ removedPath: workspace,
4230
+ repoRoot,
4231
+ reason: 'worktree_unregistered_residue_recovered',
4232
+ recovered: true,
4233
+ ...(rm.residue ? {
4234
+ residue: true,
4235
+ residueWarning: `Worktree was already de-registered from git but the directory could not be fully removed (leftover residue at '${workspace}'): ${rm.error || 'unknown error'}. The node will be dropped from the mesh; remove the directory manually if needed.`,
4236
+ residueError: rm.error,
4237
+ } : {}),
4163
4238
  };
4164
4239
  }
4165
4240
  if (managedEntry.branch && managedEntry.branch !== args.node.worktreeBranch) {
@@ -4221,29 +4296,31 @@ export class DaemonCommandRouter {
4221
4296
  convergence: forceFallbackConvergence,
4222
4297
  };
4223
4298
  } catch (deinitError: any) {
4224
- // Fallback 2: deinit+remove still failed — rmSync + prune
4299
+ // Fallback 2: deinit+remove still failed — best-effort directory
4300
+ // removal + prune. The path is already proven managed/converged
4301
+ // here, and a leftover directory must NOT gate dropping the node
4302
+ // from the mesh, so absorb Windows EINVAL/EPERM and report success
4303
+ // with a residue warning instead of failing the whole removal.
4304
+ const rm = await this.bestEffortRemoveWorktreeDir(workspace);
4225
4305
  try {
4226
- fs.rmSync(workspace, { recursive: true, force: true });
4227
4306
  await execFileAsync('git', ['worktree', 'prune'], {
4228
4307
  cwd: repoRoot, encoding: 'utf8', timeout: GIT_TIMEOUT_CLEANUP, maxBuffer: GIT_MAX_BUFFER_CLEANUP, windowsHide: true,
4229
4308
  });
4230
- return {
4231
- success: true,
4232
- removedPath: workspace,
4233
- repoRoot,
4234
- fallback: 'fs_rm_worktree_prune' as const,
4235
- forced: true,
4236
- reason: 'working_trees_containing_submodules' as const,
4237
- convergence: forceFallbackConvergence,
4238
- };
4239
- } catch (rmError: any) {
4240
- return {
4241
- success: false,
4242
- code: 'mesh_worktree_cleanup_failed',
4243
- error: `All removal fallbacks exhausted. deinit+remove: ${deinitError?.message || deinitError}; rmSync+prune: ${rmError?.message || rmError}`,
4244
- recoveryHint: 'Manually remove the worktree directory and run git worktree prune from the source repo.',
4245
- };
4246
- }
4309
+ } catch { /* prune is best-effort */ }
4310
+ return {
4311
+ success: true,
4312
+ removedPath: workspace,
4313
+ repoRoot,
4314
+ fallback: 'fs_rm_worktree_prune' as const,
4315
+ forced: true,
4316
+ reason: 'working_trees_containing_submodules' as const,
4317
+ convergence: forceFallbackConvergence,
4318
+ ...(rm.residue ? {
4319
+ residue: true,
4320
+ residueWarning: `Worktree was de-registered from git but the directory could not be fully removed (leftover residue at '${workspace}'): ${rm.error || 'unknown error'}; deinit+remove first failed with: ${deinitError?.message || deinitError}. The node will be dropped from the mesh; remove the directory manually if needed.`,
4321
+ residueError: rm.error,
4322
+ } : {}),
4323
+ };
4247
4324
  }
4248
4325
  }
4249
4326
 
@@ -6260,14 +6337,17 @@ export class DaemonCommandRouter {
6260
6337
  // Session-scoped commands issued from the dashboard (the controlbar Model/Mode
6261
6338
  // selectors → invoke_provider_script, and modal approval → resolve_action, plus the
6262
6339
  // direct set_mode/change_model/set_thought_level mutations) target a session by
6263
- // targetSessionId. When that session is a mesh worker hosted on a REMOTE daemon, this
6340
+ // targetSessionId. agent_command (send_chat / clear_history / stop) is included for the
6341
+ // same reason: a command naming a session must reach THAT session, never a different
6342
+ // local one. When that session is a mesh worker hosted on a REMOTE daemon, this
6264
6343
  // coordinator never holds its live instance, so the CommandHandler delegation would
6265
- // fail with "Live session not found". Forward to the owning worker daemon — the same
6266
- // daemon that already executes send_chat for that session so the controlbar acts on
6267
- // the real worker. _meshDirectDispatch prevents re-forwarding once the call lands on
6268
- // the owning daemon (it then handles the session locally). A locally-hosted worker (or
6269
- // any session this coordinator owns) resolves to undefined below and falls through to
6270
- // normal local handling no regression.
6344
+ // fail with "Live session not found" or, for agent_command, findAdapter would have
6345
+ // fuzzy-injected the message into the coordinator's own CLI session (TASKECHO). Forward
6346
+ // to the owning worker daemon the same daemon that already executes send_chat for that
6347
+ // session so the command acts on the real worker. _meshDirectDispatch prevents
6348
+ // re-forwarding once the call lands on the owning daemon (it then handles the session
6349
+ // locally). A locally-hosted worker (or any session this coordinator owns) resolves to
6350
+ // undefined below and falls through to normal local handling — no regression.
6271
6351
  if (MESH_FORWARDABLE_SESSION_COMMANDS.has(cmd) && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
6272
6352
  const targetSessionId = readStringValue(args?.targetSessionId, args?.sessionId, args?.instanceId);
6273
6353
  if (targetSessionId) {
@@ -8279,6 +8359,14 @@ export class DaemonCommandRouter {
8279
8359
  return (forwarded ?? { success: false, error: 'no response from remote node' }) as CommandRouterResult;
8280
8360
  }
8281
8361
  const cleanupResult = await this.cleanupLocalWorktreeNode({ mesh, node, nodeId, force: args?.force === true });
8362
+ // De-gating: membership removal is NOT gated on the worktree
8363
+ // directory actually being deleted. cleanupLocalWorktreeNode now
8364
+ // returns success:true (with a residue flag) whenever the path is
8365
+ // proven managed and the only remaining problem is leftover
8366
+ // directory bytes (e.g. Windows EINVAL). A success:false here means
8367
+ // a genuinely-unsafe condition — missing metadata, a non-managed /
8368
+ // unexpected path, a branch mismatch, a dirty worktree, or an
8369
+ // unverified force fallback — and those still block removal.
8282
8370
  if (cleanupResult.success === false) {
8283
8371
  return {
8284
8372
  success: false,
@@ -8335,7 +8423,19 @@ export class DaemonCommandRouter {
8335
8423
  } catch { /* ledger append is best-effort */ }
8336
8424
  }
8337
8425
 
8338
- return { success: true, removed, ...(sessionCleanup ? { sessionCleanup } : {}), ...(worktreeCleanup ? { worktreeCleanup } : {}) };
8426
+ // Surface leftover-directory residue at the top level so callers
8427
+ // see the node was dropped from the mesh even though the worktree
8428
+ // directory could not be fully removed (best-effort, non-gating).
8429
+ const residueWarning = worktreeCleanup?.residue === true && typeof worktreeCleanup?.residueWarning === 'string'
8430
+ ? worktreeCleanup.residueWarning
8431
+ : undefined;
8432
+ return {
8433
+ success: true,
8434
+ removed,
8435
+ ...(residueWarning ? { residueWarning } : {}),
8436
+ ...(sessionCleanup ? { sessionCleanup } : {}),
8437
+ ...(worktreeCleanup ? { worktreeCleanup } : {}),
8438
+ };
8339
8439
  } catch (e: any) {
8340
8440
  return { success: false, error: e.message };
8341
8441
  }
package/src/index.ts CHANGED
@@ -186,6 +186,11 @@ export {
186
186
  } from './config/mesh-config.js';
187
187
  export type { CreateMeshOptions, UpdateMeshOptions, AddNodeOptions } from './config/mesh-config.js';
188
188
 
189
+ // ── Mesh shared daemon-id helpers (re-export so external tooling — e.g. the
190
+ // mcp-server, which depends only on @adhdev/daemon-core — can canonicalize
191
+ // daemon-id forms without taking a direct @adhdev/mesh-shared dependency). ──
192
+ export { expandDaemonIdForms, daemonIdsEquivalent, machineCoreFromDaemonId } from '@adhdev/mesh-shared';
193
+
189
194
  // ── Mesh Coordinator ──
190
195
  export { buildCoordinatorSystemPrompt } from './mesh/coordinator-prompt.js';
191
196
  export { upsertMeshMission, getMeshMissions, getMeshMission, summarizeMissionTasks, summarizeMeshMission, getActiveMeshMissionSummaries, getMeshStatusMissionSummaries, getMeshStatusMissionsCompact, listMeshMissionSummaries, buildMissionPromptSection, GOAL_PREVIEW_MAX, COMPACT_STATUS_GOAL_PREVIEW_MAX, MESH_MISSION_STATUSES } from './mesh/mesh-missions.js';