@adhdev/daemon-core 0.9.82-rc.331 → 0.9.82-rc.333

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;
@@ -0,0 +1,21 @@
1
+ import type BetterSqlite3 from 'better-sqlite3';
2
+ /**
3
+ * Load the `better-sqlite3` constructor in a way that survives every runtime the
4
+ * daemon ships in.
5
+ *
6
+ * The naive `typeof require === 'function' ? require : createRequire(import.meta.url)`
7
+ * is unsafe inside the daemon-cloud bundle: esbuild emits CJS output but, for any
8
+ * chunk that touches `import.meta.url`, it shims the local `require` with a stub
9
+ * that THROWS `Dynamic require of "..." is not supported`. That stub is still
10
+ * `typeof === 'function'`, so the ternary picks it and the call throws — it never
11
+ * reaches the `createRequire` fallback. The failure surfaced as `mesh_send_task`
12
+ * crashing the whole tool call instead of gracefully degrading.
13
+ *
14
+ * The robust approach is to ATTEMPT the real `require` and CATCH the esbuild stub's
15
+ * throw, then fall back to `createRequire`. We try multiple resolution bases so the
16
+ * load works whether the code runs as:
17
+ * - a genuine CJS module (bare `require` works),
18
+ * - an esbuild CJS bundle with the throwing `require` shim (createRequire(import.meta.url)),
19
+ * - a pure ESM module where `require` is undefined (createRequire(import.meta.url)).
20
+ */
21
+ export declare function loadBetterSqlite3(): typeof BetterSqlite3;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.331",
3
+ "version": "0.9.82-rc.333",
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.331",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.333",
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?.();
@@ -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 {
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, renameSync, statSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
- import { createRequire } from 'module';
3
+ import { loadBetterSqlite3 } from '../system/load-better-sqlite3.js';
4
4
  import { getLedgerDir } from './mesh-ledger.js';
5
5
  import { nodeSatisfiesRequiredTags } from './mesh-work-queue.js';
6
6
  import type { MeshTaskStatus, MeshWorkQueueEntry } from './mesh-work-queue.js';
@@ -11,10 +11,7 @@ let DatabaseCtor: typeof BetterSqlite3 | undefined;
11
11
 
12
12
  function loadDatabaseCtor(): typeof BetterSqlite3 {
13
13
  if (DatabaseCtor) return DatabaseCtor;
14
- const runtimeRequire = typeof require === 'function'
15
- ? require
16
- : createRequire(import.meta.url);
17
- DatabaseCtor = runtimeRequire('better-sqlite3') as typeof BetterSqlite3;
14
+ DatabaseCtor = loadBetterSqlite3() as typeof BetterSqlite3;
18
15
  return DatabaseCtor;
19
16
  }
20
17
 
@@ -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
  });
@@ -19,6 +19,7 @@
19
19
  import * as fs from 'node:fs';
20
20
  import * as path from 'node:path';
21
21
  import * as os from 'node:os';
22
+ import { loadBetterSqlite3 } from '../../system/load-better-sqlite3.js';
22
23
 
