@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
@@ -482,6 +482,178 @@ export function sanitizeTerminalText(str: string): string {
482
482
  return stripTerminalNoise(stripAnsi(accumulator.append(str)));
483
483
  }
484
484
 
485
+ // ─── MESH-SEND-KEYS (feature 3: key injection) ─────────────────────────────
486
+ //
487
+ // The coordinator injects a STRUCTURED key sequence into a worker PTY — never
488
+ // raw/base64 bytes (auditability + safety). Each item is either literal UTF-8
489
+ // `text` or a named `key` from this closed enum; the encoder maps each key to its
490
+ // exact terminal byte sequence. Keeping raw bytes out of the input surface means
491
+ // the ledger can record the key ENUM (not the body text) and the destructive-key
492
+ // gate can reason about a small, known set.
493
+
494
+ /** Named PTY keys the send-keys tool accepts (closed enum). */
495
+ export type MeshSendKeyName =
496
+ | 'ENTER' | 'ESC' | 'CTRL_C'
497
+ | 'UP' | 'DOWN' | 'LEFT' | 'RIGHT'
498
+ | 'TAB' | 'BACKSPACE';
499
+
500
+ /** Exact terminal byte sequence for each named key. */
501
+ export const MESH_SEND_KEY_ENCODING: Record<MeshSendKeyName, string> = {
502
+ ENTER: '\r',
503
+ ESC: '\x1b',
504
+ CTRL_C: '\x03',
505
+ UP: '\x1b[A',
506
+ DOWN: '\x1b[B',
507
+ RIGHT: '\x1b[C',
508
+ LEFT: '\x1b[D',
509
+ TAB: '\t',
510
+ BACKSPACE: '\x7f',
511
+ };
512
+
513
+ // Destructive keys: they can kill or derail the worker process. CTRL_C sends
514
+ // SIGINT (kills the running command / the agent's turn); ESC dismisses / cancels
515
+ // modals and pickers. delegatedWorkerAutoApprove is a TOOL-CONSENT policy, NOT a
516
+ // PTY-input authorization — so these MUST NOT ride the auto-approve pathway; they
517
+ // require an explicit confirm + mesh-policy opt-in. Text / ENTER / arrows / TAB /
518
+ // BACKSPACE are non-destructive and need no confirm.
519
+ export const MESH_DESTRUCTIVE_KEYS: ReadonlySet<MeshSendKeyName> = new Set<MeshSendKeyName>(['CTRL_C', 'ESC']);
520
+
521
+ export type MeshSendKeyItem = { text: string } | { key: MeshSendKeyName };
522
+
523
+ export interface MeshSendKeysEncodeResult {
524
+ /** The concatenated byte sequence to write to the PTY (single atomic write). */
525
+ sequence: string;
526
+ /** Named keys present, in order (for audit — text bodies are NOT recorded). */
527
+ keys: MeshSendKeyName[];
528
+ /** True if any item is a destructive key (CTRL_C / ESC). */
529
+ hasDestructive: boolean;
530
+ /** True if the sequence submits (ends with an ENTER/CR) — informational. */
531
+ submits: boolean;
532
+ }
533
+
534
+ /** Per-call limits (auditability + safety — no unbounded blast). */
535
+ export const MESH_SEND_KEYS_MAX_ITEMS = 64;
536
+ export const MESH_SEND_KEYS_MAX_TEXT_BYTES = 4096;
537
+
538
+ /**
539
+ * Validate + encode a structured key sequence into the exact bytes to write.
540
+ * Throws on an invalid key name, an over-limit item count, or an over-limit
541
+ * total text byte length. text+ENTER (or any text followed by ENTER) is encoded
542
+ * as ONE contiguous string so the caller can submit it in a single atomic write
543
+ * — no interleaving between the text and its submit key.
544
+ */
545
+ export function encodeMeshSendKeys(items: MeshSendKeyItem[]): MeshSendKeysEncodeResult {
546
+ if (!Array.isArray(items) || items.length === 0) {
547
+ throw new Error('send_keys: sequence must be a non-empty array');
548
+ }
549
+ if (items.length > MESH_SEND_KEYS_MAX_ITEMS) {
550
+ throw new Error(`send_keys: sequence exceeds ${MESH_SEND_KEYS_MAX_ITEMS} items`);
551
+ }
552
+ const parts: string[] = [];
553
+ const keys: MeshSendKeyName[] = [];
554
+ let hasDestructive = false;
555
+ let submits = false;
556
+ let textBytes = 0;
557
+ for (const item of items) {
558
+ if (item && typeof (item as { text?: unknown }).text === 'string') {
559
+ const text = (item as { text: string }).text;
560
+ textBytes += Buffer.byteLength(text, 'utf8');
561
+ if (textBytes > MESH_SEND_KEYS_MAX_TEXT_BYTES) {
562
+ throw new Error(`send_keys: total literal text exceeds ${MESH_SEND_KEYS_MAX_TEXT_BYTES} bytes`);
563
+ }
564
+ parts.push(text);
565
+ submits = false; // literal text after a submit re-opens the line
566
+ continue;
567
+ }
568
+ const keyName = item && typeof (item as { key?: unknown }).key === 'string'
569
+ ? (item as { key: string }).key
570
+ : '';
571
+ if (!(keyName in MESH_SEND_KEY_ENCODING)) {
572
+ throw new Error(`send_keys: unknown key '${keyName}'`);
573
+ }
574
+ const key = keyName as MeshSendKeyName;
575
+ parts.push(MESH_SEND_KEY_ENCODING[key]);
576
+ keys.push(key);
577
+ if (MESH_DESTRUCTIVE_KEYS.has(key)) hasDestructive = true;
578
+ submits = key === 'ENTER';
579
+ }
580
+ return { sequence: parts.join(''), keys, hasDestructive, submits };
581
+ }
582
+
583
+ /**
584
+ * MESH-READ-TERMINAL (feature 2): result of a byte-bounded, bottom-tail terminal
585
+ * screen truncation. The bound is in BYTES (UTF-8), not characters, because a
586
+ * screen full of multi-byte glyphs can blow past an MCP payload cap even when the
587
+ * character count looks safe. The bottom (tail) of the screen is preserved — the
588
+ * prompt, an active modal and the most recent output all live at the bottom — so
589
+ * a truncated read still shows the coordinator the actionable frame.
590
+ */
591
+ export interface ByteTailTruncation {
592
+ text: string;
593
+ truncated: boolean;
594
+ /** UTF-8 byte length of the full input before truncation. */
595
+ originalBytes: number;
596
+ /** UTF-8 byte length of the returned (possibly truncated) text. */
597
+ returnedBytes: number;
598
+ }
599
+
600
+ /**
601
+ * Truncate `text` to at most `maxBytes` UTF-8 bytes, preserving whole lines from
602
+ * the BOTTOM up (the prompt/modal/recent output). Never splits a line and never
603
+ * splits a UTF-8 code point (whole-line granularity guarantees valid UTF-8). If
604
+ * even the single last line exceeds the bound, that line is hard-clipped on a
605
+ * safe UTF-8 boundary from its END so the tail is still returned intact-ish.
606
+ */
607
+ export function truncateToByteTailByLine(text: string, maxBytes: number): ByteTailTruncation {
608
+ const input = String(text ?? '');
609
+ const originalBytes = Buffer.byteLength(input, 'utf8');
610
+ if (originalBytes <= maxBytes) {
611
+ return { text: input, truncated: false, originalBytes, returnedBytes: originalBytes };
612
+ }
613
+ // Split on newlines, keeping the newline characters so reassembly is exact.
614
+ const lines = input.split('\n');
615
+ const kept: string[] = [];
616
+ let bytes = 0;
617
+ // Walk from the last line upward, adding lines while they fit. Account for the
618
+ // '\n' rejoin cost (1 byte) between kept lines.
619
+ for (let i = lines.length - 1; i >= 0; i--) {
620
+ const line = lines[i];
621
+ const lineBytes = Buffer.byteLength(line, 'utf8');
622
+ const joinCost = kept.length > 0 ? 1 : 0;
623
+ if (bytes + lineBytes + joinCost > maxBytes) break;
624
+ bytes += lineBytes + joinCost;
625
+ kept.unshift(line);
626
+ }
627
+ if (kept.length > 0) {
628
+ const out = kept.join('\n');
629
+ return {
630
+ text: out,
631
+ truncated: true,
632
+ originalBytes,
633
+ returnedBytes: Buffer.byteLength(out, 'utf8'),
634
+ };
635
+ }
636
+ // Degenerate case: the single last line alone exceeds maxBytes. Hard-clip its
637
+ // TAIL on a UTF-8 code-point boundary (Buffer slice can split a multi-byte
638
+ // sequence, so decode-and-recut with the replacement-char guard).
639
+ const lastLine = lines[lines.length - 1] ?? '';
640
+ const buf = Buffer.from(lastLine, 'utf8');
641
+ let slice = buf.subarray(Math.max(0, buf.length - maxBytes));
642
+ // Trim leading bytes until the decode has no leading replacement char (i.e. we
643
+ // landed on a valid code-point boundary).
644
+ let decoded = slice.toString('utf8');
645
+ while (decoded.length > 0 && decoded.charCodeAt(0) === 0xfffd && slice.length > 0) {
646
+ slice = slice.subarray(1);
647
+ decoded = slice.toString('utf8');
648
+ }
649
+ return {
650
+ text: decoded,
651
+ truncated: true,
652
+ originalBytes,
653
+ returnedBytes: Buffer.byteLength(decoded, 'utf8'),
654
+ };
655
+ }
656
+
485
657
  export function listCliScriptNames(scripts: CliScripts | undefined): string[] {
486
658
  if (!scripts) return [];
487
659
  return Object.entries(scripts)
@@ -56,6 +56,11 @@ export class TerminalScreen {
56
56
  return this.terminal.getCursorPosition();
57
57
  }
58
58
 
59
+ /** Current viewport dimensions (cols × rows). */
60
+ getSize(): { cols: number; rows: number } {
61
+ return { cols: this.cols, rows: this.rows };
62
+ }
63
+
59
64
  dispose(): void {
60
65
  this.terminal.dispose();
61
66
  }
@@ -564,6 +564,10 @@ export class DaemonCommandHandler implements CommandHelpers {
564
564
  // ─── PTY Raw I/O (stream-commands.ts) ─────────
565
565
  case 'pty_input': return Stream.handlePtyInput(this, args);
566
566
  case 'pty_resize': return Stream.handlePtyResize(this, args);
567
+ // ─── MESH-READ-TERMINAL (feature 2): raw viewport read ──────────
568
+ case 'read_terminal': return Stream.handleReadTerminal(this, args);
569
+ // ─── MESH-SEND-KEYS (feature 3): structured key injection ────────
570
+ case 'send_keys': return Stream.handleSendKeys(this, args);
567
571
 
568
572
  // ─── Provider Settings (stream-commands.ts) ──────────
569
573
  case 'get_provider_settings': return Stream.handleGetProviderSettings(this, args);
@@ -35,6 +35,7 @@ import {
35
35
  RefineContext,
36
36
  RefineExecFileAsync,
37
37
  RefineStageOutcome,
38
+ classifyPatchEquivalenceFailure,
38
39
  recordMeshRefineStage,
39
40
  resolveRefineryAutoPublishSubmoduleMainCommits,
40
41
  runMeshRefineEffectiveDiffGate,
@@ -616,9 +617,20 @@ export async function refineSyncBaseStage(self: DaemonCommandRouter, ctx: Refine
616
617
  error: submoduleHintPatchEquivalence.error,
617
618
  actionableHint: submoduleHintPatchEquivalence.actionableHint,
618
619
  });
620
+ const classification = await classifyPatchEquivalenceFailure(
621
+ repoRoot, baseHead, ctx.branchHead, submoduleHintPatchEquivalence,
622
+ {
623
+ targetBaseRef: baseHead,
624
+ autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(ctx.mesh, node.workspace).enabled,
625
+ },
626
+ );
619
627
  return { kind: 'terminal', result: {
620
628
  success: false,
621
629
  code: 'patch_equivalence_failed',
630
+ detailedReason: classification.detailedReason,
631
+ detailedReasonDescription: classification.detailedReasonDescription,
632
+ recommendedAction: classification.recommendedAction,
633
+ evidence: classification.evidence,
622
634
  convergenceStatus: 'blocked_review',
623
635
  error: 'Refinery patch-equivalence preflight failed (submodule gitlink conflict); merge/refine was not attempted.',
624
636
  branch,
@@ -801,7 +813,7 @@ export async function refineValidationStage(self: DaemonCommandRouter, ctx: Refi
801
813
  export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx: RefineContext): Promise<RefineStageOutcome> {
802
814
  // DS2: node/execFileAsync are no longer needed here — the rebase moved to
803
815
  // sync_base — and branchHead/patchEquivalence are no longer mutated in-stage.
804
- const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, validationSummary, refineStages } = ctx;
816
+ const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, mesh, node, validationSummary, refineStages } = ctx;
805
817
  const branchHead = ctx.branchHead;
806
818
  const patchEquivalenceStarted = Date.now();
807
819
  const patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
@@ -826,9 +838,24 @@ export async function refinePatchEquivalenceStage(self: DaemonCommandRouter, ctx
826
838
  // branch itself has no changes (degenerate), which is NOT already-merged.
827
839
  const alreadyMergedViaOtherPath = !patchEquivalence.actualPatchId && !!patchEquivalence.expectedPatchId;
828
840
  if (!alreadyMergedViaOtherPath) {
841
+ const classification = await classifyPatchEquivalenceFailure(
842
+ repoRoot, baseHead, branchHead, patchEquivalence,
843
+ {
844
+ targetBaseRef: baseHead,
845
+ autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(mesh, node.workspace).enabled,
846
+ },
847
+ );
848
+ recordMeshRefineStage(refineStages, 'patch_equivalence_classification', 'failed', patchEquivalenceStarted, {
849
+ detailedReason: classification.detailedReason,
850
+ recommendedAction: classification.recommendedAction,
851
+ });
829
852
  return { kind: 'terminal', result: {
830
853
  success: false,
831
854
  code: 'patch_equivalence_failed',
855
+ detailedReason: classification.detailedReason,
856
+ detailedReasonDescription: classification.detailedReasonDescription,
857
+ recommendedAction: classification.recommendedAction,
858
+ evidence: classification.evidence,
832
859
  convergenceStatus: 'blocked_review',
833
860
  error: 'Refinery patch-equivalence preflight failed; merge/refine was not attempted.',
834
861
  branch,
@@ -2278,6 +2305,15 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
2278
2305
  };
2279
2306
  if (typeof result.error === 'string') ctx.error = result.error;
2280
2307
  if (typeof result.blockedReason === 'string') ctx.blockedReason = result.blockedReason;
2308
+ // Detailed patch-equivalence sub-cause classification (base_divergence,
2309
+ // submodule_unreachable, actual_patch_diff, trivial_ff_misjudgment,
2310
+ // already_converged, unclassified) + recommended action + evidence.
2311
+ // Promoted onto blockerContext so coordinators reading task_failed ledger
2312
+ // entries see the cause without parsing the free-form error string.
2313
+ if (typeof result.detailedReason === 'string') ctx.detailedReason = result.detailedReason;
2314
+ if (typeof result.detailedReasonDescription === 'string') ctx.detailedReasonDescription = result.detailedReasonDescription;
2315
+ if (typeof result.recommendedAction === 'string') ctx.recommendedAction = result.recommendedAction;
2316
+ if (result.evidence && typeof result.evidence === 'object') ctx.evidence = result.evidence;
2281
2317
  // Patch equivalence details
2282
2318
  if (stage === 'patch_equivalence' && result.patchEquivalence) {
2283
2319
  const pe = result.patchEquivalence as Record<string, unknown>;
@@ -2287,6 +2323,9 @@ export async function finishMeshRefineJob(self: DaemonCommandRouter, handle: Mes
2287
2323
  status: pe.status,
2288
2324
  actionableHint: pe.actionableHint,
2289
2325
  error: pe.error,
2326
+ ...(typeof result.detailedReason === 'string' ? { detailedReason: result.detailedReason } : {}),
2327
+ ...(typeof result.recommendedAction === 'string' ? { recommendedAction: result.recommendedAction } : {}),
2328
+ ...(result.evidence && typeof result.evidence === 'object' ? { evidence: result.evidence } : {}),
2290
2329
  };
2291
2330
  }
2292
2331
  // Submodule reachability details
@@ -212,6 +212,21 @@ const MESH_FORWARDABLE_SESSION_COMMANDS = new Set([
212
212
  // daemon's own CLI session (TASKECHO coordinator self-echo). Forwarding to the owning daemon
213
213
  // delivers it to the real worker instead. (findAdapter is also fail-closed as the backstop.)
214
214
  'agent_command',
215
+ // read_terminal (MESH-READ-TERMINAL feature 2): mesh_read_terminal reads the CURRENT
216
+ // rendered PTY viewport of a specific worker session. The live viewport lives ONLY on the
217
+ // OWNING session's adapter, so when the target is a REMOTE worker the coordinator has no
218
+ // local instance and the handler would return 'Session not found' — the exact
219
+ // remote-worker forwarding gap of mission 6938892f. Forward it to the owning worker daemon
220
+ // so it reads its own live screen. (It is read-only; unlike the mutations above it makes no
221
+ // state change, but it is session-scoped identically and must reach the owning daemon.)
222
+ 'read_terminal',
223
+ // send_keys (MESH-SEND-KEYS feature 3): mesh_send_keys injects a structured key sequence
224
+ // into a specific worker session's PTY. The live PTY lives ONLY on the OWNING session's
225
+ // adapter, so a remote-worker target must be forwarded to the owning daemon or the handler
226
+ // returns 'Session not found' (same class as mission 6938892f). Unlike read_terminal this
227
+ // MUTATES the worker PTY, so forwarding to the real owner (not a wrong local session) is
228
+ // doubly important. The daemon re-enforces the destructive-key confirm gate after the forward.
229
+ 'send_keys',
215
230
  ]);
216
231
 
217
232
  function normalizeCommandSource(source: string): CommandLogEntry['source'] {
@@ -134,6 +134,131 @@ export function handlePtyResize(_h: CommandHelpers, args: any): CommandResult {
134
134
  return { success: false, error: 'PTY resize temporarily disabled', code: 'PTY_RESIZE_DISABLED' };
135
135
  }
136
136
 
137
+ interface TerminalSnapshotInstance extends ProviderInstance {
138
+ getTerminalScreenSnapshot?(maxBytes?: number): {
139
+ text: string;
140
+ cursor: { col: number; row: number };
141
+ cols: number;
142
+ rows: number;
143
+ truncated: boolean;
144
+ originalBytes: number;
145
+ returnedBytes: number;
146
+ hash: string;
147
+ } | null;
148
+ }
149
+
150
+ /**
151
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Reads the CURRENT rendered
152
+ * PTY viewport of a specific mesh worker session for the mesh_read_terminal tool.
153
+ *
154
+ * This is the daemon-side `read_terminal` verb. It runs BOTH on the coordinator
155
+ * (for a locally-hosted worker) and, after router forwarding
156
+ * (MESH_FORWARDABLE_SESSION_COMMANDS + _meshDirectDispatch), on the OWNING remote
157
+ * worker daemon — where the live viewport actually exists. The instance's
158
+ * getTerminalScreenSnapshot() is gated on isMeshWorkerSession() (returns null for
159
+ * a non-mesh session), which the MCP layer complements with a mesh/session/node
160
+ * ownership cross-check. SECURITY: the raw viewport can contain tokens / args /
161
+ * env / user data — never logged here (only its byte size / truncation flag are).
162
+ */
163
+ export function handleReadTerminal(h: CommandHelpers, args: any): CommandResult {
164
+ const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
165
+ const sessionId = targetSessionId || h.currentSession?.sessionId || '';
166
+ if (!sessionId) return { success: false, error: 'targetSessionId required' };
167
+
168
+ const session = h.ctx.sessionRegistry?.get(sessionId);
169
+ const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
170
+ const instance = h.ctx.instanceManager?.getInstance(instanceKey) as TerminalSnapshotInstance | undefined;
171
+ if (!instance) return { success: false, error: `Session not found: ${sessionId.split('_')[0]}` };
172
+ if (instance.category !== 'cli' || typeof instance.getTerminalScreenSnapshot !== 'function') {
173
+ return { success: false, error: 'read_terminal is only supported for CLI (PTY) sessions' };
174
+ }
175
+
176
+ const requestedMaxBytes = typeof args?.maxBytes === 'number' && Number.isFinite(args.maxBytes)
177
+ ? args.maxBytes
178
+ : undefined;
179
+ const snapshot = instance.getTerminalScreenSnapshot(requestedMaxBytes);
180
+ if (!snapshot) {
181
+ // getTerminalScreenSnapshot returns null when the session is NOT a mesh
182
+ // worker — the raw-viewport read is scoped to coordinator-delegated workers.
183
+ return { success: false, error: 'read_terminal is only available for coordinator-spawned mesh worker sessions' };
184
+ }
185
+ // Log size/truncation ONLY — never the screen text (may carry secrets).
186
+ LOG.info('Command', `[readTerminal] session=${sessionId.split('_')[0]} bytes=${snapshot.returnedBytes}/${snapshot.originalBytes} truncated=${snapshot.truncated} cols=${snapshot.cols} rows=${snapshot.rows}`);
187
+ return { success: true, ...snapshot };
188
+ }
189
+
190
+ interface KeyInjectableInstance extends ProviderInstance {
191
+ injectKeys?(
192
+ items: Array<{ text: string } | { key: string }>,
193
+ opts?: { allowModalOverride?: boolean },
194
+ ): Promise<
195
+ | { ok: true; keys: string[]; hasDestructive: boolean; submits: boolean; bytes: number }
196
+ | { ok: false; refused: string; keys: string[]; hasDestructive: boolean }
197
+ >;
198
+ }
199
+
200
+ /**
201
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a structured key sequence into
202
+ * a specific mesh worker session's PTY for the mesh_send_keys tool.
203
+ *
204
+ * Daemon-side `send_keys` verb. Like read_terminal it runs on the coordinator for
205
+ * a local worker and, after router forwarding (MESH_FORWARDABLE_SESSION_COMMANDS +
206
+ * _meshDirectDispatch), on the OWNING remote worker daemon. The instance's
207
+ * injectKeys() is gated on isMeshWorkerSession(); the MCP layer complements it with
208
+ * mesh/session/node ownership + the destructive-key double gate (confirm_destructive
209
+ * + mesh policy) + audit ledger.
210
+ *
211
+ * Defense-in-depth here: even though the MCP layer gates destructive keys, the
212
+ * daemon re-enforces confirm_destructive so a direct/forwarded send_keys that
213
+ * contains CTRL_C/ESC without confirm is refused at the boundary too.
214
+ * SECURITY: never logs the literal text body (only key enums / byte counts).
215
+ */
216
+ export async function handleSendKeys(h: CommandHelpers, args: any): Promise<CommandResult> {
217
+ const targetSessionId = typeof args?.targetSessionId === 'string' ? args.targetSessionId.trim() : '';
218
+ const sessionId = targetSessionId || h.currentSession?.sessionId || '';
219
+ if (!sessionId) return { success: false, error: 'targetSessionId required' };
220
+
221
+ const items = Array.isArray(args?.sequence) ? args.sequence : null;
222
+ if (!items || items.length === 0) {
223
+ return { success: false, error: 'sequence (non-empty array of {text}|{key}) required' };
224
+ }
225
+
226
+ const session = h.ctx.sessionRegistry?.get(sessionId);
227
+ const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
228
+ const instance = h.ctx.instanceManager?.getInstance(instanceKey) as KeyInjectableInstance | undefined;
229
+ if (!instance) return { success: false, error: `Session not found: ${sessionId.split('_')[0]}` };
230
+ if (instance.category !== 'cli' || typeof instance.injectKeys !== 'function') {
231
+ return { success: false, error: 'send_keys is only supported for CLI (PTY) sessions' };
232
+ }
233
+
234
+ // Defense-in-depth destructive gate: refuse CTRL_C/ESC without confirm_destructive
235
+ // even at the daemon boundary (the MCP layer is the primary gate + policy check).
236
+ const DESTRUCTIVE = new Set(['CTRL_C', 'ESC']);
237
+ const hasDestructiveRequested = items.some((it: any) => it && typeof it.key === 'string' && DESTRUCTIVE.has(it.key));
238
+ if (hasDestructiveRequested && args?.confirm_destructive !== true) {
239
+ return {
240
+ success: false,
241
+ error: 'destructive key (CTRL_C/ESC) requires confirm_destructive=true',
242
+ refused: 'destructive_unconfirmed',
243
+ };
244
+ }
245
+
246
+ try {
247
+ const result = await instance.injectKeys(items, {
248
+ allowModalOverride: args?.allow_modal_override === true,
249
+ });
250
+ if (!result.ok) {
251
+ LOG.info('Command', `[sendKeys] session=${sessionId.split('_')[0]} refused=${result.refused} keys=${result.keys.join(',')} destructive=${result.hasDestructive}`);
252
+ return { success: false, error: `send_keys refused: ${result.refused}`, refused: result.refused, keys: result.keys, hasDestructive: result.hasDestructive };
253
+ }
254
+ LOG.info('Command', `[sendKeys] session=${sessionId.split('_')[0]} injected keys=${result.keys.join(',') || '(text-only)'} bytes=${result.bytes} destructive=${result.hasDestructive} submits=${result.submits}`);
255
+ return { success: true, keys: result.keys, hasDestructive: result.hasDestructive, submits: result.submits, bytes: result.bytes };
256
+ } catch (e: any) {
257
+ // Encode/validation errors (unknown key, over-limit) surface as a clean failure.
258
+ return { success: false, error: `send_keys: ${e?.message || String(e)}` };
259
+ }
260
+ }
261
+
137
262
  // ─── Provider Settings ────────────────────────
138
263
 
139
264
  export function handleGetProviderSettings(h: CommandHelpers, args: any): CommandResult {
package/src/index.ts CHANGED
@@ -143,6 +143,7 @@ export type {
143
143
  export {
144
144
  DEFAULT_MESH_POLICY,
145
145
  resolveDelegatedWorkerAutoApprove,
146
+ resolveAllowSendKeysDestructive,
146
147
  resolveMagiSessionCleanupMode,
147
148
  magiAutoLaunchedSessionCleanupDecision,
148
149
  MESH_SCHEDULING_STRATEGIES,
@@ -743,6 +743,8 @@ const TOOLS_SECTION = `## Available Tools
743
743
  | \`mesh_launch_session\` | Start a new agent session on a node |
744
744
  | \`mesh_read_chat\` | Read recent chat messages from a delegated agent session |
745
745
  | \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
746
+ | \`mesh_read_terminal\` | Read a worker session's CURRENT raw terminal screen (the live rendered PTY viewport — prompt/modal/spinner/unparsed output), not the parsed chat. Byte-bounded (32KiB default, 64KiB max; bottom of screen kept). Use to see exactly what a worker is showing when mesh_read_chat is not enough (e.g. after a stall alert). Screen text may contain secrets — treat as sensitive |
747
+ | \`mesh_send_keys\` | Inject a STRUCTURED key sequence into a worker's live PTY (text + named keys ENTER/ESC/CTRL_C/UP/DOWN/LEFT/RIGHT/TAB/BACKSPACE). For interactions mesh_send_task can't express — answer a non-approval prompt, navigate a picker, submit a typed line, or interrupt (CTRL_C). Use mesh_approve for approval modals (send_keys is refused on one). Destructive keys (CTRL_C/ESC) need confirm_destructive=true AND mesh policy allowSendKeysDestructive. Refused on a pending submit/echo race. Audited (key enums only) |
746
748
  | \`mesh_task_history\` | Read the task ledger — dispatches, completions, failures. Use to understand what has been done before deciding next steps |
747
749
  | \`mesh_ledger_query\` | Read-only ledger query along the kind/time/node axes (complement to task-axis mesh_task_history): filter by kind, since, node, tail — answer "what happened on node X / what failed since T" without scanning transcripts |
748
750
  | \`mesh_reconcile_ledger\` | Reconcile daemon-local ledgers over P2P — import missing entries from remote nodes into the coordinator local ledger |
@@ -356,6 +356,21 @@ export function buildMeshSystemMessage(args: {
356
356
  return `[System] ${args.nodeLabel} has stopped${metadata}. Use mesh_read_chat once if you need to inspect its last output.`;
357
357
  }
358
358
  if (args.event === 'monitor:no_progress') {
359
+ // MESH-STALL-WATCH (feature 1: STALL detection): the status-agnostic stall
360
+ // watchdog fires this event regardless of the reported status — a worker's
361
+ // raw PTY output was byte-for-byte unchanged past the stall bound. Surface
362
+ // the generalized "output unchanged" wording (with the observed status,
363
+ // stalled duration and taskId as context) and make explicit this is
364
+ // INFORMATIONAL — a quiet/idle worker can trip it; it is NOT a failure or
365
+ // auto-restart. The generating-only StatusMonitor copy keeps its original
366
+ // phrasing.
367
+ if (args.metadataEvent.meshWorkerStall === true) {
368
+ const observedStatus = readNonEmptyString(args.metadataEvent.observedStatus);
369
+ const stalledMs = typeof args.metadataEvent.stalledMs === 'number' ? args.metadataEvent.stalledMs : undefined;
370
+ const stalledSuffix = stalledMs !== undefined ? ` for ${Math.round(stalledMs / 1000)}s` : '';
371
+ const statusSuffix = observedStatus ? ` (observed status: ${observedStatus})` : '';
372
+ return `[System] ${args.nodeLabel}: PTY output unchanged${stalledSuffix}${statusSuffix}${metadata}. This is an informational stall — the worker's screen has been static regardless of its reported status; it may be genuinely idle, waiting, or wedged, so this is NOT a failure or auto-restart. Judge whether to inspect it: wait for pendingCoordinatorEvents/a completion event, or make one bounded mesh_read_chat check if you need to see its current screen, then wait again.`;
373
+ }
359
374
  return `[System] ${args.nodeLabel} is still reported as generating after a long interval${metadata}. Wait for pendingCoordinatorEvents or a completion/status event; if the user explicitly asks for status, make one bounded status check and then wait again.`;
360
375
  }
361
376
  if (args.event === 'worktree_bootstrap_complete') {
@@ -82,6 +82,12 @@ export type MeshLedgerKind =
82
82
  // magi_synthesis payload: { source:'magi', consensusGroupId, missionId?, panel?, question?, synthesis }
83
83
  | 'magi_dispatched'
84
84
  | 'magi_synthesis'
85
+ // MESH-SEND-KEYS (feature 3): audit trail for coordinator PTY key injections
86
+ // via mesh_send_keys. Records the key ENUMS, destructive flag and result —
87
+ // NEVER the literal text body (may carry tokens / user data).
88
+ // payload: { keys: string[], hasDestructive: boolean, result: 'injected'|'refused'|'error',
89
+ // refused?: string, submits?: boolean, confirmDestructive?: boolean }
90
+ | 'key_injection'
85
91
  ;
86
92
 
87
93
  export interface MeshLedgerEntry {