@adhdev/daemon-core 0.9.82-rc.329 → 0.9.82-rc.330

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.
@@ -169,6 +169,14 @@ export declare class FsmDriver implements ISpecDriver {
169
169
  /** Timer that re-runs evaluate() when a time-condition would flip true
170
170
  * with no PTY frame to trigger it. */
171
171
  private wakeTimer;
172
+ /** Timer driving the focus-gated stall watchdog (refocus_when_stalled_ms).
173
+ * Re-arms itself while the machine is generating so a re-prime can fire
174
+ * even when the PTY has gone completely quiet. */
175
+ private stallTimer;
176
+ /** Wall-clock time the last stall re-prime was injected. The cooldown gate:
177
+ * after a re-prime we don't re-inject until the screen changes (which
178
+ * resets the stall reference) or another full stall window lapses. */
179
+ private lastRefocusAt;
172
180
  private currentEval;
173
181
  private stateHistory;
174
182
  private prevStateAt;
@@ -187,6 +195,11 @@ export declare class FsmDriver implements ISpecDriver {
187
195
  subscribe(listener: (ev: DashboardEvent) => void): () => void;
188
196
  start(): void;
189
197
  private scheduleSpawnPrime;
198
+ /** Write the declared `send_on_spawn` sequences to the PTY once. Used both
199
+ * at spawn (scheduleSpawnPrime) and, for focus-gated TUIs, by the stall
200
+ * watchdog to re-inject the focus-in wake mid-turn. No-op when the spec
201
+ * declares no prime, so non-focus-gated CLIs are never poked. */
202
+ private sendSpawnPrime;
190
203
  dispatch(cmd: DashboardCommand): void;
191
204
  snapshot(): string;
192
205
  getCursorPosition(): {
@@ -260,6 +273,21 @@ export declare class FsmDriver implements ISpecDriver {
260
273
  * this, a state whose only exit is time-based would never leave once the
261
274
  * PTY goes quiet. */
262
275
  private scheduleWakeForState;
276
+ /** True when this spec opts into stall recovery (declares a positive
277
+ * refocus window AND a wake sequence to re-inject). */
278
+ private stallRecoveryEnabled;
279
+ /** Wall-clock time the screen last changed in the current state, falling
280
+ * back to state entry when it has not changed since (i.e. fully stalled
281
+ * from the start of the state). */
282
+ private lastScreenChangeAt;
283
+ /** Arm a timer to re-inject the focus-in prime if the screen stays frozen
284
+ * through a `generating` state. Only active for opted-in focus-gated specs;
285
+ * a no-op (and cleared) for every other CLI and every non-generating state.
286
+ * Re-arms itself so it keeps watching while the PTY is quiet. */
287
+ private scheduleStallWatchdog;
288
+ /** Fire when the stall window elapses: if the screen is still frozen and we
289
+ * are still generating, re-inject the focus-in wake once, then re-arm. */
290
+ private onStallTick;
263
291
  private maybeMarkReady;
264
292
  private fireNotifications;
265
293
  private armOrCancelDelegateTimers;
@@ -95,6 +95,22 @@ export interface CliSpecV4 {
95
95
  send_on_spawn?: string[];
96
96
  /** Delay (ms) after spawn before writing `send_on_spawn`. Default 250. */
97
97
  send_on_spawn_delay_ms?: number;
98
+ /**
99
+ * Opt-in stall recovery for focus-gated TUIs. The same CLIs that need
100
+ * `send_on_spawn` (focus-event TUIs like antigravity's `agy`) also stop
101
+ * rendering / flushing output the moment they believe they have lost focus
102
+ * MID-TURN — the screen freezes and only repaints once the user presses a
103
+ * key, which the daemon never does on its own. When this field is set and
104
+ * the machine is in a `generating`-status state whose screen has not changed
105
+ * for `refocus_when_stalled_ms`, the engine re-injects the `send_on_spawn`
106
+ * prime (the focus-in event) once to wake the render loop, then waits for the
107
+ * screen to change or for the stall window to lapse again before re-priming
108
+ * (a cooldown that prevents a tight re-prime loop). Requires `send_on_spawn`
109
+ * to carry the wake sequence; with no `send_on_spawn` there is nothing to
110
+ * re-inject and the engine does nothing. Omitted by every non-focus-gated
111
+ * CLI spec, so the engine stays CLI-agnostic.
112
+ */
113
+ refocus_when_stalled_ms?: number;
98
114
  /**
99
115
  * Optional pre-spawn folder-trust step. When present, the engine adds the
100
116
  * launch workspace path to the declared trusted-folders array before
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.329",
3
+ "version": "0.9.82-rc.330",
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",
@@ -46,7 +46,7 @@
46
46
  "author": "vilmire",
47
47
  "license": "AGPL-3.0-or-later",
48
48
  "dependencies": {
49
- "@adhdev/mesh-shared": "0.9.82-rc.329",
49
+ "@adhdev/mesh-shared": "0.9.82-rc.330",
50
50
  "@adhdev/session-host-core": "*",
51
51
  "@agentclientprotocol/sdk": "^0.16.1",
52
52
  "ajv": "^8.20.0",
@@ -1399,6 +1399,12 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1399
1399
  meshId: args.meshId,
1400
1400
  nodeId: eventNodeId || undefined,
1401
1401
  ...args.metadataEvent,
1402
+ // Ensure a `workspace` field reaches updateMeshOwnedSession even when the
1403
+ // worker provider event only carried `workspaceName`. The merge spread of
1404
+ // metadataEvent above wins when it already has a non-empty `workspace`.
1405
+ workspace: readNonEmptyString(args.metadataEvent.workspace)
1406
+ || readNonEmptyString(args.metadataEvent.workspaceName)
1407
+ || undefined,
1402
1408
  });
1403
1409
  } catch { /* dashboard metadata sync is best-effort */ }
1404
1410
  }
@@ -1814,7 +1820,8 @@ function injectMeshSystemMessage(components: DaemonComponents, args: {
1814
1820
  meshId: args.meshId,
1815
1821
  nodeLabel: args.nodeLabel,
1816
1822
  nodeId: args.nodeId || undefined,
1817
- workspace: readNonEmptyString(args.metadataEvent.workspace),
1823
+ workspace: readNonEmptyString(args.metadataEvent.workspace)
1824
+ || readNonEmptyString(args.metadataEvent.workspaceName),
1818
1825
  metadataEvent: {
1819
1826
  ...args.metadataEvent,
1820
1827
  ...(recoveryContext ? { recoveryContext } : {}),
@@ -1816,6 +1816,14 @@ export class CliProviderInstance implements ProviderInstance {
1816
1816
  workspaceName: typeof event.workspaceName === 'string' && event.workspaceName.trim()
1817
1817
  ? event.workspaceName
1818
1818
  : this.workingDir,
1819
+ // Carry the workspace under BOTH `workspace` and `workspaceName` so the
1820
+ // downstream mesh forward/merge path — which reads `workspace` — can
1821
+ // propagate it to the coordinator snapshot. Without `workspace` the live
1822
+ // event path delivers an empty workspace and the dashboard falls back to
1823
+ // the generic "Terminal (Mesh Node)" title.
1824
+ workspace: typeof event.workspace === 'string' && event.workspace.trim()
1825
+ ? event.workspace
1826
+ : this.workingDir,
1819
1827
  providerSessionId: typeof event.providerSessionId === 'string' && event.providerSessionId.trim()
1820
1828
  ? event.providerSessionId
1821
1829
  : this.providerSessionId,
@@ -201,6 +201,14 @@ export class FsmDriver implements ISpecDriver {
201
201
  /** Timer that re-runs evaluate() when a time-condition would flip true
202
202
  * with no PTY frame to trigger it. */
203
203
  private wakeTimer: ReturnType<typeof setTimeout> | null = null;
204
+ /** Timer driving the focus-gated stall watchdog (refocus_when_stalled_ms).
205
+ * Re-arms itself while the machine is generating so a re-prime can fire
206
+ * even when the PTY has gone completely quiet. */
207
+ private stallTimer: ReturnType<typeof setTimeout> | null = null;
208
+ /** Wall-clock time the last stall re-prime was injected. The cooldown gate:
209
+ * after a re-prime we don't re-inject until the screen changes (which
210
+ * resets the stall reference) or another full stall window lapses. */
211
+ private lastRefocusAt = 0;
204
212
 
205
213
  private currentEval: CurrentEval | null = null;
206
214
  private stateHistory: HistoryEntry[] = [];
@@ -268,11 +276,19 @@ export class FsmDriver implements ISpecDriver {
268
276
  const seqs = this.spec.send_on_spawn;
269
277
  if (!Array.isArray(seqs) || seqs.length === 0) return;
270
278
  const delay = Math.max(0, this.spec.send_on_spawn_delay_ms ?? 250);
271
- setTimeout(() => {
272
- for (const seq of seqs) {
273
- if (typeof seq === 'string' && seq.length > 0) this.adapter.send_keys(seq);
274
- }
275
- }, delay);
279
+ setTimeout(() => this.sendSpawnPrime(), delay);
280
+ }
281
+
282
+ /** Write the declared `send_on_spawn` sequences to the PTY once. Used both
283
+ * at spawn (scheduleSpawnPrime) and, for focus-gated TUIs, by the stall
284
+ * watchdog to re-inject the focus-in wake mid-turn. No-op when the spec
285
+ * declares no prime, so non-focus-gated CLIs are never poked. */
286
+ private sendSpawnPrime(): void {
287
+ const seqs = this.spec.send_on_spawn;
288
+ if (!Array.isArray(seqs) || seqs.length === 0) return;
289
+ for (const seq of seqs) {
290
+ if (typeof seq === 'string' && seq.length > 0) this.adapter.send_keys(seq);
291
+ }
276
292
  }
277
293
 
278
294
  dispatch(cmd: DashboardCommand): void {
@@ -309,6 +325,7 @@ export class FsmDriver implements ISpecDriver {
309
325
  for (const t of this.delegateTimers.values()) clearTimeout(t);
310
326
  this.delegateTimers.clear();
311
327
  if (this.wakeTimer) { clearTimeout(this.wakeTimer); this.wakeTimer = null; }
328
+ if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
312
329
  this.specWatcher?.close();
313
330
  this.adapter.kill();
314
331
  }
@@ -484,6 +501,7 @@ export class FsmDriver implements ISpecDriver {
484
501
  // that's already satisfied doesn't wait for the next PTY frame.
485
502
  this.emitStateChanged(forceEmit);
486
503
  this.scheduleWakeForState();
504
+ this.scheduleStallWatchdog();
487
505
  // Drain queued sends on the SAME frame the machine reaches "ready".
488
506
  // The first delegated message is queued in pendingSends until the
489
507
  // FSM first enters a non-initial idle state (the prompt is drawn).
@@ -504,6 +522,7 @@ export class FsmDriver implements ISpecDriver {
504
522
  this.emitStateChanged(forceEmit);
505
523
  // Schedule a wake for the soonest pending time-condition.
506
524
  this.scheduleWakeForState();
525
+ this.scheduleStallWatchdog();
507
526
  // Drain queued sends once we first reach a "ready" state.
508
527
  this.maybeMarkReady();
509
528
  }
@@ -685,6 +704,71 @@ export class FsmDriver implements ISpecDriver {
685
704
  this.wakeTimer = setTimeout(() => { this.wakeTimer = null; this.reevaluate(); }, Math.max(soonest + 30, 50));
686
705
  }
687
706
 
707
+ // ── Focus-gated stall watchdog (refocus_when_stalled_ms) ──────────────────
708
+ //
709
+ // A focus-event TUI (antigravity's `agy`) freezes its render loop the moment
710
+ // it thinks it has lost focus mid-turn: the screen stops updating and only
711
+ // repaints on the next keypress, which the daemon never sends. The output
712
+ // pump is wired entirely to PTY onData (no PTY data → no reevaluate, no
713
+ // on_screen_changed), so a normal time-wake that only re-reads the screen
714
+ // can't help — there is nothing new to read. The fix is to re-inject the
715
+ // focus-in wake (`send_on_spawn`) so the CLI flushes the held output itself.
716
+
717
+ /** True when this spec opts into stall recovery (declares a positive
718
+ * refocus window AND a wake sequence to re-inject). */
719
+ private stallRecoveryEnabled(): boolean {
720
+ const ms = this.spec.refocus_when_stalled_ms;
721
+ return typeof ms === 'number' && ms > 0
722
+ && Array.isArray(this.spec.send_on_spawn) && this.spec.send_on_spawn.length > 0;
723
+ }
724
+
725
+ /** Wall-clock time the screen last changed in the current state, falling
726
+ * back to state entry when it has not changed since (i.e. fully stalled
727
+ * from the start of the state). */
728
+ private lastScreenChangeAt(): number {
729
+ return this.regionLastChangedAt.get(-1) ?? this.stateEnteredAt;
730
+ }
731
+
732
+ /** Arm a timer to re-inject the focus-in prime if the screen stays frozen
733
+ * through a `generating` state. Only active for opted-in focus-gated specs;
734
+ * a no-op (and cleared) for every other CLI and every non-generating state.
735
+ * Re-arms itself so it keeps watching while the PTY is quiet. */
736
+ private scheduleStallWatchdog(): void {
737
+ if (this.stallTimer) { clearTimeout(this.stallTimer); this.stallTimer = null; }
738
+ if (!this.stallRecoveryEnabled()) return;
739
+ const st = stateById(this.spec, this.currentStateId);
740
+ if (!st || statusForState(st) !== 'generating') return;
741
+ const windowMs = this.spec.refocus_when_stalled_ms as number;
742
+ // Cooldown reference: a re-prime defers the next one by a full window,
743
+ // even if the screen has not yet repainted, so we don't tight-loop.
744
+ const since = Math.max(this.lastScreenChangeAt(), this.lastRefocusAt);
745
+ const remaining = windowMs - (Date.now() - since);
746
+ this.stallTimer = setTimeout(
747
+ () => { this.stallTimer = null; this.onStallTick(); },
748
+ Math.max(remaining + 30, 50),
749
+ );
750
+ }
751
+
752
+ /** Fire when the stall window elapses: if the screen is still frozen and we
753
+ * are still generating, re-inject the focus-in wake once, then re-arm. */
754
+ private onStallTick(): void {
755
+ if (!this.stallRecoveryEnabled()) return;
756
+ const st = stateById(this.spec, this.currentStateId);
757
+ if (!st || statusForState(st) !== 'generating') return;
758
+ const windowMs = this.spec.refocus_when_stalled_ms as number;
759
+ const now = Date.now();
760
+ const stalledFor = now - this.lastScreenChangeAt();
761
+ const sinceLastRefocus = now - this.lastRefocusAt;
762
+ if (stalledFor >= windowMs && sinceLastRefocus >= windowMs) {
763
+ LOG.info('FsmDriver', `[${this.specTag()}] stall detected (${stalledFor}ms quiet, generating) — re-injecting focus-in`);
764
+ this.sendSpawnPrime();
765
+ this.lastRefocusAt = now;
766
+ }
767
+ // Keep watching: the re-prime may not flush instantly, and a still-quiet
768
+ // screen needs the next window to come around.
769
+ this.scheduleStallWatchdog();
770
+ }
771
+
688
772
  private maybeMarkReady(): void {
689
773
  if (this.readySeenOnce) return;
690
774
  const st = stateById(this.spec, this.currentStateId);
@@ -46,6 +46,14 @@ export function validateFsmSpec(raw: unknown): string[] {
46
46
  }
47
47
  }
48
48
 
49
+ if (spec.refocus_when_stalled_ms !== undefined) {
50
+ if (typeof spec.refocus_when_stalled_ms !== 'number' || !(spec.refocus_when_stalled_ms > 0)) {
51
+ errs.push('refocus_when_stalled_ms must be a positive number');
52
+ } else if (!Array.isArray(spec.send_on_spawn) || spec.send_on_spawn.length === 0) {
53
+ errs.push('refocus_when_stalled_ms requires send_on_spawn (the wake sequence to re-inject)');
54
+ }
55
+ }
56
+
49
57
  if (!Array.isArray(spec.states) || spec.states.length === 0) {
50
58
  errs.push('states[] must be a non-empty array');
51
59
  return errs;
@@ -160,6 +160,22 @@ export interface CliSpecV4 {
160
160
  send_on_spawn?: string[];
161
161
  /** Delay (ms) after spawn before writing `send_on_spawn`. Default 250. */
162
162
  send_on_spawn_delay_ms?: number;
163
+ /**
164
+ * Opt-in stall recovery for focus-gated TUIs. The same CLIs that need
165
+ * `send_on_spawn` (focus-event TUIs like antigravity's `agy`) also stop
166
+ * rendering / flushing output the moment they believe they have lost focus
167
+ * MID-TURN — the screen freezes and only repaints once the user presses a
168
+ * key, which the daemon never does on its own. When this field is set and
169
+ * the machine is in a `generating`-status state whose screen has not changed
170
+ * for `refocus_when_stalled_ms`, the engine re-injects the `send_on_spawn`
171
+ * prime (the focus-in event) once to wake the render loop, then waits for the
172
+ * screen to change or for the stall window to lapse again before re-priming
173
+ * (a cooldown that prevents a tight re-prime loop). Requires `send_on_spawn`
174
+ * to carry the wake sequence; with no `send_on_spawn` there is nothing to
175
+ * re-inject and the engine does nothing. Omitted by every non-focus-gated
176
+ * CLI spec, so the engine stays CLI-agnostic.
177
+ */
178
+ refocus_when_stalled_ms?: number;
163
179
  /**
164
180
  * Optional pre-spawn folder-trust step. When present, the engine adds the
165
181
  * launch workspace path to the declared trusted-folders array before