23
24
  export interface NativeHistoryMessage {
24
25
  id: string;
@@ -61,8 +62,7 @@ function statMtimeMs(p: string): number {
61
62
  function openDb(): any | null {
62
63
  if (!fs.existsSync(HERMES_STATE_DB)) return null;
63
64
  try {
64
- // eslint-disable-next-line @typescript-eslint/no-require-imports
65
- const Database = require('better-sqlite3');
65
+ const Database = loadBetterSqlite3();
66
66
  return new Database(HERMES_STATE_DB, { readonly: true, fileMustExist: true });
67
67
  } catch {
68
68
  return null;
@@ -22,6 +22,7 @@ import * as fs from 'node:fs';
22
22
  import * as os from 'node:os';
23
23
  import * as path from 'node:path';
24
24
  import { LOG } from '../../logging/logger.js';
25
+ import { loadBetterSqlite3 } from '../../system/load-better-sqlite3.js';
25
26
  import type {
26
27
  NativeHistoryConfig,
27
28
  NativeHistoryJsonlSource,
@@ -253,8 +254,7 @@ function executeSqlite(src: NativeHistorySqliteSource, input: NativeHistoryInput
253
254
 
254
255
  let Database: any;
255
256
  try {
256
- // eslint-disable-next-line @typescript-eslint/no-require-imports
257
- Database = require('better-sqlite3');
257
+ Database = loadBetterSqlite3();
258
258
  } catch { return null; }
259
259
 
260
260
  let db: any;
@@ -0,0 +1,68 @@
1
+ import { createRequire } from 'module';
2
+ import type BetterSqlite3 from 'better-sqlite3';
3
+
4
+ let cached: typeof BetterSqlite3 | undefined;
5
+
6
+ /**
7
+ * Load the `better-sqlite3` constructor in a way that survives every runtime the
8
+ * daemon ships in.
9
+ *
10
+ * The naive `typeof require === 'function' ? require : createRequire(import.meta.url)`
11
+ * is unsafe inside the daemon-cloud bundle: esbuild emits CJS output but, for any
12
+ * chunk that touches `import.meta.url`, it shims the local `require` with a stub
13
+ * that THROWS `Dynamic require of "..." is not supported`. That stub is still
14
+ * `typeof === 'function'`, so the ternary picks it and the call throws — it never
15
+ * reaches the `createRequire` fallback. The failure surfaced as `mesh_send_task`
16
+ * crashing the whole tool call instead of gracefully degrading.
17
+ *
18
+ * The robust approach is to ATTEMPT the real `require` and CATCH the esbuild stub's
19
+ * throw, then fall back to `createRequire`. We try multiple resolution bases so the
20
+ * load works whether the code runs as:
21
+ * - a genuine CJS module (bare `require` works),
22
+ * - an esbuild CJS bundle with the throwing `require` shim (createRequire(import.meta.url)),
23
+ * - a pure ESM module where `require` is undefined (createRequire(import.meta.url)).
24
+ */
25
+ export function loadBetterSqlite3(): typeof BetterSqlite3 {
26
+ if (cached) return cached;
27
+
28
+ const errors: unknown[] = [];
29
+
30
+ // 1) Real CJS require, when present and not the esbuild throwing shim.
31
+ if (typeof require === 'function') {
32
+ try {
33
+ cached = require('better-sqlite3') as typeof BetterSqlite3;
34
+ return cached;
35
+ } catch (e) {
36
+ // Either the esbuild "Dynamic require is not supported" shim, or a
37
+ // genuine resolution failure. Fall through to createRequire.
38
+ errors.push(e);
39
+ }
40
+ }
41
+
42
+ // 2) createRequire anchored at this module's URL (works in ESM and in esbuild
43
+ // CJS bundles, where import.meta.url is rewritten to a usable value).
44
+ try {
45
+ const metaUrl = typeof import.meta?.url === 'string' ? import.meta.url : undefined;
46
+ if (metaUrl) {
47
+ cached = createRequire(metaUrl)('better-sqlite3') as typeof BetterSqlite3;
48
+ return cached;
49
+ }
50
+ } catch (e) {
51
+ errors.push(e);
52
+ }
53
+
54
+ // 3) Last resort: createRequire anchored at the current working directory.
55
+ try {
56
+ cached = createRequire(`${process.cwd()}/__adhdev_better_sqlite3_loader__.js`)(
57
+ 'better-sqlite3',
58
+ ) as typeof BetterSqlite3;
59
+ return cached;
60
+ } catch (e) {
61
+ errors.push(e);
62
+ }
63
+
64
+ const detail = errors
65
+ .map((e) => (e instanceof Error ? e.message : String(e)))
66
+ .join('; ');
67
+ throw new Error(`Failed to load better-sqlite3: ${detail}`);
68
+ }