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

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 (30) hide show
  1. package/dist/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +1 -3
  2. package/dist/cli-adapters/terminal-backends/types.d.ts +1 -2
  3. package/dist/cli-adapters/terminal-screen.d.ts +2 -11
  4. package/dist/index.js +155 -213
  5. package/dist/index.js.map +1 -1
  6. package/dist/index.mjs +140 -198
  7. package/dist/index.mjs.map +1 -1
  8. package/dist/providers/spec/adapter.d.ts +11 -19
  9. package/dist/providers/spec/driver.d.ts +14 -0
  10. package/dist/providers/spec/schema.gen.d.ts +24 -1
  11. package/dist/providers/spec/types.d.ts +8 -1
  12. package/dist/shared-types-extra.d.ts +1 -3
  13. package/package.json +1 -3
  14. package/src/cli-adapters/provider-cli-adapter.ts +3 -2
  15. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.ts +11 -34
  16. package/src/cli-adapters/terminal-backends/types.ts +1 -3
  17. package/src/cli-adapters/terminal-screen.ts +11 -81
  18. package/src/commands/mesh-coordinator.ts +3 -2
  19. package/src/daemon/dev-auto-implement.ts +3 -2
  20. package/src/providers/spec/adapter.ts +40 -78
  21. package/src/providers/spec/cli-adapter.ts +2 -0
  22. package/src/providers/spec/driver.ts +68 -13
  23. package/src/providers/spec/evaluator.ts +44 -14
  24. package/src/providers/spec/loader.ts +6 -1
  25. package/src/providers/spec/schema.gen.ts +17 -1
  26. package/src/providers/spec/types.ts +5 -1
  27. package/src/shared-types-extra.ts +1 -3
  28. package/dist/cli-adapters/terminal-backends/xterm-backend.d.ts +0 -17
  29. package/src/cli-adapters/terminal-backends/ghostty-vt-backend.d.ts +0 -16
  30. package/src/cli-adapters/terminal-backends/xterm-backend.ts +0 -104
