@adhdev/daemon-core 0.9.82-rc.209 → 0.9.82-rc.210

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 (64) hide show
  1. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +2 -0
  2. package/dist/commands/router.d.ts +6 -0
  3. package/dist/git/git-commands.d.ts +2 -0
  4. package/dist/git/git-diff.d.ts +6 -0
  5. package/dist/index.d.ts +11 -5
  6. package/dist/index.js +5699 -3486
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +5679 -3483
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/mesh/coordinator-prompt.d.ts +6 -0
  11. package/dist/mesh/mesh-delivery-policy.d.ts +5 -0
  12. package/dist/mesh/mesh-events-coordinator.d.ts +151 -0
  13. package/dist/mesh/mesh-events-pending.d.ts +33 -0
  14. package/dist/mesh/mesh-events-stale.d.ts +40 -0
  15. package/dist/mesh/mesh-events-utils.d.ts +14 -0
  16. package/dist/mesh/mesh-events.d.ts +5 -198
  17. package/dist/mesh/mesh-ledger-reconciliation.d.ts +23 -3
  18. package/dist/mesh/mesh-ledger.d.ts +19 -0
  19. package/dist/mesh/mesh-missions.d.ts +58 -0
  20. package/dist/mesh/mesh-review-inbox.d.ts +90 -0
  21. package/dist/mesh/mesh-runtime-store.d.ts +175 -0
  22. package/dist/mesh/mesh-task-stats.d.ts +49 -0
  23. package/dist/mesh/mesh-work-queue.d.ts +82 -0
  24. package/dist/mesh/refine-config.d.ts +24 -2
  25. package/dist/mesh/worktree-bootstrap-config.d.ts +22 -0
  26. package/dist/providers/acp-provider-instance.d.ts +2 -0
  27. package/dist/providers/spec/driver.d.ts +8 -0
  28. package/dist/providers/spec/evaluator.d.ts +4 -5
  29. package/dist/providers/spec/loader.d.ts +1 -0
  30. package/dist/providers/spec/schema.gen.d.ts +1409 -6
  31. package/dist/providers/spec/types.d.ts +188 -175
  32. package/dist/repo-mesh-types.d.ts +1 -0
  33. package/package.json +1 -1
  34. package/src/boot/daemon-lifecycle.ts +3 -0
  35. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +20 -7
  36. package/src/commands/router.ts +594 -66
  37. package/src/git/git-commands.ts +5 -5
  38. package/src/git/git-diff.ts +53 -0
  39. package/src/index.ts +11 -5
  40. package/src/mesh/coordinator-prompt.ts +14 -1
  41. package/src/mesh/mesh-delivery-policy.ts +17 -0
  42. package/src/mesh/mesh-events-coordinator.ts +1404 -0
  43. package/src/mesh/mesh-events-pending.ts +371 -0
  44. package/src/mesh/mesh-events-stale.ts +283 -0
  45. package/src/mesh/mesh-events-utils.ts +161 -0
  46. package/src/mesh/mesh-events.ts +27 -2143
  47. package/src/mesh/mesh-ledger-reconciliation.ts +12 -5
  48. package/src/mesh/mesh-ledger.ts +134 -2
  49. package/src/mesh/mesh-missions.ts +151 -0
  50. package/src/mesh/mesh-review-inbox.ts +307 -0
  51. package/src/mesh/mesh-runtime-store.ts +539 -3
  52. package/src/mesh/mesh-task-stats.ts +154 -0
  53. package/src/mesh/mesh-work-queue.ts +233 -17
  54. package/src/mesh/refine-config.ts +42 -5
  55. package/src/mesh/worktree-bootstrap-config.ts +79 -0
  56. package/src/providers/acp-provider-instance.ts +15 -1
  57. package/src/providers/cli-provider-instance.ts +34 -13
  58. package/src/providers/spec/driver.ts +57 -29
  59. package/src/providers/spec/evaluator.ts +302 -112
  60. package/src/providers/spec/loader.ts +226 -37
  61. package/src/providers/spec/schema.gen.ts +450 -334
  62. package/src/providers/spec/schema.json +162 -75
  63. package/src/providers/spec/types.ts +234 -183
  64. package/src/repo-mesh-types.ts +1 -0
