@adhdev/daemon-core 0.9.82-rc.353 → 0.9.82-rc.355

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 (38) hide show
  1. package/dist/commands/handler.d.ts +15 -0
  2. package/dist/index.js +703 -220
  3. package/dist/index.js.map +1 -1
  4. package/dist/index.mjs +703 -220
  5. package/dist/index.mjs.map +1 -1
  6. package/dist/mesh/mesh-event-trace.d.ts +21 -0
  7. package/dist/mesh/mesh-runtime-store.d.ts +1 -1
  8. package/dist/mesh/mesh-work-queue.d.ts +1 -1
  9. package/dist/providers/acp-provider-instance.d.ts +3 -0
  10. package/dist/providers/cli-provider-instance.d.ts +14 -0
  11. package/dist/providers/manual-attendance.d.ts +63 -0
  12. package/dist/providers/provider-instance.d.ts +8 -0
  13. package/dist/providers/spec/adapter.d.ts +22 -0
  14. package/dist/providers/spec/fsm-driver.d.ts +49 -7
  15. package/dist/providers/spec/fsm-evaluator.d.ts +4 -0
  16. package/dist/providers/spec/types.d.ts +9 -5
  17. package/package.json +2 -2
  18. package/src/commands/cli-manager.ts +20 -2
  19. package/src/commands/handler.ts +32 -0
  20. package/src/commands/router.ts +19 -6
  21. package/src/git/git-diff.ts +31 -14
  22. package/src/mesh/mesh-event-trace.ts +67 -0
  23. package/src/mesh/mesh-events-coordinator.ts +117 -12
  24. package/src/mesh/mesh-events-pending.ts +33 -0
  25. package/src/mesh/mesh-events-stale.ts +3 -1
  26. package/src/mesh/mesh-reconcile-loop.ts +47 -0
  27. package/src/mesh/mesh-runtime-store.ts +18 -2
  28. package/src/mesh/mesh-work-queue.ts +8 -1
  29. package/src/providers/acp-provider-instance.ts +18 -1
  30. package/src/providers/cli-provider-instance.ts +123 -7
  31. package/src/providers/manual-attendance.ts +85 -0
  32. package/src/providers/provider-instance.ts +9 -0
  33. package/src/providers/spec/adapter.ts +67 -0
  34. package/src/providers/spec/cli-adapter.ts +6 -0
  35. package/src/providers/spec/evaluator.ts +24 -9
  36. package/src/providers/spec/fsm-driver.ts +135 -13
  37. package/src/providers/spec/fsm-evaluator.ts +19 -2
  38. package/src/providers/spec/types.ts +9 -5
@@ -21,7 +21,7 @@
21
21
  import * as fs from 'node:fs';
22
22
  import * as os from 'node:os';
23
23
  import * as path from 'node:path';
24
- import { TerminalAdapter, type TerminalAdapterOpts } from './adapter.js';
24
+ import { TerminalAdapter, type TerminalAdapterOpts, type SpecPtyEvent } from './adapter.js';
25
25
  import { resolveCliSpawnPlanFromParts } from '../../cli-adapters/provider-cli-runtime.js';
26
26
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
27
27
  import { DEFAULT_SESSION_HOST_COLS, DEFAULT_SESSION_HOST_ROWS } from '@adhdev/session-host-core';
@@ -123,6 +123,7 @@ export interface ISpecDriver {
123
123
  getCompletionIdleDebounceState(): { active: boolean; ageMs: number; holdMs: number; forceAfterMs: number } | null;
124
124
  getFsmDebug?(): unknown;
125
125
  getFsmSnapshotHistory?(): ReadonlyArray<FsmSnapshotEntry>;
126
+ getEventTimeline?(limit?: number): ReadonlyArray<SpecPtyEvent>;
126
127
  }
127
128
 