@@ -360,6 +360,8 @@ export class SpecCliAdapter implements CliAdapter {
360
360
  idleHoldPending: this.driver.hasIdleHoldPending(),
361
361
  lastBusyAt: this.driver.getLastBusyAt(),
362
362
  specPath: this.driver.getSpecPath(),
363
+ cursorPosition: this.driver.getCursorPosition(),
364
+ completionIdleDebounce: this.driver.getCompletionIdleDebounceState(),
363
365
  // Extended fields
364
366
  name: this.cliName,
365
367
  status: this.getStatus().status,
@@ -38,6 +38,7 @@ import * as os from 'node:os';
38
38
  import * as path from 'node:path';
39
39
  import { TerminalAdapter, type TerminalAdapterOpts } from './adapter.js';
40
40
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
41
+ import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
41
42
  import { evaluate, type SpecEvaluation, type TraceEntry } from './evaluator.js';
42
43
  import { loadSpec } from './loader.js';
43
44
  import type { CliSpec, Control, DelegateTrigger } from './types.js';
@@ -142,8 +143,13 @@ export function matchesCompletionIdleRule(spec: CliSpec, ev: SpecEvaluation, scr
142
143
  if (!haystack) return null;
143
144
  try {
144
145
  const regex = new RegExp(rule.regex, rule.flags || '');
145
- const match = haystack.match(regex);
146
- return match?.[0] || null;
146
+ const matched = regex.test(haystack);
147
+ // Return the regex pattern as the stable key rather than the match
148
+ // text. The match text often contains a live counter (e.g. "Compacted
149
+ // for 1m 6s") that changes every second, which would reset
150
+ // completionIdleFirstSeenAt on every PTY frame and prevent the hold
151
+ // window from ever expiring.
152
+ return matched ? rule.regex : null;
147
153
  } catch {
148
154
  return null;
149
155
  }
@@ -211,6 +217,14 @@ export class SpecDriver {
211
217
  * because the evaluator already moved past busy by the time the hold
212
218
  * kicks in. */
213
219
  private lastBusyState: SpecEvaluation['state'] | null = null;
220
+ /** Timestamp of the last time we entered a modal state (approval/picker or
221
+ * any non-busy non-idle state). Used to suppress brief busy blips that
222
+ * appear while the modal is still on screen — Claude Code streams body
223
+ * text that transiently shows a spinner even while an approval modal is
224
+ * visible, causing rapid approval→busy→approval flicker on the dashboard. */
225
+ private lastModalAt = 0;
226
+ /** The modal state snapshot held across busy blips. */
227
+ private lastModalState: SpecEvaluation['state'] | null = null;
214
228
  private completionIdleFirstSeenAt = 0;
215
229
  private completionIdleKey = '';
216
230
  /** Timer that re-runs evaluate() once the hold window expires. Needed
@@ -353,6 +367,17 @@ export class SpecDriver {
353
367
  getLastBusyAt(): number { return this.lastBusyAt; }
354
368
  hasIdleHoldPending(): boolean { return this.idleHoldTimer !== null; }
355
369
  getSpecPath(): string { return this.opts.specPath; }
370
+ getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null {
371
+ if (!this.completionIdleKey || !this.completionIdleFirstSeenAt) return null;
372
+ const rule = this.spec.debounce?.completion_idle_after;
373
+ if (!rule) return null;
374
+ return {
375
+ active: true,
376
+ ageMs: Date.now() - this.completionIdleFirstSeenAt,
377
+ holdMs: rule.hold_ms ?? 0,
378
+ forceAfterMs: typeof rule.force_after_ms === 'number' ? rule.force_after_ms : 0,
379
+ };
380
+ }
356
381
  getScreen(): string { return this.adapter.snapshot(); }
357
382
  getSections(): Array<{ id: string; text: string }> | null {
358
383
  try {
@@ -380,8 +405,8 @@ export class SpecDriver {
380
405
  args: [...baseArgs, ...extra],
381
406
  cwd: this.opts.workingDir,
382
407
  env: { ...(this.spec.env ?? {}), ...(this.opts.extraEnv ?? {}) },
383
- cols: this.opts.cols ?? 100,
384
- rows: this.opts.rows ?? 30,
408
+ cols: this.opts.cols ?? DEFAULT_SESSION_HOST_COLS,
409
+ rows: this.opts.rows ?? DEFAULT_SESSION_HOST_ROWS,
385
410
  transportFactory: this.opts.transportFactory,
386
411
  };
387
412
  }
@@ -454,6 +479,24 @@ export class SpecDriver {
454
479
  evState = this.lastBusyState ?? evState;
455
480
  }
456
481
  }
482
+ // Modal hold: when in a modal state (approval, picker, etc.) a brief
483
+ // busy reading should not interrupt the modal. Claude Code streams
484
+ // body content while the approval modal is visible, causing a spinner
485
+ // to appear transiently — without this hold, the dashboard sees a
486
+ // rapid modal→busy→modal flicker and the approval UI disappears and
487
+ // reappears every few seconds. Apply the same busy_hold_ms window:
488
+ // if the modal was entered recently and the evaluator now returns
489
+ // busy, stay in the modal state until the hold expires or a non-busy
490
+ // non-modal reading arrives.
491
+ const idleStateId = this.spec.default_state ?? 'idle';
492
+ const isModalState = (id: string | null) =>
493
+ id !== null && id !== 'busy' && id !== idleStateId;
494
+ if (isModalState(this.currentStateId) && evState.id === 'busy') {
495
+ const ageMs = Date.now() - this.lastModalAt;
496
+ if (ageMs < busyHoldMs && this.lastModalState) {
497
+ evState = this.lastModalState;
498
+ }
499
+ }
457
500
  const completionIdleRule = this.spec.debounce?.completion_idle_after;
458
501
  let busyWakeMs = busyHoldMs;
459
502
  if (evState.id === 'busy' && completionIdleRule) {
@@ -500,15 +543,17 @@ export class SpecDriver {
500
543
  this.lastBusyState = evState;
501
544
  // Cancel any pending idle commit — non-idle reading invalidates it.
502
545
  this.cancelIdleHold();
503
- // Reset completion_idle_after tracking on busy re-entry. If the
504
- // completion marker is still on screen when a new PTY burst
505
- // arrives (rapid tool-output toggle), the old firstSeenAt would
506
- // make ageMs >= holdMs immediately on the very next reevaluate,
507
- // forcing idle again before the new output has settled and
508
- // creating a rapid busy↔idle loop. Resetting here forces the
509
- // hold window to restart from the current moment.
510
- this.completionIdleKey = '';
511
- this.completionIdleFirstSeenAt = 0;
546
+ // Reset completion_idle_after tracking only when the completion
547
+ // marker is NOT present. If the marker is on screen (key is set
548
+ // by the block above), clearing it here would restart the hold
549
+ // window on every PTY frame and the hold would never expire.
550
+ // Only reset when the marker disappeared (key is empty), meaning
551
+ // a new tool-output burst arrived that pushed the marker off
552
+ // screen in that case the old firstSeenAt is stale and should
553
+ // restart when the marker reappears.
554
+ if (!this.completionIdleKey) {
555
+ this.completionIdleFirstSeenAt = 0;
556
+ }
512
557
  // Schedule a re-evaluation when the hold window expires. PTYs
513
558
  // typically stop emitting once the agent stops printing (the
514
559
  // footer settles), so without an explicit timer the driver
@@ -521,6 +566,16 @@ export class SpecDriver {
521
566
  if (evState.id !== (this.spec.default_state ?? 'idle')) {
522
567
  this.cancelIdleHold();
523
568
  }
569
+ // Track the modal entry timestamp so the modal-hold above can
570
+ // suppress brief busy blips while the modal is still on screen.
571
+ if (isModalState(evState.id)) {
572
+ this.lastModalAt = Date.now();
573
+ this.lastModalState = evState;
574
+ } else {
575
+ // Leaving modal territory (going to idle) — clear the hold.
576
+ this.lastModalAt = 0;
577
+ this.lastModalState = null;
578
+ }
524
579
  }
525
580
 
526
581
  // Idle hold: if idle_hold_ms is set, don't commit idle immediately.
@@ -212,18 +212,15 @@ function matchState(
212
212
  return { matched: true, title };
213
213
  }
214
214
 
215
- function extractModal(
216
- state: SpecState,
217
- sections: ResolvedSection[],
218
- fullScreen: string,
219
- title: string | null,
220
- trace: TraceEntry[],
221
- ): ModalSnapshot | null {
222
- if (!state.modal_buttons) return null;
223
- const hay = sectionText(sections, state.modal_buttons.section, fullScreen);
215
+ function extractButtonsWithPattern(
216
+ rule: { pattern: string; flags?: string },
217
+ hay: string,
218
+ keyTemplate: string,
219
+ continuationLines: boolean,
220
+ ): { index: number; label: string; key: string }[] {
224
221
  const buttons: { index: number; label: string; key: string }[] = [];
225
- if (state.modal_buttons.continuation_lines) {
226
- const re = compileLinePattern(state.modal_buttons);
222
+ if (continuationLines) {
223
+ const re = compileLinePattern(rule);
227
224
  const lines = hay.split('\n');
228
225
  for (let i = 0; i < lines.length; i += 1) {
229
226
  const m = re.exec(lines[i]);
@@ -241,24 +238,57 @@ function extractModal(
241
238
  j += 1;
242
239
  }
243
240
  if (buttons.some(b => b.index === idx)) continue;
244
- const key = state.modal_buttons.key_for_index.replace(/\{index\}/g, String(idx));
241
+ const key = keyTemplate.replace(/\{index\}/g, String(idx));
245
242
  buttons.push({ index: idx, label, key });
246
243
  i = j - 1;
247
244
  }
248
245
  } else {
249
- const re = compilePattern(state.modal_buttons);
246
+ const re = compilePattern(rule);
250
247
  let m: RegExpExecArray | null;
251
248
  while ((m = re.exec(hay)) !== null) {
252
249
  const idx = Number(m[1]);
253
250
  const label = String(m[2] ?? '').trim();
254
251
  if (!Number.isFinite(idx) || idx <= 0 || !label) continue;
255
252
  if (buttons.some(b => b.index === idx)) continue;
256
- const key = state.modal_buttons.key_for_index.replace(/\{index\}/g, String(idx));
253
+ const key = keyTemplate.replace(/\{index\}/g, String(idx));
257
254
  buttons.push({ index: idx, label, key });
258
255
  }
259
256
  }
260
257
  buttons.sort((a, b) => a.index - b.index);
258
+ return buttons;
259
+ }
260
+
261
+ function extractModal(
262
+ state: SpecState,
263
+ sections: ResolvedSection[],
264
+ fullScreen: string,
265
+ title: string | null,
266
+ trace: TraceEntry[],
267
+ ): ModalSnapshot | null {
268
+ if (!state.modal_buttons) return null;
269
+ const hay = sectionText(sections, state.modal_buttons.section, fullScreen);
261
270
  const minCount = state.modal_buttons.min_count ?? 2;
271
+ const keyTemplate = state.modal_buttons.key_for_index;
272
+ const continuationLines = state.modal_buttons.continuation_lines ?? false;
273
+
274
+ // Build ordered list of pattern candidates: `patterns` array takes precedence,
275
+ // falling back to the single `pattern` field.
276
+ const candidates: Array<{ pattern: string; flags?: string }> =
277
+ state.modal_buttons.patterns?.length
278
+ ? state.modal_buttons.patterns
279
+ : state.modal_buttons.pattern
280
+ ? [{ pattern: state.modal_buttons.pattern, flags: state.modal_buttons.flags }]
281
+ : [];
282
+
283
+ let buttons: { index: number; label: string; key: string }[] = [];
284
+ for (const candidate of candidates) {
285
+ const result = extractButtonsWithPattern(candidate, hay, keyTemplate, continuationLines);
286
+ if (result.length >= minCount) {
287
+ buttons = result;
288
+ break;
289
+ }
290
+ }
291
+
262
292
  if (buttons.length < minCount) {
263
293
  trace.push({ kind: 'modal', text: `modal_buttons matched ${buttons.length}/${minCount} required — discarded` });
264
294
  return null;
@@ -77,7 +77,12 @@ function validateRefs(spec: CliSpec): string[] {
77
77
  if (s.modal_buttons.section && !sectionIds.has(s.modal_buttons.section)) {
78
78
  errs.push(`states[${s.id}].modal_buttons.section "${s.modal_buttons.section}" unknown`);
79
79
  }
80
- compileRegex(s.modal_buttons.pattern, s.modal_buttons.flags ?? 'm', `states[${s.id}].modal_buttons.pattern`, errs);
80
+ if (s.modal_buttons.pattern) {
81
+ compileRegex(s.modal_buttons.pattern, s.modal_buttons.flags ?? 'm', `states[${s.id}].modal_buttons.pattern`, errs);
82
+ }
83
+ for (const [pi, p] of (s.modal_buttons.patterns ?? []).entries()) {
84
+ compileRegex(p.pattern, p.flags ?? 'm', `states[${s.id}].modal_buttons.patterns[${pi}]`, errs);
85
+ }
81
86
  }
82
87
  }
83
88
 
@@ -295,9 +295,12 @@ export const SCHEMA = {
295
295
  "type": "object",
296
296
  "additionalProperties": false,
297
297
  "required": [
298
- "pattern",
299
298
  "key_for_index"
300
299
  ],
300
+ "oneOf": [
301
+ { "required": ["pattern"] },
302
+ { "required": ["patterns"] }
303
+ ],
301
304
  "properties": {
302
305
  "section": {
303
306
  "type": "string"
@@ -309,6 +312,19 @@ export const SCHEMA = {
309
312
  "flags": {
310
313
  "type": "string"
311
314
  },
315
+ "patterns": {
316
+ "type": "array",
317
+ "minItems": 1,
318
+ "items": {
319
+ "type": "object",
320
+ "additionalProperties": false,
321
+ "required": ["pattern"],
322
+ "properties": {
323
+ "pattern": { "type": "string", "minLength": 1 },
324
+ "flags": { "type": "string" }
325
+ }
326
+ }
327
+ },
312
328
  "key_for_index": {
313
329
  "type": "string",
314
330
  "minLength": 1
@@ -71,8 +71,12 @@ export interface SectionPattern {
71
71
 
72
72
  export interface ModalButtonsRule {
73
73
  section?: string;
74
- pattern: string;
74
+ /** Single pattern (original). Use `patterns` for multi-format fallback. */
75
+ pattern?: string;
75
76
  flags?: string;
77
+ /** Ordered list of pattern alternatives. Evaluated in order; first that
78
+ * yields >= min_count buttons wins. Mutually exclusive with `pattern`. */
79
+ patterns?: Array<{ pattern: string; flags?: string }>;
76
80
  key_for_index: string;
77
81
  min_count?: number;
78
82
  continuation_lines?: boolean;
@@ -28,7 +28,5 @@ export type RecentSessionBucket = 'needs_attention' | 'working' | 'task_complete
28
28
 
29
29
  /** Terminal backend status */
30
30
  export interface TerminalBackendStatus {
31
- backend: 'xterm' | 'ghostty-vt';
32
- preference: 'auto' | 'xterm' | 'ghostty-vt';
33
- ghosttyAvailable: boolean;
31
+ backend: 'ghostty-vt';
34
32
  }
@@ -1,17 +0,0 @@
1
- import type { TerminalViewportBackend, TerminalViewportBackendOptions } from './types.js';
2
- export declare class XtermTerminalBackend implements TerminalViewportBackend {
3
- readonly kind: "xterm";
4
- private rows;
5
- private cols;
6
- private terminal;
7
- constructor(options: TerminalViewportBackendOptions);
8
- resize(rows: number, cols: number): void;
9
- write(data: string): void;
10
- getText(): string;
11
- getCursorPosition(): {
12
- col: number;
13
- row: number;
14
- };
15
- dispose(): void;
16
- private createTerminal;
17
- }
@@ -1,16 +0,0 @@
1
- import type { TerminalViewportBackend, TerminalViewportBackendOptions, TerminalViewportBackendPreference } from './types.js';
2
- export declare function resolveTerminalBackendPreference(): TerminalViewportBackendPreference;
3
- export declare function isGhosttyVtBackendAvailable(): boolean;
4
- export declare class GhosttyVtTerminalBackend implements TerminalViewportBackend {
5
- readonly kind: "ghostty-vt";
6
- private terminal;
7
- constructor(options: TerminalViewportBackendOptions);
8
- resize(rows: number, cols: number): void;
9
- write(data: string): void;
10
- getText(): string;
11
- getCursorPosition(): {
12
- col: number;
13
- row: number;
14
- };
15
- dispose(): void;
16
- }
@@ -1,104 +0,0 @@
1
- import type { TerminalViewportBackend, TerminalViewportBackendOptions } from './types.js';
2
-
3
- type XtermBufferLine = {
4
- translateToString(trimRight?: boolean): string;
5
- };
6
-
7
- type XtermBuffer = {
8
- length: number;
9
- viewportY: number;
10
- cursorX?: number;
11
- cursorY?: number;
12
- getLine(index: number): XtermBufferLine | undefined;
13
- };
14
-
15
- type XtermTerminal = {
16
- buffer: { active: XtermBuffer };
17
- write(data: string, callback?: () => void): void;
18
- resize(cols: number, rows: number): void;
19
- dispose(): void;
20
- };
21
-
22
- let TerminalCtor: (new (options: { cols: number; rows: number; scrollback: number }) => XtermTerminal) | null = null;
23
-
24
- function loadTerminalCtor(): new (options: { cols: number; rows: number; scrollback: number }) => XtermTerminal {
25
- if (!TerminalCtor) {
26
- // eslint-disable-next-line @typescript-eslint/no-var-requires
27
- const mod = require('@xterm/xterm');
28
- TerminalCtor = mod.Terminal || mod.default?.Terminal || mod.default;
29
- if (!TerminalCtor) {
30
- throw new Error('@xterm/xterm Terminal export not found');
31
- }
32
- }
33
- return TerminalCtor;
34
- }
35
-
36
- export class XtermTerminalBackend implements TerminalViewportBackend {
37
- readonly kind = 'xterm' as const;
38
- private rows: number;
39
- private cols: number;
40
- private terminal: XtermTerminal;
41
-
42
- constructor(options: TerminalViewportBackendOptions) {
43
- this.rows = Math.max(1, options.rows | 0);
44
- this.cols = Math.max(1, options.cols | 0);
45
- this.terminal = this.createTerminal(options.scrollback);
46
- }
47
-
48
- resize(rows: number, cols: number): void {
49
- this.rows = Math.max(1, rows | 0);
50
- this.cols = Math.max(1, cols | 0);
51
- this.terminal.resize(this.cols, this.rows);
52
- }
53
-
54
- write(data: string): void {
55
- if (!data) return;
56
- this.terminal.write(data);
57
- }
58
-
59
- getText(): string {
60
- const buffer = this.terminal.buffer.active;
61
- const start = Math.max(0, buffer.viewportY || 0);
62
- const end = Math.max(start, Math.min(buffer.length || 0, start + this.rows));
63
- const lines: string[] = [];
64
-
65
- for (let i = start; i < end; i++) {
66
- const line = buffer.getLine(i);
67
- // (fix) translateToString(true) strips trailing whitespace per row
68
- // AND collapses cells touched only by cursor-forward (ESC[<n>C),
69
- // so Claude Code's "Do you want to proceed?" arrives as
70
- // "Doyouwanttoproceed?" — every downstream approval/prompt regex
71
- // misses. Use false to preserve inter-word padding; we trim each
72
- // row's trailing whitespace ourselves below.
73
- const raw = line ? line.translateToString(false) : '';
74
- lines.push(raw.replace(/\s+$/, ''));
75
- }
76
-
77
- let first = 0;
78
- let last = lines.length;
79
- while (first < last && !lines[first]?.trim()) first++;
80
- while (last > first && !lines[last - 1]?.trim()) last--;
81
- return lines.slice(first, last).join('\n');
82
- }
83
-
84
- getCursorPosition(): { col: number; row: number } {
85
- const buffer = this.terminal.buffer.active;
86
- return {
87
- col: Math.max(0, buffer.cursorX || 0),
88
- row: Math.max(0, buffer.cursorY || 0),
89
- };
90
- }
91
-
92
- dispose(): void {
93
- this.terminal.dispose();
94
- }
95
-
96
- private createTerminal(scrollback: number): XtermTerminal {
97
- const Terminal = loadTerminalCtor();
98
- return new Terminal({
99
- cols: this.cols,
100
- rows: this.rows,
101
- scrollback,
102
- });
103
- }
104
- }