@adhdev/daemon-core 0.9.82-rc.330 → 0.9.82-rc.332

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.
@@ -134,6 +134,21 @@ export declare class CliProviderInstance implements ProviderInstance {
134
134
  * coordinator as if they were task completions.
135
135
  */
136
136
  detachMeshAssignment(): void;
137
+ /**
138
+ * The resolved modal-park status of this session, or null when it is not
139
+ * parked on a modal awaiting a human answer. Mirrors the overlay logic in
140
+ * getState(): an active AskUserQuestion interactive prompt resolves to
141
+ * waiting_choice; otherwise the adapter's waiting_approval (tool consent)
142
+ * counts — UNLESS auto-approve will dismiss it, in which case the session is
143
+ * effectively generating and is NOT modal-parked. This is the single signal
144
+ * the mesh force-inject guard consults, and the same status string the
145
+ * reconcile loop reads off get_status_metadata. Lowercase literals only —
146
+ * the SessionStatus enum is forked across modules and waiting_choice is
147
+ * absent from some of them.
148
+ */
149
+ resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null;
150
+ /** True when this session is parked on a modal awaiting a human answer. */
151
+ isModalParked(): boolean;
137
152
  onEvent(event: string, data?: any): void;
138
153
  recordAcknowledgedUserInput(input: InputEnvelope | string): void;
139
154
  dispose(): void;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.330",
3
+ "version": "0.9.82-rc.332",
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.330",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.332",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -1285,6 +1285,18 @@ export class ProviderCliAdapter implements CliAdapter {
1285
1285
  if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
1286
1286
  const content = String(text || '');
1287
1287
  if (!content.trim()) return;
1288
+ // Modal-park guard (defense-in-depth — the primary guard is at the
1289
+ // cli-provider-instance force-forward chokepoint). A force-write writes raw
1290
+ // keystrokes into the PTY, bypassing the busy send-guard. If the session is
1291
+ // parked on a tool-consent modal, the modal's key handler eats those bytes
1292
+ // and silently resolves an approval the user never made. Hold the write and
1293
+ // let the mesh reconcile loop redeliver once the modal is resolved. We only
1294
+ // hold for an actionable approval modal; plain generating is still force-written
1295
+ // (that is the deadlock the force path exists to break).
1296
+ if (this.engine.currentStatus === 'waiting_approval' || this.engine.hasActionableApproval()) {
1297
+ LOG.info('CLI', `[${this.cliType}] force-send held — session parked on approval modal (status=${this.engine.currentStatus})`);
1298
+ return;
1299
+ }
1288
1300
  LOG.info('CLI', `[${this.cliType}] force-sending prompt while status=${this.engine.currentStatus}`);
1289
1301
  await this.writeToPty(content + this.sendKey);
1290
1302
  this.onStatusChange?.();
@@ -175,7 +175,7 @@ function mergeMeshPolicy(base: RepoMeshPolicy | undefined, patch: Partial<RepoMe
175
175
  policy.sessionCleanupOnNodeRemove = 'preserve';
176
176
  }
177
177
  if (!SPAWNED_SESSION_VISIBILITY_MODES.has(String(policy.spawnedSessionVisibility))) {
178
- policy.spawnedSessionVisibility = 'visible';
178
+ policy.spawnedSessionVisibility = DEFAULT_MESH_POLICY.spawnedSessionVisibility;
179
179
  }
180
180
  // Load-balancing: normalize the scheduling strategy so an invalid/blank value
181
181
  // falls back to 'first_eligible' (strict no-change). Only persist the field when
@@ -1870,6 +1870,21 @@ export function handleMeshForwardEvent(components: DaemonComponents, payload: Re
1870
1870
  targetSessionId: readNonEmptyString(payload.targetSessionId) || readNonEmptyString(payload.sessionId) || readNonEmptyString(payload.instanceId),
1871
1871
  providerType: readNonEmptyString(payload.providerType),
1872
1872
  providerSessionId: readNonEmptyString(payload.providerSessionId),
1873
+ // Carry the session identity fields the worker provider event emits so the
1874
+ // coordinator's mirror (updateMeshOwnedSession) gets a real workspace/title/
1875
+ // settings. Without these the remote-relay hop reconstructs metadataEvent with
1876
+ // an empty workspace, and the dashboard flaps to the generic
1877
+ // "Terminal (Mesh Node)" title (and degrades the provider label) between live
1878
+ // events and the periodic get_status_metadata snapshot. The local in-process
1879
+ // forward path (onMeshCoordinatorEventForwarded) already preserves these; this
1880
+ // mirrors them for the remote-only relay path.
1881
+ workspace: readNonEmptyString(payload.workspace) || readNonEmptyString(payload.workspaceName),
1882
+ workspaceName: readNonEmptyString(payload.workspaceName) || readNonEmptyString(payload.workspace),
1883
+ sessionTitle: readNonEmptyString(payload.sessionTitle),
1884
+ sessionStatus: readNonEmptyString(payload.sessionStatus),
1885
+ sessionChatStatus: readNonEmptyString(payload.sessionChatStatus),
1886
+ providerName: readNonEmptyString(payload.providerName),
1887
+ ...(payload.sessionSettings && typeof payload.sessionSettings === 'object' && !Array.isArray(payload.sessionSettings) ? { sessionSettings: payload.sessionSettings } : {}),
1873
1888
  finalSummary: readNonEmptyString(payload.finalSummary) || readNonEmptyString(payload.summary),
1874
1889
  jobId: readNonEmptyString(payload.jobId),
1875
1890
  interactionId: readNonEmptyString(payload.interactionId),
@@ -98,6 +98,13 @@ interface LiveCoordinator {
98
98
  meshId: string;
99
99
  instance: ReturnType<DaemonComponents['instanceManager']['getInstance']>;
100
100
  idle: boolean;
101
+ // True when the coordinator session is parked on a harness modal awaiting a
102
+ // human answer — claude-cli AskUserQuestion (waiting_choice) or a tool-consent
103
+ // prompt (waiting_approval). A force-inject into such a session would write raw
104
+ // keystrokes the modal key handler consumes, silently selecting a choice the
105
+ // user never made (data corruption). PHASE 2 excludes these from force-inject
106
+ // and leaves the event queued for a later (modal-resolved) tick.
107
+ modalParked: boolean;
101
108
  }
102
109
 
103
110
  // The set of coordinator-daemon ids THIS daemon answers to when draining the
@@ -193,7 +200,12 @@ function findLiveCoordinators(components: DaemonComponents): LiveCoordinator[] {
193
200
  const meshId = readNonEmptyString(settings.meshCoordinatorFor);
194
201
  if (!meshId) continue;
195
202
  const status = readNonEmptyString(state.status).toLowerCase();
196
- out.push({ meshId, instance: inst, idle: status === 'idle' });
203
+ // getState() overlays the modal-park statuses: an active AskUserQuestion
204
+ // prompt surfaces as waiting_choice, a tool-consent prompt as waiting_approval.
205
+ // Lowercase literal compare — the SessionStatus enum is forked across modules
206
+ // and waiting_choice is absent from some of them (see cli-provider-instance).
207
+ const modalParked = status === 'waiting_choice' || status === 'waiting_approval';
208
+ out.push({ meshId, instance: inst, idle: status === 'idle', modalParked });
197
209
  }
198
210
  return out;
199
211
  }
@@ -390,10 +402,34 @@ export async function runMeshReconcileTick(components: DaemonComponents): Promis
390
402
  // tick — injecting them would be noise mid-generation. Both drains mark the
391
403
  // consumed rows drained=1 atomically, so the pull path can't re-deliver.
392
404
  const idleCoordinators = meshCoordinators.filter(c => c.idle);
393
- const generatingCoordinators = meshCoordinators.filter(c => !c.idle);
405
+ // A coordinator parked on a harness modal (waiting_choice / waiting_approval)
406
+ // is non-idle, so it would otherwise be treated as a force-inject target. It
407
+ // must NOT be: a force-inject writes raw keystrokes into the PTY, which the
408
+ // modal's key handler consumes and silently resolves to a choice the user
409
+ // never made. Force-inject is only safe into a coordinator parked in plain
410
+ // `generating` (the deadlock the force path exists to break). So generating
411
+ // targets are the non-idle, non-modal-parked coordinators.
412
+ const generatingCoordinators = meshCoordinators.filter(c => !c.idle && !c.modalParked);
413
+ const modalParkedCoordinators = meshCoordinators.filter(c => !c.idle && c.modalParked);
394
414
  const targetCoordinators = idleCoordinators.length > 0 ? idleCoordinators : generatingCoordinators;
395
415
  const forceOnly = idleCoordinators.length === 0;
396
416
 
417
+ // ── modal-blocked short-circuit (MUST precede the drain) ──────────────────
418
+ // When the ONLY coordinators for this mesh are modal-parked (no idle, no plain
419
+ // generating target), there is nowhere safe to deliver. We skip-and-requeue:
420
+ // by NOT draining we leave the events at drained=0 in the queue, so a later tick
421
+ // (once the modal is resolved and the coordinator returns to idle/generating)
422
+ // delivers them. This short-circuit MUST run BEFORE drainPendingMeshCoordinatorEvents
423
+ // — the drain marks rows drained=1 atomically, which would lose the events for a
424
+ // coordinator that is only transiently blocked. (Note: generating is still
425
+ // force-injected via generatingCoordinators — we never block the deadlock-break.)
426
+ if (targetCoordinators.length === 0) {
427
+ if (modalParkedCoordinators.length > 0) {
428
+ LOG.info('MeshReconcile', `Reconcile skip → modal-parked: holding pending event(s) for mesh ${meshId} (${modalParkedCoordinators.length} coordinator(s) awaiting a modal answer; events left queued)`);
429
+ }
430
+ continue;
431
+ }
432
+
397
433
  // O(1) guard: skip the drain entirely when the queue is empty.
398
434
  if (store) {
399
435
  try {
@@ -905,6 +905,37 @@ export class CliProviderInstance implements ProviderInstance {
905
905
  this.adapter.updateRuntimeSettings?.(this.settings);
906
906
  }
907
907
 
908
+ /**
909
+ * The resolved modal-park status of this session, or null when it is not
910
+ * parked on a modal awaiting a human answer. Mirrors the overlay logic in
911
+ * getState(): an active AskUserQuestion interactive prompt resolves to
912
+ * waiting_choice; otherwise the adapter's waiting_approval (tool consent)
913
+ * counts — UNLESS auto-approve will dismiss it, in which case the session is
914
+ * effectively generating and is NOT modal-parked. This is the single signal
915
+ * the mesh force-inject guard consults, and the same status string the
916
+ * reconcile loop reads off get_status_metadata. Lowercase literals only —
917
+ * the SessionStatus enum is forked across modules and waiting_choice is
918
+ * absent from some of them.
919
+ */
920
+ resolveModalParkStatus(): 'waiting_choice' | 'waiting_approval' | null {
921
+ if (this.activeInteractivePrompt) return 'waiting_choice';
922
+ let adapterStatus: { status?: string };
923
+ try {
924
+ adapterStatus = this.adapter.getStatus({ allowParse: false });
925
+ } catch {
926
+ return null;
927
+ }
928
+ if (adapterStatus.status === 'waiting_approval' && !this.shouldAutoApprove()) {
929
+ return 'waiting_approval';
930
+ }
931
+ return null;
932
+ }
933
+
934
+ /** True when this session is parked on a modal awaiting a human answer. */
935
+ isModalParked(): boolean {
936
+ return this.resolveModalParkStatus() !== null;
937
+ }
938
+
908
939
  onEvent(event: string, data?: any): void {
909
940
  if (event === 'send_message') {
910
941
  const input = normalizeInputEnvelope(data);
@@ -917,6 +948,20 @@ export class CliProviderInstance implements ProviderInstance {
917
948
  // Without it the message is queued and only flushed on the coordinator's
918
949
  // own idle transition — which never happens until it receives the message.
919
950
  const force = data?.force === true;
951
+ // Modal guard: a force-inject still writes raw keystrokes into the PTY,
952
+ // bypassing the busy send-guard. If the coordinator is parked on a
953
+ // harness modal (claude-cli AskUserQuestion → waiting_choice, or a
954
+ // tool-consent waiting_approval), those keystrokes are consumed by the
955
+ // modal's key handler and silently select a choice the user never made
956
+ // (data corruption). Hold the force-inject in that narrow window —
957
+ // the event stays queued and the reconcile loop redelivers it on the
958
+ // next tick once the modal is resolved. We ONLY hold for the two modal
959
+ // states; generating is still force-injected (that is the deadlock the
960
+ // force path exists to break — see mesh-events-coordinator).
961
+ if (force && this.isModalParked()) {
962
+ LOG.info('CLI', `[${this.type}] force send_message held — coordinator parked on modal (${this.resolveModalParkStatus()})`);
963
+ return;
964
+ }
920
965
  void this.adapter.sendMessage(promptText, force ? { force: true } : {}).catch((e: any) => {
921
966
  LOG.warn('CLI', `[${this.type}] send_message failed: ${e?.message || e}`);
922
967
  });
@@ -342,7 +342,10 @@ export const DEFAULT_MESH_POLICY: RepoMeshPolicy = {
342
342
  requireApprovalForDestructiveGit: true,
343
343
  dirtyWorkspaceBehavior: 'warn',
344
344
  maxParallelTasks: 2,
345
- spawnedSessionVisibility: 'visible',
345
+ // Coordinator-spawned worker sessions default to hidden so the dashboard is not
346
+ // flooded with mesh noise tabs/notifications. Users can still surface or unmute
347
+ // any specific session manually; that override is preserved per-device.
348
+ spawnedSessionVisibility: 'hidden',
346
349
  delegatedWorkerAutoApprove: true,
347
350
  sessionCleanupOnNodeRemove: 'preserve',
348
351
  autoFastForward: { enabled: true },
@@ -319,6 +319,9 @@ export class DaemonStatusReporter {
319
319
  cdpConnected: session.cdpConnected,
320
320
  summaryMetadata: session.summaryMetadata,
321
321
  settings: session.settings,
322
+ // Forward surfaceHidden so the server can gate push notifications for
323
+ // coordinator-hidden sessions (the WS path is the only one the server sees).
324
+ surfaceHidden: session.surfaceHidden,
322
325
  })),
323
326
  p2p: payload.p2p,
324
327
  timestamp: now,