@adhdev/daemon-core 0.9.82-rc.351 → 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.
@@ -7,7 +7,7 @@
7
7
  * 1. daemonLog(category, msg, level) — explicit per-category logging
8
8
  * 2. installGlobalInterceptor() — Auto-intercept console.log (once on daemon start)
9
9
  * 3. Recent log ring buffer — for remote transmission via P2P/WS
10
- * 4. File logging — ~/Library/Logs/adhdev/daemon.log (10MB rolling)
10
+ * 4. File logging — ~/.adhdev/logs/daemon-YYYY-MM-DD.log (date-based rolling)
11
11
  *
12
12
  * use:
13
13
  * import { daemonLog, LOG } from './daemon-logger';
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held';
17
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'task_reclaimed';
18
18
  export interface MeshLedgerEntry {
19
19
  id: string;
20
20
  meshId: string;
@@ -191,6 +191,15 @@ export declare class MeshRuntimeStore {
191
191
  createdAt: string;
192
192
  updatedAt: string;
193
193
  }>;
194
+ /**
195
+ * Bug B watchdog support: true when at least one delivery record for the task has
196
+ * reached a confirmed-handed-off status (delivered / acked / completed). The
197
+ * assigned-stranded watchdog uses this to distinguish a dispatch that was never
198
+ * confirmed (reclaimable) from one that WAS handed to the worker (a genuinely
199
+ * in-flight or completion-lost task, which is PHASE 4's responsibility, not this
200
+ * watchdog's). Indexed by (mesh_id, task_id).
201
+ */
202
+ taskHasConfirmedDelivery(meshId: string, taskId: string): boolean;
194
203
  expireStaleSessionDeliveries(meshId: string): void;
195
204
  deleteSessionDeliveries(meshId: string): void;
196
205
  recordCompletionConflict(entry: {
@@ -60,6 +60,14 @@ export interface MeshWorkQueueEntry {
60
60
  requeueCount?: number;
61
61
  /** Max automatic requeue attempts. When requeueCount reaches this, task is auto-failed. */
62
62
  maxRetries?: number;
63
+ /**
64
+ * Bug B: number of times the reconcile assigned-stranded watchdog has reclaimed this
65
+ * row from 'assigned' back to 'pending' because its dispatch was never confirmed
66
+ * delivered. Separate from requeueCount (operator/execution retries) and bounded by
67
+ * MAX_STRANDED_RECLAIMS so a permanently-undeliverable target auto-fails rather than
68
+ * cycling reclaim→re-dispatch→strand forever.
69
+ */
70
+ strandedReclaimCount?: number;
63
71
  /** Last automatic queue session spin-up attempt, for mesh_view_queue/debug visibility. */
64
72
  autoLaunch?: {
65
73
  status: 'skipped' | 'started' | 'failed' | 'completed';
@@ -230,6 +238,26 @@ export declare function requeueTask(meshId: string, taskId: string, opts?: {
230
238
  /** Per-task retry cap override. Falls back to mesh policy maxTaskRetries (default 1). */
231
239
  maxRetries?: number;
232
240
  } & MeshQueueMutationOptions): MeshWorkQueueEntry | null;
241
+ /**
242
+ * Bug B: reclaim a task stuck in 'assigned' because its dispatch was never confirmed.
243
+ *
244
+ * claimNextTask atomically marks a row 'assigned' BEFORE the fire-and-forget dispatch
245
+ * runs. If that dispatch neither rejects (→ no .catch requeue) nor is confirmed
246
+ * delivered — a relay that hangs without acking, or a confirm timer lost across a
247
+ * daemon restart — the row stays 'assigned' forever, contributing 0 pending so PHASE 3
248
+ * reconcile never re-examines it. This returns such a row to 'pending' and clears its
249
+ * dead assignment ownership (node / session / provider / dispatchTimestamp) — the same
250
+ * ownership-clear requeueTask applies — so PHASE 3 can re-dispatch it onto a fresh idle
251
+ * session.
252
+ *
253
+ * Guarded to 'assigned' rows only (a completion/cancel that already moved the row off
254
+ * 'assigned' must never be resurrected) and bounded by MAX_STRANDED_RECLAIMS (beyond
255
+ * which the task is failed so dependents unblock).
256
+ */
257
+ export declare function reclaimStrandedAssignedTask(meshId: string, taskId: string, opts?: {
258
+ reason?: string;
259
+ ageMs?: number;
260
+ } & MeshQueueMutationOptions): MeshWorkQueueEntry | null;
233
261
  /**
234
262
  * Update the status of the task currently assigned to a specific session.
235
263
  */
@@ -1,4 +1,11 @@
1
1
  import type { ProviderModule } from './contracts.js';
2
+ /**
3
+ * True when any of the given button labels reads as a decline/negative option
4
+ * (No / Deny / Cancel / Skip / …). Used as the second half of an approval-modal
5
+ * structural anchor: a real approval modal offers BOTH an affirmative and a
6
+ * decline, which distinguishes it from a generic numbered menu or prose list.
7
+ */
8
+ export declare function hasNegativeApprovalOption(buttons: string[] | null | undefined): boolean;
2
9
  export declare function getApprovalPositiveHints(provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): string[];
3
10
  export declare function pickApprovalButton(buttons: string[] | null | undefined, provider?: Pick<ProviderModule, 'approvalPositiveHints'> | null): {
4
11
  index: number;
@@ -48,6 +48,20 @@ export declare class CliProviderInstance implements ProviderInstance {
48
48
  * keystroke until the modal *content* has settled.
49
49
  */
50
50
  private static readonly AUTO_APPROVE_SETTLE_MS;
51
+ /**
52
+ * Busy-side hysteresis for the settle gate. A momentary `generating` flip
53
+ * while the SAME approval modal's button block is still on screen (its
54
+ * question line scrolled out of the captured frame, only the buttons + a
55
+ * residual `esc to interrupt` spinner remain) briefly reports
56
+ * status!=waiting_approval. Without hysteresis that flip wipes the settle
57
+ * clock, and the modal→generating→modal flap restarts the 600ms window
58
+ * every time so auto-approve never fires. We keep the in-progress settle
59
+ * gate warm across an inactive blip up to this bound; only once the modal
60
+ * has genuinely stayed gone this long (a real resolution → idle) is the
61
+ * gate cleared. Bounded so a genuinely new, later approval still re-settles
62
+ * from scratch rather than firing on a stale timestamp.
63
+ */
64
+ private static readonly AUTO_APPROVE_GATE_HYSTERESIS_MS;
51
65
  private adapter;
52
66
  private context;
53
67
  private events;
@@ -64,6 +78,7 @@ export declare class CliProviderInstance implements ProviderInstance {
64
78
  private pendingAutoApprovalSignature;
65
79
  private pendingAutoApprovalSince;
66
80
  private autoApproveSettleTimer;
81
+ private autoApproveInactiveSince;
67
82
  private controlValues;
68
83
  private summaryMetadata;
69
84
  private appliedEffectKeys;
@@ -57,6 +57,7 @@ interface ModalSpec {
57
57
  }>;
58
58
  buttonPattern: string;
59
59
  buttonFlags?: string;
60
+ buttonLabelGroup?: number;
60
61
  }
61
62
  export type DispatchGroup = 'spinner' | 'modal' | 'settled-prompt' | 'cue-ordering' | 'error-detection' | 'approval-stitching';
62
63
  export interface DispatchOrderSpec {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.351",
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.351",
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
  }
@@ -292,13 +292,14 @@ async function waitForPidExit(pid: number, timeoutMs: number): Promise<void> {
292
292
  }
293
293
  }
294
294
 
295
- export function stopSessionHostProcesses(appName: string): void {
295
+ export async function stopSessionHostProcesses(appName: string): Promise<void> {
296
296
  const pidFile = path.join(os.homedir(), '.adhdev', `${appName}-session-host.pid`);
297
+ let killedPid: number | null = null;
297
298
  try {
298
299
  if (fs.existsSync(pidFile)) {
299
300
  const pid = Number.parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
300
301
  if (Number.isFinite(pid) && pid !== process.pid && isManagedSessionHostPid(pid)) {
301
- killPid(pid);
302
+ if (killPid(pid)) killedPid = pid;
302
303
  }
303
304
  }
304
305
  } catch {
@@ -310,6 +311,31 @@ export function stopSessionHostProcesses(appName: string): void {
310
311
  // noop
311
312
  }
312
313
  }
314
+
315
+ // The session-host process keeps node-pty's `conpty.node` memory-mapped. On
316
+ // Windows a mapped native addon stays EXCLUSIVELY locked until the process
317
+ // fully exits and tears down the mapping — and that teardown lags `taskkill`
318
+ // by an indeterminate interval. `taskkill` only *requests* termination, so
319
+ // returning immediately lets the caller run `npm install` while conpty.node
320
+ // is still locked, which makes npm's copy-to-staging fail with EBUSY (the
321
+ // intermittent Windows upgrade failure). Wait for the killed process to
322
+ // actually disappear — like we already do for the parent daemon pid — so the
323
+ // file handle is released before the install runs. (POSIX can replace an open
324
+ // file freely, so the wait is harmless there.)
325
+ if (killedPid !== null) {
326
+ await waitForPidExit(killedPid, 15000);
327
+ }
328
+ }
329
+
330
+ // npm copies the current install's files into a staging dir before swapping in
331
+ // the new version. On Windows that copy of `conpty.node` can still race a
332
+ // just-killed session-host whose mapping hasn't been released yet, surfacing as
333
+ // EBUSY/EPERM. Treat those as transient and retry with backoff.
334
+ function isRetriableInstallLockError(error: any): boolean {
335
+ const code = error?.code;
336
+ if (code === 'EBUSY' || code === 'EPERM') return true;
337
+ const text = `${error?.message || ''} ${error?.stderr || ''}`;
338
+ return /\bEBUSY\b|\bEPERM\b|resource busy or locked/i.test(text);
313
339
  }
314
340
 
315
341
  function removeDaemonPidFile(): void {
@@ -412,23 +438,40 @@ async function runDaemonUpgradeHelper(payload: DaemonUpgradeHelperPayload): Prom
412
438
  await waitForPidExit(payload.parentPid, 15000);
413
439
  }
414
440
 
415
- stopSessionHostProcesses(sessionHostAppName);
441
+ await stopSessionHostProcesses(sessionHostAppName);
416
442
  removeDaemonPidFile();
417
443
  cleanupStaleGlobalInstallDirs(payload.packageName, installCommand.surface);
418
444
 
419
445
  const spec = `${payload.packageName}@${payload.targetVersion || 'latest'}`;
420
446
  appendUpgradeLog(`Installing ${spec}`);
421
- const installOutput = execFileSync(
422
- installCommand.command,
423
- installCommand.args,
424
- {
425
- encoding: 'utf8',
426
- stdio: 'pipe',
427
- maxBuffer: 20 * 1024 * 1024,
428
- env: buildInstallEnvWithNodeOnPath(),
429
- ...installCommand.execOptions,
430
- },
431
- );
447
+ // Windows can still race a lingering conpty.node mapping even after the
448
+ // session-host exits, so retry the install on transient lock errors there.
449
+ const maxInstallAttempts = process.platform === 'win32' ? 3 : 1;
450
+ let installOutput = '';
451
+ for (let attempt = 1; attempt <= maxInstallAttempts; attempt++) {
452
+ try {
453
+ installOutput = String(execFileSync(
454
+ installCommand.command,
455
+ installCommand.args,
456
+ {
457
+ encoding: 'utf8',
458
+ stdio: 'pipe',
459
+ maxBuffer: 20 * 1024 * 1024,
460
+ env: buildInstallEnvWithNodeOnPath(),
461
+ ...installCommand.execOptions,
462
+ },
463
+ ));
464
+ break;
465
+ } catch (error: any) {
466
+ if (attempt < maxInstallAttempts && isRetriableInstallLockError(error)) {
467
+ appendUpgradeLog(`Install attempt ${attempt} hit a file lock (${error?.code || 'lock'}); cleaning staging and retrying after backoff`);
468
+ cleanupStaleGlobalInstallDirs(payload.packageName, installCommand.surface);
469
+ await new Promise((resolve) => setTimeout(resolve, attempt * 1500));
470
+ continue;
471
+ }
472
+ throw error;
473
+ }
474
+ }
432
475
  if (installOutput.trim()) {
433
476
  appendUpgradeLog(installOutput.trim());
434
477
  }
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';
@@ -15,11 +15,13 @@ import * as path from 'path';
15
15
  import * as os from 'os';
16
16
 
17
17
  // ─── Config ──────────────────────────────────
18
- const LOG_DIR = process.platform === 'win32'
19
- ? path.join(process.env.LOCALAPPDATA || process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'adhdev', 'logs')
20
- : process.platform === 'darwin'
21
- ? path.join(os.homedir(), 'Library', 'Logs', 'adhdev')
22
- : path.join(os.homedir(), '.local', 'share', 'adhdev', 'logs');
18
+ // Command history lives under the unified ADHDev home (~/.adhdev/logs/) next to
19
+ // the daemon log, on every platform. Honor ADHDEV_CONFIG_DIR for isolated homes.
20
+ // Keep this in sync with logger.ts LOG_DIR.
21
+ const ADHDEV_HOME = process.env.ADHDEV_CONFIG_DIR && process.env.ADHDEV_CONFIG_DIR.trim()
22
+ ? process.env.ADHDEV_CONFIG_DIR.trim()
23
+ : path.join(os.homedir(), '.adhdev');
24
+ const LOG_DIR = path.join(ADHDEV_HOME, 'logs');
23
25
 
24
26
  const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
25
27
  const MAX_DAYS = 7;
@@ -7,7 +7,7 @@
7
7
  * 1. daemonLog(category, msg, level) — explicit per-category logging
8
8
  * 2. installGlobalInterceptor() — Auto-intercept console.log (once on daemon start)
9
9
  * 3. Recent log ring buffer — for remote transmission via P2P/WS
10
- * 4. File logging — ~/Library/Logs/adhdev/daemon.log (10MB rolling)
10
+ * 4. File logging — ~/.adhdev/logs/daemon-YYYY-MM-DD.log (date-based rolling)
11
11
  *
12
12
  * use:
13
13
  * import { daemonLog, LOG } from './daemon-logger';
@@ -37,11 +37,17 @@ export function setLogLevel(level: LogLevel): void {
37
37
 
38
38
  export function getLogLevel(): LogLevel { return currentLevel; }
39
39
  // ─── File logging (date-based rolling) ──────────────────────────────
40
- const LOG_DIR = process.platform === 'win32'
41
- ? path.join(process.env.LOCALAPPDATA || process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Local'), 'adhdev', 'logs')
42
- : process.platform === 'darwin'
43
- ? path.join(os.homedir(), 'Library', 'Logs', 'adhdev')
44
- : path.join(os.homedir(), '.local', 'share', 'adhdev', 'logs');
40
+ // Logs live under the unified ADHDev home (~/.adhdev/logs/) on every platform,
41
+ // alongside config.json, providers/, history/, daemon.pid and session-host.log.
42
+ // Earlier builds wrote to OS-specific dirs (~/Library/Logs/adhdev on macOS,
43
+ // ~/.local/share/adhdev/logs on Linux, %LOCALAPPDATA%/adhdev/logs on Windows),
44
+ // which made the daemon log undiscoverable next to everything else under
45
+ // ~/.adhdev and inconsistent with session-host.log. Honor ADHDEV_CONFIG_DIR so
46
+ // isolated/standalone namespaces keep their logs in their own home.
47
+ const ADHDEV_HOME = process.env.ADHDEV_CONFIG_DIR && process.env.ADHDEV_CONFIG_DIR.trim()
48
+ ? process.env.ADHDEV_CONFIG_DIR.trim()
49
+ : path.join(os.homedir(), '.adhdev');
50
+ const LOG_DIR = path.join(ADHDEV_HOME, 'logs');
45
51
 
46
52
  const MAX_LOG_SIZE = 5 * 1024 * 1024; // 5MB per day
47
53
  const MAX_LOG_DAYS = 7; // 7-day retention