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

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.
@@ -88,6 +88,9 @@ export declare class AcpProviderInstance implements ProviderInstance {
88
88
  sendPrompt(text: string, contentBlocks?: ContentBlock[]): Promise<void>;
89
89
  private cancelSession;
90
90
  private permissionResolvers;
91
+ private readonly manualAttendance;
92
+ /** @see ProviderInstance.noteManualInteraction */
93
+ noteManualInteraction(now?: number): void;
91
94
  resolvePermission(approved: boolean): Promise<void>;
92
95
  private handleSessionUpdate;
93
96
  /** Handle legacy session/update formats (pre-standardization compat) */
@@ -79,6 +79,7 @@ export declare class CliProviderInstance implements ProviderInstance {
79
79
  private pendingAutoApprovalSince;
80
80
  private autoApproveSettleTimer;
81
81
  private autoApproveInactiveSince;
82
+ private readonly manualAttendance;
82
83
  private controlValues;
83
84
  private summaryMetadata;
84
85
  private appliedEffectKeys;
@@ -204,6 +205,17 @@ export declare class CliProviderInstance implements ProviderInstance {
204
205
  get cliType(): string;
205
206
  get cliName(): string;
206
207
  private shouldAutoApprove;
208
+ /** @see ProviderInstance.noteManualInteraction */
209
+ noteManualInteraction(now?: number): void;
210
+ /**
211
+ * Whether auto-approve should be treated as active *right now* for display
212
+ * and firing decisions: the configured intent AND the user is not currently
213
+ * attending this session by hand. When a human is attending, auto-approve is
214
+ * held so the modal stays visible and they can drive it via the controlbar.
215
+ * Provider-agnostic — the attendance signal is the command set, never any
216
+ * CLI-specific modal text.
217
+ */
218
+ private autoApproveEffectivelyActive;
207
219
  private recordAutoApproval;
208
220
  recordApprovalSelection(buttonText: string): void;
209
221
  private formatMarkerTimestamp;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * ManualAttendanceTracker — provider-agnostic "is a human driving this session
3
+ * right now" signal, used to suppress auto-approve while the user is taking
4
+ * manual control of a session from the dashboard.
5
+ *
6
+ * Why this exists
7
+ * ---------------
8
+ * When `autoApprove` is on, an approval modal is auto-dismissed within a few
9
+ * hundred ms of appearing. For a background mesh worker that is exactly the
10
+ * desired delegated behavior. But for a session the user is actively watching
11
+ * and operating (a base-node / foreground session), the auto-fire closes the
12
+ * modal before the human can pick a button — and likewise fights their use of
13
+ * the controlbar. The fix is to give the human a short quiet window: while they
14
+ * are attending the session by hand, auto-approve holds; once they go idle it
15
+ * resumes.
16
+ *
17
+ * The tracker holds only a timestamp. The *signal* — which commands count as
18
+ * "a human attending" — is decided by the caller (the command handler), and is
19
+ * the same set for every provider: foreground tab selection (select_session /
20
+ * open_panel), controlbar use (invoke_provider_script / set_mode / change_model
21
+ * / set_thought_level), manual approval (resolve_action) and manual terminal
22
+ * input (pty_input). Notably NOT send_chat, which is also how a coordinator
23
+ * delegates a task to a worker — counting it would wrongly suppress the
24
+ * worker's delegated auto-approve.
25
+ *
26
+ * Because a background worker never receives any of those attending commands,
27
+ * it is never "attended", so its delegated auto-approve is unaffected. The
28
+ * mechanism is therefore provider-common AND preserves worker auto-approve
29
+ * without any per-provider branching.
30
+ */
31
+ /**
32
+ * How long after the last manual interaction auto-approve stays suppressed.
33
+ *
34
+ * Trade-off: long enough that after foregrounding a session's tab the user has
35
+ * a realistic chance to act on an incoming approval (the auto-approve settle
36
+ * window is only ~600ms, so a human cannot out-race it), yet short enough that
37
+ * a session left unattended — e.g. a worker tab the user briefly peeked at —
38
+ * resumes auto-approving within about a minute. Re-armed on every attending
39
+ * command, so a user who keeps interacting keeps the window fresh.
40
+ */
41
+ export declare const AUTO_APPROVE_MANUAL_ATTENDANCE_SUPPRESS_MS = 60000;
42
+ export declare class ManualAttendanceTracker {
43
+ private readonly suppressMs;
44
+ private lastInteractionAt;
45
+ constructor(suppressMs?: number);
46
+ /** Record that a human just drove this session by hand. */
47
+ note(now?: number): void;
48
+ /** True while a manual interaction is recent enough to suppress auto-approve. */
49
+ isAttended(now?: number): boolean;
50
+ /**
51
+ * Milliseconds remaining in the current suppression window, or 0 when not
52
+ * attended. Used to re-arm a re-check timer so auto-approve fires the moment
53
+ * the window lapses even if the PTY/agent has since gone silent.
54
+ */
55
+ remainingMs(now?: number): number;
56
+ }
57
+ /**
58
+ * The session-scoped commands that count as "a human is attending this session
59
+ * by hand". Shared so the command handler and any forward path agree on one
60
+ * definition. Deliberately excludes send_chat (coordinator task delegation) and
61
+ * pure read commands (read_chat / list_chats — passive polling, not driving).
62
+ */
63
+ export declare const MANUAL_ATTENDANCE_COMMANDS: ReadonlySet<string>;
@@ -194,6 +194,14 @@ export interface ProviderInstance {
194
194
  detachMeshAssignment?(): void;
195
195
  /** Refresh static provider definition/scripts without restarting the live runtime. */
196
196
  refreshProviderDefinition?(provider: ProviderModule): void;
197
+ /**
198
+ * Record that a human is actively attending this session by hand right now
199
+ * (foreground tab selection, controlbar use, manual approval, terminal
200
+ * input). Provider-common signal that suppresses auto-approve for a short
201
+ * window so the user can drive the session manually; background mesh worker
202
+ * sessions never receive it, so their delegated auto-approve is unaffected.
203
+ */
204
+ noteManualInteraction?(now?: number): void;
197
205
  /** cleanup */
198
206
  dispose(): void;
199
207
  }
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.354",
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.354",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -18,7 +18,7 @@ import { loadConfig } from '../config/config.js';
18
18
  import { loadState, saveState } from '../config/state-store.js';
19
19
  import { getWorkspaceState, resolveLaunchDirectory } from '../config/workspaces.js';
20
20
  import { appendRecentActivity } from '../config/recent-activity.js';
21
- import { unregisterMeshCoordinator } from '../mesh/coordinator-registry.js';
21
+ import { unregisterMeshCoordinator, getCoordinatorForSession } from '../mesh/coordinator-registry.js';
22
22
  import { upsertSavedProviderSession } from '../config/saved-sessions.js';
23
23
  import { buildLegacyModelModeSummaryMetadata, normalizeProviderSummaryMetadata } from '../providers/summary-metadata.js';
24
24
  import { CliProviderInstance } from '../providers/cli-provider-instance.js';
@@ -1042,6 +1042,24 @@ export class DaemonCliManager {
1042
1042
  );
1043
1043
  continue;
1044
1044
  }
1045
+ // Re-establish the launch-time settings a fresh launch applies. startSession
1046
+ // seeds every new instance with { ...providerLoader.getSettings(type), ...override };
1047
+ // passing a bare {} here on restart silently dropped TWO launch settings, so a
1048
+ // restored session diverged from a freshly-launched one:
1049
+ // - autoApprove (a provider/machine setting from getSettings) → a restored
1050
+ // coordinator self-session lost auto-approve and re-prompted on every tool call.
1051
+ // - meshCoordinatorFor (the coordinator launch's settingsOverride) → the restored
1052
+ // session was no longer recognized as this daemon's live CLI coordinator by
1053
+ // findLiveCoordinators (so pending mesh events stopped draining into its PTY) nor
1054
+ // surfaced with the coordinator badge via settings. The persisted coordinator
1055
+ // registry (loaded on boot) is the source of truth to rebuild that mark.
1056
+ // Both restores are provider-agnostic — getSettings is keyed by provider type and the
1057
+ // registry mark is type-independent.
1058
+ const restoredSettings: Record<string, any> = { ...this.providerLoader.getSettings(normalizedType) };
1059
+ const coordinatorEntry = getCoordinatorForSession(record.runtimeId);
1060
+ if (coordinatorEntry?.meshId) {
1061
+ restoredSettings.meshCoordinatorFor = coordinatorEntry.meshId;
1062
+ }
1045
1063
  try {
1046
1064
  await this.registerCliInstance(
1047
1065
  record.runtimeId,
@@ -1050,7 +1068,7 @@ export class DaemonCliManager {
1050
1068
  record.workspace,
1051
1069
  record.cliArgs,
1052
1070
  resolvedProvider,
1053
- {},
1071
+ restoredSettings,
1054
1072
  true,
1055
1073
  {
1056
1074
  providerSessionId: sessionBinding.providerSessionId,
@@ -1097,10 +1115,19 @@ export class DaemonCliManager {
1097
1115
  }
1098
1116
  }
1099
1117
  }
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 };
1118
+ // 2. Fuzzy match (returns first of multiple sessions — may be inaccurate).
1119
+ // FAIL-CLOSED: only when NO explicit instanceKey/targetSessionId was requested.
1120
+ // When a specific session WAS named (step 0) but is not hosted on this daemon,
1121
+ // falling back to the first same-cliType adapter silently redirects the command
1122
+ // into an UNRELATED session — e.g. a relayed/misrouted mesh send_chat lands in the
1123
+ // coordinator's own CLI session, echoing the dispatched task body back to the
1124
+ // coordinator (TASKECHO self-inject). Returning null instead makes the caller
1125
+ // surface an explicit "not running" error rather than mis-delivering the message.
1126
+ if (!opts?.instanceKey) {
1127
+ for (const [k, a] of this.adapters) {
1128
+ if (a.cliType === agentType) {
1129
+ return { adapter: a, key: k };
1130
+ }
1104
1131
  }
1105
1132
  }
1106
1133
  return null;
@@ -25,6 +25,7 @@ import type { SessionRegistry, SessionRuntimeTarget } from '../sessions/registry
25
25
  import { reconcileIdeRuntimeSessions } from '../sessions/reconcile.js';
26
26
  import { LOG } from '../logging/logger.js';
27
27
  import { resolveLegacyProviderScript, type LegacyStringScript } from './provider-script-resolver.js';
28
+ import { MANUAL_ATTENDANCE_COMMANDS } from '../providers/manual-attendance.js';
28
29
 
29
30
  // Sub-module imports
30
31
  import * as Chat from './chat-commands.js';
@@ -375,6 +376,36 @@ export class DaemonCommandHandler implements CommandHelpers {
375
376
  this._agentStream = manager;
376
377
  }
377
378
 
379
+ /**
380
+ * When a command in the manual-attendance set arrives for a session this
381
+ * daemon hosts, stamp the live instance so auto-approve holds while the user
382
+ * drives the session by hand. Provider-common: the signal is the command
383
+ * (foreground select_session / open_panel, controlbar invoke_provider_script
384
+ * / set_mode / change_model / set_thought_level, manual resolve_action,
385
+ * pty_input), never any CLI-specific modal text — so it works identically for
386
+ * every CLI/ACP provider. send_chat is deliberately excluded because a
387
+ * coordinator delegating a task to a worker also uses send_chat; counting it
388
+ * would wrongly suppress the worker's delegated auto-approve. For a remote
389
+ * mesh worker session the controlbar commands are forwarded to the owning
390
+ * worker daemon, which runs this same hook there, so attendance is recorded
391
+ * on the daemon that actually hosts the instance.
392
+ */
393
+ private noteManualAttendanceIfApplicable(cmd: string, args: any): void {
394
+ if (!MANUAL_ATTENDANCE_COMMANDS.has(cmd)) return;
395
+ const sessionId = this._currentRoute.session?.sessionId
396
+ || (typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '');
397
+ if (!sessionId) return;
398
+ const session = this._ctx.sessionRegistry?.get(sessionId);
399
+ const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
400
+ const instance = this._ctx.instanceManager?.getInstance(instanceKey) as
401
+ { noteManualInteraction?: (now?: number) => void } | undefined;
402
+ try {
403
+ instance?.noteManualInteraction?.();
404
+ } catch {
405
+ // attendance is best-effort — never block command dispatch
406
+ }
407
+ }
408
+
378
409
  // ─── Command Dispatcher ──────────────────────────
379
410
 
380
411
  async handle(cmd: string, args: any): Promise<CommandResult> {
@@ -382,6 +413,7 @@ export class DaemonCommandHandler implements CommandHelpers {
382
413
  this._currentRoute = this.resolveRoute(args);
383
414
  const startedAt = Date.now();
384
415
  this.logCommandStart(cmd, args);
416
+ this.noteManualAttendanceIfApplicable(cmd, args);
385
417
  let result: CommandResult;
386
418
 
387
419
  if (isGitCommandName(cmd)) {
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  import { readFile, realpath } from 'node:fs/promises';
2
2
  import * as path from 'node:path';
3
3
  import type { GitDiffSummary, GitFileChange, GitFileChangeStatus } from './git-types.js';
4
- import { GitCommandError, isPathInside, resolveGitRepository, runGit } from './git-executor.js';
4
+ import { GIT_STATUS_TIMEOUT_MS, GitCommandError, isPathInside, resolveGitRepository, runGit } from './git-executor.js';
5
5
 
6
6
  const DEFAULT_MAX_FILES = 200;
7
7
  const DEFAULT_MAX_BYTES = 200_000;
@@ -18,6 +18,21 @@ export interface GitDiffOptions {
18
18
  baseRef?: string;
19
19
  }
20
20
 
21
+ /**
22
+ * Diff collection fans out an even larger parallel git burst than status (up to five
23
+ * concurrent spawns — name-status/numstat × unstaged/staged + ls-files — plus its own
24
+ * `rev-parse --show-toplevel`) and runs concurrently with the status path. On Windows the
25
+ * per-spawn cost alone can exceed the 5s `execGitRaw` default, so — exactly like
26
+ * getGitRepoStatus — give the collection path the larger status budget unless the caller
27
+ * pinned a timeout. Without this, a slow-but-healthy Windows worktree times out on the diff
28
+ * burst and the catch below flattens it to `repoRoot:null, isGitRepo:false`, dropping the
29
+ * diff while the (already-hardened) status block reports isGitRepo:true — an asymmetric
30
+ * failure that reads as "not a git repo" for diff only.
31
+ */
32
+ function withCollectionTimeout(options: GitDiffOptions): GitDiffOptions {
33
+ return options.timeoutMs === undefined ? { ...options, timeoutMs: GIT_STATUS_TIMEOUT_MS } : options;
34
+ }
35
+
21
36
  function validateBaseRef(ref: string): string {
22
37
  const trimmed = ref.trim();
23
38
  if (!trimmed || trimmed.startsWith('-') || trimmed.includes('..') || !/^[A-Za-z0-9][A-Za-z0-9._/@-]*$/.test(trimmed)) {
@@ -54,16 +69,17 @@ export async function getGitDiffSummary(
54
69
  options: GitDiffOptions = {},
55
70
  ): Promise<GitDiffSummary> {
56
71
  const lastCheckedAt = Date.now();
72
+ const effectiveOptions = withCollectionTimeout(options);
57
73
 
58
74
  try {
59
- const repo = await resolveGitRepository(workspace, options);
75
+ const repo = await resolveGitRepository(workspace, effectiveOptions);
60
76
  const repoRoot = repo.repoRoot!;
61
77
 
62
78
  if (options.baseRef) {
63
79
  const range = `${validateBaseRef(options.baseRef)}...HEAD`;
64
80
  const [nameStatus, numstat] = await Promise.all([
65
- runGit(repo, ['diff', '--no-ext-diff', '--name-status', range, '--'], { ...options, cwd: repoRoot }),
66
- runGit(repo, ['diff', '--no-ext-diff', '--numstat', range, '--'], { ...options, cwd: repoRoot }),
81
+ runGit(repo, ['diff', '--no-ext-diff', '--name-status', range, '--'], { ...effectiveOptions, cwd: repoRoot }),
82
+ runGit(repo, ['diff', '--no-ext-diff', '--numstat', range, '--'], { ...effectiveOptions, cwd: repoRoot }),
67
83
  ]);
68
84
  const outputBytes = byteLength(nameStatus.stdout + numstat.stdout);
69
85
  const changes = combineDiffEntries(nameStatus.stdout, numstat.stdout, false);
@@ -83,11 +99,11 @@ export async function getGitDiffSummary(
83
99
  }
84
100
 
85
101
  const [unstagedNameStatus, unstagedNumstat, stagedNameStatus, stagedNumstat, untracked] = await Promise.all([
86
- runGit(repo, ['diff', '--no-ext-diff', '--name-status'], { ...options, cwd: repoRoot }),
87
- runGit(repo, ['diff', '--no-ext-diff', '--numstat'], { ...options, cwd: repoRoot }),
88
- runGit(repo, ['diff', '--cached', '--no-ext-diff', '--name-status'], { ...options, cwd: repoRoot }),
89
- runGit(repo, ['diff', '--cached', '--no-ext-diff', '--numstat'], { ...options, cwd: repoRoot }),
90
- runGit(repo, ['ls-files', '--others', '--exclude-standard'], { ...options, cwd: repoRoot }),
102
+ runGit(repo, ['diff', '--no-ext-diff', '--name-status'], { ...effectiveOptions, cwd: repoRoot }),
103
+ runGit(repo, ['diff', '--no-ext-diff', '--numstat'], { ...effectiveOptions, cwd: repoRoot }),
104
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--name-status'], { ...effectiveOptions, cwd: repoRoot }),
105
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--numstat'], { ...effectiveOptions, cwd: repoRoot }),
106
+ runGit(repo, ['ls-files', '--others', '--exclude-standard'], { ...effectiveOptions, cwd: repoRoot }),
91
107
  ]);
92
108
 
93
109
  const outputBytes = byteLength(
@@ -139,14 +155,15 @@ export async function getGitFileDiff(
139
155
  options: GitDiffOptions = {},
140
156
  ): Promise<GitFileDiffResult> {
141
157
  const lastCheckedAt = Date.now();
142
- const repo = await resolveGitRepository(workspace, options);
158
+ const effectiveOptions = withCollectionTimeout(options);
159
+ const repo = await resolveGitRepository(workspace, effectiveOptions);
143
160
  const repoRoot = repo.repoRoot!;
144
161
  const selected = await resolveRepoFilePath(repoRoot, filePath);
145
162
  const maxBytes = normalizePositiveInteger(options.maxBytes, DEFAULT_MAX_BYTES);
146
163
 
147
164
  if (options.baseRef) {
148
165
  const range = `${validateBaseRef(options.baseRef)}...HEAD`;
149
- const result = await runGit(repo, ['diff', '--no-ext-diff', range, '--', selected.relativePath], { ...options, cwd: repoRoot });
166
+ const result = await runGit(repo, ['diff', '--no-ext-diff', range, '--', selected.relativePath], { ...effectiveOptions, cwd: repoRoot });
150
167
  const bounded = truncateText(result.stdout, maxBytes);
151
168
  return {
152
169
  workspace: repo.workspace,
@@ -160,15 +177,15 @@ export async function getGitFileDiff(
160
177
  }
161
178
 
162
179
  const [unstaged, staged] = await Promise.all([
163
- runGit(repo, ['diff', '--no-ext-diff', '--', selected.relativePath], { ...options, cwd: repoRoot }),
164
- runGit(repo, ['diff', '--cached', '--no-ext-diff', '--', selected.relativePath], { ...options, cwd: repoRoot }),
180
+ runGit(repo, ['diff', '--no-ext-diff', '--', selected.relativePath], { ...effectiveOptions, cwd: repoRoot }),
181
+ runGit(repo, ['diff', '--cached', '--no-ext-diff', '--', selected.relativePath], { ...effectiveOptions, cwd: repoRoot }),
165
182
  ]);
166
183
 
167
184
  let diff = [unstaged.stdout, staged.stdout].filter((part) => part.length > 0).join('\n');
168
185
 
169
186
  if (!diff) {
170
187
  const untracked = await runGit(repo, ['ls-files', '--others', '--exclude-standard', '--', selected.relativePath], {
171
- ...options,
188
+ ...effectiveOptions,
172
189
  cwd: repoRoot,
173
190
  });
174
191
  const untrackedFiles = untracked.stdout.split('\n').filter(Boolean);
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';