@adhdev/daemon-core 0.9.82-rc.197 → 0.9.82-rc.199

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.
@@ -135,7 +135,15 @@ export declare class SpecDriver {
135
135
  * explicit wake-up there's nothing to trigger the busy → idle
136
136
  * downshift. */
137
137
  private busyExpiryTimer;
138
+ /** Pending idle-commit timer. Armed when the evaluator first returns idle;
139
+ * fires after idle_hold_ms if no non-idle reading has cancelled it. */
140
+ private idleHoldTimer;
141
+ /** State snapshot captured when the idle hold was armed — emitted on commit. */
142
+ private pendingIdleState;
138
143
  private specWatcher;
144
+ /** Ring buffer of committed state transitions (max 50). */
145
+ private stateHistory;
146
+ private prevStateAt;
139
147
  constructor(opts: SpecDriverOpts);
140
148
  /** Subscribe to outbound events. Returns an unsubscribe fn. */
141
149
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
@@ -147,6 +155,17 @@ export declare class SpecDriver {
147
155
  col: number;
148
156
  };
149
157
  shutdown(): void;
158
+ private cancelIdleHold;
159
+ private pushHistory;
160
+ getStateHistory(): ReadonlyArray<{
161
+ stateId: string;
162
+ label: string;
163
+ at: number;
164
+ durationMs: number;
165
+ }>;
166
+ getLastBusyAt(): number;
167
+ hasIdleHoldPending(): boolean;
168
+ getSpecPath(): string;
150
169
  private loadSpecOrThrow;
151
170
  private buildAdapterOpts;
152
171
  private armSpecWatcher;
@@ -249,6 +249,12 @@ export interface CliSpec {
249
249
  * Absorbs per-frame flicker in TUIs that stream output through
250
250
  * the same region as the spinner. */
251
251
  busy_hold_ms?: number;
252
+ /** Min time the idle state must remain matched before it is
253
+ * committed. Filters transient idle flickers that appear during
254
+ * approval dismissals, layout reflows, or brief spinner gaps.
255
+ * Any non-idle reading within the window cancels the transition.
256
+ * When omitted the idle transition is immediate (legacy behaviour). */
257
+ idle_hold_ms?: number;
252
258
  /** Min time after start() before a send_message is allowed to
253
259
  * reach the PTY. Banner paints + auth flows + skill listings
254
260
  * can keep the agent unable to accept input for several seconds
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.197",
3
+ "version": "0.9.82-rc.199",
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",
@@ -4178,6 +4178,25 @@ export class DaemonCommandRouter {
4178
4178
  };
4179
4179
  }
4180
4180
 
4181
+ case 'get_spec_debug': {
4182
+ const sessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim()
4183
+ : typeof args?.sessionId === 'string' ? args.sessionId.trim() : '';
4184
+ if (!sessionId) return { success: false, error: 'targetSessionId required' };
4185
+ const target = this.deps.sessionRegistry.get(sessionId);
4186
+ if (!target) return { success: false, error: 'Session not found', sessionId };
4187
+ const adapter = this.deps.cliManager.findAdapter(target.providerType, { instanceKey: sessionId })?.adapter;
4188
+ const snapshot = (adapter && typeof (adapter as any).getDebugSnapshot === 'function')
4189
+ ? (adapter as any).getDebugSnapshot()
4190
+ : null;
4191
+ return {
4192
+ success: true,
4193
+ sessionId,
4194
+ providerType: target.providerType,
4195
+ isSpecProvider: snapshot !== null,
4196
+ snapshot,
4197
+ };
4198
+ }
4199
+
4181
4200
  // ── User-level coordinator-prompt files (~/.adhdev/coordinator-prompts/).
4182
4201
  // These live on this daemon's filesystem and never sync to the
4183
4202
  // cloud / other daemons — they're per-machine config. The
@@ -349,6 +349,10 @@ export class SpecCliAdapter implements CliAdapter {
349
349
  exited: this.exited,
350
350
  screen,
351
351
  sections,
352
+ stateHistory: this.driver.getStateHistory(),
353
+ idleHoldPending: this.driver.hasIdleHoldPending(),
354
+ lastBusyAt: this.driver.getLastBusyAt(),
355
+ specPath: this.driver.getSpecPath(),
352
356
  };
353
357
  }
354
358
  getRuntimeMetadata(): unknown {
@@ -218,7 +218,15 @@ export class SpecDriver {
218
218
  * explicit wake-up there's nothing to trigger the busy → idle
219
219
  * downshift. */
220
220
  private busyExpiryTimer: ReturnType<typeof setTimeout> | null = null;
221
+ /** Pending idle-commit timer. Armed when the evaluator first returns idle;
222
+ * fires after idle_hold_ms if no non-idle reading has cancelled it. */
223
+ private idleHoldTimer: ReturnType<typeof setTimeout> | null = null;
224
+ /** State snapshot captured when the idle hold was armed — emitted on commit. */
225
+ private pendingIdleState: SpecEvaluation['state'] | null = null;
221
226
  private specWatcher: fs.FSWatcher | null = null;
227
+ /** Ring buffer of committed state transitions (max 50). */
228
+ private stateHistory: Array<{ stateId: string; label: string; at: number; durationMs: number }> = [];
229
+ private prevStateAt = 0;
222
230
 
223
231
  constructor(private readonly opts: SpecDriverOpts) {
224
232
  this.loadSpecOrThrow();
@@ -277,10 +285,33 @@ export class SpecDriver {
277
285
  shutdown(): void {
278
286
  for (const t of this.delegateTimers.values()) clearTimeout(t);
279
287
  this.delegateTimers.clear();
288
+ this.cancelIdleHold();
289
+ if (this.busyExpiryTimer) { clearTimeout(this.busyExpiryTimer); this.busyExpiryTimer = null; }
280
290
  this.specWatcher?.close();
281
291
  this.adapter.kill();
282
292
  }
283
293
 
294
+ private cancelIdleHold(): void {
295
+ if (this.idleHoldTimer) { clearTimeout(this.idleHoldTimer); this.idleHoldTimer = null; }
296
+ this.pendingIdleState = null;
297
+ }
298
+
299
+ private pushHistory(stateId: string, label: string): void {
300
+ const now = Date.now();
301
+ const durationMs = this.prevStateAt > 0 ? now - this.prevStateAt : 0;
302
+ this.prevStateAt = now;
303
+ this.stateHistory.push({ stateId, label, at: now, durationMs });
304
+ if (this.stateHistory.length > 50) this.stateHistory.shift();
305
+ }
306
+
307
+ getStateHistory(): ReadonlyArray<{ stateId: string; label: string; at: number; durationMs: number }> {
308
+ return this.stateHistory;
309
+ }
310
+
311
+ getLastBusyAt(): number { return this.lastBusyAt; }
312
+ hasIdleHoldPending(): boolean { return this.idleHoldTimer !== null; }
313
+ getSpecPath(): string { return this.opts.specPath; }
314
+
284
315
  // ────────────────────────────────────────────────────────────────────
285
316
  // Loading & adapter wiring
286
317
  // ────────────────────────────────────────────────────────────────────
@@ -307,9 +338,13 @@ export class SpecDriver {
307
338
 
308
339
  private armSpecWatcher(): void {
309
340
  try {
310
- this.specWatcher = fs.watch(this.opts.specPath, { persistent: false }, () => {
311
- // Re-read; if it parses, replace and re-evaluate. Errors are
312
- // surfaced to the dashboard so the spec author sees them live.
341
+ // Watch the parent directory so we catch atomic replacements
342
+ // (cp, install scripts) that create a new inode — a file-level
343
+ // watch misses those on macOS because the original inode is gone.
344
+ const dir = path.dirname(this.opts.specPath);
345
+ const base = path.basename(this.opts.specPath);
346
+ this.specWatcher = fs.watch(dir, { persistent: false }, (_event, filename) => {
347
+ if (filename && filename !== base) return;
313
348
  const res = loadSpec(this.opts.specPath);
314
349
  if (!res.ok) { this.emit({ kind: 'spec_error', errors: res.errors }); return; }
315
350
  this.spec = res.spec;
@@ -411,12 +446,58 @@ export class SpecDriver {
411
446
  if (evState.id === 'busy') {
412
447
  this.lastBusyAt = Date.now();
413
448
  this.lastBusyState = evState;
449
+ // Cancel any pending idle commit — non-idle reading invalidates it.
450
+ this.cancelIdleHold();
414
451
  // Schedule a re-evaluation when the hold window expires. PTYs
415
452
  // typically stop emitting once the agent stops printing (the
416
453
  // footer settles), so without an explicit timer the driver
417
454
  // never wakes up to downshift to idle and the dashboard sees
418
455
  // status stuck at generating long after the turn ended.
419
456
  this.scheduleBusyExpiry(busyWakeMs);
457
+ } else if (evState.id !== this.currentStateId && evState.id !== 'busy') {
458
+ // Non-busy modal states (approval, picker, signing_in) also cancel
459
+ // any in-flight idle hold — they are higher-priority than idle.
460
+ if (evState.id !== (this.spec.default_state ?? 'idle')) {
461
+ this.cancelIdleHold();
462
+ }
463
+ }
464
+
465
+ // Idle hold: if idle_hold_ms is set, don't commit idle immediately.
466
+ // Arm a timer; if a non-idle reading arrives before it fires, cancel.
467
+ const idleHoldMs = this.spec.debounce?.idle_hold_ms ?? 0;
468
+ const isIdleState = evState.id === (this.spec.default_state ?? 'idle');
469
+ if (isIdleState && idleHoldMs > 0 && this.currentStateId !== evState.id) {
470
+ if (!this.idleHoldTimer) {
471
+ this.pendingIdleState = evState;
472
+ this.idleHoldTimer = setTimeout(() => {
473
+ this.idleHoldTimer = null;
474
+ const committed = this.pendingIdleState;
475
+ this.pendingIdleState = null;
476
+ if (!committed) return;
477
+ LOG.debug('SpecDriver', `[${this.opts.specPath.split('/').slice(-3).join('/')}] idleHold committed after ${idleHoldMs}ms`);
478
+ this.currentStateId = committed.id;
479
+ this.currentEval = ev;
480
+ this.pushHistory(committed.id, committed.label);
481
+ this.emit({
482
+ kind: 'state_changed',
483
+ state: committed,
484
+ modal: null,
485
+ controls: ev.controls.map(c => ({ id: c.id, label: c.label, action_type: c.actionType })),
486
+ });
487
+ this.armOrCancelDelegateTimers(committed.id);
488
+ if (this.opts.emitTrace) this.emit({ kind: 'spec_trace', entries: ev.trace });
489
+ }, idleHoldMs);
490
+ }
491
+ // Don't fall through to the normal changed/emit path for idle.
492
+ this.currentEval = ev;
493
+ const graceMs2 = this.spec.debounce?.startup_grace_ms ?? STARTUP_GRACE_MS;
494
+ if (!this.idleSeenOnce && Date.now() - this.startedAtMs >= graceMs2) {
495
+ this.idleSeenOnce = true;
496
+ const queued = this.pendingSends.splice(0);
497
+ for (const text of queued) setTimeout(() => this.actuallySendMessage(text), 50);
498
+ }
499
+ if (this.pickerInProgress) this.tryAdvancePicker(screen);
500
+ return;
420
501
  }
421
502
 
422
503
  const changed = forceEmit
@@ -449,6 +530,7 @@ export class SpecDriver {
449
530
  }
450
531
  if (changed) {
451
532
  this.currentStateId = evState.id;
533
+ this.pushHistory(evState.id, evState.label);
452
534
  this.emit({
453
535
  kind: 'state_changed',
454
536
  state: evState,
@@ -268,6 +268,12 @@ export interface CliSpec {
268
268
  * Absorbs per-frame flicker in TUIs that stream output through
269
269
  * the same region as the spinner. */
270
270
  busy_hold_ms?: number;
271
+ /** Min time the idle state must remain matched before it is
272
+ * committed. Filters transient idle flickers that appear during
273
+ * approval dismissals, layout reflows, or brief spinner gaps.
274
+ * Any non-idle reading within the window cancels the transition.
275
+ * When omitted the idle transition is immediate (legacy behaviour). */
276
+ idle_hold_ms?: number;
271
277
  /** Min time after start() before a send_message is allowed to
272
278
  * reach the PTY. Banner paints + auth flows + skill listings
273
279
  * can keep the agent unable to accept input for several seconds