@@ -1,6 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'fs';
2
2
  import { join, resolve as pathResolve } from 'path';
3
3
  import { execFile } from 'node:child_process';
4
+ import { createHash } from 'node:crypto';
4
5
  import { promisify } from 'node:util';
5
6
  import * as yaml from 'js-yaml';
6
7
  import {
@@ -31,6 +32,15 @@ export interface WorktreeBootstrapState extends MeshAsyncJobLifecycle {
31
32
  exitCode?: number | null;
32
33
  commandsRun?: Array<Record<string, unknown>>;
33
34
  staleInputs?: string[];
35
+ /**
36
+ * M2-1: sha256 per staleInputs path recorded when the bootstrap reached
37
+ * 'ready'. evaluateWorktreeBootstrapState compares current file hashes
38
+ * against this to detect staleness (e.g. base merge changed a lockfile).
39
+ * Missing files hash to the literal 'absent'.
40
+ */
41
+ staleInputsDigest?: Record<string, string>;
42
+ /** M2-1: why an evaluated state resolved to stale (digest_mismatch | never_ran). */
43
+ staleReason?: string;
34
44
  }
35
45
 
36
46
  export interface WorktreeBootstrapConfigLoadResult {
@@ -151,6 +161,70 @@ export function loadMeshWorktreeBootstrapConfig(mesh: any, workspace: string): W
151
161
  return { source: 'unavailable', sourceType: 'unavailable', error: `No worktree bootstrap config found. Checked: ${MESH_WORKTREE_BOOTSTRAP_CONFIG_LOCATIONS.join(', ')}` };
152
162
  }
153
163
 
164
+ /** M2-1: hash staleInputs files so 'ready' can be invalidated when they change. */
165
+ export function computeStaleInputsDigest(workspace: string, staleInputs: string[] | undefined): Record<string, string> {
166
+ const digest: Record<string, string> = {};
167
+ for (const relative of staleInputs ?? []) {
168
+ const filePath = join(workspace, relative);
169
+ try {
170
+ digest[relative] = createHash('sha256').update(readFileSync(filePath)).digest('hex');
171
+ } catch {
172
+ digest[relative] = 'absent';
173
+ }
174
+ }
175
+ return digest;
176
+ }
177
+
178
+ /**
179
+ * M2-1: the official bootstrap state contract. Resolves the effective state
180
+ * for a node workspace from config presence + the persisted last-run state +
181
+ * a staleInputs digest comparison. Read-only — never runs commands.
182
+ *
183
+ * ready — last run succeeded and staleInputs are unchanged
184
+ * stale — never ran, or a staleInputs file changed since 'ready'
185
+ * running/failed — persisted lifecycle state passes through
186
+ * not_configured / disabled / (invalid → failed) — from config resolution
187
+ */
188
+ export function evaluateWorktreeBootstrapState(mesh: any, workspace: string, persisted?: WorktreeBootstrapState | null): WorktreeBootstrapState {
189
+ const loaded = loadMeshWorktreeBootstrapConfig(mesh, workspace);
190
+ if (!loaded.config) {
191
+ return { status: 'not_configured', required: false, configSource: loaded.source, configSourceType: loaded.sourceType, error: loaded.error };
192
+ }
193
+ const required = loaded.config.required !== false;
194
+ if (loaded.config.enabled === false || loaded.config.runOnClone === false) {
195
+ return { status: 'disabled', required, configSource: loaded.path || loaded.source, configSourceType: loaded.sourceType };
196
+ }
197
+ if (loaded.sourceType === 'invalid') {
198
+ return { status: 'failed', required, configSource: loaded.path || loaded.source, configSourceType: 'invalid', error: loaded.error };
199
+ }
200
+ if (persisted?.status === 'running') return { ...persisted, required };
201
+ if (persisted?.status === 'failed') return { ...persisted, required };
202
+ if (persisted?.status === 'ready') {
203
+ const staleInputs = loaded.config.staleInputs ?? persisted.staleInputs ?? [];
204
+ if (staleInputs.length > 0 && persisted.staleInputsDigest) {
205
+ const current = computeStaleInputsDigest(workspace, staleInputs);
206
+ const changed = staleInputs.filter(p => current[p] !== persisted.staleInputsDigest![p]);
207
+ if (changed.length > 0) {
208
+ return {
209
+ ...persisted,
210
+ status: 'stale',
211
+ required,
212
+ staleReason: `digest_mismatch: ${changed.join(', ')}`,
213
+ };
214
+ }
215
+ }
216
+ return { ...persisted, required };
217
+ }
218
+ return {
219
+ status: 'stale',
220
+ required,
221
+ configSource: loaded.path || loaded.source,
222
+ configSourceType: loaded.sourceType,
223
+ staleInputs: loaded.config.staleInputs,
224
+ staleReason: 'never_ran',
225
+ };
226
+ }
227
+
154
228
  export async function runMeshWorktreeBootstrap(mesh: any, workspace: string): Promise<WorktreeBootstrapState> {
155
229
  const loaded = loadMeshWorktreeBootstrapConfig(mesh, workspace);
156
230
  if (!loaded.config) {
@@ -243,5 +317,10 @@ export async function runMeshWorktreeBootstrap(mesh: any, workspace: string): Pr
243
317
  state.status = 'ready';
244
318
  state.exitCode = 0;
245
319
  state.completedAt = new Date().toISOString();
320
+ // M2-1: record staleInputs hashes so evaluateWorktreeBootstrapState can
321
+ // invalidate this 'ready' when an input (e.g. lockfile) changes later.
322
+ if (staleInputPaths.length > 0) {
323
+ state.staleInputsDigest = computeStaleInputsDigest(workspace, staleInputPaths);
324
+ }
246
325
  return state;
247
326
  }
@@ -287,6 +287,8 @@ export class AcpProviderInstance implements ProviderInstance {
287
287
  private partialBlocks: ContentBlock[] = [];
288
288
  /** Tool calls collected during current turn */
289
289
  private turnToolCalls: ToolCallInfo[] = [];
290
+ /** Guard: prevent concurrent sendPrompt calls from racing on shared state */
291
+ private _sendPromptInFlight = false;
290
292
 
291
293
  // Error tracking
292
294
  private errorMessage: string | null = null;
@@ -1002,7 +1004,11 @@ export class AcpProviderInstance implements ProviderInstance {
1002
1004
  }
1003
1005
  } catch (e: any) {
1004
1006
  this.log.warn(`[${this.type}] session/new failed: ${e?.message}`);
1005
- this.currentStatus = 'idle';
1007
+ if (!this.errorReason) {
1008
+ this.errorReason = 'init_failed';
1009
+ this.errorMessage = `ACP session creation failed: ${e?.message}`;
1010
+ }
1011
+ this.currentStatus = 'error';
1006
1012
  }
1007
1013
  }
1008
1014
 
@@ -1012,6 +1018,12 @@ export class AcpProviderInstance implements ProviderInstance {
1012
1018
  return;
1013
1019
  }
1014
1020
 
1021
+ if (this._sendPromptInFlight) {
1022
+ this.log.warn(`[${this.type}] sendPrompt already in flight — dropping concurrent request`);
1023
+ throw new Error('ACP sendPrompt already in flight');
1024
+ }
1025
+ this._sendPromptInFlight = true;
1026
+
1015
1027
  // Build prompt content
1016
1028
  const promptParts: any[] = contentBlocks && contentBlocks.length > 0
1017
1029
  ? contentBlocks.map((b) => {
@@ -1098,6 +1110,8 @@ export class AcpProviderInstance implements ProviderInstance {
1098
1110
  this.finalizeAssistantMessage();
1099
1111
  this.currentStatus = 'idle';
1100
1112
  this.detectStatusTransition();
1113
+ } finally {
1114
+ this._sendPromptInFlight = false;
1101
1115
  }
1102
1116
  }
1103
1117
 
@@ -1561,6 +1561,15 @@ export class CliProviderInstance implements ProviderInstance {
1561
1561
  }
1562
1562
  } else if (newStatus === 'idle' && (this.lastStatus === 'generating' || this.lastStatus === 'waiting_approval')) {
1563
1563
  const duration = this.generatingStartedAt ? Math.round((now - this.generatingStartedAt) / 1000) : 0;
1564
+ // Guard: if generatingStartedAt===0 and no debounce pending, the generating phase
1565
+ // was entered from 'starting' state (startup PTY noise), not from a real idle→generating
1566
+ // task dispatch. The idle→generating handler is the only code path that sets
1567
+ // generatingStartedAt and generatingDebouncePending, so both being absent means no
1568
+ // task was ever dispatched. Suppress the spurious completion event and fall through
1569
+ // to a simple lastStatus update.
1570
+ if (!this.generatingStartedAt && !this.generatingDebouncePending) {
1571
+ LOG.debug('CLI', `[${this.type}] suppressed startup-phase generating→idle blip (generatingStartedAt=0, no debounce pending)`);
1572
+ } else
1564
1573
  // If debounce still pending (generating lasted < 1s), cancel both UI events.
1565
1574
  // Still emit agent:generating_completed so mesh orchestration can record
1566
1575
  // task_completed for direct dispatches that complete faster than the debounce.
@@ -1589,19 +1598,31 @@ export class CliProviderInstance implements ProviderInstance {
1589
1598
  if (missingEvidence) {
1590
1599
  LOG.warn('CLI', `[${this.type}] short completion missing final assistant evidence (source=${shortEvidenceSource})`);
1591
1600
  }
1592
- this.pushEvent({
1593
- event: 'agent:generating_completed',
1594
- chatTitle,
1595
- duration: 0,
1596
- timestamp: now,
1597
- finalSummary: shortFinalSummary,
1598
- completionDiagnostic: {
1599
- reason: 'short_generating_suppressed',
1600
- shortDurationMs,
1601
- finalAssistantEvidenceSource: shortEvidenceSource,
1602
- ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
1603
- },
1604
- });
1601
+ // When evidence is missing and there is no active mesh task context, suppress
1602
+ // the completion event. Providers with requiresFinalAssistantBeforeIdle or
1603
+ // external-native history must confirm a final assistant message before the
1604
+ // coordinator records task_completed. Only emit here if a mesh task is active
1605
+ // so the coordinator can apply its own timeout/retry logic.
1606
+ const hasMeshContext = !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.launchedByCoordinator);
1607
+ if (missingEvidence && !hasMeshContext) {
1608
+ LOG.info('CLI', `[${this.type}] short completion suppressed: missing final assistant evidence, no mesh context (source=${shortEvidenceSource})`);
1609
+ // completedDebouncePending intentionally left null — the session is now idle
1610
+ // with no confirmed turn, matching the startup-blip suppression semantics.
1611
+ } else {
1612
+ this.pushEvent({
1613
+ event: 'agent:generating_completed',
1614
+ chatTitle,
1615
+ duration: 0,
1616
+ timestamp: now,
1617
+ finalSummary: shortFinalSummary,
1618
+ completionDiagnostic: {
1619
+ reason: 'short_generating_suppressed',
1620
+ shortDurationMs,
1621
+ finalAssistantEvidenceSource: shortEvidenceSource,
1622
+ ...(missingEvidence ? { blockReason: 'missing_final_assistant' } : {}),
1623
+ },
1624
+ });
1625
+ }
1605
1626
  } else {
1606
1627
  // Debounce completed, then require the rich transcript path that read_chat
1607
1628
  // uses to show an idle turn whose last user-facing message is assistant.
@@ -41,7 +41,7 @@ import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
41
41
  import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
42
42
  import { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
43
43
  import { loadSpec } from './loader.js';
44
- import type { CliSpec, Control, DelegateTrigger } from './types.js';
44
+ import type { CliSpec, Control, DelegateTrigger, SectionDef } from './types.js';
45
45
  import { LOG } from '../../logging/logger.js';
46
46
 
47
47
  export type DashboardEvent =
@@ -161,32 +161,27 @@ export function matchesCompletionIdleTargetState(
161
161
  screen: string,
162
162
  cursor?: { row: number; col: number },
163
163
  ): boolean {
164
- const target = spec.states.find(state => state.id === spec.default_state)
164
+ // Test whether the idle/default state's when-condition actually matches
165
+ // the current screen, WITHOUT the default fallback. We don't want to
166
+ // return true just because no other state matched (which is what
167
+ // evaluate() does via its default_state fallback).
168
+ const targetId = spec.default_state ?? 'idle';
169
+ const target = spec.states.find(state => state.id === targetId)
165
170
  ?? spec.states.find(state => state.id === 'idle');
166
- if (!target?.when) return false;
167
-
168
- // Cursor-only idle: if the target state has cursor guards but no regex,
169
- // treat a cursor match alone as sufficient.
170
- const hasCursorGuard = target.when.cursor_row_min !== undefined
171
- || target.when.cursor_row_max !== undefined
172
- || target.when.cursor_col_min !== undefined
173
- || target.when.cursor_col_max !== undefined;
174
- if (hasCursorGuard && cursor !== undefined) {
175
- const { cursor_row_min, cursor_row_max, cursor_col_min, cursor_col_max } = target.when;
176
- const cursorOk = (cursor_row_min === undefined || cursor.row >= cursor_row_min)
177
- && (cursor_row_max === undefined || cursor.row <= cursor_row_max)
178
- && (cursor_col_min === undefined || cursor.col >= cursor_col_min)
179
- && (cursor_col_max === undefined || cursor.col <= cursor_col_max);
180
- if (cursorOk) return true;
181
- }
182
-
183
- if (!target.when.regex) return false;
184
- const haystack = target.when.section
185
- ? ev.sections.find(section => section.id === target.when.section)?.text ?? ''
186
- : screen;
187
- if (!haystack) return false;
171
+ if (!target) return false;
172
+
173
+ // Re-use the resolved sections already computed by evaluate().
174
+ const sections = ev.sections;
175
+ const cleanScreen = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l).join('\n');
176
+
188
177
  try {
189
- return new RegExp(target.when.regex, target.when.flags || 'i').test(haystack);
178
+ // Import the condition evaluator — but we need it as a module-level fn.
179
+ // Instead, call evaluate() and check: if the evaluator chose the target
180
+ // via an *explicit* state match (not fallback), return true.
181
+ // We detect a fallback via trace: the fallback emits "(no state matched".
182
+ const result = evaluate(spec, screen, cursor);
183
+ const wasFallback = result.trace.some(t => t.text.startsWith('(no state matched'));
184
+ return result.state.id === targetId && !wasFallback;
190
185
  } catch {
191
186
  return false;
192
187
  }
@@ -225,8 +220,16 @@ export class SpecDriver {
225
220
  private lastModalAt = 0;
226
221
  /** The modal state snapshot held across busy blips. */
227
222
  private lastModalState: SpecEvaluation['state'] | null = null;
223
+ /** Timestamp of when we last *exited* a modal state (approval/picker → idle
224
+ * or directly via completion_idle_after). Used to suppress completion_idle_after
225
+ * firings that were queued before the modal appeared and expire immediately
226
+ * after the modal is dismissed — without this the agent appears idle even
227
+ * though it is still generating. */
228
+ private lastModalExitAt = 0;
228
229
  private completionIdleFirstSeenAt = 0;
229
230
  private completionIdleKey = '';
231
+ /** Previous screen lines — passed to evaluate() for `changed` condition detection. */
232
+ private prevScreenLines: string[] = [];
230
233
  /** Timer that re-runs evaluate() once the hold window expires. Needed
231
234
  * because the PTY stops emitting once the agent finishes; without an
232
235
  * explicit wake-up there's nothing to trigger the busy → idle
@@ -457,7 +460,9 @@ export class SpecDriver {
457
460
  private reevaluate(forceEmit = false): void {
458
461
  const screen = this.adapter.snapshot();
459
462
  const cursor = this.adapter.getCursorPosition();
460
- const ev = evaluate(this.spec, screen, cursor);
463
+ const ev = evaluate(this.spec, screen, cursor, this.prevScreenLines.length > 0 ? this.prevScreenLines : undefined);
464
+ // Update prevScreenLines for next evaluation's `changed` condition detection.
465
+ this.prevScreenLines = screen.split('\n').map(l => l.endsWith('\r') ? l.slice(0, -1) : l);
461
466
 
462
467
  // Busy hold: many TUIs flicker between busy and idle every frame
463
468
  // (claude in particular — its token counter appears and disappears
@@ -499,7 +504,18 @@ export class SpecDriver {
499
504
  }
500
505
  const completionIdleRule = this.spec.debounce?.completion_idle_after;
501
506
  let busyWakeMs = busyHoldMs;
502
- if (evState.id === 'busy' && completionIdleRule) {
507
+ // Don't fire completion_idle_after while in a modal state (approval,
508
+ // picker, etc.) or within a grace period after leaving one. Two cases:
509
+ // 1. Modal still active: the hold window may have expired so evState
510
+ // resolves to busy, but the user is still looking at the approval screen.
511
+ // 2. Modal just dismissed: the timer may have been queued *before* the
512
+ // approval appeared and its hold expires immediately after dismissal,
513
+ // causing a false-idle even though the agent is still generating.
514
+ const postModalGraceMs = busyHoldMs;
515
+ const now = Date.now();
516
+ const recentlyInModal = isModalState(this.currentStateId) || (this.lastModalAt > 0 && now - this.lastModalAt < postModalGraceMs);
517
+ const recentlyLeftModal = !recentlyInModal && this.lastModalExitAt > 0 && now - this.lastModalExitAt < postModalGraceMs;
518
+ if (evState.id === 'busy' && completionIdleRule && !recentlyInModal && !recentlyLeftModal) {
503
519
  const completionKey = matchesCompletionIdleRule(this.spec, ev, screen);
504
520
  if (completionKey) {
505
521
  const now = Date.now();
@@ -551,7 +567,15 @@ export class SpecDriver {
551
567
  // a new tool-output burst arrived that pushed the marker off
552
568
  // screen — in that case the old firstSeenAt is stale and should
553
569
  // restart when the marker reappears.
554
- if (!this.completionIdleKey) {
570
+ //
571
+ // Also reset on busy re-entry (transitioning back from a non-busy
572
+ // state). The previous generation may have set completionIdleKey
573
+ // and firstSeenAt; without this reset the hold window appears
574
+ // instantly expired on the new generation's first PTY frame that
575
+ // re-matches the same completion regex key, causing a false-idle
576
+ // before the new generation has a chance to run.
577
+ if (!this.completionIdleKey || this.currentStateId !== 'busy') {
578
+ this.completionIdleKey = '';
555
579
  this.completionIdleFirstSeenAt = 0;
556
580
  }
557
581
  // Schedule a re-evaluation when the hold window expires. PTYs
@@ -572,7 +596,10 @@ export class SpecDriver {
572
596
  this.lastModalAt = Date.now();
573
597
  this.lastModalState = evState;
574
598
  } else {
575
- // Leaving modal territory (going to idle) — clear the hold.
599
+ // Leaving modal territory (going to idle) — clear the hold,
600
+ // but record the exit time so completion_idle_after can be
601
+ // suppressed during the grace period after dismissal.
602
+ if (this.lastModalAt > 0) this.lastModalExitAt = Date.now();
576
603
  this.lastModalAt = 0;
577
604
  this.lastModalState = null;
578
605
  }
@@ -819,6 +846,7 @@ export class SpecDriver {
819
846
  const action = picker.spec.action;
820
847
  if (action.type !== 'open_picker') return;
821
848
  const hay = sectionTextFromSnapshot(this.spec, screen, action.wait_for.section) ?? screen;
849
+ if (!action.wait_for.regex) return;
822
850
  const re = new RegExp(action.wait_for.regex, action.wait_for.flags ?? 'i');
823
851
  if (!re.test(hay)) return;
824
852
  // Cue arrived — the dashboard now sees the picker modal via