@adhdev/daemon-core 0.9.82-rc.552 → 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.
Files changed (39) hide show
  1. package/dist/cli-adapter-types.d.ts +29 -0
  2. package/dist/cli-adapters/provider-cli-adapter.d.ts +80 -1
  3. package/dist/cli-adapters/provider-cli-shared.d.ts +55 -0
  4. package/dist/cli-adapters/terminal-screen.d.ts +5 -0
  5. package/dist/commands/stream-commands.d.ts +31 -0
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +789 -15
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +785 -12
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/mesh-ledger.d.ts +1 -1
  12. package/dist/mesh/mesh-refine-gates.d.ts +86 -0
  13. package/dist/providers/cli-provider-instance.d.ts +97 -0
  14. package/dist/providers/provider-instance.d.ts +9 -0
  15. package/dist/providers/spec/adapter.d.ts +7 -0
  16. package/dist/providers/spec/cli-adapter.d.ts +64 -0
  17. package/dist/providers/spec/fsm-driver.d.ts +11 -0
  18. package/dist/repo-mesh-types.d.ts +23 -0
  19. package/package.json +3 -3
  20. package/src/cli-adapter-types.ts +29 -0
  21. package/src/cli-adapters/provider-cli-adapter.ts +135 -0
  22. package/src/cli-adapters/provider-cli-shared.ts +172 -0
  23. package/src/cli-adapters/terminal-screen.ts +5 -0
  24. package/src/commands/handler.ts +4 -0
  25. package/src/commands/router-refine.ts +40 -1
  26. package/src/commands/router.ts +15 -0
  27. package/src/commands/stream-commands.ts +125 -0
  28. package/src/index.ts +1 -0
  29. package/src/mesh/coordinator-prompt.ts +2 -0
  30. package/src/mesh/mesh-events-utils.ts +15 -0
  31. package/src/mesh/mesh-ledger.ts +6 -0
  32. package/src/mesh/mesh-refine-gates.ts +256 -0
  33. package/src/providers/cli-provider-instance.ts +277 -6
  34. package/src/providers/provider-instance-manager.ts +11 -0
  35. package/src/providers/provider-instance.ts +10 -0
  36. package/src/providers/spec/adapter.ts +7 -0
  37. package/src/providers/spec/cli-adapter.ts +122 -0
  38. package/src/providers/spec/fsm-driver.ts +5 -0
  39. package/src/repo-mesh-types.ts +35 -0
@@ -15,7 +15,7 @@
15
15
  import { EventEmitter } from 'events';
16
16
  import { MeshRuntimeStore } from './mesh-runtime-store.js';
17
17
  import { type MeshLedgerOriginatingCoordinatorV2 } from './contracts.js';
