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

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.
Files changed (38) hide show
  1. package/dist/commands/handler.d.ts +15 -0
  2. package/dist/index.js +703 -220
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +703 -220
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  7. package/dist/mesh/mesh-runtime-store.d.ts +1 -1
  8. package/dist/mesh/mesh-work-queue.d.ts +1 -1
  9. package/dist/providers/acp-provider-instance.d.ts +3 -0
  10. package/dist/providers/cli-provider-instance.d.ts +14 -0
  11. package/dist/providers/manual-attendance.d.ts +63 -0
  12. package/dist/providers/provider-instance.d.ts +8 -0
  13. package/dist/providers/spec/adapter.d.ts +22 -0
  14. package/dist/providers/spec/fsm-driver.d.ts +49 -7
  15. package/dist/providers/spec/fsm-evaluator.d.ts +4 -0
  16. package/dist/providers/spec/types.d.ts +9 -5
  17. package/package.json +2 -2
  18. package/src/commands/cli-manager.ts +20 -2
  19. package/src/commands/handler.ts +32 -0
  20. package/src/commands/router.ts +19 -6
  21. package/src/git/git-diff.ts +31 -14
  22. package/src/mesh/mesh-event-trace.ts +67 -0
  23. package/src/mesh/mesh-events-coordinator.ts +117 -12
  24. package/src/mesh/mesh-events-pending.ts +33 -0
  25. package/src/mesh/mesh-events-stale.ts +3 -1
  26. package/src/mesh/mesh-reconcile-loop.ts +47 -0
  27. package/src/mesh/mesh-runtime-store.ts +18 -2
  28. package/src/mesh/mesh-work-queue.ts +8 -1
  29. package/src/providers/acp-provider-instance.ts +18 -1
  30. package/src/providers/cli-provider-instance.ts +123 -7
  31. package/src/providers/manual-attendance.ts +85 -0
  32. package/src/providers/provider-instance.ts +9 -0
  33. package/src/providers/spec/adapter.ts +67 -0
  34. package/src/providers/spec/cli-adapter.ts +6 -0
  35. package/src/providers/spec/evaluator.ts +24 -9
  36. package/src/providers/spec/fsm-driver.ts +135 -13
  37. package/src/providers/spec/fsm-evaluator.ts +19 -2
  38. package/src/providers/spec/types.ts +9 -5
@@ -0,0 +1,21 @@
1
+ export interface MeshEventTraceCtx {
2
+ /** Primary correlation anchor — the mesh task id (meshActiveTaskId / metadataEvent.taskId). */
3
+ taskId?: unknown;
4
+ /** Optional per-event id when the producer assigns one. */
5
+ eventId?: unknown;
6
+ /** Worker session id — the fallback anchor when no task is attached. */
7
+ sessionId?: unknown;
8
+ nodeId?: unknown;
9
+ meshId?: unknown;
10
+ event?: unknown;
11
+ }
12
+ /**
13
+ * Stable, greppable correlation key. `task=` and `sess=` are ALWAYS rendered (as `-`
14
+ * when absent) so the key shape is uniform across stages and a single grep alternation
15
+ * (`task=<id>\|sess=<id>`) follows the event end-to-end.
16
+ */
17
+ export declare function meshEventTraceKey(ctx: MeshEventTraceCtx): string;
18
+ /** Lifecycle progress (INFO). One line per stage the event clears. */
19
+ export declare function traceMeshEventStage(stage: string, ctx: MeshEventTraceCtx, detail?: string): void;
20
+ /** Event did not advance — rejected / held / skipped / deduped (WARN). */
21
+ export declare function traceMeshEventDrop(reason: string, ctx: MeshEventTraceCtx, detail?: string): void;
@@ -129,7 +129,7 @@ export declare class MeshRuntimeStore {
129
129
  dispatchedAt: string;
130
130
  updatedAt: string;
131
131
  }>;
132
- updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void;
132
+ updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void;
133
133
  cleanupTerminalDirectDispatches(olderThanMs: number): void;
134
134
  deleteDirectDispatches(meshId: string): void;
