@adhdev/daemon-core 0.9.82-rc.553 → 0.9.82-rc.554

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.
@@ -1527,6 +1527,26 @@ export class CliProviderInstance implements ProviderInstance {
1527
1527
  return '';
1528
1528
  }
1529
1529
 
1530
+ /**
1531
+ * NOTIF Defect-B: the final assistant summary this instance ALREADY parsed and
1532
+ * cached for the current turn (lastCompletionSummary), if any. The evidence
1533
+ * probe (completionFinalAssistantEvidence) is a POINT-SAMPLE: on a native-source
1534
+ * provider (antigravity) the parsed screen and the native transcript can both
1535
+ * momentarily yield no in-turn final assistant at the exact instant the
1536
+ * completion gate fires — source='unavailable', missingEvidence=true — even
1537
+ * though a prior poll already read the real answer off native-history and cached
1538
+ * it here (the same value mesh_read_chat.summary shows). Consulting the cache at
1539
+ * emit time lets that already-secured summary count as evidence, so the completion
1540
+ * notification carries the answer instead of completion_diagnostic=missing_final_assistant
1541
+ * with an empty summary. Returns '' when the cache is empty or was reset by the
1542
+ * next turn (see lastCompletionSummary = null on onTurnStarted).
1543
+ */
1544
+ private cachedCompletionSummaryContent(): string {
1545
+ const cached = this.lastCompletionSummary;
1546
+ const content = typeof cached?.content === 'string' ? cached.content.trim() : '';
1547
+ return content;
1548
+ }
1549
+
1530
1550
  private completionFinalAssistantEvidence(parsedMessages: unknown, turnStartedAt?: number): CompletionFinalAssistantEvidence {
1531
1551
  // (FALSEIDLE FixB) UPPER-BOUND turn-end evidence. completionHasFinalAssistantMessage is a
1532
1552
  // pure message-content check ("does the last visible bubble read as a finalized assistant
@@ -1679,12 +1699,39 @@ export class CliProviderInstance implements ProviderInstance {
1679
1699
  const lastVisibleKind = typeof (lastVisible as any)?.kind === 'string' ? (lastVisible as any).kind : null;
1680
1700
  const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
1681
1701
 
1702
+ // NOTIF Defect-B: when the live evidence probe momentarily yields no in-turn
1703
+ // final assistant (source='unavailable'/external-native with present=false) but
1704
+ // a prior poll already parsed and CACHED the real answer for this turn
1705
+ // (lastCompletionSummary — the same value mesh_read_chat.summary surfaces),
1706
+ // credit the cache as evidence. This flips finalAssistantPresent to true and
1707
+ // records the cached source so the completion notification carries
1708
+ // completion_diagnostic=present with the summary, instead of
1709
+ // missing_final_assistant with an empty payload. Only ever UPGRADES a
1710
+ // point-sample miss — a genuine present=true is unchanged, and an empty cache
1711
+ // leaves the missing-evidence diagnostic exactly as before.
1712
+ const cachedSummary = evidence.present ? '' : this.cachedCompletionSummaryContent();
1713
+ const creditedFromCache = !evidence.present && cachedSummary.length > 0;
1714
+ const finalAssistantPresent = evidence.present || creditedFromCache;
1715
+ const finalAssistantEvidenceSource = evidence.present
1716
+ ? evidence.source
1717
+ : (creditedFromCache ? 'cached-summary' : evidence.source);
1718
+ // When the cached summary rescues the evidence, the turn is no longer
1719
+ // "missing final assistant" — clear that blockReason so isMissingFinalAssistant‑
1720
+ // Diagnostic()/isWeakCompletionEvidence() no longer flag it (both key off
1721
+ // blockReason='missing_final_assistant' independently of finalAssistantPresent)
1722
+ // and the coordinator log's formatCompletionMetadata reads
1723
+ // completion_diagnostic=present (empty blockReason → 'present'). The ORIGINAL
1724
+ // reason is preserved under originalBlockReason for diagnostics.
1725
+ const clearMissingBlock = creditedFromCache && args.blockReason === 'missing_final_assistant';
1726
+ const effectiveBlockReason = clearMissingBlock ? undefined : args.blockReason;
1727
+
1682
1728
  return {
1683
1729
  providerType: this.type,
1684
1730
  sessionId: this.instanceId,
1685
1731
  providerSessionId: this.providerSessionId || null,
1686
1732
  workspace: this.workingDir,
1687
- blockReason: args.blockReason,
1733
+ ...(effectiveBlockReason ? { blockReason: effectiveBlockReason } : {}),
1734
+ ...(clearMissingBlock ? { originalBlockReason: args.blockReason } : {}),
1688
1735
  emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
1689
1736
  waitedMs: args.waitedMs,
1690
1737
  maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
@@ -1692,8 +1739,9 @@ export class CliProviderInstance implements ProviderInstance {
1692
1739
  latestVisibleStatus: args.latestVisibleStatus,
1693
1740
  parsedStatus: typeof parsed?.status === 'string' ? parsed.status : (parseError ? 'parse_error' : 'unknown'),
1694
1741
  parseError: parseError || undefined,
1695
- finalAssistantPresent: evidence.present,
1696
- finalAssistantEvidenceSource: evidence.source,
1742
+ finalAssistantPresent,
1743
+ finalAssistantFromCachedSummary: !evidence.present && cachedSummary.length > 0,
1744
+ finalAssistantEvidenceSource,
1697
1745
  visibleMessageCount: visibleMessages.length,
1698
1746
  lastVisibleRole,
1699
1747
  lastVisibleKind,
@@ -2012,6 +2060,13 @@ export class CliProviderInstance implements ProviderInstance {
2012
2060
  hash: string;
2013
2061
  } | null {
2014
2062
  if (!this.isMeshWorkerSession()) return null;
2063
+ // Defensive: not every CliAdapter implementation exposes the raw-terminal
2064
+ // read (the surface is declared optional on CliAdapter). Returning null
2065
+ // for an adapter that lacks it surfaces a clean unsupported refusal at
2066
+ // the daemon command instead of "getTerminalScreenSnapshot is not a
2067
+ // function" — the failure mode that broke mesh_read_terminal on the
2068
+ // spec-driven path before SpecCliAdapter implemented it.
2069
+ if (typeof this.adapter.getTerminalScreenSnapshot !== 'function') return null;
2015
2070
  return this.adapter.getTerminalScreenSnapshot(maxBytes);
2016
2071
  }
2017
2072
 
@@ -2032,11 +2087,18 @@ export class CliProviderInstance implements ProviderInstance {
2032
2087
  opts: { allowModalOverride?: boolean } = {},
2033
2088
  ): Promise<
2034
2089
  | { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
2035
- | { ok: false; refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker'; keys: MeshSendKeyName[]; hasDestructive: boolean }
2090
+ | { ok: false; refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker' | 'unsupported'; keys: MeshSendKeyName[]; hasDestructive: boolean }
2036
2091
  > {
2037
2092
  if (!this.isMeshWorkerSession()) {
2038
2093
  return { ok: false, refused: 'not_mesh_worker', keys: [], hasDestructive: false };
2039
2094
  }
2095
+ // Defensive: injectKeys is optional on CliAdapter. An adapter without it
2096
+ // yields a clean 'unsupported' refusal instead of throwing
2097
+ // "injectKeys is not a function" — the failure that broke mesh_send_keys
2098
+ // on the spec-driven path before SpecCliAdapter implemented it.
2099
+ if (typeof this.adapter.injectKeys !== 'function') {
2100
+ return { ok: false, refused: 'unsupported', keys: [], hasDestructive: false };
2101
+ }
2040
2102
  return this.adapter.injectKeys(items, opts);
2041
2103
  }
2042
2104
 
@@ -2072,7 +2134,14 @@ export class CliProviderInstance implements ProviderInstance {
2072
2134
  this.meshStallEmittedForAnchor = false;
2073
2135
  return;
2074
2136
  }
2075
- if (!this.adapter.isAlive()) {
2137
+ // Defensive: not every CliAdapter implementation exposes isAlive() — the
2138
+ // spec-driven adapter (SpecCliAdapter, used by native-source providers like
2139
+ // antigravity-cli) historically had none, so an unguarded call threw
2140
+ // `this.adapter.isAlive is not a function` on EVERY 5s tick, disabling stall
2141
+ // detection for those sessions entirely. A missing method is treated as alive
2142
+ // (the session lifecycle drops the anchor via isMeshWorkerSession()/exit paths),
2143
+ // never as a throw. When present, a dead process drops the armed episode.
2144
+ if (typeof this.adapter.isAlive === 'function' && !this.adapter.isAlive()) {
2076
2145
  // Dead PTY: nothing to watch. agent:stopped covers the exit; re-arm on
2077
2146
  // the next live session so a restart starts a fresh episode.
2078
2147
  this.meshStallAnchorAt = -1;
@@ -2541,9 +2610,15 @@ export class CliProviderInstance implements ProviderInstance {
2541
2610
  // delegated session's inbox preview blank — or, for a LOCAL worktree session,
2542
2611
  // stuck on the dispatched user task. If the parser DID surface assistant text,
2543
2612
  // prefer it; only fall back to '' when no assistant summary can be derived.
2544
- finalSummary: blockReason.startsWith('parsed_status:')
2545
- ? (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) ?? '')
2546
- : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
2613
+ // NOTIF Defect-B: completionFinalSummary is a point-sample of native-history/
2614
+ // screen at THIS instant; on a native-source provider (antigravity) it can be
2615
+ // empty at the forced-emit instant even though a prior poll already cached the
2616
+ // real answer (lastCompletionSummary). Fall back to the cache so the notification
2617
+ // carries the summary that mesh_read_chat.summary already shows — consistent with
2618
+ // completionDiagnostic.finalAssistantPresent being credited from the same cache.
2619
+ finalSummary: (this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt)
2620
+ || this.cachedCompletionSummaryContent()
2621
+ || (blockReason.startsWith('parsed_status:') ? '' : undefined)),
2547
2622
  completionDiagnostic,
2548
2623
  });
2549
2624
  this.completedDebouncePending = null;
@@ -175,6 +175,13 @@ export class TerminalAdapter {
175
175
  return { row: pos.row, col: pos.col };
176
176
  }
177
177
 
178
+ /** Current terminal geometry (columns × rows). Tracked here rather than
179
+ * read off the screen buffer so a resize is reflected immediately, before
180
+ * the next repaint. Consumed by the mesh_read_terminal viewport read. */
181
+ getScreenSize(): { cols: number; rows: number } {
182
+ return { cols: this.cols, rows: this.rows };
183
+ }
184
+
178
185
  send_keys(text: string): void {
179
186
  this.recordEvent('input', capPreview(escapeControl(text)), text.length);
180
187
  this.pty?.write(text);
@@ -23,10 +23,17 @@ import { lastContiguousNumberedBlock } from './evaluator.js';
23
23
  import { executeNativeHistory } from './native-history-executor.js';
24
24
  import { detectBackgroundTaskActive } from './background-task-detector.js';
25
25
  import * as fs from 'node:fs';
26
+ import { createHash } from 'node:crypto';
26
27
  import type { NativeHistoryConfig, Control, ControlAction } from './types.js';
27
28
  import type { CliAdapter, CliAdapterStatus } from '../../cli-adapter-types.js';
28
29
  import type { ChatMessage } from '../../types.js';
29
30
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
31
+ import {
32
+ encodeMeshSendKeys,
33
+ truncateToByteTailByLine,
34
+ type MeshSendKeyItem,
35
+ type MeshSendKeyName,
36
+ } from '../../cli-adapters/provider-cli-shared.js';
30
37
  import { LOG } from '../../logging/logger.js';
31
38
  import {
32
39
  buildClaudeInteractiveTuiAnswerSteps,
@@ -277,6 +284,121 @@ export class SpecCliAdapter implements CliAdapter {
277
284
  return this.spawned && !this.exited;
278
285
  }
279
286
 
287
+ // Process liveness for the MESH-STALL-WATCH watchdog (checkMeshWorkerStall).
288
+ // The spec path drives the child through the transport/driver rather than a
289
+ // directly-held ptyProcess handle, so liveness is tracked by the spawned/exited
290
+ // lifecycle flags — the same pair isReady() uses. A spawned, not-yet-exited
291
+ // session is alive. ProviderCliAdapter exposes the equivalent via `ptyProcess !== null`.
292
+ isAlive(): boolean {
293
+ return this.spawned && !this.exited;
294
+ }
295
+
296
+ // MESH-READ-TERMINAL / MESH-SEND-KEYS byte caps — same envelope as
297
+ // ProviderCliAdapter (32KiB default view, 64KiB absolute hard cap). Bytes,
298
+ // not chars: a multi-byte-glyph screen can exceed an MCP payload cap while
299
+ // the char count still looks safe.
300
+ private static readonly TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES = 32 * 1024;
301
+ private static readonly TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES = 64 * 1024;
302
+
303
+ /**
304
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Least-privilege read
305
+ * of the CURRENT rendered viewport for mesh_read_terminal on the spec path
306
+ * (claude-cli / antigravity / codex-cli — the native-source providers that
307
+ * route through SpecCliAdapter). Mirrors ProviderCliAdapter.getTerminalScreenSnapshot:
308
+ * - returns ONLY the driver's current viewport snapshot, the cursor
309
+ * position and the terminal geometry — NO scrollback, NO parser/FSM
310
+ * state, NO debug buffers;
311
+ * - the payload is byte-bounded (UTF-8) with bottom-tail preservation so a
312
+ * screen of multi-byte glyphs can never exceed the MCP payload cap;
313
+ * - `hash` is over the FULL untruncated viewport so a caller can detect a
314
+ * screen change across polls even when the returned text was truncated.
315
+ *
316
+ * SECURITY: the raw viewport can carry tokens / command args / env / user
317
+ * data. Callers MUST gate this on mesh ownership and MUST NOT log the text.
318
+ */
319
+ getTerminalScreenSnapshot(maxBytes = SpecCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES): {
320
+ text: string;
321
+ cursor: { col: number; row: number };
322
+ cols: number;
323
+ rows: number;
324
+ truncated: boolean;
325
+ originalBytes: number;
326
+ returnedBytes: number;
327
+ hash: string;
328
+ } {
329
+ const cap = Math.min(
330
+ SpecCliAdapter.TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES,
331
+ Math.max(1024, Math.floor(maxBytes) || SpecCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES),
332
+ );
333
+ let rawViewport = '';
334
+ try { rawViewport = this.driver.snapshot() || ''; } catch { rawViewport = ''; }
335
+ let cursor = { row: 0, col: 0 };
336
+ try { cursor = this.driver.getCursorPosition(); } catch { /* keep 0,0 */ }
337
+ // getScreenSize is optional on ISpecDriver; a test double may omit it.
338
+ let size = { cols: 0, rows: 0 };
339
+ try { size = this.driver.getScreenSize?.() ?? size; } catch { /* keep 0,0 */ }
340
+ const truncation = truncateToByteTailByLine(rawViewport, cap);
341
+ const hash = createHash('sha256').update(rawViewport, 'utf8').digest('hex').slice(0, 16);
342
+ return {
343
+ text: truncation.text,
344
+ cursor: { col: cursor.col, row: cursor.row },
345
+ cols: size.cols,
346
+ rows: size.rows,
347
+ truncated: truncation.truncated,
348
+ originalBytes: truncation.originalBytes,
349
+ returnedBytes: truncation.returnedBytes,
350
+ hash,
351
+ };
352
+ }
353
+
354
+ /**
355
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key
356
+ * sequence into the spec-driven PTY for mesh_send_keys. Mirrors
357
+ * ProviderCliAdapter.injectKeys' modal fail-closed guard, then writes the
358
+ * whole encoded sequence in ONE pty_write dispatch (text+ENTER is a single
359
+ * contiguous string, so a submit key can never be separated from the text
360
+ * it submits).
361
+ *
362
+ * The spec path drives the child through the FsmDriver, not a directly-held
363
+ * ptyProcess — there is no adapter-level echo-gate/submit-retry FIFO to race
364
+ * against here (the driver serializes its own writes), so the only guard is
365
+ * the modal fail-closed: a NON-destructive injection into an actionable
366
+ * approval modal is refused (use mesh_approve) unless explicitly overridden.
367
+ * A destructive ESC/CTRL_C dismisses rather than confirms, so it is allowed
368
+ * past this gate (the tool layer owns the destructive double-gate + audit).
369
+ * This method NEVER logs the literal text — only key enums / byte length.
370
+ */
371
+ async injectKeys(
372
+ items: MeshSendKeyItem[],
373
+ opts: { allowModalOverride?: boolean } = {},
374
+ ): Promise<
375
+ | { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
376
+ | { ok: false; refused: 'submit_race' | 'actionable_modal'; keys: MeshSendKeyName[]; hasDestructive: boolean }
377
+ > {
378
+ if (!this.spawned || this.exited) throw new Error(`${this.cliName} is not running`);
379
+ const encoded = encodeMeshSendKeys(items);
380
+
381
+ // Modal fail-closed — a NON-destructive injection while parked on an
382
+ // actionable approval modal is refused so a modal choice can't be
383
+ // confirmed via send_keys and bypass the approval policy.
384
+ const modalActive = this.latestState?.status === 'approval';
385
+ if (modalActive && !encoded.hasDestructive && !opts.allowModalOverride) {
386
+ LOG.warn('SpecAdapter', `[${this.cliType}] send_keys refused (actionable_modal): keys=${encoded.keys.join(',')} — use mesh_approve`);
387
+ return { ok: false, refused: 'actionable_modal', keys: encoded.keys, hasDestructive: encoded.hasDestructive };
388
+ }
389
+
390
+ // Atomic write: the full encoded sequence goes out in ONE pty_write.
391
+ this.driver.dispatch({ kind: 'pty_write', data: encoded.sequence });
392
+ LOG.info('SpecAdapter', `[${this.cliType}] send_keys injected keys=${encoded.keys.join(',') || '(text-only)'} bytes=${Buffer.byteLength(encoded.sequence, 'utf8')} destructive=${encoded.hasDestructive}`);
393
+ return {
394
+ ok: true,
395
+ keys: encoded.keys,
396
+ hasDestructive: encoded.hasDestructive,
397
+ submits: encoded.submits,
398
+ bytes: Buffer.byteLength(encoded.sequence, 'utf8'),
399
+ };
400
+ }
401
+
280
402
  setOnStatusChange(cb: () => void): void {
281
403
  this.statusCallback = cb;
282
404
  }
@@ -122,6 +122,10 @@ export interface ISpecDriver {
122
122
  snapshot(): string;
123
123
  getCursorPosition(): { row: number; col: number };
124
124
  getScreen(): string;
125
+ /** Current terminal geometry (columns × rows). Optional so a non-Fsm
126
+ * ISpecDriver implementation (test doubles) need not provide it; the
127
+ * mesh_read_terminal path falls back to a 0×0 geometry when absent. */
128
+ getScreenSize?(): { cols: number; rows: number };
125
129
  getSpecPath(): string;
126
130
  shutdown(): void;
127
131
  getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
@@ -443,6 +447,7 @@ export class FsmDriver implements ISpecDriver {
443
447
  snapshot(): string { return this.adapter.snapshot(); }
444
448
  getCursorPosition(): { row: number; col: number } { return this.adapter.getCursorPosition(); }
445
449
  getScreen(): string { return this.adapter.snapshot(); }
450
+ getScreenSize(): { cols: number; rows: number } { return this.adapter.getScreenSize(); }
446
451
 
447
452
  /** Scrollback-inclusive screen as line array — used only for modal/button
448
453
  * content extraction so a tall prompt's off-screen anchors stay matchable.