18
- export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'event_held_requeued' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis';
18
+ export type MeshLedgerKind = 'task_dispatched' | 'task_completed' | 'task_failed' | 'task_stalled' | 'task_approval_needed' | 'p2p_dispatch_failed' | 'session_launched' | 'session_auto_launch' | 'session_stopped' | 'checkpoint_created' | 'node_cloned' | 'node_joined' | 'node_removed' | 'coordinator_started' | 'recovery_attempted' | 'ledger_replicated' | 'ledger_reconciled' | 'direct_fast_forward' | 'delivery_unroutable' | 'direct_dispatch_pruned' | 'event_held' | 'event_held_requeued' | 'task_reclaimed' | 'coordinator_operating_note' | 'coordinator_operating_note_tombstone' | 'mission_created' | 'mission_status_changed' | 'mission_goal_updated' | 'magi_dispatched' | 'magi_synthesis' | 'key_injection';
19
19
  export interface MeshLedgerEntry {
20
20
  id: string;
21
21
  meshId: string;
@@ -327,6 +327,92 @@ export declare function resolveRefineryAutoPublishSubmoduleMainCommits(mesh: any
327
327
  source?: string;
328
328
  };
329
329
  export declare function runMeshRefinePatchEquivalenceGate(repoRoot: string, baseHead: string, branchHead: string): Promise<MeshRefinePatchEquivalenceSummary>;
330
+ /**
331
+ * Machine-readable sub-classification of a `patch_equivalence_failed` (and the
332
+ * related submodule-gitlink preflight blocks). The opaque top-level
333
+ * `patch_equivalence_failed` code is preserved for backward compatibility; this
334
+ * detailed reason is added ALONGSIDE it so coordinators no longer have to guess
335
+ * WHY the preflight blocked (the 2026-07-17 hidden-spinner convergence incident:
336
+ * the real cause was a diverged base + an unreachable submodule gitlink artifact,
337
+ * not a real patch conflict, but Refinery only returned the opaque code and the
338
+ * coordinator mis-attributed it to a stale daemon version).
339
+ */
340
+ export type MeshRefinePatchEquivalenceDetailedReasonCode =
341
+ /** Worktree base diverged from target base (HEAD is not a descendant of origin/main). */
342
+ 'base_divergence'
343
+ /** Submodule gitlink commit is not reachable from the submodule's remote main branch (publish needed). */
344
+ | 'submodule_unreachable'
345
+ /** Genuine non-equivalent content: expected tree vs actual merge diff differ. */
346
+ | 'actual_patch_diff'
347
+ /** Submodule gitlink trivial fast-forward mis-judged as non-equivalent (HEAD descends origin/main, patch-id equal, blocked only by the gitlink). */
348
+ | 'trivial_ff_misjudgment'
349
+ /** Already identical to origin/main (ahead 0 / behind 0, no diff) — should be treated as success/no-op. */
350
+ | 'already_converged'
351
+ /** Fallback when the classifier itself could not run (git error); keep the opaque code, note the reason. */
352
+ | 'unclassified';
353
+ export type MeshRefinePatchEquivalenceFailureClassification = {
354
+ detailedReason: MeshRefinePatchEquivalenceDetailedReasonCode;
355
+ /** Human-readable one-line description of the sub-cause. */
356
+ detailedReasonDescription: string;
357
+ /** Suggested next action for the coordinator/owner (free-form, actionable). */
358
+ recommendedAction: string;
359
+ /** Structured supporting evidence: SHAs, ahead/behind, submodule reachability, patch-id comparison, diff stat. */
360
+ evidence: {
361
+ baseHead?: string;
362
+ branchHead?: string;
363
+ mergeBase?: string;
364
+ /** How many commits base (origin/main) is ahead of the branch's merge-base (branch is behind). */
365
+ behind?: number;
366
+ /** How many commits the branch is ahead of the merge-base. */
367
+ ahead?: number;
368
+ /** True when HEAD is NOT a descendant of the target base (diverged). */
369
+ baseDiverged?: boolean;
370
+ expectedPatchId?: string;
371
+ actualPatchId?: string;
372
+ patchIdEqual?: boolean;
373
+ /** Compact one-line diff stat summary of the residual/actual merge diff (best-effort). */
374
+ diffStat?: string;
375
+ /** Per-submodule gitlink reachability against submodule origin/main (best-effort). */
376
+ submoduleGitlinks?: Array<{
377
+ path: string;
378
+ baseCommit?: string;
379
+ branchCommit?: string;
380
+ /** True when branchCommit descends baseCommit (a strict fast-forward advance). */
381
+ fastForward?: boolean;
382
+ /** True when branchCommit is reachable from the submodule's local origin/main. */
383
+ reachableFromOriginMain?: boolean;
384
+ }>;
385
+ /** Effective auto-publish-submodule-main-commits policy value at classification time. */
386
+ autoPublishSubmoduleMainCommits?: boolean;
387
+ /** Set when the classifier itself errored (detailedReason === 'unclassified'). */
388
+ classifierError?: string;
389
+ };
390
+ };
391
+ /**
392
+ * Classify WHY a patch-equivalence preflight blocked, turning the opaque
393
+ * `patch_equivalence_failed` code into a machine-readable {@link
394
+ * MeshRefinePatchEquivalenceDetailedReasonCode} plus a recommended action and
395
+ * structured evidence. Read-only: runs only `git` inspection commands (rev-list,
396
+ * merge-base, diff --stat, submodule reachability probes) against the already-set
397
+ * worktree — it never mutates the repo.
398
+ *
399
+ * Priority of classification (first match wins):
400
+ * 1. already_converged — ahead 0 & behind 0 & no residual diff
401
+ * 2. submodule_unreachable — a changed gitlink commit is not reachable from the
402
+ * submodule's origin/main (publish needed)
403
+ * 3. trivial_ff_misjudgment — HEAD descends origin/main AND (excl. gitlinks) the
404
+ * patch-ids match — blocked only by a ff gitlink
405
+ * 4. base_divergence — HEAD is not a descendant of the target base
406
+ * 5. actual_patch_diff — genuine content divergence (the residual case)
407
+ *
408
+ * `targetBaseRef` is the ref the branch is meant to land on (e.g. 'origin/main'
409
+ * or the pinned baseHead SHA). `autoPublishSubmoduleMainCommits` is threaded in so
410
+ * the submodule_unreachable recommendation can name the current policy value.
411
+ */
412
+ export declare function classifyPatchEquivalenceFailure(repoRoot: string, baseHead: string, branchHead: string, summary: MeshRefinePatchEquivalenceSummary, options?: {
413
+ targetBaseRef?: string;
414
+ autoPublishSubmoduleMainCommits?: boolean;
415
+ }): Promise<MeshRefinePatchEquivalenceFailureClassification>;
330
416
  export type MeshWorktreePatchContainmentSummary = {
331
417
  /** True only when merging worktreeHead into ref introduces no new patch. */
332
418
  contained: boolean;
@@ -7,6 +7,7 @@
7
7
  import { type ProviderModule, type InputEnvelope } from './contracts.js';
8
8
  import type { ProviderInstance, ProviderState, InstanceContext, HotChatSessionState, SessionModalState } from './provider-instance.js';
9
9
  import { ProviderCliAdapter } from '../cli-adapters/provider-cli-adapter.js';
10
+ import type { MeshSendKeyItem, MeshSendKeyName } from '../cli-adapters/provider-cli-shared.js';
10
11
  import type { PtyTransportFactory } from '../cli-adapters/pty-transport.js';
11
12
  import type { ChatMessage } from '../types.js';
12
13
  export { buildCliStructuredInputPrompt } from './cli-provider-input-prompt.js';
@@ -169,12 +170,15 @@ export declare class CliProviderInstance implements ProviderInstance {
169
170
  * with no approval never carries recency and is never held (no regression).
170
171
  */
171
172
  private static readonly APPROVAL_RESUME_GRACE_MS;
173
+ private static readonly MESH_WORKER_STALL_THRESHOLD_MS;
172
174
  private adapter;
173
175
  private context;
174
176
  private events;
175
177
  private lastStatus;
176
178
  private agentReadyEmitted;
177
179
  private generatingStartedAt;
180
+ private meshStallAnchorAt;
181
+ private meshStallEmittedForAnchor;
178
182
  private busyEpoch;
179
183
  private fastCollapseSynthesizedTaskId;
180
184
  private startupGraceCollapseAt;
@@ -419,6 +423,21 @@ export declare class CliProviderInstance implements ProviderInstance {
419
423
  * the dashboard tail-repair cache — a display value, not a completion decision.
420
424
  */
421
425
  private lastVisibleAssistantSummary;
426
+ /**
427
+ * NOTIF Defect-B: the final assistant summary this instance ALREADY parsed and
428
+ * cached for the current turn (lastCompletionSummary), if any. The evidence
429
+ * probe (completionFinalAssistantEvidence) is a POINT-SAMPLE: on a native-source
430
+ * provider (antigravity) the parsed screen and the native transcript can both
431
+ * momentarily yield no in-turn final assistant at the exact instant the
432
+ * completion gate fires — source='unavailable', missingEvidence=true — even
433
+ * though a prior poll already read the real answer off native-history and cached
434
+ * it here (the same value mesh_read_chat.summary shows). Consulting the cache at
435
+ * emit time lets that already-secured summary count as evidence, so the completion
436
+ * notification carries the answer instead of completion_diagnostic=missing_final_assistant
437
+ * with an empty summary. Returns '' when the cache is empty or was reset by the
438
+ * next turn (see lastCompletionSummary = null on onTurnStarted).
439
+ */
440
+ private cachedCompletionSummaryContent;
422
441
  private completionFinalAssistantEvidence;
423
442
  private completionFinalSummary;
424
443
  private buildCompletedFinalizationDiagnostic;
@@ -429,6 +448,84 @@ export declare class CliProviderInstance implements ProviderInstance {
429
448
  private approvalResolutionFinalizationBlock;
430
449
  private scheduleCompletedDebounceFlush;
431
450
  private isMeshWorkerSession;
451
+ /**
452
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Public read of the
453
+ * CURRENT rendered PTY viewport for the mesh_read_terminal tool, delegating to
454
+ * the adapter's narrow getTerminalScreenSnapshot() (viewport + cursor + size
455
+ * only; no debug buffers / parser state / history; byte-bounded, bottom-tail
456
+ * preserved).
457
+ *
458
+ * Gated on isMeshWorkerSession(): this raw viewport can expose tokens /
459
+ * command args / env / user data, so only a coordinator-spawned worker session
460
+ * is readable. The MCP layer ALSO cross-checks mesh/session/node ownership
461
+ * (isMeshOwnedDelegateSession) — isMeshWorkerSession alone is a broad
462
+ * "delegated" gate, so the two together block cross-mesh access. Returns null
463
+ * for a non-mesh session so the daemon command surfaces a clean refusal.
464
+ */
465
+ getTerminalScreenSnapshot(maxBytes?: number): {
466
+ text: string;
467
+ cursor: {
468
+ col: number;
469
+ row: number;
470
+ };
471
+ cols: number;
472
+ rows: number;
473
+ truncated: boolean;
474
+ originalBytes: number;
475
+ returnedBytes: number;
476
+ hash: string;
477
+ } | null;
478
+ /**
479
+ * MESH-SEND-KEYS (feature 3: key injection). Public entry for the
480
+ * mesh_send_keys tool, delegating to the adapter's injectKeys() (structured
481
+ * key encoding + atomic write + submit-race recheck + modal fail-closed).
482
+ *
483
+ * Gated on isMeshWorkerSession(): PTY input into a worker is a
484
+ * coordinator-only capability. The MCP layer ALSO cross-checks mesh/session/
485
+ * node ownership (isMeshOwnedDelegateSession) and owns the destructive-key
486
+ * double gate (confirm_destructive + policy) and the audit ledger. Returns a
487
+ * refusal object for a non-mesh session so the daemon command surfaces a clean
488
+ * error (never silently writes to a non-worker PTY).
489
+ */
490
+ injectKeys(items: MeshSendKeyItem[], opts?: {
491
+ allowModalOverride?: boolean;
492
+ }): Promise<{
493
+ ok: true;
494
+ keys: MeshSendKeyName[];
495
+ hasDestructive: boolean;
496
+ submits: boolean;
497
+ bytes: number;
498
+ } | {
499
+ ok: false;
500
+ refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker' | 'unsupported';
501
+ keys: MeshSendKeyName[];
502
+ hasDestructive: boolean;
503
+ }>;
504
+ /**
505
+ * MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
506
+ * watchdog for coordinator-spawned mesh worker sessions. Driven by the
507
+ * ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
508
+ * ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
509
+ * clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
510
+ * live worker's screen has been byte-for-byte unchanged for
511
+ * MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
512
+ * monitor:no_progress event down the existing task_stalled ledger +
513
+ * pendingCoordinatorEvent path.
514
+ *
515
+ * Deliberately status-agnostic: it does NOT read getStatus()'s reported status
516
+ * (which would couple it to the generating-only StatusMonitor and the
517
+ * idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
518
+ * minutes; that is accepted and surfaced as an informational stall (NOT a
519
+ * failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
520
+ * called here, so status heartbeats never move the stall anchor.
521
+ *
522
+ * Anchoring: the episode arms against the current lastOutputAt; a worker that
523
+ * has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
524
+ * time) so a silent spawn is still caught. Any new output re-arms the anchor
525
+ * and clears the emitted flag, so one continuous stall emits at most once and a
526
+ * later stall re-arms cleanly.
527
+ */
528
+ checkMeshWorkerStall(now?: number): void;
432
529
  /**
433
530
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
434
531
  * persist before the in-progress settle gate is torn down. For a delegated
@@ -164,6 +164,15 @@ export interface ProviderInstance {
164
164
  init(context: InstanceContext): Promise<void>;
165
165
  /** Tick — periodic status refresh (IDE: readChat, Extension: stream collection) */
166
166
  onTick(): Promise<void>;
167
+ /**
168
+ * MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
169
+ * watchdog for coordinator-spawned mesh worker sessions, invoked from the
170
+ * ProviderInstanceManager's existing tick loop (no separate timer). Fires ONE
171
+ * informational monitor:no_progress event when a live worker's raw PTY output
172
+ * has been unchanged past the stall threshold. Optional — only CLI instances
173
+ * (which own a PTY / lastOutputAt clock) implement it; a no-op elsewhere.
174
+ */
175
+ checkMeshWorkerStall?(now?: number): void;
167
176
  /** Return current status */
168
177
  getState(): ProviderState;
169
178
  /**
@@ -79,6 +79,13 @@ export declare class TerminalAdapter {
79
79
  row: number;
80
80
  col: number;
81
81
  };
82
+ /** Current terminal geometry (columns × rows). Tracked here rather than
83
+ * read off the screen buffer so a resize is reflected immediately, before
84
+ * the next repaint. Consumed by the mesh_read_terminal viewport read. */
85
+ getScreenSize(): {
86
+ cols: number;
87
+ rows: number;
88
+ };
82
89
  send_keys(text: string): void;
83
90
  /** Forward runtime metadata (meshNodeId, workspaceLabel, lifecycle, …) to
84
91
  * the underlying transport so it reaches the session registry. The spec
@@ -1,5 +1,6 @@
1
1
  import type { CliAdapter, CliAdapterStatus } from '../../cli-adapter-types.js';
2
2
  import type { PtyTransportFactory } from '../../cli-adapters/pty-transport.js';
3
+ import { type MeshSendKeyItem, type MeshSendKeyName } from '../../cli-adapters/provider-cli-shared.js';
3
4
  import { type InteractivePromptResponse } from '../types/interactive-prompt.js';
4
5
  export declare class SpecCliAdapter implements CliAdapter {
5
6
  readonly cliType: string;
@@ -74,6 +75,69 @@ export declare class SpecCliAdapter implements CliAdapter {
74
75
  cancel(): void;
75
76
  isProcessing(): boolean;
76
77
  isReady(): boolean;
78
+ isAlive(): boolean;
79
+ private static readonly TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES;
80
+ private static readonly TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES;
81
+ /**
82
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Least-privilege read
83
+ * of the CURRENT rendered viewport for mesh_read_terminal on the spec path
84
+ * (claude-cli / antigravity / codex-cli — the native-source providers that
85
+ * route through SpecCliAdapter). Mirrors ProviderCliAdapter.getTerminalScreenSnapshot:
86
+ * - returns ONLY the driver's current viewport snapshot, the cursor
87
+ * position and the terminal geometry — NO scrollback, NO parser/FSM
88
+ * state, NO debug buffers;
89
+ * - the payload is byte-bounded (UTF-8) with bottom-tail preservation so a
90
+ * screen of multi-byte glyphs can never exceed the MCP payload cap;
91
+ * - `hash` is over the FULL untruncated viewport so a caller can detect a
92
+ * screen change across polls even when the returned text was truncated.
93
+ *
94
+ * SECURITY: the raw viewport can carry tokens / command args / env / user
95
+ * data. Callers MUST gate this on mesh ownership and MUST NOT log the text.
96
+ */
97
+ getTerminalScreenSnapshot(maxBytes?: number): {
98
+ text: string;
99
+ cursor: {
100
+ col: number;
101
+ row: number;
102
+ };
103
+ cols: number;
104
+ rows: number;
105
+ truncated: boolean;
106
+ originalBytes: number;
107
+ returnedBytes: number;
108
+ hash: string;
109
+ };
110
+ /**
111
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key
112
+ * sequence into the spec-driven PTY for mesh_send_keys. Mirrors
113
+ * ProviderCliAdapter.injectKeys' modal fail-closed guard, then writes the
114
+ * whole encoded sequence in ONE pty_write dispatch (text+ENTER is a single
115
+ * contiguous string, so a submit key can never be separated from the text
116
+ * it submits).
117
+ *
118
+ * The spec path drives the child through the FsmDriver, not a directly-held
119
+ * ptyProcess — there is no adapter-level echo-gate/submit-retry FIFO to race
120
+ * against here (the driver serializes its own writes), so the only guard is
121
+ * the modal fail-closed: a NON-destructive injection into an actionable
122
+ * approval modal is refused (use mesh_approve) unless explicitly overridden.
123
+ * A destructive ESC/CTRL_C dismisses rather than confirms, so it is allowed
124
+ * past this gate (the tool layer owns the destructive double-gate + audit).
125
+ * This method NEVER logs the literal text — only key enums / byte length.
126
+ */
127
+ injectKeys(items: MeshSendKeyItem[], opts?: {
128
+ allowModalOverride?: boolean;
129
+ }): Promise<{
130
+ ok: true;
131
+ keys: MeshSendKeyName[];
132
+ hasDestructive: boolean;
133
+ submits: boolean;
134
+ bytes: number;
135
+ } | {
136
+ ok: false;
137
+ refused: 'submit_race' | 'actionable_modal';
138
+ keys: MeshSendKeyName[];
139
+ hasDestructive: boolean;
140
+ }>;
77
141
  setOnStatusChange(cb: () => void): void;
78
142
  setOnPtyData(cb: (data: string) => void): void;
79
143
  writeRaw(data: string): void;
@@ -126,6 +126,13 @@ export interface ISpecDriver {
126
126
  col: number;
127
127
  };
128
128
  getScreen(): string;
129
+ /** Current terminal geometry (columns × rows). Optional so a non-Fsm
130
+ * ISpecDriver implementation (test doubles) need not provide it; the
131
+ * mesh_read_terminal path falls back to a 0×0 geometry when absent. */
132
+ getScreenSize?(): {
133
+ cols: number;
134
+ rows: number;
135
+ };
129
136
  getSpecPath(): string;
130
137
  shutdown(): void;
131
138
  getStateHistory(): ReadonlyArray<DriverHistoryEntry>;
@@ -251,6 +258,10 @@ export declare class FsmDriver implements ISpecDriver {
251
258
  col: number;
252
259
  };
253
260
  getScreen(): string;
261
+ getScreenSize(): {
262
+ cols: number;
263
+ rows: number;
264
+ };
254
265
  /** Scrollback-inclusive screen as line array — used only for modal/button
255
266
  * content extraction so a tall prompt's off-screen anchors stay matchable.
256
267
  * Falls back to the viewport snapshot if scrollback read is unavailable. */
@@ -255,6 +255,17 @@ export interface RepoMeshPolicy {
255
255
  * A node policy may override this per-node (RepoMeshNodePolicy.delegatedWorkerAutoApprove).
256
256
  */
257
257
  delegatedWorkerAutoApprove?: boolean;
258
+ /**
259
+ * MESH-SEND-KEYS (feature 3): opt-in to allow the coordinator to inject
260
+ * DESTRUCTIVE keys (CTRL_C / ESC) into a worker PTY via mesh_send_keys. These
261
+ * can kill or derail the worker process, and delegatedWorkerAutoApprove is a
262
+ * TOOL-CONSENT policy, not a PTY-input authorization — so a destructive key
263
+ * injection additionally requires this explicit mesh-owner opt-in AND a
264
+ * per-call confirm_destructive=true. Defaults to false (destructive keys
265
+ * refused). Non-destructive keys (text/ENTER/arrows/TAB/BACKSPACE) are
266
+ * unaffected. A node policy may override per-node.
267
+ */
268
+ allowSendKeysDestructive?: boolean;
258
269
  /**
259
270
  * What to do with delegated session-host records for a node when it is removed.
260
271
  * Defaults to 'preserve' so completed work can be reviewed later and live
@@ -374,6 +385,11 @@ export interface RepoMeshNodePolicy {
374
385
  * precedence over the mesh-level policy for worker sessions launched onto this node.
375
386
  */
376
387
  delegatedWorkerAutoApprove?: boolean;
388
+ /**
389
+ * MESH-SEND-KEYS (feature 3): per-node override for
390
+ * RepoMeshPolicy.allowSendKeysDestructive.
391
+ */
392
+ allowSendKeysDestructive?: boolean;
377
393
  /**
378
394
  * Optional associated/external repos that must be checked alongside this node.
379
395
  * These are explicit policy/config entries only; Repo Mesh does not auto-discover
@@ -479,6 +495,13 @@ export declare function mergeAndNormalizePolicy(base: RepoMeshPolicy | undefined
479
495
  * launch path merges the envelope as a settingsOverride on top of the provider defaults.
480
496
  */
481
497
  export declare function resolveDelegatedWorkerAutoApprove(meshPolicy?: Pick<RepoMeshPolicy, 'delegatedWorkerAutoApprove'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'delegatedWorkerAutoApprove'> | null): boolean;
498
+ /**
499
+ * MESH-SEND-KEYS (feature 3): resolve whether DESTRUCTIVE key injection
500
+ * (CTRL_C/ESC via mesh_send_keys) is permitted for a node. Node policy overrides
501
+ * mesh policy; DEFAULTS TO FALSE (fail-closed) — a destructive key still requires
502
+ * a per-call confirm_destructive=true on top of this opt-in.
503
+ */
504
+ export declare function resolveAllowSendKeysDestructive(meshPolicy?: Pick<RepoMeshPolicy, 'allowSendKeysDestructive'> | null, nodePolicy?: Pick<RepoMeshNodePolicy, 'allowSendKeysDestructive'> | null): boolean;
482
505
  /**
483
506
  * Resolve the enforced per-(node, provider) maxParallel cap from a node's resolved
484
507
  * capability slots, or undefined when no matching slot declares a finite cap. Used
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adhdev/daemon-core",
3
- "version": "0.9.82-rc.552",
3
+ "version": "0.9.82-rc.554",
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",
@@ -47,8 +47,8 @@
47
47
  "author": "vilmire",
48
48
  "license": "AGPL-3.0-or-later",
49
49
  "dependencies": {
50
- "@adhdev/mesh-shared": "0.9.82-rc.552",
51
- "@adhdev/session-host-core": "0.9.82-rc.552",
50
+ "@adhdev/mesh-shared": "0.9.82-rc.554",
51
+ "@adhdev/session-host-core": "0.9.82-rc.554",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { ChatMessage } from './types.js';
8
8
  import type { InteractivePrompt, InteractivePromptResponse } from './providers/types/interactive-prompt.js';
9
+ import type { MeshSendKeyItem, MeshSendKeyName } from './cli-adapters/provider-cli-shared.js';
9
10
 
10
11
  export interface CliAdapterStatus {
11
12
  status?: string;
@@ -118,6 +119,34 @@ export interface CliAdapter {
118
119
  cancel(): void;
119
120
  isProcessing(): boolean;
120
121
  isReady(): boolean;
122
+ // Liveness of the underlying process/PTY. Optional because not every adapter
123
+ // implementation exposes it (the spec-driven path historically did not); the
124
+ // MESH-STALL-WATCH watchdog must call it defensively (typeof guard) so a
125
+ // missing implementation never throws in the 5s tick.
126
+ isAlive?(): boolean;
127
+ // MESH-READ-TERMINAL (feature 2) / MESH-SEND-KEYS (feature 3). Optional
128
+ // because not every adapter implements the raw-terminal read / structured
129
+ // key-injection surface; callers (cli-provider-instance) MUST typeof-guard
130
+ // and return a clean unsupported result rather than throwing when absent.
131
+ // Both ProviderCliAdapter (PTY path) and SpecCliAdapter (native-source spec
132
+ // path — claude-cli / antigravity / codex-cli) implement them.
133
+ getTerminalScreenSnapshot?(maxBytes?: number): {
134
+ text: string;
135
+ cursor: { col: number; row: number };
136
+ cols: number;
137
+ rows: number;
138
+ truncated: boolean;
139
+ originalBytes: number;
140
+ returnedBytes: number;
141
+ hash: string;
142
+ };
143
+ injectKeys?(
144
+ items: MeshSendKeyItem[],
145
+ opts?: { allowModalOverride?: boolean },
146
+ ): Promise<
147
+ | { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
148
+ | { ok: false; refused: 'submit_race' | 'actionable_modal'; keys: MeshSendKeyName[]; hasDestructive: boolean }
149
+ >;
121
150
  setOnStatusChange(callback: () => void): void;
122
151
  updateRuntimeSettings?(settings: Record<string, unknown>): void;
123
152
  setCliScripts?(scripts: Record<string, unknown>): void;
@@ -15,6 +15,7 @@
15
15
  */
16
16
 
17
17
  import * as os from 'os';
18
+ import { createHash } from 'crypto';
18
19
  import type { CliAdapter, CliLaunchInfo } from '../cli-adapter-types.js';
19
20
  import type { InteractivePromptResponse } from '../providers/types/interactive-prompt.js';
20
21
  import { LOG } from '../logging/logger.js';
@@ -43,7 +44,11 @@ import {
43
44
  normalizeScreenSnapshot,
44
45
  promptLikelyVisible,
45
46
  sanitizeTerminalText,
47
+ truncateToByteTailByLine,
48
+ encodeMeshSendKeys,
46
49
  TerminalTranscriptAccumulator,
50
+ type MeshSendKeyItem,
51
+ type MeshSendKeyName,
47
52
  type CliChatMessage,
48
53
  type CliProviderModule,
49
54
  type CliScriptInput,
@@ -308,6 +313,11 @@ export class ProviderCliAdapter implements CliAdapter {
308
313
  result: any;
309
314
  } | null = null;
310
315
  private static readonly SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
316
+ // MESH-READ-TERMINAL (feature 2): byte caps for getTerminalScreenSnapshot.
317
+ // Byte, not char — a multi-byte-glyph screen can exceed an MCP payload cap
318
+ // while the char count still looks safe. 32KiB default, 64KiB absolute hard cap.
319
+ private static readonly TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES = 32 * 1024;
320
+ private static readonly TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES = 64 * 1024;
311
321
  // (FALSEIDLE Path-C) Consecutive gate-eligible getStatus polls a mesh/autonomous
312
322
  // session must show before the poll-static-idle confirm fires. 2 = one extra
313
323
  // status tick of hysteresis: enough to reject a single momentary-silence
@@ -2297,6 +2307,131 @@ export class ProviderCliAdapter implements CliAdapter {
2297
2307
  };
2298
2308
  }
2299
2309
  isAlive(): boolean { return this.ptyProcess !== null; }
2310
+
2311
+ /**
2312
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Narrow, least-privilege
2313
+ * read of the CURRENT rendered viewport for mesh_read_terminal. Deliberately
2314
+ * NARROWER than getSnapshot()/getDebugSnapshot():
2315
+ * - It returns ONLY the terminal's current rendered viewport (what a human
2316
+ * would see on screen right now), the cursor position and the viewport
2317
+ * size. NO debug buffers, NO parser/FSM state, NO scrollback/history.
2318
+ * - It does NOT call getParseScreenText() (which may graft an older snapshot
2319
+ * onto the current frame for parse accuracy) — the caller asked for the
2320
+ * live viewport, not a parse-optimized composite.
2321
+ * - The payload is bounded in BYTES (UTF-8) with bottom-tail preservation so
2322
+ * a screen of multi-byte glyphs can never exceed the MCP payload cap. See
2323
+ * truncateToByteTailByLine.
2324
+ *
2325
+ * SECURITY NOTE: the raw viewport can contain tokens / command args / env
2326
+ * values / user data. Callers MUST gate this on mesh ownership and MUST NOT
2327
+ * log the returned text. Opt-in redaction is intentionally out of scope for
2328
+ * this feature and left as a future enhancement.
2329
+ *
2330
+ * `maxBytes` is clamped to [1KiB, ABSOLUTE_MAX] (default 32KiB, hard cap 64KiB).
2331
+ */
2332
+ getTerminalScreenSnapshot(maxBytes = ProviderCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES): {
2333
+ text: string;
2334
+ cursor: { col: number; row: number };
2335
+ cols: number;
2336
+ rows: number;
2337
+ truncated: boolean;
2338
+ originalBytes: number;
2339
+ returnedBytes: number;
2340
+ hash: string;
2341
+ } {
2342
+ const cap = Math.min(
2343
+ ProviderCliAdapter.TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES,
2344
+ Math.max(1024, Math.floor(maxBytes) || ProviderCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES),
2345
+ );
2346
+ // Current rendered viewport only — no scrollback, no parse composite.
2347
+ const rawViewport = this.terminalScreen.getText() || '';
2348
+ const size = this.terminalScreen.getSize();
2349
+ const cursor = this.terminalScreen.getCursorPosition();
2350
+ const truncation = truncateToByteTailByLine(rawViewport, cap);
2351
+ const hash = createHash('sha256').update(rawViewport, 'utf8').digest('hex').slice(0, 16);
2352
+ return {
2353
+ text: truncation.text,
2354
+ cursor,
2355
+ cols: size.cols,
2356
+ rows: size.rows,
2357
+ truncated: truncation.truncated,
2358
+ originalBytes: truncation.originalBytes,
2359
+ returnedBytes: truncation.returnedBytes,
2360
+ hash,
2361
+ };
2362
+ }
2363
+
2364
+ /**
2365
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key sequence
2366
+ * into the PTY for the mesh_send_keys tool. Reuses the same serialized write
2367
+ * path as sends (writeToPty via writeRaw semantics) so the injection FIFO-
2368
+ * orders behind any in-flight write.
2369
+ *
2370
+ * Two guards run INSIDE this method, immediately before the write, so they see
2371
+ * a consistent snapshot of the adapter's submit/echo/queue state (no async gap
2372
+ * between check and write — the encode is synchronous and the write is chained
2373
+ * atomically after it):
2374
+ *
2375
+ * 1. submit-race recheck (echo-gate): even though writeToPty is FIFO, an
2376
+ * already-SCHEDULED echo-gated Enter (engine.submitPendingUntil in the
2377
+ * future), an armed stuck-submit retry (submitRetryTimer), or an in-flight
2378
+ * pending-outbound flush can submit at a DIFFERENT tick than our write —
2379
+ * so an injected literal could be submitted by a pending Enter, or our
2380
+ * ENTER could submit a half-typed pending body. When any of those is live
2381
+ * we REFUSE the injection rather than interleave.
2382
+ *
2383
+ * 2. modal fail-closed: if the session is parked on an actionable approval
2384
+ * modal, refuse a NON-destructive injection (ENTER/text/arrows) and direct
2385
+ * the caller to mesh_approve — so a modal choice can't be confirmed via
2386
+ * send_keys to bypass the approval policy. (A destructive ESC/CTRL_C, which
2387
+ * dismisses rather than confirms, is allowed to proceed past this gate; it
2388
+ * is separately gated by confirm_destructive + policy at the tool layer.)
2389
+ *
2390
+ * The caller (tool layer) owns the destructive-key double gate and the audit
2391
+ * ledger. This method NEVER logs the literal text (only key enums / byte len).
2392
+ */
2393
+ async injectKeys(
2394
+ items: MeshSendKeyItem[],
2395
+ opts: { allowModalOverride?: boolean } = {},
2396
+ ): Promise<
2397
+ | { ok: true; keys: MeshSendKeyName[]; hasDestructive: boolean; submits: boolean; bytes: number }
2398
+ | { ok: false; refused: 'submit_race' | 'actionable_modal'; keys: MeshSendKeyName[]; hasDestructive: boolean }
2399
+ > {
2400
+ if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
2401
+ const encoded = encodeMeshSendKeys(items);
2402
+
2403
+ // (1) submit-race recheck — atomic w.r.t. the write below (no await between).
2404
+ const now = Date.now();
2405
+ const submitPending = this.engine.submitPendingUntil > now;
2406
+ const submitRetryArmed = this.submitRetryTimer !== null;
2407
+ const outboundBusy = this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length > 0;
2408
+ if (submitPending || submitRetryArmed || outboundBusy) {
2409
+ LOG.warn('CLI', `[${this.cliType}] send_keys refused (submit_race): submitPending=${submitPending} submitRetry=${submitRetryArmed} outboundBusy=${outboundBusy} keys=${encoded.keys.join(',')}`);
2410
+ return { ok: false, refused: 'submit_race', keys: encoded.keys, hasDestructive: encoded.hasDestructive };
2411
+ }
2412
+
2413
+ // (2) modal fail-closed — a NON-destructive injection into an actionable
2414
+ // modal is refused unless explicitly overridden.
2415
+ const modalActive = this.engine.hasActionableApproval();
2416
+ if (modalActive && !encoded.hasDestructive && !opts.allowModalOverride) {
2417
+ LOG.warn('CLI', `[${this.cliType}] send_keys refused (actionable_modal): keys=${encoded.keys.join(',')} — use mesh_approve`);
2418
+ return { ok: false, refused: 'actionable_modal', keys: encoded.keys, hasDestructive: encoded.hasDestructive };
2419
+ }
2420
+
2421
+ // Atomic write: the full encoded sequence goes out in ONE writeToPty (which
2422
+ // chains onto the write FIFO). text+ENTER is already one contiguous string,
2423
+ // so the submit key can never be separated from the text it submits.
2424
+ await this.writeToPty(encoded.sequence);
2425
+ LOG.info('CLI', `[${this.cliType}] send_keys injected keys=${encoded.keys.join(',') || '(text-only)'} bytes=${Buffer.byteLength(encoded.sequence, 'utf8')} destructive=${encoded.hasDestructive}`);
2426
+ return {
2427
+ ok: true,
2428
+ keys: encoded.keys,
2429
+ hasDestructive: encoded.hasDestructive,
2430
+ submits: encoded.submits,
2431
+ bytes: Buffer.byteLength(encoded.sequence, 'utf8'),
2432
+ };
2433
+ }
2434
+
2300
2435
  flushOutboundQueue(): void { this.schedulePendingOutboundFlush(); }
2301
2436
 
2302
2437
  async writeRaw(data: string | Buffer): Promise<void> {