@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/dist/commands/router.d.ts +12 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +426 -346
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +423 -346
- package/dist/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/commands/cli-manager.ts +13 -4
- package/src/commands/router.ts +133 -33
- package/src/index.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@adhdev/daemon-core",
|
|
3
|
-
"version": "0.9.82-rc.
|
|
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.
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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;
|
package/src/commands/router.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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:
|
|
4160
|
-
|
|
4161
|
-
|
|
4162
|
-
|
|
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 —
|
|
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
|
-
|
|
4231
|
-
|
|
4232
|
-
|
|
4233
|
-
|
|
4234
|
-
|
|
4235
|
-
|
|
4236
|
-
|
|
4237
|
-
|
|
4238
|
-
|
|
4239
|
-
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
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.
|
|
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"
|
|
6266
|
-
//
|
|
6267
|
-
// the
|
|
6268
|
-
//
|
|
6269
|
-
//
|
|
6270
|
-
//
|
|
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
|
-
|
|
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';
|