@adhdev/daemon-standalone 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.
package/dist/index.js CHANGED
@@ -29898,6 +29898,15 @@ var require_dist3 = __commonJS({
29898
29898
  }
29899
29899
  return true;
29900
29900
  }
29901
+ function resolveAllowSendKeysDestructive(meshPolicy, nodePolicy) {
29902
+ if (typeof nodePolicy?.allowSendKeysDestructive === "boolean") {
29903
+ return nodePolicy.allowSendKeysDestructive;
29904
+ }
29905
+ if (typeof meshPolicy?.allowSendKeysDestructive === "boolean") {
29906
+ return meshPolicy.allowSendKeysDestructive;
29907
+ }
29908
+ return false;
29909
+ }
29901
29910
  function resolveProviderMaxParallel(slots, providerType) {
29902
29911
  const wanted = typeof providerType === "string" ? providerType.trim().toLowerCase() : "";
29903
29912
  if (!wanted) return void 0;
@@ -30208,10 +30217,10 @@ var require_dist3 = __commonJS({
30208
30217
  }
30209
30218
  function getDaemonBuildInfo() {
30210
30219
  if (cached2) return cached2;
30211
- const commit = readInjected(true ? "178a437592e2f9544675a54768b48281804bdddb" : void 0) ?? "unknown";
30212
- const commitShort = readInjected(true ? "178a4375" : void 0) ?? (commit !== "unknown" ? commit.slice(0, 7) : "unknown");
30213
- const version2 = readInjected(true ? "0.9.82-rc.552" : void 0) ?? readInjected(typeof process !== "undefined" ? process.env?.ADHDEV_PKG_VERSION : void 0) ?? "unknown";
30214
- const builtAt = readInjected(true ? "2026-07-17T04:27:07.205Z" : void 0);
30220
+ const commit = readInjected(true ? "23529650799df69f9a14f16f6c2b34db153f1b27" : void 0) ?? "unknown";
30221
+ const commitShort = readInjected(true ? "23529650" : void 0) ?? (commit !== "unknown" ? commit.slice(0, 7) : "unknown");
30222
+ const version2 = readInjected(true ? "0.9.82-rc.554" : void 0) ?? readInjected(typeof process !== "undefined" ? process.env?.ADHDEV_PKG_VERSION : void 0) ?? "unknown";
30223
+ const builtAt = readInjected(true ? "2026-07-17T07:58:44.290Z" : void 0);
30215
30224
  cached2 = builtAt ? { commit, commitShort, version: version2, builtAt } : { commit, commitShort, version: version2 };
30216
30225
  return cached2;
30217
30226
  }
@@ -32799,6 +32808,8 @@ ${error48.message || ""}`;
32799
32808
  "mesh_send_task",
32800
32809
  "mesh_read_chat",
32801
32810
  "mesh_read_debug",
32811
+ "mesh_read_terminal",
32812
+ "mesh_send_keys",
32802
32813
  "mesh_launch_session",
32803
32814
  "mesh_git_status",
32804
32815
  "mesh_read_node_logs",
@@ -33951,6 +33962,8 @@ When you compose the task message you dispatch to a node, include these requirem
33951
33962
  | \`mesh_launch_session\` | Start a new agent session on a node |
33952
33963
  | \`mesh_read_chat\` | Read recent chat messages from a delegated agent session |
33953
33964
  | \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
33965
+ | \`mesh_read_terminal\` | Read a worker session's CURRENT raw terminal screen (the live rendered PTY viewport \u2014 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 \u2014 treat as sensitive |
33966
+ | \`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 \u2014 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) |
33954
33967
  | \`mesh_task_history\` | Read the task ledger \u2014 dispatches, completions, failures. Use to understand what has been done before deciding next steps |
33955
33968
  | \`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 \u2014 answer "what happened on node X / what failed since T" without scanning transcripts |
33956
33969
  | \`mesh_reconcile_ledger\` | Reconcile daemon-local ledgers over P2P \u2014 import missing entries from remote nodes into the coordinator local ledger |
@@ -38661,6 +38674,13 @@ Do NOT retry on this node. Consider reassigning to a different node or asking th
38661
38674
  return `[System] ${args.nodeLabel} has stopped${metadata}. Use mesh_read_chat once if you need to inspect its last output.`;
38662
38675
  }
38663
38676
  if (args.event === "monitor:no_progress") {
38677
+ if (args.metadataEvent.meshWorkerStall === true) {
38678
+ const observedStatus = readNonEmptyString2(args.metadataEvent.observedStatus);
38679
+ const stalledMs = typeof args.metadataEvent.stalledMs === "number" ? args.metadataEvent.stalledMs : void 0;
38680
+ const stalledSuffix = stalledMs !== void 0 ? ` for ${Math.round(stalledMs / 1e3)}s` : "";
38681
+ const statusSuffix = observedStatus ? ` (observed status: ${observedStatus})` : "";
38682
+ return `[System] ${args.nodeLabel}: PTY output unchanged${stalledSuffix}${statusSuffix}${metadata}. This is an informational stall \u2014 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.`;
38683
+ }
38664
38684
  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.`;
38665
38685
  }
38666
38686
  if (args.event === "worktree_bootstrap_complete") {
@@ -42486,6 +42506,82 @@ ${rendered}`, "utf-8");
42486
42506
  const accumulator = new TerminalTranscriptAccumulator();
42487
42507
  return stripTerminalNoise(stripAnsi(accumulator.append(str)));
42488
42508
  }
42509
+ function encodeMeshSendKeys(items) {
42510
+ if (!Array.isArray(items) || items.length === 0) {
42511
+ throw new Error("send_keys: sequence must be a non-empty array");
42512
+ }
42513
+ if (items.length > MESH_SEND_KEYS_MAX_ITEMS) {
42514
+ throw new Error(`send_keys: sequence exceeds ${MESH_SEND_KEYS_MAX_ITEMS} items`);
42515
+ }
42516
+ const parts = [];
42517
+ const keys = [];
42518
+ let hasDestructive = false;
42519
+ let submits = false;
42520
+ let textBytes = 0;
42521
+ for (const item of items) {
42522
+ if (item && typeof item.text === "string") {
42523
+ const text = item.text;
42524
+ textBytes += Buffer.byteLength(text, "utf8");
42525
+ if (textBytes > MESH_SEND_KEYS_MAX_TEXT_BYTES) {
42526
+ throw new Error(`send_keys: total literal text exceeds ${MESH_SEND_KEYS_MAX_TEXT_BYTES} bytes`);
42527
+ }
42528
+ parts.push(text);
42529
+ submits = false;
42530
+ continue;
42531
+ }
42532
+ const keyName = item && typeof item.key === "string" ? item.key : "";
42533
+ if (!(keyName in MESH_SEND_KEY_ENCODING)) {
42534
+ throw new Error(`send_keys: unknown key '${keyName}'`);
42535
+ }
42536
+ const key2 = keyName;
42537
+ parts.push(MESH_SEND_KEY_ENCODING[key2]);
42538
+ keys.push(key2);
42539
+ if (MESH_DESTRUCTIVE_KEYS.has(key2)) hasDestructive = true;
42540
+ submits = key2 === "ENTER";
42541
+ }
42542
+ return { sequence: parts.join(""), keys, hasDestructive, submits };
42543
+ }
42544
+ function truncateToByteTailByLine(text, maxBytes) {
42545
+ const input = String(text ?? "");
42546
+ const originalBytes = Buffer.byteLength(input, "utf8");
42547
+ if (originalBytes <= maxBytes) {
42548
+ return { text: input, truncated: false, originalBytes, returnedBytes: originalBytes };
42549
+ }
42550
+ const lines = input.split("\n");
42551
+ const kept = [];
42552
+ let bytes = 0;
42553
+ for (let i = lines.length - 1; i >= 0; i--) {
42554
+ const line = lines[i];
42555
+ const lineBytes = Buffer.byteLength(line, "utf8");
42556
+ const joinCost = kept.length > 0 ? 1 : 0;
42557
+ if (bytes + lineBytes + joinCost > maxBytes) break;
42558
+ bytes += lineBytes + joinCost;
42559
+ kept.unshift(line);
42560
+ }
42561
+ if (kept.length > 0) {
42562
+ const out = kept.join("\n");
42563
+ return {
42564
+ text: out,
42565
+ truncated: true,
42566
+ originalBytes,
42567
+ returnedBytes: Buffer.byteLength(out, "utf8")
42568
+ };
42569
+ }
42570
+ const lastLine = lines[lines.length - 1] ?? "";
42571
+ const buf = Buffer.from(lastLine, "utf8");
42572
+ let slice = buf.subarray(Math.max(0, buf.length - maxBytes));
42573
+ let decoded = slice.toString("utf8");
42574
+ while (decoded.length > 0 && decoded.charCodeAt(0) === 65533 && slice.length > 0) {
42575
+ slice = slice.subarray(1);
42576
+ decoded = slice.toString("utf8");
42577
+ }
42578
+ return {
42579
+ text: decoded,
42580
+ truncated: true,
42581
+ originalBytes,
42582
+ returnedBytes: Buffer.byteLength(decoded, "utf8")
42583
+ };
42584
+ }
42489
42585
  function listCliScriptNames(scripts) {
42490
42586
  if (!scripts) return [];
42491
42587
  return Object.entries(scripts).filter(([, fn]) => typeof fn === "function").map(([name]) => name);
@@ -42690,6 +42786,10 @@ ${rendered}`, "utf-8");
42690
42786
  var os52;
42691
42787
  var path11;
42692
42788
  var TerminalTranscriptAccumulator;
42789
+ var MESH_SEND_KEY_ENCODING;
42790
+ var MESH_DESTRUCTIVE_KEYS;
42791
+ var MESH_SEND_KEYS_MAX_ITEMS;
42792
+ var MESH_SEND_KEYS_MAX_TEXT_BYTES;
42693
42793
  var buildCliSpawnEnv;
42694
42794
  var init_provider_cli_shared = __esm2({
42695
42795
  "src/cli-adapters/provider-cli-shared.ts"() {
@@ -42853,6 +42953,20 @@ ${rendered}`, "utf-8");
42853
42953
  this.ensureRow();
42854
42954
  }
42855
42955
  };
42956
+ MESH_SEND_KEY_ENCODING = {
42957
+ ENTER: "\r",
42958
+ ESC: "\x1B",
42959
+ CTRL_C: "",
42960
+ UP: "\x1B[A",
42961
+ DOWN: "\x1B[B",
42962
+ RIGHT: "\x1B[C",
42963
+ LEFT: "\x1B[D",
42964
+ TAB: " ",
42965
+ BACKSPACE: "\x7F"
42966
+ };
42967
+ MESH_DESTRUCTIVE_KEYS = /* @__PURE__ */ new Set(["CTRL_C", "ESC"]);
42968
+ MESH_SEND_KEYS_MAX_ITEMS = 64;
42969
+ MESH_SEND_KEYS_MAX_TEXT_BYTES = 4096;
42856
42970
  buildCliSpawnEnv = import_session_host_core22.sanitizeSpawnEnv;
42857
42971
  }
42858
42972
  });
@@ -48014,6 +48128,10 @@ The mesh has no work in flight. For each mission, decide its outcome: continue i
48014
48128
  getCursorPosition() {
48015
48129
  return this.terminal.getCursorPosition();
48016
48130
  }
48131
+ /** Current viewport dimensions (cols × rows). */
48132
+ getSize() {
48133
+ return { cols: this.cols, rows: this.rows };
48134
+ }
48017
48135
  dispose() {
48018
48136
  this.terminal.dispose();
48019
48137
  }
@@ -56766,6 +56884,7 @@ ${cont}` : cont;
56766
56884
  return current.slice(-keepFromCurrent) + chunk;
56767
56885
  }
56768
56886
  var os16;
56887
+ var import_crypto10;
56769
56888
  var import_session_host_core6;
56770
56889
  var FORCE_SUBMIT_SETTLE_MS;
56771
56890
  var ProviderCliAdapter;
@@ -56773,6 +56892,7 @@ ${cont}` : cont;
56773
56892
  "src/cli-adapters/provider-cli-adapter.ts"() {
56774
56893
  "use strict";
56775
56894
  os16 = __toESM2(require("os"));
56895
+ import_crypto10 = require("crypto");
56776
56896
  init_logger();
56777
56897
  init_debug_config();
56778
56898
  init_terminal_screen();
@@ -56974,6 +57094,11 @@ ${cont}` : cont;
56974
57094
  static MAX_ACCUMULATED_BUFFER = 262144;
56975
57095
  parsedStatusCache = null;
56976
57096
  static SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
57097
+ // MESH-READ-TERMINAL (feature 2): byte caps for getTerminalScreenSnapshot.
57098
+ // Byte, not char — a multi-byte-glyph screen can exceed an MCP payload cap
57099
+ // while the char count still looks safe. 32KiB default, 64KiB absolute hard cap.
57100
+ static TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES = 32 * 1024;
57101
+ static TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES = 64 * 1024;
56977
57102
  // (FALSEIDLE Path-C) Consecutive gate-eligible getStatus polls a mesh/autonomous
56978
57103
  // session must show before the poll-static-idle confirm fires. 2 = one extra
56979
57104
  // status tick of hysteresis: enough to reject a single momentary-silence
@@ -58552,6 +58677,103 @@ ${lastSnapshot}`;
58552
58677
  isAlive() {
58553
58678
  return this.ptyProcess !== null;
58554
58679
  }
58680
+ /**
58681
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Narrow, least-privilege
58682
+ * read of the CURRENT rendered viewport for mesh_read_terminal. Deliberately
58683
+ * NARROWER than getSnapshot()/getDebugSnapshot():
58684
+ * - It returns ONLY the terminal's current rendered viewport (what a human
58685
+ * would see on screen right now), the cursor position and the viewport
58686
+ * size. NO debug buffers, NO parser/FSM state, NO scrollback/history.
58687
+ * - It does NOT call getParseScreenText() (which may graft an older snapshot
58688
+ * onto the current frame for parse accuracy) — the caller asked for the
58689
+ * live viewport, not a parse-optimized composite.
58690
+ * - The payload is bounded in BYTES (UTF-8) with bottom-tail preservation so
58691
+ * a screen of multi-byte glyphs can never exceed the MCP payload cap. See
58692
+ * truncateToByteTailByLine.
58693
+ *
58694
+ * SECURITY NOTE: the raw viewport can contain tokens / command args / env
58695
+ * values / user data. Callers MUST gate this on mesh ownership and MUST NOT
58696
+ * log the returned text. Opt-in redaction is intentionally out of scope for
58697
+ * this feature and left as a future enhancement.
58698
+ *
58699
+ * `maxBytes` is clamped to [1KiB, ABSOLUTE_MAX] (default 32KiB, hard cap 64KiB).
58700
+ */
58701
+ getTerminalScreenSnapshot(maxBytes = _ProviderCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES) {
58702
+ const cap = Math.min(
58703
+ _ProviderCliAdapter.TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES,
58704
+ Math.max(1024, Math.floor(maxBytes) || _ProviderCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES)
58705
+ );
58706
+ const rawViewport = this.terminalScreen.getText() || "";
58707
+ const size = this.terminalScreen.getSize();
58708
+ const cursor = this.terminalScreen.getCursorPosition();
58709
+ const truncation = truncateToByteTailByLine(rawViewport, cap);
58710
+ const hash2 = (0, import_crypto10.createHash)("sha256").update(rawViewport, "utf8").digest("hex").slice(0, 16);
58711
+ return {
58712
+ text: truncation.text,
58713
+ cursor,
58714
+ cols: size.cols,
58715
+ rows: size.rows,
58716
+ truncated: truncation.truncated,
58717
+ originalBytes: truncation.originalBytes,
58718
+ returnedBytes: truncation.returnedBytes,
58719
+ hash: hash2
58720
+ };
58721
+ }
58722
+ /**
58723
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key sequence
58724
+ * into the PTY for the mesh_send_keys tool. Reuses the same serialized write
58725
+ * path as sends (writeToPty via writeRaw semantics) so the injection FIFO-
58726
+ * orders behind any in-flight write.
58727
+ *
58728
+ * Two guards run INSIDE this method, immediately before the write, so they see
58729
+ * a consistent snapshot of the adapter's submit/echo/queue state (no async gap
58730
+ * between check and write — the encode is synchronous and the write is chained
58731
+ * atomically after it):
58732
+ *
58733
+ * 1. submit-race recheck (echo-gate): even though writeToPty is FIFO, an
58734
+ * already-SCHEDULED echo-gated Enter (engine.submitPendingUntil in the
58735
+ * future), an armed stuck-submit retry (submitRetryTimer), or an in-flight
58736
+ * pending-outbound flush can submit at a DIFFERENT tick than our write —
58737
+ * so an injected literal could be submitted by a pending Enter, or our
58738
+ * ENTER could submit a half-typed pending body. When any of those is live
58739
+ * we REFUSE the injection rather than interleave.
58740
+ *
58741
+ * 2. modal fail-closed: if the session is parked on an actionable approval
58742
+ * modal, refuse a NON-destructive injection (ENTER/text/arrows) and direct
58743
+ * the caller to mesh_approve — so a modal choice can't be confirmed via
58744
+ * send_keys to bypass the approval policy. (A destructive ESC/CTRL_C, which
58745
+ * dismisses rather than confirms, is allowed to proceed past this gate; it
58746
+ * is separately gated by confirm_destructive + policy at the tool layer.)
58747
+ *
58748
+ * The caller (tool layer) owns the destructive-key double gate and the audit
58749
+ * ledger. This method NEVER logs the literal text (only key enums / byte len).
58750
+ */
58751
+ async injectKeys(items, opts = {}) {
58752
+ if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
58753
+ const encoded = encodeMeshSendKeys(items);
58754
+ const now = Date.now();
58755
+ const submitPending = this.engine.submitPendingUntil > now;
58756
+ const submitRetryArmed = this.submitRetryTimer !== null;
58757
+ const outboundBusy = this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length > 0;
58758
+ if (submitPending || submitRetryArmed || outboundBusy) {
58759
+ LOG2.warn("CLI", `[${this.cliType}] send_keys refused (submit_race): submitPending=${submitPending} submitRetry=${submitRetryArmed} outboundBusy=${outboundBusy} keys=${encoded.keys.join(",")}`);
58760
+ return { ok: false, refused: "submit_race", keys: encoded.keys, hasDestructive: encoded.hasDestructive };
58761
+ }
58762
+ const modalActive = this.engine.hasActionableApproval();
58763
+ if (modalActive && !encoded.hasDestructive && !opts.allowModalOverride) {
58764
+ LOG2.warn("CLI", `[${this.cliType}] send_keys refused (actionable_modal): keys=${encoded.keys.join(",")} \u2014 use mesh_approve`);
58765
+ return { ok: false, refused: "actionable_modal", keys: encoded.keys, hasDestructive: encoded.hasDestructive };
58766
+ }
58767
+ await this.writeToPty(encoded.sequence);
58768
+ LOG2.info("CLI", `[${this.cliType}] send_keys injected keys=${encoded.keys.join(",") || "(text-only)"} bytes=${Buffer.byteLength(encoded.sequence, "utf8")} destructive=${encoded.hasDestructive}`);
58769
+ return {
58770
+ ok: true,
58771
+ keys: encoded.keys,
58772
+ hasDestructive: encoded.hasDestructive,
58773
+ submits: encoded.submits,
58774
+ bytes: Buffer.byteLength(encoded.sequence, "utf8")
58775
+ };
58776
+ }
58555
58777
  flushOutboundQueue() {
58556
58778
  this.schedulePendingOutboundFlush();
58557
58779
  }
@@ -59390,6 +59612,7 @@ ${lastSnapshot}`;
59390
59612
  resetConfig: () => resetConfig,
59391
59613
  resetDebugRuntimeConfig: () => resetDebugRuntimeConfig,
59392
59614
  resetState: () => resetState,
59615
+ resolveAllowSendKeysDestructive: () => resolveAllowSendKeysDestructive,
59393
59616
  resolveAutoConvergeCodeChange: () => resolveAutoConvergeCodeChange,
59394
59617
  resolveChatMessageKind: () => resolveChatMessageKind,
59395
59618
  resolveConvergeRequiredTags: () => resolveConvergeRequiredTags,
@@ -69670,6 +69893,63 @@ ${effect.notification.body || ""}`.trim();
69670
69893
  if (!cols || !rows) return { success: false, error: "cols and rows required" };
69671
69894
  return { success: false, error: "PTY resize temporarily disabled", code: "PTY_RESIZE_DISABLED" };
69672
69895
  }
69896
+ function handleReadTerminal(h, args) {
69897
+ const targetSessionId = typeof args?.targetSessionId === "string" ? args.targetSessionId.trim() : "";
69898
+ const sessionId = targetSessionId || h.currentSession?.sessionId || "";
69899
+ if (!sessionId) return { success: false, error: "targetSessionId required" };
69900
+ const session = h.ctx.sessionRegistry?.get(sessionId);
69901
+ const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
69902
+ const instance = h.ctx.instanceManager?.getInstance(instanceKey);
69903
+ if (!instance) return { success: false, error: `Session not found: ${sessionId.split("_")[0]}` };
69904
+ if (instance.category !== "cli" || typeof instance.getTerminalScreenSnapshot !== "function") {
69905
+ return { success: false, error: "read_terminal is only supported for CLI (PTY) sessions" };
69906
+ }
69907
+ const requestedMaxBytes = typeof args?.maxBytes === "number" && Number.isFinite(args.maxBytes) ? args.maxBytes : void 0;
69908
+ const snapshot = instance.getTerminalScreenSnapshot(requestedMaxBytes);
69909
+ if (!snapshot) {
69910
+ return { success: false, error: "read_terminal is only available for coordinator-spawned mesh worker sessions" };
69911
+ }
69912
+ LOG2.info("Command", `[readTerminal] session=${sessionId.split("_")[0]} bytes=${snapshot.returnedBytes}/${snapshot.originalBytes} truncated=${snapshot.truncated} cols=${snapshot.cols} rows=${snapshot.rows}`);
69913
+ return { success: true, ...snapshot };
69914
+ }
69915
+ async function handleSendKeys(h, args) {
69916
+ const targetSessionId = typeof args?.targetSessionId === "string" ? args.targetSessionId.trim() : "";
69917
+ const sessionId = targetSessionId || h.currentSession?.sessionId || "";
69918
+ if (!sessionId) return { success: false, error: "targetSessionId required" };
69919
+ const items = Array.isArray(args?.sequence) ? args.sequence : null;
69920
+ if (!items || items.length === 0) {
69921
+ return { success: false, error: "sequence (non-empty array of {text}|{key}) required" };
69922
+ }
69923
+ const session = h.ctx.sessionRegistry?.get(sessionId);
69924
+ const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
69925
+ const instance = h.ctx.instanceManager?.getInstance(instanceKey);
69926
+ if (!instance) return { success: false, error: `Session not found: ${sessionId.split("_")[0]}` };
69927
+ if (instance.category !== "cli" || typeof instance.injectKeys !== "function") {
69928
+ return { success: false, error: "send_keys is only supported for CLI (PTY) sessions" };
69929
+ }
69930
+ const DESTRUCTIVE = /* @__PURE__ */ new Set(["CTRL_C", "ESC"]);
69931
+ const hasDestructiveRequested = items.some((it) => it && typeof it.key === "string" && DESTRUCTIVE.has(it.key));
69932
+ if (hasDestructiveRequested && args?.confirm_destructive !== true) {
69933
+ return {
69934
+ success: false,
69935
+ error: "destructive key (CTRL_C/ESC) requires confirm_destructive=true",
69936
+ refused: "destructive_unconfirmed"
69937
+ };
69938
+ }
69939
+ try {
69940
+ const result = await instance.injectKeys(items, {
69941
+ allowModalOverride: args?.allow_modal_override === true
69942
+ });
69943
+ if (!result.ok) {
69944
+ LOG2.info("Command", `[sendKeys] session=${sessionId.split("_")[0]} refused=${result.refused} keys=${result.keys.join(",")} destructive=${result.hasDestructive}`);
69945
+ return { success: false, error: `send_keys refused: ${result.refused}`, refused: result.refused, keys: result.keys, hasDestructive: result.hasDestructive };
69946
+ }
69947
+ LOG2.info("Command", `[sendKeys] session=${sessionId.split("_")[0]} injected keys=${result.keys.join(",") || "(text-only)"} bytes=${result.bytes} destructive=${result.hasDestructive} submits=${result.submits}`);
69948
+ return { success: true, keys: result.keys, hasDestructive: result.hasDestructive, submits: result.submits, bytes: result.bytes };
69949
+ } catch (e) {
69950
+ return { success: false, error: `send_keys: ${e?.message || String(e)}` };
69951
+ }
69952
+ }
69673
69953
  function handleGetProviderSettings(h, args) {
69674
69954
  const loader = h.ctx.providerLoader;
69675
69955
  const { providerType } = args || {};
@@ -70523,6 +70803,12 @@ ${effect.notification.body || ""}`.trim();
70523
70803
  return handlePtyInput(this, args);
70524
70804
  case "pty_resize":
70525
70805
  return handlePtyResize(this, args);
70806
+ // ─── MESH-READ-TERMINAL (feature 2): raw viewport read ──────────
70807
+ case "read_terminal":
70808
+ return handleReadTerminal(this, args);
70809
+ // ─── MESH-SEND-KEYS (feature 3): structured key injection ────────
70810
+ case "send_keys":
70811
+ return handleSendKeys(this, args);
70526
70812
  // ─── Provider Settings (stream-commands.ts) ──────────
70527
70813
  case "get_provider_settings":
70528
70814
  return handleGetProviderSettings(this, args);
@@ -73225,6 +73511,12 @@ ${body}
73225
73511
  const pos = this.screen.getCursorPosition();
73226
73512
  return { row: pos.row, col: pos.col };
73227
73513
  }
73514
+ /** Current terminal geometry (columns × rows). Tracked here rather than
73515
+ * read off the screen buffer so a resize is reflected immediately, before
73516
+ * the next repaint. Consumed by the mesh_read_terminal viewport read. */
73517
+ getScreenSize() {
73518
+ return { cols: this.cols, rows: this.rows };
73519
+ }
73228
73520
  send_keys(text) {
73229
73521
  this.recordEvent("input", capPreview(escapeControl(text)), text.length);
73230
73522
  this.pty?.write(text);
@@ -73557,6 +73849,9 @@ ${body}
73557
73849
  getScreen() {
73558
73850
  return this.adapter.snapshot();
73559
73851
  }
73852
+ getScreenSize() {
73853
+ return this.adapter.getScreenSize();
73854
+ }
73560
73855
  /** Scrollback-inclusive screen as line array — used only for modal/button
73561
73856
  * content extraction so a tall prompt's off-screen anchors stay matchable.
73562
73857
  * Falls back to the viewport snapshot if scrollback read is unavailable. */
@@ -75583,6 +75878,8 @@ ${body}
75583
75878
  return out;
75584
75879
  }
75585
75880
  var fs20 = __toESM2(require("fs"));
75881
+ var import_node_crypto4 = require("crypto");
75882
+ init_provider_cli_shared();
75586
75883
  init_logger();
75587
75884
  function stripAnsi3(text) {
75588
75885
  return String(text || "").replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, "").replace(/\x1B[P^_X][\s\S]*?(?:\x07|\x1B\\)/g, "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
@@ -75761,6 +76058,105 @@ ${body}
75761
76058
  isReady() {
75762
76059
  return this.spawned && !this.exited;
75763
76060
  }
76061
+ // Process liveness for the MESH-STALL-WATCH watchdog (checkMeshWorkerStall).
76062
+ // The spec path drives the child through the transport/driver rather than a
76063
+ // directly-held ptyProcess handle, so liveness is tracked by the spawned/exited
76064
+ // lifecycle flags — the same pair isReady() uses. A spawned, not-yet-exited
76065
+ // session is alive. ProviderCliAdapter exposes the equivalent via `ptyProcess !== null`.
76066
+ isAlive() {
76067
+ return this.spawned && !this.exited;
76068
+ }
76069
+ // MESH-READ-TERMINAL / MESH-SEND-KEYS byte caps — same envelope as
76070
+ // ProviderCliAdapter (32KiB default view, 64KiB absolute hard cap). Bytes,
76071
+ // not chars: a multi-byte-glyph screen can exceed an MCP payload cap while
76072
+ // the char count still looks safe.
76073
+ static TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES = 32 * 1024;
76074
+ static TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES = 64 * 1024;
76075
+ /**
76076
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Least-privilege read
76077
+ * of the CURRENT rendered viewport for mesh_read_terminal on the spec path
76078
+ * (claude-cli / antigravity / codex-cli — the native-source providers that
76079
+ * route through SpecCliAdapter). Mirrors ProviderCliAdapter.getTerminalScreenSnapshot:
76080
+ * - returns ONLY the driver's current viewport snapshot, the cursor
76081
+ * position and the terminal geometry — NO scrollback, NO parser/FSM
76082
+ * state, NO debug buffers;
76083
+ * - the payload is byte-bounded (UTF-8) with bottom-tail preservation so a
76084
+ * screen of multi-byte glyphs can never exceed the MCP payload cap;
76085
+ * - `hash` is over the FULL untruncated viewport so a caller can detect a
76086
+ * screen change across polls even when the returned text was truncated.
76087
+ *
76088
+ * SECURITY: the raw viewport can carry tokens / command args / env / user
76089
+ * data. Callers MUST gate this on mesh ownership and MUST NOT log the text.
76090
+ */
76091
+ getTerminalScreenSnapshot(maxBytes = _SpecCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES) {
76092
+ const cap = Math.min(
76093
+ _SpecCliAdapter.TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES,
76094
+ Math.max(1024, Math.floor(maxBytes) || _SpecCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES)
76095
+ );
76096
+ let rawViewport = "";
76097
+ try {
76098
+ rawViewport = this.driver.snapshot() || "";
76099
+ } catch {
76100
+ rawViewport = "";
76101
+ }
76102
+ let cursor = { row: 0, col: 0 };
76103
+ try {
76104
+ cursor = this.driver.getCursorPosition();
76105
+ } catch {
76106
+ }
76107
+ let size = { cols: 0, rows: 0 };
76108
+ try {
76109
+ size = this.driver.getScreenSize?.() ?? size;
76110
+ } catch {
76111
+ }
76112
+ const truncation = truncateToByteTailByLine(rawViewport, cap);
76113
+ const hash2 = (0, import_node_crypto4.createHash)("sha256").update(rawViewport, "utf8").digest("hex").slice(0, 16);
76114
+ return {
76115
+ text: truncation.text,
76116
+ cursor: { col: cursor.col, row: cursor.row },
76117
+ cols: size.cols,
76118
+ rows: size.rows,
76119
+ truncated: truncation.truncated,
76120
+ originalBytes: truncation.originalBytes,
76121
+ returnedBytes: truncation.returnedBytes,
76122
+ hash: hash2
76123
+ };
76124
+ }
76125
+ /**
76126
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key
76127
+ * sequence into the spec-driven PTY for mesh_send_keys. Mirrors
76128
+ * ProviderCliAdapter.injectKeys' modal fail-closed guard, then writes the
76129
+ * whole encoded sequence in ONE pty_write dispatch (text+ENTER is a single
76130
+ * contiguous string, so a submit key can never be separated from the text
76131
+ * it submits).
76132
+ *
76133
+ * The spec path drives the child through the FsmDriver, not a directly-held
76134
+ * ptyProcess — there is no adapter-level echo-gate/submit-retry FIFO to race
76135
+ * against here (the driver serializes its own writes), so the only guard is
76136
+ * the modal fail-closed: a NON-destructive injection into an actionable
76137
+ * approval modal is refused (use mesh_approve) unless explicitly overridden.
76138
+ * A destructive ESC/CTRL_C dismisses rather than confirms, so it is allowed
76139
+ * past this gate (the tool layer owns the destructive double-gate + audit).
76140
+ * This method NEVER logs the literal text — only key enums / byte length.
76141
+ */
76142
+ async injectKeys(items, opts = {}) {
76143
+ if (!this.spawned || this.exited) throw new Error(`${this.cliName} is not running`);
76144
+ const encoded = encodeMeshSendKeys(items);
76145
+ const modalActive = this.latestState?.status === "approval";
76146
+ if (modalActive && !encoded.hasDestructive && !opts.allowModalOverride) {
76147
+ LOG2.warn("SpecAdapter", `[${this.cliType}] send_keys refused (actionable_modal): keys=${encoded.keys.join(",")} \u2014 use mesh_approve`);
76148
+ return { ok: false, refused: "actionable_modal", keys: encoded.keys, hasDestructive: encoded.hasDestructive };
76149
+ }
76150
+ this.driver.dispatch({ kind: "pty_write", data: encoded.sequence });
76151
+ LOG2.info("SpecAdapter", `[${this.cliType}] send_keys injected keys=${encoded.keys.join(",") || "(text-only)"} bytes=${Buffer.byteLength(encoded.sequence, "utf8")} destructive=${encoded.hasDestructive}`);
76152
+ return {
76153
+ ok: true,
76154
+ keys: encoded.keys,
76155
+ hasDestructive: encoded.hasDestructive,
76156
+ submits: encoded.submits,
76157
+ bytes: Buffer.byteLength(encoded.sequence, "utf8")
76158
+ };
76159
+ }
75764
76160
  setOnStatusChange(cb) {
75765
76161
  this.statusCallback = cb;
75766
76162
  }
@@ -77049,6 +77445,15 @@ ${body}
77049
77445
  * with no approval never carries recency and is never held (no regression).
77050
77446
  */
77051
77447
  static APPROVAL_RESUME_GRACE_MS = 18e3;
77448
+ // MESH-STALL-WATCH (feature 1: STALL detection): how long a coordinator-spawned
77449
+ // mesh worker's raw PTY output (lastOutputAt) may stay unchanged before the
77450
+ // status-agnostic stall watchdog fires ONE informational monitor:no_progress
77451
+ // event. Unlike the StatusMonitor no-progress watchdog (which only runs while a
77452
+ // turn is generating), this observes pure screen stasis regardless of the
77453
+ // reported status — a worker parked idle, wedged mid-turn, or spawned with no
77454
+ // output at all. 180s matches DEFAULT_MONITOR_CONFIG.noProgressThresholdSec so
77455
+ // the two watchdogs agree on the same "long interval" bound.
77456
+ static MESH_WORKER_STALL_THRESHOLD_MS = 18e4;
77052
77457
  adapter;
77053
77458
  context = null;
77054
77459
  events = [];
@@ -77062,6 +77467,16 @@ ${body}
77062
77467
  // first sets it; the other becomes a no-op.
77063
77468
  agentReadyEmitted = false;
77064
77469
  generatingStartedAt = 0;
77470
+ // MESH-STALL-WATCH (feature 1): the lastOutputAt value the stall episode is
77471
+ // currently armed against. A stall episode is "the raw PTY output has not
77472
+ // advanced past this anchor". When the adapter has never emitted output
77473
+ // (lastOutputAt === 0) the anchor is the spawn time (this.startedAt) so a
77474
+ // worker that produced NOTHING is still caught. On any new output the anchor
77475
+ // re-arms to the fresh lastOutputAt and meshStallEmittedForAnchor resets, so a
77476
+ // single continuous stall fires AT MOST ONCE and a later stall re-arms cleanly.
77477
+ // -1 = not yet initialised for this session.
77478
+ meshStallAnchorAt = -1;
77479
+ meshStallEmittedForAnchor = false;
77065
77480
  // FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
77066
77481
  // phase (→generating or →waiting_approval). The completedDebouncePending snapshots
77067
77482
  // this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
@@ -77985,6 +78400,25 @@ ${body}
77985
78400
  }
77986
78401
  return "";
77987
78402
  }
78403
+ /**
78404
+ * NOTIF Defect-B: the final assistant summary this instance ALREADY parsed and
78405
+ * cached for the current turn (lastCompletionSummary), if any. The evidence
78406
+ * probe (completionFinalAssistantEvidence) is a POINT-SAMPLE: on a native-source
78407
+ * provider (antigravity) the parsed screen and the native transcript can both
78408
+ * momentarily yield no in-turn final assistant at the exact instant the
78409
+ * completion gate fires — source='unavailable', missingEvidence=true — even
78410
+ * though a prior poll already read the real answer off native-history and cached
78411
+ * it here (the same value mesh_read_chat.summary shows). Consulting the cache at
78412
+ * emit time lets that already-secured summary count as evidence, so the completion
78413
+ * notification carries the answer instead of completion_diagnostic=missing_final_assistant
78414
+ * with an empty summary. Returns '' when the cache is empty or was reset by the
78415
+ * next turn (see lastCompletionSummary = null on onTurnStarted).
78416
+ */
78417
+ cachedCompletionSummaryContent() {
78418
+ const cached3 = this.lastCompletionSummary;
78419
+ const content = typeof cached3?.content === "string" ? cached3.content.trim() : "";
78420
+ return content;
78421
+ }
77988
78422
  completionFinalAssistantEvidence(parsedMessages, turnStartedAt) {
77989
78423
  const turnClosed = !this.hasAdapterPendingResponse();
77990
78424
  if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
@@ -78048,12 +78482,19 @@ ${body}
78048
78482
  const lastVisibleRole = typeof lastVisible?.role === "string" ? lastVisible.role.trim().toLowerCase() : null;
78049
78483
  const lastVisibleKind = typeof lastVisible?.kind === "string" ? lastVisible.kind : null;
78050
78484
  const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
78485
+ const cachedSummary = evidence.present ? "" : this.cachedCompletionSummaryContent();
78486
+ const creditedFromCache = !evidence.present && cachedSummary.length > 0;
78487
+ const finalAssistantPresent = evidence.present || creditedFromCache;
78488
+ const finalAssistantEvidenceSource = evidence.present ? evidence.source : creditedFromCache ? "cached-summary" : evidence.source;
78489
+ const clearMissingBlock = creditedFromCache && args.blockReason === "missing_final_assistant";
78490
+ const effectiveBlockReason = clearMissingBlock ? void 0 : args.blockReason;
78051
78491
  return {
78052
78492
  providerType: this.type,
78053
78493
  sessionId: this.instanceId,
78054
78494
  providerSessionId: this.providerSessionId || null,
78055
78495
  workspace: this.workingDir,
78056
- blockReason: args.blockReason,
78496
+ ...effectiveBlockReason ? { blockReason: effectiveBlockReason } : {},
78497
+ ...clearMissingBlock ? { originalBlockReason: args.blockReason } : {},
78057
78498
  emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
78058
78499
  waitedMs: args.waitedMs,
78059
78500
  maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
@@ -78061,8 +78502,9 @@ ${body}
78061
78502
  latestVisibleStatus: args.latestVisibleStatus,
78062
78503
  parsedStatus: typeof parsed?.status === "string" ? parsed.status : parseError ? "parse_error" : "unknown",
78063
78504
  parseError: parseError || void 0,
78064
- finalAssistantPresent: evidence.present,
78065
- finalAssistantEvidenceSource: evidence.source,
78505
+ finalAssistantPresent,
78506
+ finalAssistantFromCachedSummary: !evidence.present && cachedSummary.length > 0,
78507
+ finalAssistantEvidenceSource,
78066
78508
  visibleMessageCount: visibleMessages.length,
78067
78509
  lastVisibleRole,
78068
78510
  lastVisibleKind,
@@ -78240,6 +78682,128 @@ ${body}
78240
78682
  isMeshWorkerSession() {
78241
78683
  return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.meshNodeId || this.settings.launchedByCoordinator);
78242
78684
  }
78685
+ /**
78686
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Public read of the
78687
+ * CURRENT rendered PTY viewport for the mesh_read_terminal tool, delegating to
78688
+ * the adapter's narrow getTerminalScreenSnapshot() (viewport + cursor + size
78689
+ * only; no debug buffers / parser state / history; byte-bounded, bottom-tail
78690
+ * preserved).
78691
+ *
78692
+ * Gated on isMeshWorkerSession(): this raw viewport can expose tokens /
78693
+ * command args / env / user data, so only a coordinator-spawned worker session
78694
+ * is readable. The MCP layer ALSO cross-checks mesh/session/node ownership
78695
+ * (isMeshOwnedDelegateSession) — isMeshWorkerSession alone is a broad
78696
+ * "delegated" gate, so the two together block cross-mesh access. Returns null
78697
+ * for a non-mesh session so the daemon command surfaces a clean refusal.
78698
+ */
78699
+ getTerminalScreenSnapshot(maxBytes) {
78700
+ if (!this.isMeshWorkerSession()) return null;
78701
+ if (typeof this.adapter.getTerminalScreenSnapshot !== "function") return null;
78702
+ return this.adapter.getTerminalScreenSnapshot(maxBytes);
78703
+ }
78704
+ /**
78705
+ * MESH-SEND-KEYS (feature 3: key injection). Public entry for the
78706
+ * mesh_send_keys tool, delegating to the adapter's injectKeys() (structured
78707
+ * key encoding + atomic write + submit-race recheck + modal fail-closed).
78708
+ *
78709
+ * Gated on isMeshWorkerSession(): PTY input into a worker is a
78710
+ * coordinator-only capability. The MCP layer ALSO cross-checks mesh/session/
78711
+ * node ownership (isMeshOwnedDelegateSession) and owns the destructive-key
78712
+ * double gate (confirm_destructive + policy) and the audit ledger. Returns a
78713
+ * refusal object for a non-mesh session so the daemon command surfaces a clean
78714
+ * error (never silently writes to a non-worker PTY).
78715
+ */
78716
+ async injectKeys(items, opts = {}) {
78717
+ if (!this.isMeshWorkerSession()) {
78718
+ return { ok: false, refused: "not_mesh_worker", keys: [], hasDestructive: false };
78719
+ }
78720
+ if (typeof this.adapter.injectKeys !== "function") {
78721
+ return { ok: false, refused: "unsupported", keys: [], hasDestructive: false };
78722
+ }
78723
+ return this.adapter.injectKeys(items, opts);
78724
+ }
78725
+ /**
78726
+ * MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
78727
+ * watchdog for coordinator-spawned mesh worker sessions. Driven by the
78728
+ * ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
78729
+ * ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
78730
+ * clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
78731
+ * live worker's screen has been byte-for-byte unchanged for
78732
+ * MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
78733
+ * monitor:no_progress event down the existing task_stalled ledger +
78734
+ * pendingCoordinatorEvent path.
78735
+ *
78736
+ * Deliberately status-agnostic: it does NOT read getStatus()'s reported status
78737
+ * (which would couple it to the generating-only StatusMonitor and the
78738
+ * idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
78739
+ * minutes; that is accepted and surfaced as an informational stall (NOT a
78740
+ * failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
78741
+ * called here, so status heartbeats never move the stall anchor.
78742
+ *
78743
+ * Anchoring: the episode arms against the current lastOutputAt; a worker that
78744
+ * has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
78745
+ * time) so a silent spawn is still caught. Any new output re-arms the anchor
78746
+ * and clears the emitted flag, so one continuous stall emits at most once and a
78747
+ * later stall re-arms cleanly.
78748
+ */
78749
+ checkMeshWorkerStall(now = Date.now()) {
78750
+ if (!this.isMeshWorkerSession()) {
78751
+ this.meshStallAnchorAt = -1;
78752
+ this.meshStallEmittedForAnchor = false;
78753
+ return;
78754
+ }
78755
+ if (typeof this.adapter.isAlive === "function" && !this.adapter.isAlive()) {
78756
+ this.meshStallAnchorAt = -1;
78757
+ this.meshStallEmittedForAnchor = false;
78758
+ return;
78759
+ }
78760
+ let lastOutputAt;
78761
+ try {
78762
+ const status = this.adapter.getStatus({ allowParse: false });
78763
+ lastOutputAt = typeof status?.lastOutputAt === "number" && Number.isFinite(status.lastOutputAt) ? status.lastOutputAt : 0;
78764
+ } catch {
78765
+ return;
78766
+ }
78767
+ const anchor = lastOutputAt > 0 ? lastOutputAt : this.startedAt;
78768
+ if (this.meshStallAnchorAt === -1) {
78769
+ this.meshStallAnchorAt = anchor;
78770
+ this.meshStallEmittedForAnchor = false;
78771
+ return;
78772
+ }
78773
+ if (anchor > this.meshStallAnchorAt) {
78774
+ this.meshStallAnchorAt = anchor;
78775
+ this.meshStallEmittedForAnchor = false;
78776
+ return;
78777
+ }
78778
+ if (this.meshStallEmittedForAnchor) return;
78779
+ const stalledMs = now - this.meshStallAnchorAt;
78780
+ if (stalledMs < _CliProviderInstance.MESH_WORKER_STALL_THRESHOLD_MS) return;
78781
+ this.meshStallEmittedForAnchor = true;
78782
+ let observedStatus = "unknown";
78783
+ try {
78784
+ const s2 = this.adapter.getStatus({ allowParse: false })?.status;
78785
+ if (typeof s2 === "string" && s2) observedStatus = s2;
78786
+ } catch {
78787
+ }
78788
+ if (this.isMeshWorkerSession()) {
78789
+ traceMeshEventStage("fired", this.meshTraceCtx("monitor:no_progress"), "mesh_worker_stall_watchdog");
78790
+ }
78791
+ const stalledSec = Math.round(stalledMs / 1e3);
78792
+ this.pushEvent({
78793
+ event: "monitor:no_progress",
78794
+ agentKey: `${this.type}:cli`,
78795
+ elapsedSec: stalledSec,
78796
+ timestamp: now,
78797
+ // MESH-STALL-WATCH marker: buildMeshSystemMessage generalizes the
78798
+ // coordinator message (generating-specific → "PTY output unchanged")
78799
+ // when this is set, since this watchdog fires status-agnostically.
78800
+ meshWorkerStall: true,
78801
+ lastOutputAt: this.meshStallAnchorAt,
78802
+ stalledMs,
78803
+ observedStatus,
78804
+ taskId: this.completingTurnTaskId()
78805
+ });
78806
+ }
78243
78807
  /**
78244
78808
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
78245
78809
  * persist before the in-progress settle gate is torn down. For a delegated
@@ -78545,7 +79109,13 @@ ${body}
78545
79109
  // delegated session's inbox preview blank — or, for a LOCAL worktree session,
78546
79110
  // stuck on the dispatched user task. If the parser DID surface assistant text,
78547
79111
  // prefer it; only fall back to '' when no assistant summary can be derived.
78548
- finalSummary: blockReason.startsWith("parsed_status:") ? this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) ?? "" : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
79112
+ // NOTIF Defect-B: completionFinalSummary is a point-sample of native-history/
79113
+ // screen at THIS instant; on a native-source provider (antigravity) it can be
79114
+ // empty at the forced-emit instant even though a prior poll already cached the
79115
+ // real answer (lastCompletionSummary). Fall back to the cache so the notification
79116
+ // carries the summary that mesh_read_chat.summary already shows — consistent with
79117
+ // completionDiagnostic.finalAssistantPresent being credited from the same cache.
79118
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) || this.cachedCompletionSummaryContent() || (blockReason.startsWith("parsed_status:") ? "" : void 0),
78549
79119
  completionDiagnostic
78550
79120
  });
78551
79121
  this.completedDebouncePending = null;
@@ -79201,6 +79771,10 @@ ${buttons.join("\n")}`;
79201
79771
  this.completedDebouncePending = null;
79202
79772
  continue;
79203
79773
  }
79774
+ if (me.type === "monitor:no_progress" && this.isMeshWorkerSession()) {
79775
+ traceMeshEventDrop("mesh_worker_stall_watchdog_owns_no_progress", this.meshTraceCtx("monitor:no_progress"));
79776
+ continue;
79777
+ }
79204
79778
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
79205
79779
  }
79206
79780
  }
@@ -90157,6 +90731,150 @@ ${e?.stderr || ""}`
90157
90731
  };
90158
90732
  }
90159
90733
  }
90734
+ async function classifyPatchEquivalenceFailure(repoRoot, baseHead, branchHead, summary, options = {}) {
90735
+ const targetBaseRef = options.targetBaseRef || baseHead;
90736
+ const autoPublish = options.autoPublishSubmoduleMainCommits;
90737
+ const evidence = {
90738
+ baseHead,
90739
+ branchHead,
90740
+ mergeBase: summary.mergeBase,
90741
+ expectedPatchId: summary.expectedPatchId,
90742
+ actualPatchId: summary.actualPatchId,
90743
+ patchIdEqual: !!summary.expectedPatchId && summary.expectedPatchId === summary.actualPatchId,
90744
+ ...autoPublish !== void 0 ? { autoPublishSubmoduleMainCommits: autoPublish } : {}
90745
+ };
90746
+ try {
90747
+ const git = (args) => (0, import_node_child_process6.execFileSync)(GIT2, args, {
90748
+ cwd: repoRoot,
90749
+ encoding: "utf8",
90750
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
90751
+ windowsHide: true
90752
+ });
90753
+ const gitOk = (args) => {
90754
+ try {
90755
+ git(args);
90756
+ return true;
90757
+ } catch {
90758
+ return false;
90759
+ }
90760
+ };
90761
+ let ahead = 0;
90762
+ let behind = 0;
90763
+ try {
90764
+ const out = git(["rev-list", "--left-right", "--count", `${targetBaseRef}...${branchHead}`]).trim();
90765
+ const [left, right] = out.split(/\s+/).map((n) => Number.parseInt(n, 10));
90766
+ behind = Number.isFinite(left) ? left : 0;
90767
+ ahead = Number.isFinite(right) ? right : 0;
90768
+ } catch {
90769
+ }
90770
+ evidence.ahead = ahead;
90771
+ evidence.behind = behind;
90772
+ const baseIsAncestor = gitOk(["merge-base", "--is-ancestor", targetBaseRef, branchHead]);
90773
+ evidence.baseDiverged = !baseIsAncestor;
90774
+ let diffStat = "";
90775
+ try {
90776
+ if (summary.mergedTree) {
90777
+ diffStat = git(["diff", "--stat", baseHead, summary.mergedTree]).trim().split("\n").filter(Boolean).slice(-1)[0] || "";
90778
+ } else {
90779
+ diffStat = git(["diff", "--stat", baseHead, branchHead]).trim().split("\n").filter(Boolean).slice(-1)[0] || "";
90780
+ }
90781
+ } catch {
90782
+ }
90783
+ if (diffStat) evidence.diffStat = diffStat;
90784
+ const submoduleGitlinks = [];
90785
+ try {
90786
+ const nameStatus = git(["diff", "--name-only", "--diff-filter=d", baseHead, branchHead]).trim();
90787
+ const changedPaths = nameStatus ? nameStatus.split("\n").map((p) => p.trim()).filter(Boolean) : [];
90788
+ for (const p of changedPaths) {
90789
+ let baseCommit;
90790
+ let branchCommit;
90791
+ try {
90792
+ const baseLs = git(["ls-tree", baseHead, "--", p]).trim();
90793
+ const branchLs = git(["ls-tree", branchHead, "--", p]).trim();
90794
+ const isGitlink = /(^|\s)160000\s/.test(baseLs) || /(^|\s)160000\s/.test(branchLs);
90795
+ if (!isGitlink) continue;
90796
+ baseCommit = baseLs.split(/\s+/)[2];
90797
+ branchCommit = branchLs.split(/\s+/)[2];
90798
+ } catch {
90799
+ continue;
90800
+ }
90801
+ const submoduleRepo = (0, import_path14.join)(repoRoot, p);
90802
+ let fastForward;
90803
+ let reachableFromOriginMain;
90804
+ if (branchCommit) {
90805
+ if (baseCommit) {
90806
+ fastForward = execGitOk(submoduleRepo, ["merge-base", "--is-ancestor", baseCommit, branchCommit]);
90807
+ }
90808
+ reachableFromOriginMain = execGitOk(submoduleRepo, ["merge-base", "--is-ancestor", branchCommit, "refs/remotes/origin/main"]);
90809
+ }
90810
+ submoduleGitlinks.push({ path: p, baseCommit, branchCommit, fastForward, reachableFromOriginMain });
90811
+ }
90812
+ } catch {
90813
+ }
90814
+ if (submoduleGitlinks.length) evidence.submoduleGitlinks = submoduleGitlinks;
90815
+ const gitlinkFf = summary.gitlinkTrivialFastForward;
90816
+ const noResidualDiff = !evidence.diffStat && (!summary.actualPatchId || summary.actualPatchId === "");
90817
+ if (ahead === 0 && behind === 0 && noResidualDiff) {
90818
+ return {
90819
+ detailedReason: "already_converged",
90820
+ detailedReasonDescription: "Branch is already identical to the target base (ahead 0, behind 0, no residual diff); the merge would be a no-op.",
90821
+ recommendedAction: "Treat as already converged \u2014 no merge needed. Verify with `git range-diff` / patch-id, then mark the branch merged (or clean up the worktree).",
90822
+ evidence
90823
+ };
90824
+ }
90825
+ const unreachable = submoduleGitlinks.filter((g) => g.reachableFromOriginMain === false);
90826
+ if (unreachable.length > 0) {
90827
+ const paths = unreachable.map((g) => g.path).join(", ");
90828
+ return {
90829
+ detailedReason: "submodule_unreachable",
90830
+ detailedReasonDescription: `Submodule gitlink commit(s) not reachable from submodule origin/main (publish needed): ${paths}.`,
90831
+ recommendedAction: `Publish the submodule commit(s) to submodule origin/main, then retry mesh_refine_node (policy allowAutoPublishSubmoduleMainCommits=${autoPublish === void 0 ? "unknown" : autoPublish}).`,
90832
+ evidence
90833
+ };
90834
+ }
90835
+ const changedGitlinks = submoduleGitlinks.length > 0;
90836
+ const allGitlinksFf = changedGitlinks && submoduleGitlinks.every((g) => g.fastForward === true);
90837
+ const gateSawUnresolvedGitlinkFf = gitlinkFf?.resolved === false && Array.isArray(gitlinkFf.gitlinks) && gitlinkFf.gitlinks.some((g) => g.fastForward);
90838
+ if (baseIsAncestor && (evidence.patchIdEqual || allGitlinksFf || gateSawUnresolvedGitlinkFf)) {
90839
+ return {
90840
+ detailedReason: "trivial_ff_misjudgment",
90841
+ detailedReasonDescription: "HEAD descends the target base and the patch content matches; the block is a submodule gitlink trivial fast-forward that merge-tree refused, not a real divergence.",
90842
+ recommendedAction: "Converge via the strict fast-forward-only bypass (verify HEAD descends origin/main and patch-id equality, then merge --ff-only) instead of the refine gate.",
90843
+ evidence
90844
+ };
90845
+ }
90846
+ if (!baseIsAncestor) {
90847
+ return {
90848
+ detailedReason: "base_divergence",
90849
+ detailedReasonDescription: `Worktree base has diverged from ${targetBaseRef} (HEAD is not a descendant; ahead ${ahead}, behind ${behind}).`,
90850
+ recommendedAction: `Rebase the branch onto ${targetBaseRef}, then retry mesh_refine_node.`,
90851
+ evidence
90852
+ };
90853
+ }
90854
+ return {
90855
+ detailedReason: "actual_patch_diff",
90856
+ detailedReasonDescription: "The merge introduces content not equivalent to the branch's cumulative patch (expected tree vs actual merge diff differ).",
90857
+ recommendedAction: "Manual review required \u2014 inspect the residual diff; the branch content is not patch-equivalent to a clean merge onto the base.",
90858
+ evidence
90859
+ };
90860
+ } catch (e) {
90861
+ evidence.classifierError = e?.message || String(e);
90862
+ return {
90863
+ detailedReason: "unclassified",
90864
+ detailedReasonDescription: "Patch-equivalence sub-cause could not be classified (git inspection failed); see classifierError.",
90865
+ recommendedAction: "Inspect the refineStages and patchEquivalence summary manually to determine the cause.",
90866
+ evidence
90867
+ };
90868
+ }
90869
+ }
90870
+ function execGitOk(cwd, args) {
90871
+ try {
90872
+ (0, import_node_child_process6.execFileSync)(GIT2, args, { cwd, encoding: "utf8", maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES, windowsHide: true });
90873
+ return true;
90874
+ } catch {
90875
+ return false;
90876
+ }
90877
+ }
90160
90878
  async function checkWorktreeChangesPatchEquivalentInRef(repoRoot, ref, worktreeHead) {
90161
90879
  const startedAt = Date.now();
90162
90880
  try {
@@ -91474,9 +92192,23 @@ ${mergeTreeErr?.stderr || ""}`;
91474
92192
  error: submoduleHintPatchEquivalence.error,
91475
92193
  actionableHint: submoduleHintPatchEquivalence.actionableHint
91476
92194
  });
92195
+ const classification = await classifyPatchEquivalenceFailure(
92196
+ repoRoot,
92197
+ baseHead,
92198
+ ctx.branchHead,
92199
+ submoduleHintPatchEquivalence,
92200
+ {
92201
+ targetBaseRef: baseHead,
92202
+ autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(ctx.mesh, node.workspace).enabled
92203
+ }
92204
+ );
91477
92205
  return { kind: "terminal", result: {
91478
92206
  success: false,
91479
92207
  code: "patch_equivalence_failed",
92208
+ detailedReason: classification.detailedReason,
92209
+ detailedReasonDescription: classification.detailedReasonDescription,
92210
+ recommendedAction: classification.recommendedAction,
92211
+ evidence: classification.evidence,
91480
92212
  convergenceStatus: "blocked_review",
91481
92213
  error: "Refinery patch-equivalence preflight failed (submodule gitlink conflict); merge/refine was not attempted.",
91482
92214
  branch,
@@ -91616,7 +92348,7 @@ ${tail}` : ""
91616
92348
  return { kind: "continue", ctx };
91617
92349
  }
91618
92350
  async function refinePatchEquivalenceStage(self, ctx) {
91619
- const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, validationSummary, refineStages } = ctx;
92351
+ const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, mesh, node, validationSummary, refineStages } = ctx;
91620
92352
  const branchHead = ctx.branchHead;
91621
92353
  const patchEquivalenceStarted = Date.now();
91622
92354
  const patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
@@ -91630,9 +92362,27 @@ ${tail}` : ""
91630
92362
  if (!patchEquivalence.equivalent) {
91631
92363
  const alreadyMergedViaOtherPath = !patchEquivalence.actualPatchId && !!patchEquivalence.expectedPatchId;
91632
92364
  if (!alreadyMergedViaOtherPath) {
92365
+ const classification = await classifyPatchEquivalenceFailure(
92366
+ repoRoot,
92367
+ baseHead,
92368
+ branchHead,
92369
+ patchEquivalence,
92370
+ {
92371
+ targetBaseRef: baseHead,
92372
+ autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(mesh, node.workspace).enabled
92373
+ }
92374
+ );
92375
+ recordMeshRefineStage(refineStages, "patch_equivalence_classification", "failed", patchEquivalenceStarted, {
92376
+ detailedReason: classification.detailedReason,
92377
+ recommendedAction: classification.recommendedAction
92378
+ });
91633
92379
  return { kind: "terminal", result: {
91634
92380
  success: false,
91635
92381
  code: "patch_equivalence_failed",
92382
+ detailedReason: classification.detailedReason,
92383
+ detailedReasonDescription: classification.detailedReasonDescription,
92384
+ recommendedAction: classification.recommendedAction,
92385
+ evidence: classification.evidence,
91636
92386
  convergenceStatus: "blocked_review",
91637
92387
  error: "Refinery patch-equivalence preflight failed; merge/refine was not attempted.",
91638
92388
  branch,
@@ -92739,6 +93489,10 @@ ${e?.stderr || ""}`;
92739
93489
  };
92740
93490
  if (typeof result.error === "string") ctx.error = result.error;
92741
93491
  if (typeof result.blockedReason === "string") ctx.blockedReason = result.blockedReason;
93492
+ if (typeof result.detailedReason === "string") ctx.detailedReason = result.detailedReason;
93493
+ if (typeof result.detailedReasonDescription === "string") ctx.detailedReasonDescription = result.detailedReasonDescription;
93494
+ if (typeof result.recommendedAction === "string") ctx.recommendedAction = result.recommendedAction;
93495
+ if (result.evidence && typeof result.evidence === "object") ctx.evidence = result.evidence;
92742
93496
  if (stage === "patch_equivalence" && result.patchEquivalence) {
92743
93497
  const pe = result.patchEquivalence;
92744
93498
  ctx.details = {
@@ -92746,7 +93500,10 @@ ${e?.stderr || ""}`;
92746
93500
  actualPatchId: pe.actualPatchId,
92747
93501
  status: pe.status,
92748
93502
  actionableHint: pe.actionableHint,
92749
- error: pe.error
93503
+ error: pe.error,
93504
+ ...typeof result.detailedReason === "string" ? { detailedReason: result.detailedReason } : {},
93505
+ ...typeof result.recommendedAction === "string" ? { recommendedAction: result.recommendedAction } : {},
93506
+ ...result.evidence && typeof result.evidence === "object" ? { evidence: result.evidence } : {}
92750
93507
  };
92751
93508
  }
92752
93509
  if (stage === "submodule_reachability" && Array.isArray(result.unreachableSubmoduleCommits)) {
@@ -93704,7 +94461,22 @@ ${e?.stderr || ""}`;
93704
94461
  // worker session that reaches the wrong daemon used to fuzzy-inject the task body into that
93705
94462
  // daemon's own CLI session (TASKECHO coordinator self-echo). Forwarding to the owning daemon
93706
94463
  // delivers it to the real worker instead. (findAdapter is also fail-closed as the backstop.)
93707
- "agent_command"
94464
+ "agent_command",
94465
+ // read_terminal (MESH-READ-TERMINAL feature 2): mesh_read_terminal reads the CURRENT
94466
+ // rendered PTY viewport of a specific worker session. The live viewport lives ONLY on the
94467
+ // OWNING session's adapter, so when the target is a REMOTE worker the coordinator has no
94468
+ // local instance and the handler would return 'Session not found' — the exact
94469
+ // remote-worker forwarding gap of mission 6938892f. Forward it to the owning worker daemon
94470
+ // so it reads its own live screen. (It is read-only; unlike the mutations above it makes no
94471
+ // state change, but it is session-scoped identically and must reach the owning daemon.)
94472
+ "read_terminal",
94473
+ // send_keys (MESH-SEND-KEYS feature 3): mesh_send_keys injects a structured key sequence
94474
+ // into a specific worker session's PTY. The live PTY lives ONLY on the OWNING session's
94475
+ // adapter, so a remote-worker target must be forwarded to the owning daemon or the handler
94476
+ // returns 'Session not found' (same class as mission 6938892f). Unlike read_terminal this
94477
+ // MUTATES the worker PTY, so forwarding to the real owner (not a wrong local session) is
94478
+ // doubly important. The daemon re-enforces the destructive-key confirm gate after the forward.
94479
+ "send_keys"
93708
94480
  ]);
93709
94481
  function normalizeCommandSource(source) {
93710
94482
  switch (source) {
@@ -95827,7 +96599,13 @@ ${e?.stderr || ""}`;
95827
96599
  if (this.tickTimer) return;
95828
96600
  this.tickInterval = intervalMs || this.tickInterval;
95829
96601
  this.tickTimer = setInterval(async () => {
96602
+ const now = Date.now();
95830
96603
  for (const [id, instance] of this.instances) {
96604
+ try {
96605
+ instance.checkMeshWorkerStall?.(now);
96606
+ } catch (e) {
96607
+ LOG2.warn("InstanceMgr", `[InstanceManager] Mesh stall check failed for ${id}: ${e.message}`);
96608
+ }
95831
96609
  try {
95832
96610
  await instance.onTick();
95833
96611
  } catch (e) {
@@ -101276,7 +102054,7 @@ data: ${JSON.stringify(msg.data)}
101276
102054
  });
101277
102055
  }
101278
102056
  };
101279
- var import_crypto10 = require("crypto");
102057
+ var import_crypto11 = require("crypto");
101280
102058
  var import_session_host_core11 = require_dist();
101281
102059
  var BASE_KEY_SEQUENCES = {
101282
102060
  enter: "\r",
@@ -101374,7 +102152,7 @@ data: ${JSON.stringify(msg.data)}
101374
102152
  const sessionId = String(options.sessionId || "").trim();
101375
102153
  if (!sessionId) throw new Error("sessionId is required");
101376
102154
  const mode = options.mode || "read";
101377
- const clientId = options.clientId || `raw-terminal-${process.pid}-${(0, import_crypto10.randomUUID)().slice(0, 8)}`;
102155
+ const clientId = options.clientId || `raw-terminal-${process.pid}-${(0, import_crypto11.randomUUID)().slice(0, 8)}`;
101378
102156
  const client = options.client || new import_session_host_core11.SessionHostClient({ endpoint: options.endpoint });
101379
102157
  await client.connect();
101380
102158
  const attachResponse = await client.request({