@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.
- package/dist/commands/handler.d.ts +15 -0
- package/dist/commands/router.d.ts +12 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +558 -364
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +555 -364
- package/dist/index.mjs.map +1 -1
- package/dist/providers/acp-provider-instance.d.ts +3 -0
- package/dist/providers/cli-provider-instance.d.ts +12 -0
- package/dist/providers/manual-attendance.d.ts +63 -0
- package/dist/providers/provider-instance.d.ts +8 -0
- package/package.json +2 -2
- package/src/commands/cli-manager.ts +33 -6
- package/src/commands/handler.ts +32 -0
- package/src/commands/router.ts +133 -33
- package/src/git/git-diff.ts +31 -14
- package/src/index.ts +5 -0
- package/src/providers/acp-provider-instance.ts +18 -1
- package/src/providers/cli-provider-instance.ts +54 -3
- package/src/providers/manual-attendance.ts +85 -0
- package/src/providers/provider-instance.ts +9 -0
|
@@ -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.
|
|
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.
|
|
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
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
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;
|
package/src/commands/handler.ts
CHANGED
|
@@ -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)) {
|
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/git/git-diff.ts
CHANGED
|
@@ -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,
|
|
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, '--'], { ...
|
|
66
|
-
runGit(repo, ['diff', '--no-ext-diff', '--numstat', range, '--'], { ...
|
|
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'], { ...
|
|
87
|
-
runGit(repo, ['diff', '--no-ext-diff', '--numstat'], { ...
|
|
88
|
-
runGit(repo, ['diff', '--cached', '--no-ext-diff', '--name-status'], { ...
|
|
89
|
-
runGit(repo, ['diff', '--cached', '--no-ext-diff', '--numstat'], { ...
|
|
90
|
-
runGit(repo, ['ls-files', '--others', '--exclude-standard'], { ...
|
|
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
|
|
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], { ...
|
|
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], { ...
|
|
164
|
-
runGit(repo, ['diff', '--cached', '--no-ext-diff', '--', selected.relativePath], { ...
|
|
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
|
-
...
|
|
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';
|