@adhdev/daemon-core 0.9.82-rc.135 → 0.9.82-rc.137

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 (48) hide show
  1. package/dist/cli-adapter-types.d.ts +1 -0
  2. package/dist/cli-adapters/cli-script-runner.d.ts +45 -0
  3. package/dist/cli-adapters/cli-state-engine.d.ts +154 -0
  4. package/dist/cli-adapters/provider-cli-adapter.d.ts +75 -74
  5. package/dist/cli-adapters/provider-cli-parse.d.ts +2 -0
  6. package/dist/cli-adapters/provider-cli-shared.d.ts +6 -0
  7. package/dist/config/chat-history.d.ts +1 -0
  8. package/dist/index.d.ts +3 -3
  9. package/dist/index.js +2624 -1946
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +2627 -1954
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/mesh/beads-db.d.ts +54 -0
  14. package/dist/mesh/mesh-active-work.d.ts +7 -1
  15. package/dist/mesh/mesh-events.d.ts +10 -4
  16. package/dist/mesh/mesh-ledger.d.ts +21 -1
  17. package/dist/mesh/mesh-refine-status.d.ts +2 -3
  18. package/dist/mesh/mesh-work-queue.d.ts +17 -0
  19. package/dist/mesh/worktree-bootstrap-config.d.ts +2 -4
  20. package/dist/providers/approval-utils.d.ts +9 -0
  21. package/dist/repo-mesh-types.d.ts +5 -0
  22. package/package.json +1 -1
  23. package/src/cli-adapter-types.d.ts +1 -0
  24. package/src/cli-adapter-types.ts +1 -0
  25. package/src/cli-adapters/cli-script-runner.ts +145 -0
  26. package/src/cli-adapters/cli-state-engine.ts +957 -0
  27. package/src/cli-adapters/provider-cli-adapter.d.ts +1 -1
  28. package/src/cli-adapters/provider-cli-adapter.ts +377 -1387
  29. package/src/cli-adapters/provider-cli-parse.ts +6 -0
  30. package/src/cli-adapters/provider-cli-shared.ts +6 -0
  31. package/src/commands/chat-commands.ts +23 -1
  32. package/src/commands/cli-manager.ts +3 -1
  33. package/src/commands/router.ts +8 -0
  34. package/src/config/chat-history.ts +7 -3
  35. package/src/git/git-worktree.ts +8 -1
  36. package/src/index.ts +3 -2
  37. package/src/mesh/beads-db.ts +305 -2
  38. package/src/mesh/coordinator-prompt.ts +12 -17
  39. package/src/mesh/mesh-active-work.ts +162 -59
  40. package/src/mesh/mesh-events.ts +198 -53
  41. package/src/mesh/mesh-ledger.ts +321 -105
  42. package/src/mesh/mesh-refine-status.ts +2 -3
  43. package/src/mesh/mesh-work-queue.ts +116 -120
  44. package/src/mesh/worktree-bootstrap-config.ts +17 -4
  45. package/src/providers/approval-utils.ts +27 -0
  46. package/src/providers/cli-provider-instance.ts +26 -2
  47. package/src/providers/provider-schema.ts +2 -0
  48. package/src/repo-mesh-types.ts +10 -0