135
135
  /**
@@ -320,7 +320,7 @@ export declare function insertDirectDispatch(meshId: string, data: {
320
320
  dispatchedAt: string;
321
321
  }): void;
322
322
  export declare function getActiveDirectDispatches(meshId: string): DirectDispatchRecord[];
323
- export declare function updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void;
323
+ export declare function updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale', taskId?: string): void;
324
324
  export declare function cleanupTerminalDirectDispatches(olderThanMs?: number): void;
325
325
  export declare function markStaleDirectDispatches(meshId: string, olderThanMs?: number): void;
326
326
  /**
@@ -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;
@@ -183,6 +184,8 @@ export declare class CliProviderInstance implements ProviderInstance {
183
184
  private shouldSuppressStaleParsedBusyStatus;
184
185
  private getCompletedFinalizationBlock;
185
186
  private scheduleCompletedDebounceFlush;
187
+ private isMeshWorkerSession;
188
+ private meshTraceCtx;
186
189
  private flushCompletedDebounceIfFinalized;
187
190
  private maybeAutoApproveStatus;
188
191
  /**
@@ -204,6 +207,17 @@ export declare class CliProviderInstance implements ProviderInstance {
204
207
  get cliType(): string;
205
208
  get cliName(): string;
206
209
  private shouldAutoApprove;
210
+ /** @see ProviderInstance.noteManualInteraction */
211
+ noteManualInteraction(now?: number): void;
212
+ /**
213
+ * Whether auto-approve should be treated as active *right now* for display
214
+ * and firing decisions: the configured intent AND the user is not currently
215
+ * attending this session by hand. When a human is attending, auto-approve is
216
+ * held so the modal stays visible and they can drive it via the controlbar.
217
+ * Provider-agnostic — the attendance signal is the command set, never any
218
+ * CLI-specific modal text.
219
+ */
220
+ private autoApproveEffectivelyActive;
207
221
  private recordAutoApproval;
208
222
  recordApprovalSelection(buttonText: string): void;
209
223
  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
  }
@@ -33,6 +33,21 @@ export interface TerminalAdapterHandlers {
33
33
  }): void;
34
34
  tick?(): void;
35
35
  }