128
129
  export interface SpecDriverOpts {
@@ -162,6 +163,23 @@ const SUBMIT_DELAY_FLOOR_MS = 200;
162
163
  // trimmed by the TUI on submit.
163
164
  const WIN32_SUBMIT_RESEND_GAP_MS = 350;
164
165
  const WIN32_SUBMIT_MAX_RESENDS = 14;
166
+ // Settle-gate for the win32 FIRST submit CR. Hold the CR until the PTY output has
167
+ // gone quiet for WIN32_SUBMIT_SETTLE_MS after the last input write — i.e. the full
168
+ // (possibly multi-KB / multiline) message body has finished arriving in the
169
+ // composer and echoing back. A long message waits until it actually lands; a short
170
+ // one settles almost immediately. WIN32_SUBMIT_MAX_SETTLE_WAIT_MS bounds the wait
171
+ // so a perpetually-noisy screen can never hang the submit. This is what stops a
172
+ // blind fixed-delay CR from submitting a half-arrived prompt and dropping its
173
+ // leading lines. The phase-2 verified-resend loop (below) is unchanged.
174
+ const WIN32_SUBMIT_SETTLE_MS = 500;
175
+ const WIN32_SUBMIT_MAX_SETTLE_WAIT_MS = 10_000;
176
+ const WIN32_SUBMIT_SETTLE_POLL_MS = 120;
177
+ // Defensive paced PTY write. A single unbounded ConPTY write can overflow the
178
+ // input pipe and drop leading bytes; split a large body into bounded chunks with a
179
+ // short inter-chunk gap so the console input buffer keeps up. Small bodies still
180
+ // write in one shot.
181
+ const WIN32_PTY_WRITE_CHUNK_CHARS = 1024;
182
+ const WIN32_PTY_WRITE_CHUNK_GAP_MS = 8;
165
183
 
166
184
  export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text: string): number {
167
185
  const lines = countNewlines(text);
@@ -170,6 +188,27 @@ export function resolveSubmitDelayMs(specBeforeSubmit: number | undefined, text:
170
188
  return Math.max(spec, SUBMIT_DELAY_FLOOR_MS + linesBonus);
171
189
  }
172
190
 
191
+ /** Split `text` into chunks of at most `size` UTF-16 units without ever cutting
192
+ * between a high and low surrogate (which would corrupt an astral char — emoji,
193
+ * etc. — on the UTF-8 PTY write). */
194
+ export function chunkPreservingSurrogates(text: string, size: number): string[] {
195
+ const chunks: string[] = [];
196
+ let offset = 0;
197
+ while (offset < text.length) {
198
+ let end = Math.min(text.length, offset + size);
199
+ if (end < text.length) {
200
+ const code = text.charCodeAt(end - 1);
201
+ // Boundary lands on a high surrogate → pull back one so the pair stays
202
+ // together in the next chunk.
203
+ if (code >= 0xd800 && code <= 0xdbff) end -= 1;
204
+ }
205
+ if (end <= offset) end = Math.min(text.length, offset + size); // size 1 on a lone surrogate
206
+ chunks.push(text.slice(offset, end));
207
+ offset = end;
208
+ }
209
+ return chunks;
210
+ }
211
+
173
212
  export function guessExt(mime: string): string {
174
213
  if (/png/i.test(mime)) return '.png';
175
214
  if (/jpe?g/i.test(mime)) return '.jpg';
@@ -231,6 +270,16 @@ export class FsmDriver implements ISpecDriver {
231
270
  * WIN32_SUBMIT_* and scheduleWin32Submit). Re-arms itself until the FSM
232
271
  * leaves idle (submitted) or the resend budget is spent. */
233
272
  private win32SubmitTimer: ReturnType<typeof setTimeout> | null = null;
273
+ /** Wall-clock (ms) of the most recent raw PTY output chunk. Advances on every
274
+ * on_pty_data — including the echo of text written into the composer — so the
275
+ * win32 submit settle-gate can tell when input has finished landing. */
276
+ private lastPtyDataAt = 0;
277
+ /** Wall-clock (ms) of the most recent win32 message-body input write. Bridges
278
+ * the gap between writing a chunk and its echo so the settle-gate does not
279
+ * declare "quiet" mid-write. */
280
+ private lastWin32WriteAt = 0;
281
+ /** Pending paced chunk-write timer for a large win32 body (see writeWin32Body). */
282
+ private win32WriteTimer: ReturnType<typeof setTimeout> | null = null;
234
283
 
235
284
  private currentEval: CurrentEval | null = null;
236
285
  private stateHistory: HistoryEntry[] = [];
@@ -257,7 +306,7 @@ export class FsmDriver implements ISpecDriver {
257
306
  this.buildAdapterOpts(),
258
307
  {
259
308
  init: () => this.emitInitialState(),
260
- on_pty_data: (chunk) => this.emit({ kind: 'pty_data', chunk }),
309
+ on_pty_data: (chunk) => { this.lastPtyDataAt = Date.now(); this.emit({ kind: 'pty_data', chunk }); },
261
310
  on_screen_changed: () => this.reevaluate(),
262
311
  on_exit: ({ exitCode }) => this.handleExit(exitCode),
263
312
  },
@@ -349,6 +398,7 @@ export class FsmDriver implements ISpecDriver {
349
398
  if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
350
399
  if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
351
400
  if (this.win32SubmitTimer) { clearTimeout(this.win32SubmitTimer); this.win32SubmitTimer = null; }
401
+ if (this.win32WriteTimer) { clearTimeout(this.win32WriteTimer); this.win32WriteTimer = null; }
352
402
  this.specWatcher?.close();
353
403
  this.adapter.kill();
354
404
  }
@@ -385,6 +435,10 @@ export class FsmDriver implements ISpecDriver {
385
435
 
386
436
  getStateHistory(): ReadonlyArray<HistoryEntry> { return this.stateHistory; }
387
437
  getFsmSnapshotHistory(): ReadonlyArray<FsmSnapshotEntry> { return this.fsmSnapshotHistory; }
438
+ /** Debug-only PTY input/output/resize/cursor timeline from the adapter. */
439
+ getEventTimeline(limit?: number): ReadonlyArray<SpecPtyEvent> {
440
+ return this.adapter.getEventTimeline(limit);
441
+ }
388
442
  getSections(): Array<{ id: string; text: string }> | null {
389
443
  try {
390
444
  const screen = this.adapter.snapshot();
@@ -867,7 +921,7 @@ export class FsmDriver implements ISpecDriver {
867
921
  // typing simulation is skipped on win32; correctness of submission wins
868
922
  // over the typing visual there.
869
923
  if (process.platform === 'win32') {
870
- this.adapter.send_keys(text);
924
+ this.writeWin32Body(text);
871
925
  this.scheduleWin32Submit(sm.submit_key, beforeSubmit);
872
926
  return;
873
927
  }
@@ -896,17 +950,70 @@ export class FsmDriver implements ISpecDriver {
896
950
  return st ? statusForState(st) : 'idle';
897
951
  }
898
952
 
953
+ /** Record a win32 body write so the settle-gate counts it as input activity
954
+ * even before the echo arrives. */
955
+ private markWin32Write(): void {
956
+ this.lastWin32WriteAt = Date.now();
957
+ }
958
+
959
+ /** Most recent win32 input activity — a write we issued OR a PTY output chunk
960
+ * (echo). The submit settle-gate waits for this to go quiet. */
961
+ private lastWin32InputActivityAt(): number {
962
+ return Math.max(this.lastPtyDataAt, this.lastWin32WriteAt);
963
+ }
964
+
965
+ /**
966
+ * Write the message body to the PTY for win32, paced into bounded chunks. A
967
+ * single unbounded ConPTY write can overflow the input pipe and drop leading
968
+ * bytes; splitting it with a short inter-chunk gap keeps the console input
969
+ * buffer from overflowing. Small bodies still go out in a single write. Each
970
+ * chunk advances lastWin32WriteAt so the submit settle-gate keeps waiting until
971
+ * the final chunk is out and echoed.
972
+ */
973
+ private writeWin32Body(text: string): void {
974
+ if (this.win32WriteTimer) { clearTimeout(this.win32WriteTimer); this.win32WriteTimer = null; }
975
+ if (text.length <= WIN32_PTY_WRITE_CHUNK_CHARS) {
976
+ this.markWin32Write();
977
+ this.adapter.send_keys(text);
978
+ return;
979
+ }
980
+ const chunks = chunkPreservingSurrogates(text, WIN32_PTY_WRITE_CHUNK_CHARS);
981
+ let idx = 0;
982
+ const writeNext = (): void => {
983
+ this.win32WriteTimer = null;
984
+ if (idx >= chunks.length) return;
985
+ this.markWin32Write();
986
+ this.adapter.send_keys(chunks[idx]);
987
+ idx += 1;
988
+ if (idx < chunks.length) {
989
+ this.win32WriteTimer = setTimeout(writeNext, WIN32_PTY_WRITE_CHUNK_GAP_MS);
990
+ }
991
+ };
992
+ writeNext();
993
+ }
994
+
899
995
  /**
900
- * win32 verification-based submit. Sends the submit key, waits a gap, and if
901
- * the FSM is still 'idle' (the prompt did not submit — the CR was absorbed as
902
- * a multiline-paste newline) resends, up to WIN32_SUBMIT_MAX_RESENDS. The
903
- * first CR always fires (so a stale/edge status never suppresses the submit);
904
- * subsequent resends are gated on still being idle, and stop the instant the
905
- * agent leaves idle (submitted generating / approval). This converges the
906
- * nondeterministic multiline window without spamming Enter into the next turn.
996
+ * win32 submit. Two phases:
997
+ *
998
+ * Phase 1 (settle-gate): hold the first CR until the PTY output has been quiet
999
+ * for WIN32_SUBMIT_SETTLE_MS after the last input write i.e. the full
1000
+ * (possibly multi-KB / multiline) body has finished arriving in the composer
1001
+ * and echoing. Honors an initial minimum delay and is bounded by
1002
+ * WIN32_SUBMIT_MAX_SETTLE_WAIT_MS so a noisy screen can never hang the submit.
1003
+ * This is what stops a long message from being submitted half-arrived (its
1004
+ * leading lines lost). A short message settles almost immediately.
1005
+ *
1006
+ * Phase 2 (verified resend — unchanged): send the submit key, wait a gap, and
1007
+ * if the FSM is still 'idle' (the CR was absorbed as a multiline-paste
1008
+ * newline) resend, up to WIN32_SUBMIT_MAX_RESENDS. The first CR always fires
1009
+ * (a stale/edge status never suppresses it); resends are gated on still being
1010
+ * idle and stop the instant the agent leaves idle (submitted → generating /
1011
+ * approval). This preserves the win32 lone-CR-swallow handling.
907
1012
  */
908
1013
  private scheduleWin32Submit(submitKey: string, initialDelayMs: number): void {
909
1014
  if (this.win32SubmitTimer) { clearTimeout(this.win32SubmitTimer); this.win32SubmitTimer = null; }
1015
+ const startedAt = Date.now();
1016
+
910
1017
  const fire = (attempt: number): void => {
911
1018
  this.win32SubmitTimer = null;
912
1019
  this.adapter.send_keys(submitKey);
@@ -917,8 +1024,22 @@ export class FsmDriver implements ISpecDriver {
917
1024
  fire(attempt + 1);
918
1025
  }, WIN32_SUBMIT_RESEND_GAP_MS);
919
1026
  };
920
- if (initialDelayMs > 0) this.win32SubmitTimer = setTimeout(() => fire(0), initialDelayMs);
921
- else fire(0);
1027
+
1028
+ const waitForSettle = (): void => {
1029
+ this.win32SubmitTimer = null;
1030
+ const now = Date.now();
1031
+ const quietFor = now - this.lastWin32InputActivityAt();
1032
+ const waited = now - startedAt;
1033
+ if (quietFor >= WIN32_SUBMIT_SETTLE_MS || waited >= WIN32_SUBMIT_MAX_SETTLE_WAIT_MS) {
1034
+ fire(0);
1035
+ return;
1036
+ }
1037
+ const recheckIn = Math.min(WIN32_SUBMIT_SETTLE_MS - quietFor, WIN32_SUBMIT_SETTLE_POLL_MS);
1038
+ this.win32SubmitTimer = setTimeout(waitForSettle, Math.max(recheckIn, 30));
1039
+ };
1040
+
1041
+ if (initialDelayMs > 0) this.win32SubmitTimer = setTimeout(waitForSettle, initialDelayMs);
1042
+ else waitForSettle();
922
1043
  }
923
1044
 
924
1045
  private handleClickControl(controlId: string, payload?: unknown): void {
@@ -1039,7 +1160,8 @@ function summarizeTransition(t: TransitionEval): string[] {
1039
1160
  }
1040
1161
 
1041
1162
  function flattenCond(c: import('./fsm-evaluator.js').CondResult, out: string[], depth: number): void {
1042
- out.push(`${' '.repeat(depth)}${c.kind} ${c.detail} = ${c.result}${c.remainingMs ? ` (${c.remainingMs}ms left)` : ''}`);
1163
+ const matched = c.matchedText ? ` matched=${JSON.stringify(c.matchedText)}` : '';
1164
+ out.push(`${' '.repeat(depth)}${c.kind} ${c.detail} = ${c.result}${c.remainingMs ? ` (${c.remainingMs}ms left)` : ''}${matched}`);
1043
1165
  for (const child of c.children ?? []) flattenCond(child, out, depth + 1);
1044
1166
  }
1045
1167
 
@@ -16,7 +16,7 @@
16
16
 
17
17
  import type { Condition, SectionDef } from './types.js';
18
18
  import {
19
- resolveSections, evaluateCondition, type ResolvedSection, type TraceEntry,
19
+ resolveSections, evaluateCondition, sectionText, type ResolvedSection, type TraceEntry,
20
20
  } from './evaluator.js';
21
21
  import {
22
22
  type CliSpecV4, type FsmCondition, type FsmTransition,
@@ -42,6 +42,10 @@ export interface CondResult {
42
42
  /** Remaining ms until a time-based condition would flip to true. 0 if
43
43
  * already true or not applicable. Lets the UI show a countdown. */
44
44
  remainingMs?: number;
45
+ /** Debug-only: the actual substring a TRUE regex condition matched, so the
46
+ * snapshot shows WHAT text the rule fired on — not just which regex. Never
47
+ * read by the FSM; purely for the Spec Debug Snapshot. */
48
+ matchedText?: string;
45
49
  children?: CondResult[];
46
50
  }
47
51
 
@@ -164,7 +168,20 @@ function evalCond(
164
168
  const detail = isRegex(cond)
165
169
  ? `${(cond as any).section ?? '*'}~/${(cond as any).matches}/`
166
170
  : `cursor_above=${(cond as any).cursor_above} changed=${(cond as any).changed}`;
167
- return { kind, result, detail };
171
+ // Debug-only: when a regex condition is TRUE, also capture the substring
172
+ // it matched so the snapshot can show the exact text the rule fired on.
173
+ // This re-runs the regex (read-only) and CANNOT change `result` above —
174
+ // the FSM decision is still entirely owned by evaluateCondition().
175
+ let matchedText: string | undefined;
176
+ if (result && isRegex(cond)) {
177
+ try {
178
+ const hay = sectionText(sections, (cond as any).section, fullScreen);
179
+ const re = new RegExp((cond as any).matches, (cond as any).flags ?? 'i');
180
+ const m = re.exec(hay);
181
+ if (m && m[0]) matchedText = m[0].replace(/\s+/g, ' ').trim().slice(0, 160);
182
+ } catch { /* capture is best-effort; never affects result */ }
183
+ }
184
+ return matchedText ? { kind, result, detail, matchedText } : { kind, result, detail };
168
185
  }
169
186
  return { kind: 'all', result: false, detail: 'unknown condition' };
170
187
  }
@@ -154,11 +154,15 @@ export interface SectionDef {
154
154
  until?: string; // section id OR regex (starts with ^)
155
155
  /**
156
156
  * Anchor regex(es). A single string anchors on the first/last matching line
157
- * (per `anchor_last`). An array is an OR-set: every candidate line is one
158
- * that matches ANY entry; with `anchor_last` the LAST such line across all
159
- * patterns wins, otherwise the FIRST. This lets one section capture two
160
- * different shapese.g. a box-divider modal AND a divider-less modal whose
161
- * only stable landmark is the question line above its numbered choices.
157
+ * (per `anchor_last`). An array is an OR-set of candidate shapes: each
158
+ * candidate resolves its OWN anchor line independently (anchor_last that
159
+ * candidate's last matching line, else its first), then the TOPMOST resolved
160
+ * line across candidates wins — a section's anchor marks the top of its
161
+ * block, so the highest recognized landmark bounds the whole block. This
162
+ * lets one section capture two shapes — e.g. a box-divider modal AND a
163
+ * divider-less modal whose only stable landmark is the question line above
164
+ * its numbered choices — while preventing a stray LOWER landmark (e.g. an
165
+ * input-box `────` rule below the choices) from clipping the block.
162
166
  */
163
167
  anchor?: string | string[];
164
168
  anchor_flags?: string;