@@ -36,6 +36,8 @@ export function buildCliParseInput(options: {
36
36
  recentOutputBuffer: string;
37
37
  terminalScreenText: string;
38
38
  workingDir?: string;
39
+ providerSessionId?: string;
40
+ historySessionId?: string;
39
41
  baseMessages: CliChatMessage[];
40
42
  partialResponse: string;
41
43
  isWaitingForResponse?: boolean;
@@ -48,6 +50,8 @@ export function buildCliParseInput(options: {
48
50
  recentOutputBuffer,
49
51
  terminalScreenText,
50
52
  workingDir,
53
+ providerSessionId,
54
+ historySessionId,
51
55
  baseMessages,
52
56
  partialResponse,
53
57
  isWaitingForResponse,
@@ -70,6 +74,8 @@ export function buildCliParseInput(options: {
70
74
  screenText,
71
75
  workspace: workingDir,
72
76
  workingDir,
77
+ providerSessionId,
78
+ historySessionId,
73
79
  screen: buildCliScreenSnapshot(screenText),
74
80
  bufferScreen: buildCliScreenSnapshot(buffer),
75
81
  recentScreen: buildCliScreenSnapshot(recentBuffer),
@@ -108,6 +108,8 @@ export interface CliScriptInput {
108
108
  screenText: string;
109
109
  workspace?: string;
110
110
  workingDir?: string;
111
+ providerSessionId?: string;
112
+ historySessionId?: string;
111
113
  screen: CliScreenSnapshot;
112
114
  bufferScreen: CliScreenSnapshot;
113
115
  recentScreen: CliScreenSnapshot;
@@ -161,6 +163,10 @@ export interface CliProviderModule {
161
163
  requirePromptEchoBeforeSubmit?: boolean;
162
164
  /** Allow sending another prompt while the CLI is still generating so users can intervene mid-turn. */
163
165
  allowInputDuringGeneration?: boolean;
166
+ /** When true, only transition to idle after the parsed transcript includes a final standard assistant message. */
167
+ requiresFinalAssistantBeforeIdle?: boolean;
168
+ /** When true, allow providers to augment stale snapshot data before parse. Reserved for future use. */
169
+ augmentStaleSnapshot?: boolean;
164
170
  /** When provider-owned, daemon treats provider parser output as canonical transcript authority. */
165
171
  transcriptAuthority?: 'provider' | 'daemon';
166
172
  /** Full context lets provider-owned parsers canonicalize retained history instead of daemon prefix stitching. */
@@ -650,6 +650,7 @@ function readCliProviderNativeHistory(agentStr: string, args: {
650
650
  excludeRecentCount: number;
651
651
  historyBehavior?: ProviderModule['historyBehavior'];
652
652
  scripts?: ProviderScripts;
653
+ excludeInProgressTurn?: boolean;
653
654
  }): ReturnType<typeof readProviderChatHistory> & { lookup: 'session' | 'workspace' } {
654
655
  if (!args.historySessionId) {
655
656
  return {
@@ -669,6 +670,7 @@ function readCliProviderNativeHistory(agentStr: string, args: {
669
670
  excludeRecentCount: args.excludeRecentCount,
670
671
  historyBehavior: args.historyBehavior,
671
672
  scripts: args.scripts as any,
673
+ excludeInProgressTurn: args.excludeInProgressTurn,
672
674
  });
673
675
  // Native transcripts are keyed by provider/runtime session identity. Falling
674
676
  // back to workspace makes concurrent local Codex/Hermes sessions alias each
@@ -1533,6 +1535,7 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1533
1535
  excludeRecentCount: 0,
1534
1536
  historyBehavior: provider?.historyBehavior,
1535
1537
  scripts: provider?.scripts as any,
1538
+ excludeInProgressTurn: returnedStatus === 'waiting_approval',
1536
1539
  });
1537
1540
  } catch (error: any) {
1538
1541
  const fallbackReason = `native_history_error:${error?.message || String(error)}`;
@@ -1593,8 +1596,16 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1593
1596
  && nativeHistoryCoverage !== 'partial'
1594
1597
  && nativeHistoryCoverage !== 'unavailable'
1595
1598
  && safeMapping;
1596
- const allowStaleNativeChatMessages = adapter.cliType === 'antigravity-cli' && nativeUsableForChatMessages;
1599
+ // Sticky native anchor: once native was confirmed for this session, keep using it
1600
+ // even if freshEnough flips false due to PTY buffer activity.
1601
+ const NATIVE_ANCHOR_TTL_MS = 30 * 60_000;
1602
+ const nativeAnchoredAt = (adapter as any).nativeHistoryAnchoredAt ?? 0;
1603
+ const nativeIsAnchored = nativeAnchoredAt > 0
1604
+ && (Date.now() - nativeAnchoredAt) < NATIVE_ANCHOR_TTL_MS;
1605
+ const allowStaleNativeChatMessages = (adapter.cliType === 'antigravity-cli' || nativeIsAnchored)
1606
+ && nativeUsableForChatMessages;
1597
1607
  if (nativeUsableForChatMessages && (freshEnough || allowStaleNativeChatMessages)) {
1608
+ (adapter as any).nativeHistoryAnchoredAt = Date.now();
1598
1609
  selectedMessages = finalizeStreamingMessagesWhenIdle(nativeMessages, returnedStatus);
1599
1610
  selectedProviderSessionId = historyProviderSessionId || providerSessionId;
1600
1611
  selectedTranscriptAuthority = 'provider';
@@ -1620,6 +1631,11 @@ export async function handleReadChat(h: CommandHelpers, args: any): Promise<Comm
1620
1631
  ptyStatusApprovalOnly: true,
1621
1632
  });
1622
1633
  } else {
1634
+ // Hard failure (no messages, partial coverage, or safeMapping broken) — clear anchor.
1635
+ // Do not clear on mere staleness: PTY can race ahead of native mtime legitimately.
1636
+ if (!nativeUsableForChatMessages && (adapter as any).nativeHistoryAnchoredAt) {
1637
+ (adapter as any).nativeHistoryAnchoredAt = 0;
1638
+ }
1623
1639
  const liveCurrentRuntimePtySafe = isCurrentRuntimePtySafelyAttributed({
1624
1640
  adapter,
1625
1641
  helpers: h,
@@ -2770,6 +2786,12 @@ export async function handleResolveAction(h: CommandHelpers, args: any): Promise
2770
2786
  if (buttonIndex < 0) {
2771
2787
  return { success: false, error: 'Approval action did not match any visible button' };
2772
2788
  }
2789
+ // Idempotency: if the adapter already resolved this approval within cooldown, report
2790
+ // stale_prompt rather than writing a second key to the PTY.
2791
+ if (typeof adapter.isApprovalRecentlyResolved === 'function' && adapter.isApprovalRecentlyResolved()) {
2792
+ LOG.info('Command', `[resolveAction] CLI PTY → stale_prompt (already resolved within cooldown)`);
2793
+ return { success: true, stalePrompt: true, buttonIndex, button: buttons[buttonIndex] ?? button };
2794
+ }
2773
2795
  if (typeof adapter.resolveModal === 'function') {
2774
2796
  adapter.resolveModal(buttonIndex);
2775
2797
  } else {
@@ -553,7 +553,9 @@ export class DaemonCliManager {
553
553
  providerSessionId,
554
554
  attachExisting,
555
555
  );
556
- return new ProviderCliAdapter(resolvedProvider as CliProviderModule, workingDir, cliArgs, extraEnv || {}, transportFactory);
556
+ const adapter = new ProviderCliAdapter(resolvedProvider as CliProviderModule, workingDir, cliArgs, extraEnv || {}, transportFactory);
557
+ if (providerSessionId) adapter.updateRuntimeMeta({ providerSessionId });
558
+ return adapter;
557
559
  }
558
560
 
559
561
  throw new Error(`No CLI provider found for '${cliType}'. Create a provider.js in providers/cli/${cliType}/`);
@@ -1434,10 +1434,18 @@ function buildSubmodulePublishRequiredNextStep(entries: MeshRefineSubmoduleReach
1434
1434
 
1435
1435
  function resolveRefineryAutoPublishSubmoduleMainCommits(mesh: any, workspace: string): { enabled: boolean; source?: string } {
1436
1436
  if (mesh?.policy?.allowAutoPublishSubmoduleMainCommits === true) {
1437
+ process.stderr.write(
1438
+ `[adhdev-mesh] WARNING: allowAutoPublishSubmoduleMainCommits is ENABLED via mesh.policy. `
1439
+ + `Refinery may push unreachable submodule commits to submodule origin/main without additional user approval.\n`,
1440
+ );
1437
1441
  return { enabled: true, source: 'mesh.policy.allowAutoPublishSubmoduleMainCommits' };
1438
1442
  }
1439
1443
  const loaded = loadMeshRefineConfig(mesh, workspace);
1440
1444
  if (loaded.config?.allowAutoPublishSubmoduleMainCommits === true) {
1445
+ process.stderr.write(
1446
+ `[adhdev-mesh] WARNING: allowAutoPublishSubmoduleMainCommits is ENABLED via ${loaded.path || loaded.source}. `
1447
+ + `Refinery may push unreachable submodule commits to submodule origin/main without additional user approval.\n`,
1448
+ );
1441
1449
  return { enabled: true, source: loaded.path || loaded.source };
1442
1450
  }
1443
1451
  return { enabled: false };
@@ -1397,6 +1397,7 @@ function callProviderNativeHistoryRead(
1397
1397
  scripts: ProviderNativeHistoryScripts | undefined,
1398
1398
  historySessionId: string | undefined,
1399
1399
  workspace?: string,
1400
+ excludeInProgressTurn?: boolean,
1400
1401
  ): ProviderNativeHistoryReadResult | null {
1401
1402
  const fn = getProviderNativeHistoryScript(scripts, canonicalHistory, 'readSession');
1402
1403
  if (!fn) return null;
@@ -1408,7 +1409,8 @@ function callProviderNativeHistoryRead(
1408
1409
  workspace,
1409
1410
  format: canonicalHistory?.format,
1410
1411
  watchPath: canonicalHistory?.watchPath,
1411
- args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, workspace },
1412
+ excludeInProgressTurn: excludeInProgressTurn === true,
1413
+ args: { sessionId: normalizedSessionId, historySessionId: normalizedSessionId, workspace, excludeInProgressTurn: excludeInProgressTurn === true },
1412
1414
  });
1413
1415
  if (!result || typeof result !== 'object') return null;
1414
1416
  const records = normalizeProviderNativeHistoryRecords(agentType, normalizedSessionId, (result as any).messages || (result as any).records);
@@ -1430,11 +1432,12 @@ function buildNativeHistoryReadResult(
1430
1432
  scripts: ProviderNativeHistoryScripts | undefined,
1431
1433
  historySessionId: string | undefined,
1432
1434
  workspace?: string,
1435
+ excludeInProgressTurn?: boolean,
1433
1436
  ): ProviderNativeHistoryReadResult | null {
1434
1437
  const normalizedSessionId = normalizeSavedHistorySessionId(historySessionId || '');
1435
1438
  const normalizedWorkspace = typeof workspace === 'string' ? workspace.trim() : '';
1436
1439
  if (!canonicalHistory || (!normalizedSessionId && !normalizedWorkspace) || !isNativeSourceCanonicalHistory(canonicalHistory)) return null;
1437
- return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace);
1440
+ return callProviderNativeHistoryRead(agentType, canonicalHistory, scripts, normalizedSessionId, workspace, excludeInProgressTurn);
1438
1441
  }
1439
1442
 
1440
1443
  function materializeNativeHistoryToMirror(
@@ -1490,6 +1493,7 @@ export function readProviderChatHistory(
1490
1493
  excludeRecentCount?: number;
1491
1494
  historyBehavior?: ProviderHistoryBehavior;
1492
1495
  scripts?: ProviderNativeHistoryScripts;
1496
+ excludeInProgressTurn?: boolean;
1493
1497
  } = {},
1494
1498
  ): {
1495
1499
  messages: HistoryMessage[];
@@ -1503,7 +1507,7 @@ export function readProviderChatHistory(
1503
1507
  unavailableReason?: string;
1504
1508
  } {
1505
1509
  if (isNativeSourceCanonicalHistory(options.canonicalHistory) && (options.historySessionId || options.workspace)) {
1506
- const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace);
1510
+ const nativeResult = buildNativeHistoryReadResult(agentType, options.canonicalHistory, options.scripts, options.historySessionId, options.workspace, options.excludeInProgressTurn);
1507
1511
  if (!nativeResult) return { messages: [], hasMore: false, source: 'native-unavailable' };
1508
1512
  return {
1509
1513
  ...pageHistoryRecords(agentType, nativeResult.records, options.offset || 0, options.limit || 30, options.excludeRecentCount || 0, options.historyBehavior),
@@ -116,8 +116,11 @@ export async function createWorktree(opts: WorktreeCreateOptions): Promise<Workt
116
116
  });
117
117
  } catch (error: any) {
118
118
  const stderr = typeof error.stderr === 'string' ? error.stderr : '';
119
- // Clean error messages for common failures
120
119
  if (/already exists/i.test(stderr)) {
120
+ // Distinguish directory-collision (TOCTOU race) from branch-already-exists
121
+ if (existsSync(targetDir)) {
122
+ throw new Error(`Worktree target directory was created concurrently: ${targetDir}`);
123
+ }
121
124
  throw new Error(`Branch '${branch}' already exists or is checked out in another worktree`);
122
125
  }
123
126
  throw new Error(`git worktree add failed: ${stderr.trim() || error.message}`);
@@ -170,6 +173,10 @@ export async function removeWorktree(repoRoot: string, worktreePath: string, opt
170
173
  const stdout = typeof error.stdout === 'string' ? error.stdout : '';
171
174
  const detail = `${stderr}\n${stdout}\n${error.message || ''}`;
172
175
  if (opts.allowSubmoduleForceFallback && SUBMODULE_WORKTREE_REMOVE_RE.test(detail)) {
176
+ process.stderr.write(
177
+ `[adhdev-mesh] WARNING: git worktree remove --force fallback for submodule worktree '${worktreePath}'. `
178
+ + `Any uncommitted changes inside submodules will be lost.\n`,
179
+ );
173
180
  try {
174
181
  await execFileAsync('git', ['worktree', 'remove', '--force', worktreePath], {
175
182
  cwd: repoRoot,
package/src/index.ts CHANGED
@@ -115,6 +115,7 @@ export type {
115
115
  RepoMeshLedgerEntryStatus,
116
116
  RepoMeshLedgerSummaryStatus,
117
117
  RepoMeshLedgerStatus,
118
+ MeshAsyncJobLifecycle,
118
119
  } from './repo-mesh-types.js';
119
120
  export { DEFAULT_MESH_POLICY } from './repo-mesh-types.js';
120
121
 
@@ -195,8 +196,8 @@ export { buildMeshLedgerReconciliationEvidence, buildMeshLedgerReplicaEvidence }
195
196
  export type { MeshLedgerReconciliationEvidence, MeshLedgerReplicaEvidence, MeshLedgerReplicaStatus } from './mesh/mesh-ledger-reconciliation.js';
196
197
 
197
198
  // ── Mesh Work Queue (GUPP) ──
198
- export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest } from './mesh/mesh-work-queue.js';
199
- export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult } from './mesh/mesh-work-queue.js';
199
+ export { enqueueTask, getQueue, claimNextTask, updateTaskStatus, updateSessionTaskStatus, cancelTask, requeueTask, getMeshQueueStats, getMeshQueueRevision, normalizeMeshTaskMode, validateMeshTaskModeRequest, insertDirectDispatch, getActiveDirectDispatches, updateDirectDispatchStatus, cleanupTerminalDirectDispatches, markStaleDirectDispatches } from './mesh/mesh-work-queue.js';
200
+ export type { MeshWorkQueueEntry, MeshTaskStatus, MeshTaskMode, MeshWorkQueueStats, MeshQueueMutationOptions, MeshTaskModeValidationResult, DirectDispatchRecord } from './mesh/mesh-work-queue.js';
200
201
  export { buildCompactStaleDirectWorkSummary, buildMeshActiveWork, buildMeshActiveWorkSummary } from './mesh/mesh-active-work.js';
201
202
  export type { MeshActiveWorkRecord, MeshActiveWorkStatus, MeshActiveWorkSummary, MeshActiveWorkSource, MeshStaleDirectWorkSummary } from './mesh/mesh-active-work.js';
202
203
  export { buildMeshAsyncRefineJobs } from './mesh/mesh-refine-status.js';
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync } from 'fs';
1
+ import { existsSync, mkdirSync, readFileSync, statSync } from 'fs';
2
2
  import { dirname, join } from 'path';
3
3
  import { createRequire } from 'module';
4
4
  import { getLedgerDir } from './mesh-ledger.js';
@@ -28,12 +28,18 @@ function legacyQueuePath(meshId: string): string {
28
28
  export class BeadsDB {
29
29
  private static instance: BeadsDB | undefined;
30
30
  private readonly db: DatabaseHandle;
31
+ private readonly dbPath: string;
31
32
  private readonly migratedMeshIds = new Set<string>();
33
+ private fingerprintSweepCounter = 0;
34
+ private walWriteCounter = 0;
35
+ private static readonly WAL_CHECK_INTERVAL = 500;
36
+ private static readonly WAL_MAX_BYTES = 50 * 1024 * 1024; // 50 MB
32
37
 
33
38
  private constructor(dbPath: string) {
34
39
  const dir = dirname(dbPath);
35
40
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
36
41
 
42
+ this.dbPath = dbPath;
37
43
  this.db = new (loadDatabaseCtor())(dbPath);
38
44
  this.db.pragma('journal_mode = WAL');
39
45
  this.db.pragma('synchronous = NORMAL');
@@ -81,9 +87,72 @@ export class BeadsDB {
81
87
  ON mesh_queue(mesh_id, status, created_at);
82
88
  CREATE INDEX IF NOT EXISTS idx_mesh_queue_assignment
83
89
  ON mesh_queue(mesh_id, assigned_node_id, assigned_session_id, status);
90
+
91
+ CREATE TABLE IF NOT EXISTS mesh_completion_fingerprints (
92
+ fingerprint TEXT PRIMARY KEY,
93
+ expires_at INTEGER NOT NULL
94
+ );
95
+
96
+ CREATE TABLE IF NOT EXISTS mesh_direct_dispatches (
97
+ task_id TEXT PRIMARY KEY,
98
+ mesh_id TEXT NOT NULL,
99
+ node_id TEXT,
100
+ session_id TEXT,
101
+ provider_type TEXT,
102
+ message TEXT NOT NULL,
103
+ task_mode TEXT,
104
+ via TEXT NOT NULL,
105
+ status TEXT NOT NULL DEFAULT 'dispatched',
106
+ dispatched_to_idle_session INTEGER NOT NULL DEFAULT 0,
107
+ dispatched_at TEXT NOT NULL,
108
+ updated_at TEXT NOT NULL
109
+ );
110
+
111
+ CREATE INDEX IF NOT EXISTS idx_direct_dispatches_mesh_session
112
+ ON mesh_direct_dispatches(mesh_id, session_id, status);
84
113
  `);
85
114
  }
86
115
 
116
+ hasCompletionFingerprint(fingerprint: string): boolean {
117
+ const now = Date.now();
118
+ const row = this.db
119
+ .prepare('SELECT 1 FROM mesh_completion_fingerprints WHERE fingerprint = ? AND expires_at > ?')
120
+ .get(fingerprint, now) as { 1: number } | undefined;
121
+ // Sweep expired fingerprints every 100 reads so stale rows don't accumulate
122
+ // even during read-heavy (non-write) periods when recordFingerprintSeen is idle.
123
+ if (++this.fingerprintSweepCounter >= 100) {
124
+ this.fingerprintSweepCounter = 0;
125
+ this.sweepExpiredFingerprints();
126
+ }
127
+ return row !== undefined;
128
+ }
129
+
130
+ recordCompletionFingerprint(fingerprint: string, ttlMs: number): void {
131
+ const expiresAt = Date.now() + ttlMs;
132
+ this.db.prepare('INSERT OR REPLACE INTO mesh_completion_fingerprints (fingerprint, expires_at) VALUES (?, ?)')
133
+ .run(fingerprint, expiresAt);
134
+ this.maybeCheckpointWal();
135
+ }
136
+
137
+ sweepExpiredFingerprints(): void {
138
+ this.db.prepare('DELETE FROM mesh_completion_fingerprints WHERE expires_at <= ?').run(Date.now());
139
+ }
140
+
141
+ private maybeCheckpointWal(): void {
142
+ if (++this.walWriteCounter < BeadsDB.WAL_CHECK_INTERVAL) return;
143
+ this.walWriteCounter = 0;
144
+ try {
145
+ const walPath = `${this.dbPath}-wal`;
146
+ if (!existsSync(walPath)) return;
147
+ const size = statSync(walPath).size;
148
+ if (size < BeadsDB.WAL_MAX_BYTES) return;
149
+ process.stderr.write(
150
+ `[adhdev-mesh] WAL file ${Math.round(size / 1024 / 1024)}MB exceeds threshold; forcing checkpoint\n`,
151
+ );
152
+ this.db.pragma('wal_checkpoint(TRUNCATE)');
153
+ } catch { /* best-effort */ }
154
+ }
155
+
87
156
  private ensureLegacyQueueMigrated(meshId: string): void {
88
157
  if (this.migratedMeshIds.has(meshId)) return;
89
158
  this.migratedMeshIds.add(meshId);
@@ -136,7 +205,8 @@ export class BeadsDB {
136
205
  const rows = this.db
137
206
  .prepare('SELECT id, status, updated_at FROM mesh_queue WHERE mesh_id = ? ORDER BY id ASC')
138
207
  .all(meshId) as Array<{ id: string; status: string; updated_at: string }>;
139
- return rows.map(row => `${row.id}:${row.status}:${row.updated_at}`).join('|');
208
+ // Tab as field delimiter (UUIDs and ISO timestamps never contain tabs).
209
+ return rows.map(row => `${row.id}\t${row.status}\t${row.updated_at}`).join('\n');
140
210
  }
141
211
 
142
212
  replaceQueue(meshId: string, queue: MeshWorkQueueEntry[]): void {
@@ -152,6 +222,7 @@ export class BeadsDB {
152
222
  `);
153
223
  deleteStmt.run(meshId);
154
224
  for (const entry of queue) insert.run(this.toRow(entry));
225
+ this.maybeCheckpointWal();
155
226
  }
156
227
 
157
228
  deleteQueue(meshId: string): void {
@@ -159,6 +230,134 @@ export class BeadsDB {
159
230
  this.migratedMeshIds.delete(meshId);
160
231
  }
161
232
 
233
+ insertQueueEntry(entry: MeshWorkQueueEntry): void {
234
+ this.db.prepare(`
235
+ INSERT INTO mesh_queue (
236
+ id, mesh_id, status, target_node_id, target_session_id,
237
+ assigned_node_id, assigned_session_id, created_at, updated_at, payload
238
+ ) VALUES (
239
+ @id, @meshId, @status, @targetNodeId, @targetSessionId,
240
+ @assignedNodeId, @assignedSessionId, @createdAt, @updatedAt, @payload
241
+ )
242
+ `).run(this.toRow(entry));
243
+ this.maybeCheckpointWal();
244
+ }
245
+
246
+ updateQueueEntry(entry: MeshWorkQueueEntry): void {
247
+ const now = new Date().toISOString();
248
+ entry.updatedAt = now;
249
+ this.db.prepare(`
250
+ UPDATE mesh_queue SET
251
+ status = @status,
252
+ target_node_id = @targetNodeId,
253
+ target_session_id = @targetSessionId,
254
+ assigned_node_id = @assignedNodeId,
255
+ assigned_session_id = @assignedSessionId,
256
+ updated_at = @updatedAt,
257
+ payload = @payload
258
+ WHERE id = @id AND mesh_id = @meshId
259
+ `).run(this.toRow(entry));
260
+ this.maybeCheckpointWal();
261
+ }
262
+
263
+ findQueueEntryById(meshId: string, id: string): MeshWorkQueueEntry | null {
264
+ this.ensureLegacyQueueMigrated(meshId);
265
+ const row = this.db.prepare(
266
+ 'SELECT payload FROM mesh_queue WHERE id = ? AND mesh_id = ?'
267
+ ).get(id, meshId) as { payload: string } | undefined;
268
+ return row ? JSON.parse(row.payload) as MeshWorkQueueEntry : null;
269
+ }
270
+
271
+ hasActiveAssignment(meshId: string, sessionId: string, nodeId: string): boolean {
272
+ this.ensureLegacyQueueMigrated(meshId);
273
+ const row = this.db.prepare(`
274
+ SELECT 1 FROM mesh_queue
275
+ WHERE mesh_id = ? AND status = 'assigned'
276
+ AND (assigned_session_id = ? OR assigned_node_id = ?)
277
+ LIMIT 1
278
+ `).get(meshId, sessionId, nodeId);
279
+ return row !== undefined;
280
+ }
281
+
282
+ // O(1) claim: transaction ensures only one session claims a pending task
283
+ claimNextQueueTask(meshId: string, nodeId: string, sessionId: string): MeshWorkQueueEntry | null {
284
+ return this.transaction(() => {
285
+ this.ensureLegacyQueueMigrated(meshId);
286
+ if (this.hasActiveAssignment(meshId, sessionId, nodeId)) return null;
287
+
288
+ // Priority: session-targeted > node-targeted (no session) > unconstrained
289
+ const row = (
290
+ this.db.prepare(`
291
+ SELECT payload FROM mesh_queue
292
+ WHERE mesh_id = ? AND status = 'pending' AND target_session_id = ?
293
+ ORDER BY created_at ASC LIMIT 1
294
+ `).get(meshId, sessionId) as { payload: string } | undefined
295
+ ) || (
296
+ this.db.prepare(`
297
+ SELECT payload FROM mesh_queue
298
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id = ? AND target_session_id IS NULL
299
+ ORDER BY created_at ASC LIMIT 1
300
+ `).get(meshId, nodeId) as { payload: string } | undefined
301
+ ) || (
302
+ this.db.prepare(`
303
+ SELECT payload FROM mesh_queue
304
+ WHERE mesh_id = ? AND status = 'pending' AND target_node_id IS NULL AND target_session_id IS NULL
305
+ ORDER BY created_at ASC LIMIT 1
306
+ `).get(meshId) as { payload: string } | undefined
307
+ );
308
+ if (!row) return null;
309
+
310
+ const entry = JSON.parse(row.payload) as MeshWorkQueueEntry;
311
+ const now = new Date().toISOString();
312
+ entry.status = 'assigned';
313
+ entry.assignedNodeId = nodeId;
314
+ entry.assignedSessionId = sessionId;
315
+ entry.dispatchTimestamp = now;
316
+ entry.updatedAt = now;
317
+
318
+ this.db.prepare(`
319
+ UPDATE mesh_queue SET
320
+ status = 'assigned', assigned_node_id = ?, assigned_session_id = ?,
321
+ updated_at = ?, payload = ?
322
+ WHERE id = ? AND mesh_id = ?
323
+ `).run(nodeId, sessionId, now, JSON.stringify(entry), entry.id, meshId);
324
+
325
+ this.maybeCheckpointWal();
326
+ return entry;
327
+ });
328
+ }
329
+
330
+ getQueueStatsByStatus(meshId: string): { status: string; count: number }[] {
331
+ this.ensureLegacyQueueMigrated(meshId);
332
+ return this.db.prepare(
333
+ `SELECT status, COUNT(*) as count FROM mesh_queue WHERE mesh_id = ? GROUP BY status`
334
+ ).all(meshId) as { status: string; count: number }[];
335
+ }
336
+
337
+ getActiveAssignmentDetails(meshId: string): Array<{ id: string; nodeId?: string; sessionId?: string; message: string }> {
338
+ this.ensureLegacyQueueMigrated(meshId);
339
+ const rows = this.db.prepare(`
340
+ SELECT assigned_node_id, assigned_session_id, payload
341
+ FROM mesh_queue WHERE mesh_id = ? AND status = 'assigned'
342
+ `).all(meshId) as Array<{ assigned_node_id: string | null; assigned_session_id: string | null; payload: string }>;
343
+ return rows.map(r => {
344
+ let id = '', message = '';
345
+ try { const e = JSON.parse(r.payload) as MeshWorkQueueEntry; id = e.id; message = e.message; } catch { /* ignore */ }
346
+ return { id, nodeId: r.assigned_node_id ?? undefined, sessionId: r.assigned_session_id ?? undefined, message };
347
+ });
348
+ }
349
+
350
+ findAssignedBySession(meshId: string, sessionId: string, occurredAtIso?: string): MeshWorkQueueEntry | null {
351
+ this.ensureLegacyQueueMigrated(meshId);
352
+ // Use updated_at (≈ dispatchTimestamp when status='assigned') for the occurredAt filter.
353
+ const sql = occurredAtIso
354
+ ? `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned' AND updated_at <= ? ORDER BY updated_at DESC LIMIT 1`
355
+ : `SELECT payload FROM mesh_queue WHERE mesh_id = ? AND assigned_session_id = ? AND status = 'assigned' ORDER BY updated_at DESC LIMIT 1`;
356
+ const args: string[] = occurredAtIso ? [meshId, sessionId, occurredAtIso] : [meshId, sessionId];
357
+ const row = this.db.prepare(sql).get(...args as [string, string, string?]) as { payload: string } | undefined;
358
+ return row ? JSON.parse(row.payload) as MeshWorkQueueEntry : null;
359
+ }
360
+
162
361
  private toRow(entry: MeshWorkQueueEntry): Record<string, unknown> {
163
362
  return {
164
363
  id: entry.id,
@@ -173,4 +372,108 @@ export class BeadsDB {
173
372
  payload: JSON.stringify(entry),
174
373
  };
175
374
  }
375
+
376
+ // ── Direct Dispatch Tracking ─────────────────────────────────────────────
377
+
378
+ insertDirectDispatch(entry: {
379
+ taskId: string;
380
+ meshId: string;
381
+ nodeId?: string;
382
+ sessionId?: string;
383
+ providerType?: string;
384
+ message: string;
385
+ taskMode?: string;
386
+ via: string;
387
+ dispatchedToIdleSession?: boolean;
388
+ dispatchedAt: string;
389
+ }): void {
390
+ const now = new Date().toISOString();
391
+ this.db.prepare(`
392
+ INSERT OR REPLACE INTO mesh_direct_dispatches
393
+ (task_id, mesh_id, node_id, session_id, provider_type, message, task_mode, via,
394
+ status, dispatched_to_idle_session, dispatched_at, updated_at)
395
+ VALUES
396
+ (@taskId, @meshId, @nodeId, @sessionId, @providerType, @message, @taskMode, @via,
397
+ 'dispatched', @dispatchedToIdle, @dispatchedAt, @updatedAt)
398
+ `).run({
399
+ taskId: entry.taskId,
400
+ meshId: entry.meshId,
401
+ nodeId: entry.nodeId ?? null,
402
+ sessionId: entry.sessionId ?? null,
403
+ providerType: entry.providerType ?? null,
404
+ message: entry.message,
405
+ taskMode: entry.taskMode ?? null,
406
+ via: entry.via,
407
+ dispatchedToIdle: entry.dispatchedToIdleSession ? 1 : 0,
408
+ dispatchedAt: entry.dispatchedAt,
409
+ updatedAt: now,
410
+ });
411
+ }
412
+
413
+ getActiveDirectDispatches(meshId: string): Array<{
414
+ taskId: string;
415
+ meshId: string;
416
+ nodeId: string | null;
417
+ sessionId: string | null;
418
+ providerType: string | null;
419
+ message: string;
420
+ taskMode: string | null;
421
+ via: string;
422
+ status: string;
423
+ dispatchedToIdleSession: boolean;
424
+ dispatchedAt: string;
425
+ updatedAt: string;
426
+ }> {
427
+ const rows = this.db.prepare(`
428
+ SELECT task_id, mesh_id, node_id, session_id, provider_type, message, task_mode, via,
429
+ status, dispatched_to_idle_session, dispatched_at, updated_at
430
+ FROM mesh_direct_dispatches
431
+ WHERE mesh_id = ? AND status NOT IN ('completed', 'failed', 'stale')
432
+ ORDER BY dispatched_at ASC
433
+ `).all(meshId) as Array<Record<string, unknown>>;
434
+ return rows.map(r => ({
435
+ taskId: r.task_id as string,
436
+ meshId: r.mesh_id as string,
437
+ nodeId: r.node_id as string | null,
438
+ sessionId: r.session_id as string | null,
439
+ providerType: r.provider_type as string | null,
440
+ message: r.message as string,
441
+ taskMode: r.task_mode as string | null,
442
+ via: r.via as string,
443
+ status: r.status as string,
444
+ dispatchedToIdleSession: (r.dispatched_to_idle_session as number) === 1,
445
+ dispatchedAt: r.dispatched_at as string,
446
+ updatedAt: r.updated_at as string,
447
+ }));
448
+ }
449
+
450
+ updateDirectDispatchStatus(meshId: string, sessionId: string, status: 'acked' | 'completed' | 'failed' | 'stale'): void {
451
+ if (!sessionId) return; // never update rows without a session binding
452
+ const now = new Date().toISOString();
453
+ this.db.prepare(`
454
+ UPDATE mesh_direct_dispatches
455
+ SET status = @status, updated_at = @updatedAt
456
+ WHERE mesh_id = @meshId AND session_id = @sessionId
457
+ AND session_id IS NOT NULL
458
+ AND status NOT IN ('completed', 'failed')
459
+ `).run({ status, meshId, sessionId, updatedAt: now });
460
+ }
461
+
462
+ cleanupTerminalDirectDispatches(olderThanMs: number): void {
463
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
464
+ this.db.prepare(`
465
+ DELETE FROM mesh_direct_dispatches
466
+ WHERE status IN ('completed', 'failed', 'stale') AND updated_at < ?
467
+ `).run(cutoff);
468
+ }
469
+
470
+ markStaleDirectDispatches(meshId: string, olderThanMs: number): void {
471
+ const cutoff = new Date(Date.now() - olderThanMs).toISOString();
472
+ const now = new Date().toISOString();
473
+ this.db.prepare(`
474
+ UPDATE mesh_direct_dispatches
475
+ SET status = 'stale', updated_at = ?
476
+ WHERE mesh_id = ? AND status = 'dispatched' AND dispatched_at < ?
477
+ `).run(now, meshId, cutoff);
478
+ }
176
479
  }
@@ -209,21 +209,16 @@ function buildRulesSection(coordinatorCliType?: string): string {
209
209
 
210
210
  return `## Rules
211
211
 
212
- - **Minimize coordinator context.** The coordinator's job is routing, not implementing. Do not read source files, run commands, or analyze code directly delegate all of that to node agents. Your context should stay lean.
213
- - **Delegate analysis too.** If you need to understand a bug or explore the codebase, send that investigation as a task to the queue or a node. Do not do it yourself.
214
- - **Respect explicit provider requests.** If the user names an agent/provider, pass the matching provider type to \`mesh_launch_session\`: Hermes \`hermes-cli\`, Claude Code/Claude \`claude-cli\`, Codex \`codex-cli\`, Gemini \`gemini-cli\`, Antigravity \`antigravity-cli\`. Never substitute \`claude-cli\` just because the coordinator itself is Claude Code.
215
- - **Front-load new task messages.** When calling \`mesh_enqueue_task\` or \`mesh_send_task\` for a new task, include everything the agent needs: what files to touch, what the problem is, what the fix should look like. The agent won't ask follow-up questions.
216
- - **Avoid context-wasting restarts.** For follow-up, retry, commit/push, preview, or cleanup work on the same issue, prefer the existing idle session and send only the delta from its last verified state. Start a fresh chat/session only for genuinely independent work, explicit provider/user request, unsafe transcript contamination, or required branch/worktree isolation.
217
- - **Don't inspect code.** Treat delegated agent summaries as self-reports, not verification. Verify side effects via \`mesh_git_status\` (including related repo freshness when configured), not by reading source files.
218
- - **Don't over-parallelize.** Start with 1-2 concurrent tasks. Scale up if they succeed. Never launch a duplicate session or second worker solely because \`mesh_read_chat\` has no final assistant message while the delegated session is still showing tool/terminal activity.
219
- - **Handle failures with context.** If a task fails, check \`mesh_task_history\` first to see if this task was attempted before and how it failed. Read the chat to understand why, then decide: retry on the same node, reassign to a different node, or escalate to the user.
220
- - **Check history before starting.** At the beginning of a coordination session, call \`mesh_task_history\` to understand what was previously delegated and its outcomes. This prevents duplicate work and informs recovery decisions.
221
- - **Keep the user informed.** Report progress after each delegation round one or two sentences, not a narration.
222
- - **Respect node capabilities.** Don't send build tasks to read-only nodes. Don't push from nodes that aren't allowed to.
223
- - **Never fabricate tool results.** Always call the actual tool; never pretend you did.
224
- - **Clean up worktree nodes.** After a worktree task completes and its changes are merged or checkpointed, call \`mesh_remove_node\` to free resources.
225
- - **Do not strand completed branches.** A checkpointed or clean feature/worktree branch is not done by itself. Merge/refine it to the mesh default branch, fast-forward obvious clean behind-only branches with \`mesh_fast_forward_node\`, or explicitly report one of \`pushed_feature_branch_needs_merge\`, \`blocked_review\`, \`cleanup_candidate\`, or \`not_mergeable\` with the next action.
226
- - **Keep Refinery validation project-configurable.** \`mesh_refine_node\` must execute validation from repo mesh/refine config (for example \`.adhdev/refine.{json,yaml,yml}\`, \`.adhdev/repo-mesh-refine.*\`, or \`repo-mesh.refine.*\`). Heuristics are suggestions/scaffolding only, not the execution path.
227
- - **Treat submodule main reachability as publish-needed.** A \`submodule_reachability_failed\` refine result means the root gitlink points at a submodule commit that is not reachable from the configured submodule remote main branch. Do not treat feature-branch reachability as complete, retry validation blindly, or start code review first. Classify it as \`blocked_review\`, request user approval to push/publish the submodule commit to submodule main, then rerun \`mesh_refine_node\`, unless the mesh or repo refine config explicitly enabled \`allowAutoPublishSubmoduleMainCommits\` and Refinery reports exact path/commit/remote/branch evidence with post-publish verification.
228
- - **Name worktree branches meaningfully.** Use descriptive names like \`feat/auth-refactor\` or \`fix/build-123\`.${coordinatorNote}`;
212
+ - **Route, don't implement.** Delegate all code reading, analysis, and execution to node agents. Never read source files or run commands in the coordinatorkeep context lean.
213
+ - **Front-load task messages.** Include everything the agent needs (files, problem, expected fix) in \`mesh_enqueue_task\` / \`mesh_send_task\`. Append a structured result request at the end: ask the worker to conclude with a JSON block containing \`status\`, \`changedFiles\`, \`gitStatus\`, \`validationResults\`, \`errors\`, \`nextAction\`. The daemon parses this automatically; you can read it from \`mesh_task_history\`.
214
+ - **Reuse idle sessions.** For follow-up, retry, commit/push, or cleanup on the same issue, send only the delta to the existing idle session. Start fresh only for independent work, provider mismatch, transcript contamination, or required worktree isolation.
215
+ - **Respect explicit provider requests.** Map: Hermes \`hermes-cli\`, Claude/Claude Code \`claude-cli\`, Codex \`codex-cli\`, Gemini \`gemini-cli\`, Antigravity \`antigravity-cli\`. Never substitute the coordinator's own runtime.
216
+ - **Verify via git, not source.** Use \`mesh_git_status\` to confirm side effects. Treat agent summaries as self-reports, not verification.
217
+ - **Limit parallelism.** Start with 1–2 tasks; scale only on success. Never duplicate a session because \`mesh_read_chat\` shows no final message while tool/terminal activity is ongoing.
218
+ - **Check history first.** Call \`mesh_task_history\` at session start to avoid duplicate work and inform recovery. On failure, read task history before retrying.
219
+ - **Converge branches.** After worktree tasks: refine/fast-forward, or classify as \`pushed_feature_branch_needs_merge\` / \`blocked_review\` / \`cleanup_candidate\` / \`not_mergeable\`. Clean up with \`mesh_remove_node\`.
220
+ - **Refinery is config-driven.** \`mesh_refine_node\` must run validation from \`.adhdev/refine.{json,yaml,yml}\` or \`repo-mesh.refine.*\`. Heuristics are scaffolding only.
221
+ - **Submodule reachability = publish-needed.** \`submodule_reachability_failed\` classify as \`blocked_review\`, request user approval to push to submodule main, then rerun \`mesh_refine_node\`.
222
+ - **Never fabricate tool results.** Always call the actual tool.
223
+ - **Keep the user informed.** One or two sentences after each delegation round.${coordinatorNote}`;
229
224
  }