36
+ /**
37
+ * One entry in the PTY input/output event timeline (debug-only). Captured at
38
+ * the single common point every spec@4 provider funnels through — this adapter
39
+ * — so the Spec Debug Snapshot can answer "what input / output preceded a status
40
+ * transition?". Observation only; nothing here feeds the FSM decision.
41
+ */
42
+ export interface SpecPtyEvent {
43
+ /** Wall-clock ms. */
44
+ ts: number;
45
+ kind: 'spawn' | 'input' | 'output' | 'resize' | 'cursor' | 'exit';
46
+ /** Human-readable, control-char-escaped, length-capped preview. */
47
+ content: string;
48
+ /** Raw byte length before truncation (output/input only). */
49
+ bytes?: number;
50
+ }
36
51
  export declare class TerminalAdapter {
37
52
  private readonly opts;
38
53
  private readonly handlers;
@@ -46,6 +61,9 @@ export declare class TerminalAdapter {
46
61
  private screenTimer;
47
62
  private tickTimer;
48
63
  private lastScreen;
64
+ /** Debug-only ring buffer of PTY input/output/resize/cursor events. */
65
+ private events;
66
+ private lastCursorKey;
49
67
  constructor(opts: TerminalAdapterOpts, handlers: TerminalAdapterHandlers);
50
68
  start(): void;
51
69
  resize(cols: number, rows: number): void;
@@ -62,6 +80,10 @@ export declare class TerminalAdapter {
62
80
  col: number;
63
81
  };
64
82
  send_keys(text: string): void;
83
+ /** Debug-only: most-recent PTY input/output/resize/cursor events, oldest
84
+ * first. Pure observation — never consulted by the FSM. */
85
+ getEventTimeline(limit?: number): SpecPtyEvent[];
86
+ private recordEvent;
65
87
  kill(): void;
66
88
  private onChunk;
67
89
  private computeScreen;
@@ -1,3 +1,4 @@
1
+ import { type SpecPtyEvent } from './adapter.js';
1
2
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
2
3
  import { type TraceEntry } from './evaluator.js';
3
4
  import { type TransitionEval } from './fsm-evaluator.js';
@@ -139,6 +140,7 @@ export interface ISpecDriver {
139
140
  } | null;
140
141
  getFsmDebug?(): unknown;
141
142
  getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
143
+ getEventTimeline?(limit?: number): ReadonlyArray<SpecPtyEvent>;
142
144
  }
143
145
  export interface SpecDriverOpts {
144
146
  specPath: string;
@@ -152,6 +154,10 @@ export interface SpecDriverOpts {
152
154
  extraCliArgs?: string[];
153
155
  }
154
156
  export declare function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number;
157
+ /** Split `text` into chunks of at most `size` UTF-16 units without ever cutting
158
+ * between a high and low surrogate (which would corrupt an astral char — emoji,
159
+ * etc. — on the UTF-8 PTY write). */
160
+ export declare function chunkPreservingSurrogates(text: string, size: number): string[];
155
161
  export declare function guessExt(mime: string): string;
156
162
  type HistoryEntry = DriverHistoryEntry;
157
163
  export declare class FsmDriver implements ISpecDriver {
@@ -181,6 +187,16 @@ export declare class FsmDriver implements ISpecDriver {
181
187
  * WIN32_SUBMIT_* and scheduleWin32Submit). Re-arms itself until the FSM
182
188
  * leaves idle (submitted) or the resend budget is spent. */
183
189
  private win32SubmitTimer;
190
+ /** Wall-clock (ms) of the most recent raw PTY output chunk. Advances on every
191
+ * on_pty_data — including the echo of text written into the composer — so the
192
+ * win32 submit settle-gate can tell when input has finished landing. */
193
+ private lastPtyDataAt;
194
+ /** Wall-clock (ms) of the most recent win32 message-body input write. Bridges
195
+ * the gap between writing a chunk and its echo so the settle-gate does not
196
+ * declare "quiet" mid-write. */
197
+ private lastWin32WriteAt;
198
+ /** Pending paced chunk-write timer for a large win32 body (see writeWin32Body). */
199
+ private win32WriteTimer;
184
200
  private currentEval;
185
201
  private stateHistory;
186
202
  private prevStateAt;
@@ -230,6 +246,8 @@ export declare class FsmDriver implements ISpecDriver {
230
246
  };
231
247
  getStateHistory(): ReadonlyArray<HistoryEntry>;
232
248
  getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry>;
249
+ /** Debug-only PTY input/output/resize/cursor timeline from the adapter. */
250
+ getEventTimeline(limit?: number): ReadonlyArray<SpecPtyEvent>;
233
251
  getSections(): Array<{
234
252
  id: string;
235
253
  text: string;
@@ -300,14 +318,38 @@ export declare class FsmDriver implements ISpecDriver {
300
318
  private actuallySendMessage;
301
319
  /** The agent's current coarse status, derived from the FSM node we're in. */
302
320
  private currentStatus;
321
+ /** Record a win32 body write so the settle-gate counts it as input activity
322
+ * even before the echo arrives. */
323
+ private markWin32Write;
324
+ /** Most recent win32 input activity — a write we issued OR a PTY output chunk
325
+ * (echo). The submit settle-gate waits for this to go quiet. */
326
+ private lastWin32InputActivityAt;
303
327
  /**
304
- * win32 verification-based submit. Sends the submit key, waits a gap, and if
305
- * the FSM is still 'idle' (the prompt did not submit — the CR was absorbed as
306
- * a multiline-paste newline) resends, up to WIN32_SUBMIT_MAX_RESENDS. The
307
- * first CR always fires (so a stale/edge status never suppresses the submit);
308
- * subsequent resends are gated on still being idle, and stop the instant the
309
- * agent leaves idle (submitted generating / approval). This converges the
310
- * nondeterministic multiline window without spamming Enter into the next turn.
328
+ * Write the message body to the PTY for win32, paced into bounded chunks. A
329
+ * single unbounded ConPTY write can overflow the input pipe and drop leading
330
+ * bytes; splitting it with a short inter-chunk gap keeps the console input
331
+ * buffer from overflowing. Small bodies still go out in a single write. Each
332
+ * chunk advances lastWin32WriteAt so the submit settle-gate keeps waiting until
333
+ * the final chunk is out and echoed.
334
+ */
335
+ private writeWin32Body;
336
+ /**
337
+ * win32 submit. Two phases:
338
+ *
339
+ * Phase 1 (settle-gate): hold the first CR until the PTY output has been quiet
340
+ * for WIN32_SUBMIT_SETTLE_MS after the last input write — i.e. the full
341
+ * (possibly multi-KB / multiline) body has finished arriving in the composer
342
+ * and echoing. Honors an initial minimum delay and is bounded by
343
+ * WIN32_SUBMIT_MAX_SETTLE_WAIT_MS so a noisy screen can never hang the submit.
344
+ * This is what stops a long message from being submitted half-arrived (its
345
+ * leading lines lost). A short message settles almost immediately.
346
+ *
347
+ * Phase 2 (verified resend — unchanged): send the submit key, wait a gap, and
348
+ * if the FSM is still 'idle' (the CR was absorbed as a multiline-paste
349
+ * newline) resend, up to WIN32_SUBMIT_MAX_RESENDS. The first CR always fires
350
+ * (a stale/edge status never suppresses it); resends are gated on still being
351
+ * idle and stop the instant the agent leaves idle (submitted → generating /
352
+ * approval). This preserves the win32 lone-CR-swallow handling.
311
353
  */
312
354
  private scheduleWin32Submit;
313
355
  private handleClickControl;
@@ -19,6 +19,10 @@ export interface CondResult {
19
19
  /** Remaining ms until a time-based condition would flip to true. 0 if
20
20
  * already true or not applicable. Lets the UI show a countdown. */
21
21
  remainingMs?: number;
22
+ /** Debug-only: the actual substring a TRUE regex condition matched, so the
23
+ * snapshot shows WHAT text the rule fired on — not just which regex. Never
24
+ * read by the FSM; purely for the Spec Debug Snapshot. */
25
+ matchedText?: string;
22
26
  children?: CondResult[];
23
27
  }
24
28
  /** One evaluated transition with its full reasoning. */
@@ -123,11 +123,15 @@ export interface SectionDef {
123
123
  until?: string;
124
124
  /**
125
125
  * Anchor regex(es). A single string anchors on the first/last matching line
126
- * (per `anchor_last`). An array is an OR-set: every candidate line is one
127
- * that matches ANY entry; with `anchor_last` the LAST such line across all
128
- * patterns wins, otherwise the FIRST. This lets one section capture two
129
- * different shapese.g. a box-divider modal AND a divider-less modal whose
130
- * only stable landmark is the question line above its numbered choices.
126
+ * (per `anchor_last`). An array is an OR-set of candidate shapes: each
127
+ * candidate resolves its OWN anchor line independently (anchor_last that
128
+ * candidate's last matching line, else its first), then the TOPMOST resolved
129
+ * line across candidates wins — a section's anchor marks the top of its
130
+ * block, so the highest recognized landmark bounds the whole block. This
131
+ * lets one section capture two shapes — e.g. a box-divider modal AND a
132
+ * divider-less modal whose only stable landmark is the question line above
133
+ * its numbered choices — while preventing a stray LOWER landmark (e.g. an
134
+ * input-box `────` rule below the choices) from clipping the block.
131
135
  */
132
136
  anchor?: string | string[];
133
137
  anchor_flags?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.353",
3
+ "version": "0.9.82-rc.355",
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.353",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.355",
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,
@@ -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)) {
@@ -8110,7 +8110,10 @@ export class DaemonCommandRouter {
8110
8110
  // _meshDirectDispatch prevents re-forwarding (and P2P self-dial) when the stored
8111
8111
  // daemonId uses a legacy format that doesn't match the receiving daemon's identity.
8112
8112
  const selfDaemonId = this.deps.statusInstanceId;
8113
- const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
8113
+ // daemonIdsEquivalent: a legacy-form stored daemonId that resolves to THIS
8114
+ // machine's core must be treated as local (not remote) so it is not forwarded /
8115
+ // P2P self-dialed. Equivalent → local.
8116
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
8114
8117
  if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
8115
8118
  const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId!, 'fast_forward_mesh_node', {
8116
8119
  ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
@@ -8154,7 +8157,9 @@ export class DaemonCommandRouter {
8154
8157
  // once the call lands on the owning daemon — that daemon then reads
8155
8158
  // its own logs even if the stored daemonId uses a legacy form.
8156
8159
  const selfDaemonId = this.deps.statusInstanceId;
8157
- const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
8160
+ // daemonIdsEquivalent: a legacy-form daemonId resolving to this machine's core is
8161
+ // local — read locally instead of forwarding. Equivalent → local.
8162
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
8158
8163
  if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
8159
8164
  const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId!, 'get_mesh_node_logs', {
8160
8165
  ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
@@ -8230,7 +8235,9 @@ export class DaemonCommandRouter {
8230
8235
  const forwardNode = meshRecordForForward?.mesh?.nodes?.find((n: any) => meshNodeIdMatches(n, nodeId));
8231
8236
  const nodeDaemonId = typeof forwardNode?.daemonId === 'string' ? forwardNode.daemonId.trim() : undefined;
8232
8237
  const selfDaemonId = this.deps.statusInstanceId;
8233
- const isRemote = nodeDaemonId && selfDaemonId && nodeDaemonId !== selfDaemonId;
8238
+ // daemonIdsEquivalent: a legacy-form daemonId resolving to this machine's core
8239
+ // is local — execute locally instead of forwarding. Equivalent → local.
8240
+ const isRemote = nodeDaemonId && selfDaemonId && !daemonIdsEquivalent(nodeDaemonId, selfDaemonId);
8234
8241
  if (isRemote && this.deps.dispatchMeshCommand && !args?._meshDirectDispatch) {
8235
8242
  const callerCoordinatorDaemonId = typeof args?.coordinatorDaemonId === 'string' && args.coordinatorDaemonId.trim()
8236
8243
  ? args.coordinatorDaemonId.trim()
@@ -8347,7 +8354,9 @@ export class DaemonCommandRouter {
8347
8354
  let worktreeCleanup: Record<string, unknown> | undefined;
8348
8355
  if (node?.isLocalWorktree) {
8349
8356
  const nodeDaemonId = typeof node.daemonId === 'string' ? node.daemonId.trim() : undefined;
8350
- const isRemoteWorktree = nodeDaemonId && nodeDaemonId !== this.deps.statusInstanceId && this.deps.dispatchMeshCommand
8357
+ // daemonIdsEquivalent: an equivalent-form daemonId is this machine
8358
+ // clean up locally, do not forward. Equivalent → local.
8359
+ const isRemoteWorktree = nodeDaemonId && !daemonIdsEquivalent(nodeDaemonId, this.deps.statusInstanceId) && this.deps.dispatchMeshCommand
8351
8360
  && !args?._meshDirectDispatch;
8352
8361
  if (isRemoteWorktree) {
8353
8362
  // Worktree lives on a different machine — ask that daemon to clean it up.
@@ -8473,7 +8482,9 @@ export class DaemonCommandRouter {
8473
8482
  // _meshDirectDispatch prevents infinite re-forwarding when the stored daemonId
8474
8483
  // uses a legacy format that doesn't match the receiving daemon's statusInstanceId.
8475
8484
  const sourceDaemonId = typeof sourceNode.daemonId === 'string' ? sourceNode.daemonId.trim() : undefined;
8476
- if (sourceDaemonId && sourceDaemonId !== this.deps.statusInstanceId && this.deps.dispatchMeshCommand
8485
+ // daemonIdsEquivalent: an equivalent-form source daemonId is this machine
8486
+ // clone locally, do not forward. Equivalent → local.
8487
+ if (sourceDaemonId && !daemonIdsEquivalent(sourceDaemonId, this.deps.statusInstanceId) && this.deps.dispatchMeshCommand
8477
8488
  && !args?._meshDirectDispatch) {
8478
8489
  const forwarded = await this.deps.dispatchMeshCommand(sourceDaemonId, 'clone_mesh_node', {
8479
8490
  ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
@@ -8748,7 +8759,9 @@ export class DaemonCommandRouter {
8748
8759
  // Bootstrap runs scripts in the worktree path — forward to the node's daemon if remote.
8749
8760
  // _meshDirectDispatch prevents re-forwarding when stored daemonId uses legacy format.
8750
8761
  const nodeDaemonId = typeof node.daemonId === 'string' ? node.daemonId.trim() : undefined;
8751
- if (nodeDaemonId && nodeDaemonId !== this.deps.statusInstanceId && this.deps.dispatchMeshCommand
8762
+ // daemonIdsEquivalent: an equivalent-form daemonId is this machine
8763
+ // bootstrap locally, do not forward. Equivalent → local.
8764
+ if (nodeDaemonId && !daemonIdsEquivalent(nodeDaemonId, this.deps.statusInstanceId) && this.deps.dispatchMeshCommand
8752
8765
  && !args?._meshDirectDispatch) {
8753
8766
  const forwarded = await this.deps.dispatchMeshCommand(nodeDaemonId, 'retry_mesh_node_bootstrap', {
8754
8767
  ...(typeof args === 'object' && args !== null ? args as Record<string, unknown> : {}),
@@ -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);