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

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.
@@ -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;
@@ -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;
@@ -429,6 +433,84 @@ export declare class CliProviderInstance implements ProviderInstance {
429
433
  private approvalResolutionFinalizationBlock;
430
434
  private scheduleCompletedDebounceFlush;
431
435
  private isMeshWorkerSession;
436
+ /**
437
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Public read of the
438
+ * CURRENT rendered PTY viewport for the mesh_read_terminal tool, delegating to
439
+ * the adapter's narrow getTerminalScreenSnapshot() (viewport + cursor + size
440
+ * only; no debug buffers / parser state / history; byte-bounded, bottom-tail
441
+ * preserved).
442
+ *
443
+ * Gated on isMeshWorkerSession(): this raw viewport can expose tokens /
444
+ * command args / env / user data, so only a coordinator-spawned worker session
445
+ * is readable. The MCP layer ALSO cross-checks mesh/session/node ownership
446
+ * (isMeshOwnedDelegateSession) — isMeshWorkerSession alone is a broad
447
+ * "delegated" gate, so the two together block cross-mesh access. Returns null
448
+ * for a non-mesh session so the daemon command surfaces a clean refusal.
449
+ */
450
+ getTerminalScreenSnapshot(maxBytes?: number): {
451
+ text: string;
452
+ cursor: {
453
+ col: number;
454
+ row: number;
455
+ };
456
+ cols: number;
457
+ rows: number;
458
+ truncated: boolean;
459
+ originalBytes: number;
460
+ returnedBytes: number;
461
+ hash: string;
462
+ } | null;
463
+ /**
464
+ * MESH-SEND-KEYS (feature 3: key injection). Public entry for the
465
+ * mesh_send_keys tool, delegating to the adapter's injectKeys() (structured
466
+ * key encoding + atomic write + submit-race recheck + modal fail-closed).
467
+ *
468
+ * Gated on isMeshWorkerSession(): PTY input into a worker is a
469
+ * coordinator-only capability. The MCP layer ALSO cross-checks mesh/session/
470
+ * node ownership (isMeshOwnedDelegateSession) and owns the destructive-key
471
+ * double gate (confirm_destructive + policy) and the audit ledger. Returns a
472
+ * refusal object for a non-mesh session so the daemon command surfaces a clean
473
+ * error (never silently writes to a non-worker PTY).
474
+ */
475
+ injectKeys(items: MeshSendKeyItem[], opts?: {
476
+ allowModalOverride?: boolean;
477
+ }): Promise<{
478
+ ok: true;
479
+ keys: MeshSendKeyName[];
480
+ hasDestructive: boolean;
481
+ submits: boolean;
482
+ bytes: number;
483
+ } | {
484
+ ok: false;
485
+ refused: 'submit_race' | 'actionable_modal' | 'not_mesh_worker';
486
+ keys: MeshSendKeyName[];
487
+ hasDestructive: boolean;
488
+ }>;
489
+ /**
490
+ * MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
491
+ * watchdog for coordinator-spawned mesh worker sessions. Driven by the
492
+ * ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
493
+ * ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
494
+ * clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
495
+ * live worker's screen has been byte-for-byte unchanged for
496
+ * MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
497
+ * monitor:no_progress event down the existing task_stalled ledger +
498
+ * pendingCoordinatorEvent path.
499
+ *
500
+ * Deliberately status-agnostic: it does NOT read getStatus()'s reported status
501
+ * (which would couple it to the generating-only StatusMonitor and the
502
+ * idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
503
+ * minutes; that is accepted and surfaced as an informational stall (NOT a
504
+ * failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
505
+ * called here, so status heartbeats never move the stall anchor.
506
+ *
507
+ * Anchoring: the episode arms against the current lastOutputAt; a worker that
508
+ * has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
509
+ * time) so a silent spawn is still caught. Any new output re-arms the anchor
510
+ * and clears the emitted flag, so one continuous stall emits at most once and a
511
+ * later stall re-arms cleanly.
512
+ */
513
+ checkMeshWorkerStall(now?: number): void;
432
514
  /**
433
515
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
434
516
  * 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
  /**
@@ -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.553",
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.553",
51
+ "@adhdev/session-host-core": "0.9.82-rc.553",
52
52
  "@agentclientprotocol/sdk": "^0.16.1",
53
53
  "ajv": "^8.20.0",
54
54
  "ajv-formats": "^3.0.1",
@@ -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> {
@@ -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);
@@ -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'] {