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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/dist/cli-adapter-types.d.ts +29 -0
  2. package/dist/cli-adapters/provider-cli-adapter.d.ts +80 -1
  3. package/dist/cli-adapters/provider-cli-shared.d.ts +55 -0
  4. package/dist/cli-adapters/terminal-screen.d.ts +5 -0
  5. package/dist/commands/stream-commands.d.ts +31 -0
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +789 -15
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +785 -12
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/mesh/mesh-ledger.d.ts +1 -1
  12. package/dist/mesh/mesh-refine-gates.d.ts +86 -0
  13. package/dist/providers/cli-provider-instance.d.ts +97 -0
  14. package/dist/providers/provider-instance.d.ts +9 -0
  15. package/dist/providers/spec/adapter.d.ts +7 -0
  16. package/dist/providers/spec/cli-adapter.d.ts +64 -0
  17. package/dist/providers/spec/fsm-driver.d.ts +11 -0
  18. package/dist/repo-mesh-types.d.ts +23 -0
  19. package/package.json +3 -3
  20. package/src/cli-adapter-types.ts +29 -0
  21. package/src/cli-adapters/provider-cli-adapter.ts +135 -0
  22. package/src/cli-adapters/provider-cli-shared.ts +172 -0
  23. package/src/cli-adapters/terminal-screen.ts +5 -0
  24. package/src/commands/handler.ts +4 -0
  25. package/src/commands/router-refine.ts +40 -1
  26. package/src/commands/router.ts +15 -0
  27. package/src/commands/stream-commands.ts +125 -0
  28. package/src/index.ts +1 -0
  29. package/src/mesh/coordinator-prompt.ts +2 -0
  30. package/src/mesh/mesh-events-utils.ts +15 -0
  31. package/src/mesh/mesh-ledger.ts +6 -0
  32. package/src/mesh/mesh-refine-gates.ts +256 -0
  33. package/src/providers/cli-provider-instance.ts +277 -6
  34. package/src/providers/provider-instance-manager.ts +11 -0
  35. package/src/providers/provider-instance.ts +10 -0
  36. package/src/providers/spec/adapter.ts +7 -0
  37. package/src/providers/spec/cli-adapter.ts +122 -0
  38. package/src/providers/spec/fsm-driver.ts +5 -0
  39. package/src/repo-mesh-types.ts +35 -0
package/dist/index.mjs CHANGED
@@ -119,6 +119,15 @@ function resolveDelegatedWorkerAutoApprove(meshPolicy, nodePolicy) {
119
119
  }
120
120
  return true;
121
121
  }
122
+ function resolveAllowSendKeysDestructive(meshPolicy, nodePolicy) {
123
+ if (typeof nodePolicy?.allowSendKeysDestructive === "boolean") {
124
+ return nodePolicy.allowSendKeysDestructive;
125
+ }
126
+ if (typeof meshPolicy?.allowSendKeysDestructive === "boolean") {
127
+ return meshPolicy.allowSendKeysDestructive;
128
+ }
129
+ return false;
130
+ }
122
131
  function resolveProviderMaxParallel(slots, providerType) {
123
132
  const wanted = typeof providerType === "string" ? providerType.trim().toLowerCase() : "";
124
133
  if (!wanted) return void 0;
@@ -414,10 +423,10 @@ function readInjected(value) {
414
423
  }
415
424
  function getDaemonBuildInfo() {
416
425
  if (cached) return cached;
417
- const commit = readInjected(true ? "178a437592e2f9544675a54768b48281804bdddb" : void 0) ?? "unknown";
418
- const commitShort = readInjected(true ? "178a4375" : void 0) ?? (commit !== "unknown" ? commit.slice(0, 7) : "unknown");
419
- const version = readInjected(true ? "0.9.82-rc.552" : void 0) ?? readInjected(typeof process !== "undefined" ? process.env?.ADHDEV_PKG_VERSION : void 0) ?? "unknown";
420
- const builtAt = readInjected(true ? "2026-07-17T04:25:41.391Z" : void 0);
426
+ const commit = readInjected(true ? "23529650799df69f9a14f16f6c2b34db153f1b27" : void 0) ?? "unknown";
427
+ const commitShort = readInjected(true ? "23529650" : void 0) ?? (commit !== "unknown" ? commit.slice(0, 7) : "unknown");
428
+ const version = readInjected(true ? "0.9.82-rc.554" : void 0) ?? readInjected(typeof process !== "undefined" ? process.env?.ADHDEV_PKG_VERSION : void 0) ?? "unknown";
429
+ const builtAt = readInjected(true ? "2026-07-17T07:57:39.144Z" : void 0);
421
430
  cached = builtAt ? { commit, commitShort, version, builtAt } : { commit, commitShort, version };
422
431
  return cached;
423
432
  }
@@ -2982,6 +2991,8 @@ var init_dist = __esm({
2982
2991
  "mesh_send_task",
2983
2992
  "mesh_read_chat",
2984
2993
  "mesh_read_debug",
2994
+ "mesh_read_terminal",
2995
+ "mesh_send_keys",
2985
2996
  "mesh_launch_session",
2986
2997
  "mesh_git_status",
2987
2998
  "mesh_read_node_logs",
@@ -4128,6 +4139,8 @@ var init_coordinator_prompt = __esm({
4128
4139
  | \`mesh_launch_session\` | Start a new agent session on a node |
4129
4140
  | \`mesh_read_chat\` | Read recent chat messages from a delegated agent session |
4130
4141
  | \`mesh_read_debug\` | Collect a daemon-side chat/parser debug bundle for a session |
4142
+ | \`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 |
4143
+ | \`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) |
4131
4144
  | \`mesh_task_history\` | Read the task ledger \u2014 dispatches, completions, failures. Use to understand what has been done before deciding next steps |
4132
4145
  | \`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 |
4133
4146
  | \`mesh_reconcile_ledger\` | Reconcile daemon-local ledgers over P2P \u2014 import missing entries from remote nodes into the coordinator local ledger |
@@ -8785,6 +8798,13 @@ Do NOT retry on this node. Consider reassigning to a different node or asking th
8785
8798
  return `[System] ${args.nodeLabel} has stopped${metadata}. Use mesh_read_chat once if you need to inspect its last output.`;
8786
8799
  }
8787
8800
  if (args.event === "monitor:no_progress") {
8801
+ if (args.metadataEvent.meshWorkerStall === true) {
8802
+ const observedStatus = readNonEmptyString2(args.metadataEvent.observedStatus);
8803
+ const stalledMs = typeof args.metadataEvent.stalledMs === "number" ? args.metadataEvent.stalledMs : void 0;
8804
+ const stalledSuffix = stalledMs !== void 0 ? ` for ${Math.round(stalledMs / 1e3)}s` : "";
8805
+ const statusSuffix = observedStatus ? ` (observed status: ${observedStatus})` : "";
8806
+ 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.`;
8807
+ }
8788
8808
  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.`;
8789
8809
  }
8790
8810
  if (args.event === "worktree_bootstrap_complete") {
@@ -12582,6 +12602,82 @@ function sanitizeTerminalText(str) {
12582
12602
  const accumulator = new TerminalTranscriptAccumulator();
12583
12603
  return stripTerminalNoise(stripAnsi(accumulator.append(str)));
12584
12604
  }
12605
+ function encodeMeshSendKeys(items) {
12606
+ if (!Array.isArray(items) || items.length === 0) {
12607
+ throw new Error("send_keys: sequence must be a non-empty array");
12608
+ }
12609
+ if (items.length > MESH_SEND_KEYS_MAX_ITEMS) {
12610
+ throw new Error(`send_keys: sequence exceeds ${MESH_SEND_KEYS_MAX_ITEMS} items`);
12611
+ }
12612
+ const parts = [];
12613
+ const keys = [];
12614
+ let hasDestructive = false;
12615
+ let submits = false;
12616
+ let textBytes = 0;
12617
+ for (const item of items) {
12618
+ if (item && typeof item.text === "string") {
12619
+ const text = item.text;
12620
+ textBytes += Buffer.byteLength(text, "utf8");
12621
+ if (textBytes > MESH_SEND_KEYS_MAX_TEXT_BYTES) {
12622
+ throw new Error(`send_keys: total literal text exceeds ${MESH_SEND_KEYS_MAX_TEXT_BYTES} bytes`);
12623
+ }
12624
+ parts.push(text);
12625
+ submits = false;
12626
+ continue;
12627
+ }
12628
+ const keyName = item && typeof item.key === "string" ? item.key : "";
12629
+ if (!(keyName in MESH_SEND_KEY_ENCODING)) {
12630
+ throw new Error(`send_keys: unknown key '${keyName}'`);
12631
+ }
12632
+ const key2 = keyName;
12633
+ parts.push(MESH_SEND_KEY_ENCODING[key2]);
12634
+ keys.push(key2);
12635
+ if (MESH_DESTRUCTIVE_KEYS.has(key2)) hasDestructive = true;
12636
+ submits = key2 === "ENTER";
12637
+ }
12638
+ return { sequence: parts.join(""), keys, hasDestructive, submits };
12639
+ }
12640
+ function truncateToByteTailByLine(text, maxBytes) {
12641
+ const input = String(text ?? "");
12642
+ const originalBytes = Buffer.byteLength(input, "utf8");
12643
+ if (originalBytes <= maxBytes) {
12644
+ return { text: input, truncated: false, originalBytes, returnedBytes: originalBytes };
12645
+ }
12646
+ const lines = input.split("\n");
12647
+ const kept = [];
12648
+ let bytes = 0;
12649
+ for (let i = lines.length - 1; i >= 0; i--) {
12650
+ const line = lines[i];
12651
+ const lineBytes = Buffer.byteLength(line, "utf8");
12652
+ const joinCost = kept.length > 0 ? 1 : 0;
12653
+ if (bytes + lineBytes + joinCost > maxBytes) break;
12654
+ bytes += lineBytes + joinCost;
12655
+ kept.unshift(line);
12656
+ }
12657
+ if (kept.length > 0) {
12658
+ const out = kept.join("\n");
12659
+ return {
12660
+ text: out,
12661
+ truncated: true,
12662
+ originalBytes,
12663
+ returnedBytes: Buffer.byteLength(out, "utf8")
12664
+ };
12665
+ }
12666
+ const lastLine = lines[lines.length - 1] ?? "";
12667
+ const buf = Buffer.from(lastLine, "utf8");
12668
+ let slice = buf.subarray(Math.max(0, buf.length - maxBytes));
12669
+ let decoded = slice.toString("utf8");
12670
+ while (decoded.length > 0 && decoded.charCodeAt(0) === 65533 && slice.length > 0) {
12671
+ slice = slice.subarray(1);
12672
+ decoded = slice.toString("utf8");
12673
+ }
12674
+ return {
12675
+ text: decoded,
12676
+ truncated: true,
12677
+ originalBytes,
12678
+ returnedBytes: Buffer.byteLength(decoded, "utf8")
12679
+ };
12680
+ }
12585
12681
  function listCliScriptNames(scripts) {
12586
12682
  if (!scripts) return [];
12587
12683
  return Object.entries(scripts).filter(([, fn]) => typeof fn === "function").map(([name]) => name);
@@ -12783,7 +12879,7 @@ function normalizeCliProviderForRuntime(raw) {
12783
12879
  }
12784
12880
  };
12785
12881
  }
12786
- var TerminalTranscriptAccumulator, buildCliSpawnEnv;
12882
+ var TerminalTranscriptAccumulator, MESH_SEND_KEY_ENCODING, MESH_DESTRUCTIVE_KEYS, MESH_SEND_KEYS_MAX_ITEMS, MESH_SEND_KEYS_MAX_TEXT_BYTES, buildCliSpawnEnv;
12787
12883
  var init_provider_cli_shared = __esm({
12788
12884
  "src/cli-adapters/provider-cli-shared.ts"() {
12789
12885
  "use strict";
@@ -12944,6 +13040,20 @@ var init_provider_cli_shared = __esm({
12944
13040
  this.ensureRow();
12945
13041
  }
12946
13042
  };
13043
+ MESH_SEND_KEY_ENCODING = {
13044
+ ENTER: "\r",
13045
+ ESC: "\x1B",
13046
+ CTRL_C: "",
13047
+ UP: "\x1B[A",
13048
+ DOWN: "\x1B[B",
13049
+ RIGHT: "\x1B[C",
13050
+ LEFT: "\x1B[D",
13051
+ TAB: " ",
13052
+ BACKSPACE: "\x7F"
13053
+ };
13054
+ MESH_DESTRUCTIVE_KEYS = /* @__PURE__ */ new Set(["CTRL_C", "ESC"]);
13055
+ MESH_SEND_KEYS_MAX_ITEMS = 64;
13056
+ MESH_SEND_KEYS_MAX_TEXT_BYTES = 4096;
12947
13057
  buildCliSpawnEnv = sanitizeSpawnEnv;
12948
13058
  }
12949
13059
  });
@@ -18074,6 +18184,10 @@ var init_terminal_screen = __esm({
18074
18184
  getCursorPosition() {
18075
18185
  return this.terminal.getCursorPosition();
18076
18186
  }
18187
+ /** Current viewport dimensions (cols × rows). */
18188
+ getSize() {
18189
+ return { cols: this.cols, rows: this.rows };
18190
+ }
18077
18191
  dispose() {
18078
18192
  this.terminal.dispose();
18079
18193
  }
@@ -26820,6 +26934,7 @@ __export(provider_cli_adapter_exports, {
26820
26934
  normalizeCliProviderForRuntime: () => normalizeCliProviderForRuntime
26821
26935
  });
26822
26936
  import * as os16 from "os";
26937
+ import { createHash as createHash4 } from "crypto";
26823
26938
  import { DEFAULT_SESSION_HOST_COLS as DEFAULT_SESSION_HOST_COLS4, DEFAULT_SESSION_HOST_ROWS as DEFAULT_SESSION_HOST_ROWS4 } from "@adhdev/session-host-core";
26824
26939
  function appendBoundedText(current, chunk, maxChars) {
26825
26940
  if (!chunk) return current.length <= maxChars ? current : current.slice(-maxChars);
@@ -27033,6 +27148,11 @@ var init_provider_cli_adapter = __esm({
27033
27148
  static MAX_ACCUMULATED_BUFFER = 262144;
27034
27149
  parsedStatusCache = null;
27035
27150
  static SCREEN_SNAPSHOT_MIN_INTERVAL_MS = 250;
27151
+ // MESH-READ-TERMINAL (feature 2): byte caps for getTerminalScreenSnapshot.
27152
+ // Byte, not char — a multi-byte-glyph screen can exceed an MCP payload cap
27153
+ // while the char count still looks safe. 32KiB default, 64KiB absolute hard cap.
27154
+ static TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES = 32 * 1024;
27155
+ static TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES = 64 * 1024;
27036
27156
  // (FALSEIDLE Path-C) Consecutive gate-eligible getStatus polls a mesh/autonomous
27037
27157
  // session must show before the poll-static-idle confirm fires. 2 = one extra
27038
27158
  // status tick of hysteresis: enough to reject a single momentary-silence
@@ -28611,6 +28731,103 @@ ${lastSnapshot}`;
28611
28731
  isAlive() {
28612
28732
  return this.ptyProcess !== null;
28613
28733
  }
28734
+ /**
28735
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Narrow, least-privilege
28736
+ * read of the CURRENT rendered viewport for mesh_read_terminal. Deliberately
28737
+ * NARROWER than getSnapshot()/getDebugSnapshot():
28738
+ * - It returns ONLY the terminal's current rendered viewport (what a human
28739
+ * would see on screen right now), the cursor position and the viewport
28740
+ * size. NO debug buffers, NO parser/FSM state, NO scrollback/history.
28741
+ * - It does NOT call getParseScreenText() (which may graft an older snapshot
28742
+ * onto the current frame for parse accuracy) — the caller asked for the
28743
+ * live viewport, not a parse-optimized composite.
28744
+ * - The payload is bounded in BYTES (UTF-8) with bottom-tail preservation so
28745
+ * a screen of multi-byte glyphs can never exceed the MCP payload cap. See
28746
+ * truncateToByteTailByLine.
28747
+ *
28748
+ * SECURITY NOTE: the raw viewport can contain tokens / command args / env
28749
+ * values / user data. Callers MUST gate this on mesh ownership and MUST NOT
28750
+ * log the returned text. Opt-in redaction is intentionally out of scope for
28751
+ * this feature and left as a future enhancement.
28752
+ *
28753
+ * `maxBytes` is clamped to [1KiB, ABSOLUTE_MAX] (default 32KiB, hard cap 64KiB).
28754
+ */
28755
+ getTerminalScreenSnapshot(maxBytes = _ProviderCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES) {
28756
+ const cap = Math.min(
28757
+ _ProviderCliAdapter.TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES,
28758
+ Math.max(1024, Math.floor(maxBytes) || _ProviderCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES)
28759
+ );
28760
+ const rawViewport = this.terminalScreen.getText() || "";
28761
+ const size = this.terminalScreen.getSize();
28762
+ const cursor = this.terminalScreen.getCursorPosition();
28763
+ const truncation = truncateToByteTailByLine(rawViewport, cap);
28764
+ const hash = createHash4("sha256").update(rawViewport, "utf8").digest("hex").slice(0, 16);
28765
+ return {
28766
+ text: truncation.text,
28767
+ cursor,
28768
+ cols: size.cols,
28769
+ rows: size.rows,
28770
+ truncated: truncation.truncated,
28771
+ originalBytes: truncation.originalBytes,
28772
+ returnedBytes: truncation.returnedBytes,
28773
+ hash
28774
+ };
28775
+ }
28776
+ /**
28777
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key sequence
28778
+ * into the PTY for the mesh_send_keys tool. Reuses the same serialized write
28779
+ * path as sends (writeToPty via writeRaw semantics) so the injection FIFO-
28780
+ * orders behind any in-flight write.
28781
+ *
28782
+ * Two guards run INSIDE this method, immediately before the write, so they see
28783
+ * a consistent snapshot of the adapter's submit/echo/queue state (no async gap
28784
+ * between check and write — the encode is synchronous and the write is chained
28785
+ * atomically after it):
28786
+ *
28787
+ * 1. submit-race recheck (echo-gate): even though writeToPty is FIFO, an
28788
+ * already-SCHEDULED echo-gated Enter (engine.submitPendingUntil in the
28789
+ * future), an armed stuck-submit retry (submitRetryTimer), or an in-flight
28790
+ * pending-outbound flush can submit at a DIFFERENT tick than our write —
28791
+ * so an injected literal could be submitted by a pending Enter, or our
28792
+ * ENTER could submit a half-typed pending body. When any of those is live
28793
+ * we REFUSE the injection rather than interleave.
28794
+ *
28795
+ * 2. modal fail-closed: if the session is parked on an actionable approval
28796
+ * modal, refuse a NON-destructive injection (ENTER/text/arrows) and direct
28797
+ * the caller to mesh_approve — so a modal choice can't be confirmed via
28798
+ * send_keys to bypass the approval policy. (A destructive ESC/CTRL_C, which
28799
+ * dismisses rather than confirms, is allowed to proceed past this gate; it
28800
+ * is separately gated by confirm_destructive + policy at the tool layer.)
28801
+ *
28802
+ * The caller (tool layer) owns the destructive-key double gate and the audit
28803
+ * ledger. This method NEVER logs the literal text (only key enums / byte len).
28804
+ */
28805
+ async injectKeys(items, opts = {}) {
28806
+ if (!this.ptyProcess) throw new Error(`${this.cliName} is not running`);
28807
+ const encoded = encodeMeshSendKeys(items);
28808
+ const now = Date.now();
28809
+ const submitPending = this.engine.submitPendingUntil > now;
28810
+ const submitRetryArmed = this.submitRetryTimer !== null;
28811
+ const outboundBusy = this.pendingOutboundFlushInFlight || this.pendingOutboundQueue.length > 0;
28812
+ if (submitPending || submitRetryArmed || outboundBusy) {
28813
+ LOG.warn("CLI", `[${this.cliType}] send_keys refused (submit_race): submitPending=${submitPending} submitRetry=${submitRetryArmed} outboundBusy=${outboundBusy} keys=${encoded.keys.join(",")}`);
28814
+ return { ok: false, refused: "submit_race", keys: encoded.keys, hasDestructive: encoded.hasDestructive };
28815
+ }
28816
+ const modalActive = this.engine.hasActionableApproval();
28817
+ if (modalActive && !encoded.hasDestructive && !opts.allowModalOverride) {
28818
+ LOG.warn("CLI", `[${this.cliType}] send_keys refused (actionable_modal): keys=${encoded.keys.join(",")} \u2014 use mesh_approve`);
28819
+ return { ok: false, refused: "actionable_modal", keys: encoded.keys, hasDestructive: encoded.hasDestructive };
28820
+ }
28821
+ await this.writeToPty(encoded.sequence);
28822
+ LOG.info("CLI", `[${this.cliType}] send_keys injected keys=${encoded.keys.join(",") || "(text-only)"} bytes=${Buffer.byteLength(encoded.sequence, "utf8")} destructive=${encoded.hasDestructive}`);
28823
+ return {
28824
+ ok: true,
28825
+ keys: encoded.keys,
28826
+ hasDestructive: encoded.hasDestructive,
28827
+ submits: encoded.submits,
28828
+ bytes: Buffer.byteLength(encoded.sequence, "utf8")
28829
+ };
28830
+ }
28614
28831
  flushOutboundQueue() {
28615
28832
  this.schedulePendingOutboundFlush();
28616
28833
  }
@@ -39406,6 +39623,63 @@ function handlePtyResize(_h, args) {
39406
39623
  if (!cols || !rows) return { success: false, error: "cols and rows required" };
39407
39624
  return { success: false, error: "PTY resize temporarily disabled", code: "PTY_RESIZE_DISABLED" };
39408
39625
  }
39626
+ function handleReadTerminal(h, args) {
39627
+ const targetSessionId = typeof args?.targetSessionId === "string" ? args.targetSessionId.trim() : "";
39628
+ const sessionId = targetSessionId || h.currentSession?.sessionId || "";
39629
+ if (!sessionId) return { success: false, error: "targetSessionId required" };
39630
+ const session = h.ctx.sessionRegistry?.get(sessionId);
39631
+ const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
39632
+ const instance = h.ctx.instanceManager?.getInstance(instanceKey);
39633
+ if (!instance) return { success: false, error: `Session not found: ${sessionId.split("_")[0]}` };
39634
+ if (instance.category !== "cli" || typeof instance.getTerminalScreenSnapshot !== "function") {
39635
+ return { success: false, error: "read_terminal is only supported for CLI (PTY) sessions" };
39636
+ }
39637
+ const requestedMaxBytes = typeof args?.maxBytes === "number" && Number.isFinite(args.maxBytes) ? args.maxBytes : void 0;
39638
+ const snapshot = instance.getTerminalScreenSnapshot(requestedMaxBytes);
39639
+ if (!snapshot) {
39640
+ return { success: false, error: "read_terminal is only available for coordinator-spawned mesh worker sessions" };
39641
+ }
39642
+ LOG.info("Command", `[readTerminal] session=${sessionId.split("_")[0]} bytes=${snapshot.returnedBytes}/${snapshot.originalBytes} truncated=${snapshot.truncated} cols=${snapshot.cols} rows=${snapshot.rows}`);
39643
+ return { success: true, ...snapshot };
39644
+ }
39645
+ async function handleSendKeys(h, args) {
39646
+ const targetSessionId = typeof args?.targetSessionId === "string" ? args.targetSessionId.trim() : "";
39647
+ const sessionId = targetSessionId || h.currentSession?.sessionId || "";
39648
+ if (!sessionId) return { success: false, error: "targetSessionId required" };
39649
+ const items = Array.isArray(args?.sequence) ? args.sequence : null;
39650
+ if (!items || items.length === 0) {
39651
+ return { success: false, error: "sequence (non-empty array of {text}|{key}) required" };
39652
+ }
39653
+ const session = h.ctx.sessionRegistry?.get(sessionId);
39654
+ const instanceKey = session?.adapterKey || session?.instanceKey || sessionId;
39655
+ const instance = h.ctx.instanceManager?.getInstance(instanceKey);
39656
+ if (!instance) return { success: false, error: `Session not found: ${sessionId.split("_")[0]}` };
39657
+ if (instance.category !== "cli" || typeof instance.injectKeys !== "function") {
39658
+ return { success: false, error: "send_keys is only supported for CLI (PTY) sessions" };
39659
+ }
39660
+ const DESTRUCTIVE = /* @__PURE__ */ new Set(["CTRL_C", "ESC"]);
39661
+ const hasDestructiveRequested = items.some((it) => it && typeof it.key === "string" && DESTRUCTIVE.has(it.key));
39662
+ if (hasDestructiveRequested && args?.confirm_destructive !== true) {
39663
+ return {
39664
+ success: false,
39665
+ error: "destructive key (CTRL_C/ESC) requires confirm_destructive=true",
39666
+ refused: "destructive_unconfirmed"
39667
+ };
39668
+ }
39669
+ try {
39670
+ const result = await instance.injectKeys(items, {
39671
+ allowModalOverride: args?.allow_modal_override === true
39672
+ });
39673
+ if (!result.ok) {
39674
+ LOG.info("Command", `[sendKeys] session=${sessionId.split("_")[0]} refused=${result.refused} keys=${result.keys.join(",")} destructive=${result.hasDestructive}`);
39675
+ return { success: false, error: `send_keys refused: ${result.refused}`, refused: result.refused, keys: result.keys, hasDestructive: result.hasDestructive };
39676
+ }
39677
+ LOG.info("Command", `[sendKeys] session=${sessionId.split("_")[0]} injected keys=${result.keys.join(",") || "(text-only)"} bytes=${result.bytes} destructive=${result.hasDestructive} submits=${result.submits}`);
39678
+ return { success: true, keys: result.keys, hasDestructive: result.hasDestructive, submits: result.submits, bytes: result.bytes };
39679
+ } catch (e) {
39680
+ return { success: false, error: `send_keys: ${e?.message || String(e)}` };
39681
+ }
39682
+ }
39409
39683
  function handleGetProviderSettings(h, args) {
39410
39684
  const loader = h.ctx.providerLoader;
39411
39685
  const { providerType } = args || {};
@@ -40263,6 +40537,12 @@ var DaemonCommandHandler = class {
40263
40537
  return handlePtyInput(this, args);
40264
40538
  case "pty_resize":
40265
40539
  return handlePtyResize(this, args);
40540
+ // ─── MESH-READ-TERMINAL (feature 2): raw viewport read ──────────
40541
+ case "read_terminal":
40542
+ return handleReadTerminal(this, args);
40543
+ // ─── MESH-SEND-KEYS (feature 3): structured key injection ────────
40544
+ case "send_keys":
40545
+ return handleSendKeys(this, args);
40266
40546
  // ─── Provider Settings (stream-commands.ts) ──────────
40267
40547
  case "get_provider_settings":
40268
40548
  return handleGetProviderSettings(this, args);
@@ -43009,6 +43289,12 @@ var TerminalAdapter = class {
43009
43289
  const pos = this.screen.getCursorPosition();
43010
43290
  return { row: pos.row, col: pos.col };
43011
43291
  }
43292
+ /** Current terminal geometry (columns × rows). Tracked here rather than
43293
+ * read off the screen buffer so a resize is reflected immediately, before
43294
+ * the next repaint. Consumed by the mesh_read_terminal viewport read. */
43295
+ getScreenSize() {
43296
+ return { cols: this.cols, rows: this.rows };
43297
+ }
43012
43298
  send_keys(text) {
43013
43299
  this.recordEvent("input", capPreview(escapeControl(text)), text.length);
43014
43300
  this.pty?.write(text);
@@ -43347,6 +43633,9 @@ var FsmDriver = class {
43347
43633
  getScreen() {
43348
43634
  return this.adapter.snapshot();
43349
43635
  }
43636
+ getScreenSize() {
43637
+ return this.adapter.getScreenSize();
43638
+ }
43350
43639
  /** Scrollback-inclusive screen as line array — used only for modal/button
43351
43640
  * content extraction so a tall prompt's off-screen anchors stay matchable.
43352
43641
  * Falls back to the viewport snapshot if scrollback read is unavailable. */
@@ -45384,8 +45673,10 @@ function readTailJsonlLines(filePath, maxBytes) {
45384
45673
  }
45385
45674
 
45386
45675
  // src/providers/spec/cli-adapter.ts
45676
+ init_provider_cli_shared();
45387
45677
  init_logger();
45388
45678
  import * as fs20 from "fs";
45679
+ import { createHash as createHash5 } from "crypto";
45389
45680
  function stripAnsi3(text) {
45390
45681
  return String(text || "").replace(/\x1B\][^\x07]*(?:\x07|\x1B\\)/g, "").replace(/\x1B[P^_X][\s\S]*?(?:\x07|\x1B\\)/g, "").replace(/\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g, "");
45391
45682
  }
@@ -45563,6 +45854,105 @@ var SpecCliAdapter = class _SpecCliAdapter {
45563
45854
  isReady() {
45564
45855
  return this.spawned && !this.exited;
45565
45856
  }
45857
+ // Process liveness for the MESH-STALL-WATCH watchdog (checkMeshWorkerStall).
45858
+ // The spec path drives the child through the transport/driver rather than a
45859
+ // directly-held ptyProcess handle, so liveness is tracked by the spawned/exited
45860
+ // lifecycle flags — the same pair isReady() uses. A spawned, not-yet-exited
45861
+ // session is alive. ProviderCliAdapter exposes the equivalent via `ptyProcess !== null`.
45862
+ isAlive() {
45863
+ return this.spawned && !this.exited;
45864
+ }
45865
+ // MESH-READ-TERMINAL / MESH-SEND-KEYS byte caps — same envelope as
45866
+ // ProviderCliAdapter (32KiB default view, 64KiB absolute hard cap). Bytes,
45867
+ // not chars: a multi-byte-glyph screen can exceed an MCP payload cap while
45868
+ // the char count still looks safe.
45869
+ static TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES = 32 * 1024;
45870
+ static TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES = 64 * 1024;
45871
+ /**
45872
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Least-privilege read
45873
+ * of the CURRENT rendered viewport for mesh_read_terminal on the spec path
45874
+ * (claude-cli / antigravity / codex-cli — the native-source providers that
45875
+ * route through SpecCliAdapter). Mirrors ProviderCliAdapter.getTerminalScreenSnapshot:
45876
+ * - returns ONLY the driver's current viewport snapshot, the cursor
45877
+ * position and the terminal geometry — NO scrollback, NO parser/FSM
45878
+ * state, NO debug buffers;
45879
+ * - the payload is byte-bounded (UTF-8) with bottom-tail preservation so a
45880
+ * screen of multi-byte glyphs can never exceed the MCP payload cap;
45881
+ * - `hash` is over the FULL untruncated viewport so a caller can detect a
45882
+ * screen change across polls even when the returned text was truncated.
45883
+ *
45884
+ * SECURITY: the raw viewport can carry tokens / command args / env / user
45885
+ * data. Callers MUST gate this on mesh ownership and MUST NOT log the text.
45886
+ */
45887
+ getTerminalScreenSnapshot(maxBytes = _SpecCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES) {
45888
+ const cap = Math.min(
45889
+ _SpecCliAdapter.TERMINAL_SNAPSHOT_ABSOLUTE_MAX_BYTES,
45890
+ Math.max(1024, Math.floor(maxBytes) || _SpecCliAdapter.TERMINAL_SNAPSHOT_DEFAULT_MAX_BYTES)
45891
+ );
45892
+ let rawViewport = "";
45893
+ try {
45894
+ rawViewport = this.driver.snapshot() || "";
45895
+ } catch {
45896
+ rawViewport = "";
45897
+ }
45898
+ let cursor = { row: 0, col: 0 };
45899
+ try {
45900
+ cursor = this.driver.getCursorPosition();
45901
+ } catch {
45902
+ }
45903
+ let size = { cols: 0, rows: 0 };
45904
+ try {
45905
+ size = this.driver.getScreenSize?.() ?? size;
45906
+ } catch {
45907
+ }
45908
+ const truncation = truncateToByteTailByLine(rawViewport, cap);
45909
+ const hash = createHash5("sha256").update(rawViewport, "utf8").digest("hex").slice(0, 16);
45910
+ return {
45911
+ text: truncation.text,
45912
+ cursor: { col: cursor.col, row: cursor.row },
45913
+ cols: size.cols,
45914
+ rows: size.rows,
45915
+ truncated: truncation.truncated,
45916
+ originalBytes: truncation.originalBytes,
45917
+ returnedBytes: truncation.returnedBytes,
45918
+ hash
45919
+ };
45920
+ }
45921
+ /**
45922
+ * MESH-SEND-KEYS (feature 3: key injection). Inject a STRUCTURED key
45923
+ * sequence into the spec-driven PTY for mesh_send_keys. Mirrors
45924
+ * ProviderCliAdapter.injectKeys' modal fail-closed guard, then writes the
45925
+ * whole encoded sequence in ONE pty_write dispatch (text+ENTER is a single
45926
+ * contiguous string, so a submit key can never be separated from the text
45927
+ * it submits).
45928
+ *
45929
+ * The spec path drives the child through the FsmDriver, not a directly-held
45930
+ * ptyProcess — there is no adapter-level echo-gate/submit-retry FIFO to race
45931
+ * against here (the driver serializes its own writes), so the only guard is
45932
+ * the modal fail-closed: a NON-destructive injection into an actionable
45933
+ * approval modal is refused (use mesh_approve) unless explicitly overridden.
45934
+ * A destructive ESC/CTRL_C dismisses rather than confirms, so it is allowed
45935
+ * past this gate (the tool layer owns the destructive double-gate + audit).
45936
+ * This method NEVER logs the literal text — only key enums / byte length.
45937
+ */
45938
+ async injectKeys(items, opts = {}) {
45939
+ if (!this.spawned || this.exited) throw new Error(`${this.cliName} is not running`);
45940
+ const encoded = encodeMeshSendKeys(items);
45941
+ const modalActive = this.latestState?.status === "approval";
45942
+ if (modalActive && !encoded.hasDestructive && !opts.allowModalOverride) {
45943
+ LOG.warn("SpecAdapter", `[${this.cliType}] send_keys refused (actionable_modal): keys=${encoded.keys.join(",")} \u2014 use mesh_approve`);
45944
+ return { ok: false, refused: "actionable_modal", keys: encoded.keys, hasDestructive: encoded.hasDestructive };
45945
+ }
45946
+ this.driver.dispatch({ kind: "pty_write", data: encoded.sequence });
45947
+ LOG.info("SpecAdapter", `[${this.cliType}] send_keys injected keys=${encoded.keys.join(",") || "(text-only)"} bytes=${Buffer.byteLength(encoded.sequence, "utf8")} destructive=${encoded.hasDestructive}`);
45948
+ return {
45949
+ ok: true,
45950
+ keys: encoded.keys,
45951
+ hasDestructive: encoded.hasDestructive,
45952
+ submits: encoded.submits,
45953
+ bytes: Buffer.byteLength(encoded.sequence, "utf8")
45954
+ };
45955
+ }
45566
45956
  setOnStatusChange(cb) {
45567
45957
  this.statusCallback = cb;
45568
45958
  }
@@ -46875,6 +47265,15 @@ var CliProviderInstance = class _CliProviderInstance {
46875
47265
  * with no approval never carries recency and is never held (no regression).
46876
47266
  */
46877
47267
  static APPROVAL_RESUME_GRACE_MS = 18e3;
47268
+ // MESH-STALL-WATCH (feature 1: STALL detection): how long a coordinator-spawned
47269
+ // mesh worker's raw PTY output (lastOutputAt) may stay unchanged before the
47270
+ // status-agnostic stall watchdog fires ONE informational monitor:no_progress
47271
+ // event. Unlike the StatusMonitor no-progress watchdog (which only runs while a
47272
+ // turn is generating), this observes pure screen stasis regardless of the
47273
+ // reported status — a worker parked idle, wedged mid-turn, or spawned with no
47274
+ // output at all. 180s matches DEFAULT_MONITOR_CONFIG.noProgressThresholdSec so
47275
+ // the two watchdogs agree on the same "long interval" bound.
47276
+ static MESH_WORKER_STALL_THRESHOLD_MS = 18e4;
46878
47277
  adapter;
46879
47278
  context = null;
46880
47279
  events = [];
@@ -46888,6 +47287,16 @@ var CliProviderInstance = class _CliProviderInstance {
46888
47287
  // first sets it; the other becomes a no-op.
46889
47288
  agentReadyEmitted = false;
46890
47289
  generatingStartedAt = 0;
47290
+ // MESH-STALL-WATCH (feature 1): the lastOutputAt value the stall episode is
47291
+ // currently armed against. A stall episode is "the raw PTY output has not
47292
+ // advanced past this anchor". When the adapter has never emitted output
47293
+ // (lastOutputAt === 0) the anchor is the spawn time (this.startedAt) so a
47294
+ // worker that produced NOTHING is still caught. On any new output the anchor
47295
+ // re-arms to the fresh lastOutputAt and meshStallEmittedForAnchor resets, so a
47296
+ // single continuous stall fires AT MOST ONCE and a later stall re-arms cleanly.
47297
+ // -1 = not yet initialised for this session.
47298
+ meshStallAnchorAt = -1;
47299
+ meshStallEmittedForAnchor = false;
46891
47300
  // FALSE-IDLE continuity epoch: monotonically bumped on EVERY entry into a busy
46892
47301
  // phase (→generating or →waiting_approval). The completedDebouncePending snapshots
46893
47302
  // this value at arm time (busyEpochAtArm); the flush guard requires it UNCHANGED
@@ -47811,6 +48220,25 @@ var CliProviderInstance = class _CliProviderInstance {
47811
48220
  }
47812
48221
  return "";
47813
48222
  }
48223
+ /**
48224
+ * NOTIF Defect-B: the final assistant summary this instance ALREADY parsed and
48225
+ * cached for the current turn (lastCompletionSummary), if any. The evidence
48226
+ * probe (completionFinalAssistantEvidence) is a POINT-SAMPLE: on a native-source
48227
+ * provider (antigravity) the parsed screen and the native transcript can both
48228
+ * momentarily yield no in-turn final assistant at the exact instant the
48229
+ * completion gate fires — source='unavailable', missingEvidence=true — even
48230
+ * though a prior poll already read the real answer off native-history and cached
48231
+ * it here (the same value mesh_read_chat.summary shows). Consulting the cache at
48232
+ * emit time lets that already-secured summary count as evidence, so the completion
48233
+ * notification carries the answer instead of completion_diagnostic=missing_final_assistant
48234
+ * with an empty summary. Returns '' when the cache is empty or was reset by the
48235
+ * next turn (see lastCompletionSummary = null on onTurnStarted).
48236
+ */
48237
+ cachedCompletionSummaryContent() {
48238
+ const cached3 = this.lastCompletionSummary;
48239
+ const content = typeof cached3?.content === "string" ? cached3.content.trim() : "";
48240
+ return content;
48241
+ }
47814
48242
  completionFinalAssistantEvidence(parsedMessages, turnStartedAt) {
47815
48243
  const turnClosed = !this.hasAdapterPendingResponse();
47816
48244
  if (this.completionHasFinalAssistantMessage(parsedMessages, turnStartedAt)) {
@@ -47874,12 +48302,19 @@ var CliProviderInstance = class _CliProviderInstance {
47874
48302
  const lastVisibleRole = typeof lastVisible?.role === "string" ? lastVisible.role.trim().toLowerCase() : null;
47875
48303
  const lastVisibleKind = typeof lastVisible?.kind === "string" ? lastVisible.kind : null;
47876
48304
  const lastVisibleContentLength = lastVisible ? flattenContent(lastVisible.content).trim().length : 0;
48305
+ const cachedSummary = evidence.present ? "" : this.cachedCompletionSummaryContent();
48306
+ const creditedFromCache = !evidence.present && cachedSummary.length > 0;
48307
+ const finalAssistantPresent = evidence.present || creditedFromCache;
48308
+ const finalAssistantEvidenceSource = evidence.present ? evidence.source : creditedFromCache ? "cached-summary" : evidence.source;
48309
+ const clearMissingBlock = creditedFromCache && args.blockReason === "missing_final_assistant";
48310
+ const effectiveBlockReason = clearMissingBlock ? void 0 : args.blockReason;
47877
48311
  return {
47878
48312
  providerType: this.type,
47879
48313
  sessionId: this.instanceId,
47880
48314
  providerSessionId: this.providerSessionId || null,
47881
48315
  workspace: this.workingDir,
47882
- blockReason: args.blockReason,
48316
+ ...effectiveBlockReason ? { blockReason: effectiveBlockReason } : {},
48317
+ ...clearMissingBlock ? { originalBlockReason: args.blockReason } : {},
47883
48318
  emittedAfterFinalizationTimeout: args.emittedAfterFinalizationTimeout,
47884
48319
  waitedMs: args.waitedMs,
47885
48320
  maxWaitMs: COMPLETED_FINALIZATION_MAX_WAIT_MS,
@@ -47887,8 +48322,9 @@ var CliProviderInstance = class _CliProviderInstance {
47887
48322
  latestVisibleStatus: args.latestVisibleStatus,
47888
48323
  parsedStatus: typeof parsed?.status === "string" ? parsed.status : parseError ? "parse_error" : "unknown",
47889
48324
  parseError: parseError || void 0,
47890
- finalAssistantPresent: evidence.present,
47891
- finalAssistantEvidenceSource: evidence.source,
48325
+ finalAssistantPresent,
48326
+ finalAssistantFromCachedSummary: !evidence.present && cachedSummary.length > 0,
48327
+ finalAssistantEvidenceSource,
47892
48328
  visibleMessageCount: visibleMessages.length,
47893
48329
  lastVisibleRole,
47894
48330
  lastVisibleKind,
@@ -48066,6 +48502,128 @@ var CliProviderInstance = class _CliProviderInstance {
48066
48502
  isMeshWorkerSession() {
48067
48503
  return !!(this.settings.meshNodeFor || this.settings.meshActiveTaskId || this.settings.meshNodeId || this.settings.launchedByCoordinator);
48068
48504
  }
48505
+ /**
48506
+ * MESH-READ-TERMINAL (feature 2: RAW terminal read). Public read of the
48507
+ * CURRENT rendered PTY viewport for the mesh_read_terminal tool, delegating to
48508
+ * the adapter's narrow getTerminalScreenSnapshot() (viewport + cursor + size
48509
+ * only; no debug buffers / parser state / history; byte-bounded, bottom-tail
48510
+ * preserved).
48511
+ *
48512
+ * Gated on isMeshWorkerSession(): this raw viewport can expose tokens /
48513
+ * command args / env / user data, so only a coordinator-spawned worker session
48514
+ * is readable. The MCP layer ALSO cross-checks mesh/session/node ownership
48515
+ * (isMeshOwnedDelegateSession) — isMeshWorkerSession alone is a broad
48516
+ * "delegated" gate, so the two together block cross-mesh access. Returns null
48517
+ * for a non-mesh session so the daemon command surfaces a clean refusal.
48518
+ */
48519
+ getTerminalScreenSnapshot(maxBytes) {
48520
+ if (!this.isMeshWorkerSession()) return null;
48521
+ if (typeof this.adapter.getTerminalScreenSnapshot !== "function") return null;
48522
+ return this.adapter.getTerminalScreenSnapshot(maxBytes);
48523
+ }
48524
+ /**
48525
+ * MESH-SEND-KEYS (feature 3: key injection). Public entry for the
48526
+ * mesh_send_keys tool, delegating to the adapter's injectKeys() (structured
48527
+ * key encoding + atomic write + submit-race recheck + modal fail-closed).
48528
+ *
48529
+ * Gated on isMeshWorkerSession(): PTY input into a worker is a
48530
+ * coordinator-only capability. The MCP layer ALSO cross-checks mesh/session/
48531
+ * node ownership (isMeshOwnedDelegateSession) and owns the destructive-key
48532
+ * double gate (confirm_destructive + policy) and the audit ledger. Returns a
48533
+ * refusal object for a non-mesh session so the daemon command surfaces a clean
48534
+ * error (never silently writes to a non-worker PTY).
48535
+ */
48536
+ async injectKeys(items, opts = {}) {
48537
+ if (!this.isMeshWorkerSession()) {
48538
+ return { ok: false, refused: "not_mesh_worker", keys: [], hasDestructive: false };
48539
+ }
48540
+ if (typeof this.adapter.injectKeys !== "function") {
48541
+ return { ok: false, refused: "unsupported", keys: [], hasDestructive: false };
48542
+ }
48543
+ return this.adapter.injectKeys(items, opts);
48544
+ }
48545
+ /**
48546
+ * MESH-STALL-WATCH (feature 1: STALL detection). Status-agnostic stall
48547
+ * watchdog for coordinator-spawned mesh worker sessions. Driven by the
48548
+ * ProviderInstanceManager's existing 5s onTick loop (NO new timer) — see
48549
+ * ProviderInstanceManager.startTicking. Reuses the adapter's raw-PTY-output
48550
+ * clock (lastOutputAt, bumped on every output chunk) as the sole signal: if a
48551
+ * live worker's screen has been byte-for-byte unchanged for
48552
+ * MESH_WORKER_STALL_THRESHOLD_MS (180s), fire ONE informational
48553
+ * monitor:no_progress event down the existing task_stalled ledger +
48554
+ * pendingCoordinatorEvent path.
48555
+ *
48556
+ * Deliberately status-agnostic: it does NOT read getStatus()'s reported status
48557
+ * (which would couple it to the generating-only StatusMonitor and the
48558
+ * idle-timeout FSM). A normally-idle worker CAN trip this after 3 quiet
48559
+ * minutes; that is accepted and surfaced as an informational stall (NOT a
48560
+ * failure/auto-restart) so the coordinator judges. getStatus/getState are NOT
48561
+ * called here, so status heartbeats never move the stall anchor.
48562
+ *
48563
+ * Anchoring: the episode arms against the current lastOutputAt; a worker that
48564
+ * has emitted nothing yet (lastOutputAt === 0) anchors on this.startedAt (spawn
48565
+ * time) so a silent spawn is still caught. Any new output re-arms the anchor
48566
+ * and clears the emitted flag, so one continuous stall emits at most once and a
48567
+ * later stall re-arms cleanly.
48568
+ */
48569
+ checkMeshWorkerStall(now = Date.now()) {
48570
+ if (!this.isMeshWorkerSession()) {
48571
+ this.meshStallAnchorAt = -1;
48572
+ this.meshStallEmittedForAnchor = false;
48573
+ return;
48574
+ }
48575
+ if (typeof this.adapter.isAlive === "function" && !this.adapter.isAlive()) {
48576
+ this.meshStallAnchorAt = -1;
48577
+ this.meshStallEmittedForAnchor = false;
48578
+ return;
48579
+ }
48580
+ let lastOutputAt;
48581
+ try {
48582
+ const status = this.adapter.getStatus({ allowParse: false });
48583
+ lastOutputAt = typeof status?.lastOutputAt === "number" && Number.isFinite(status.lastOutputAt) ? status.lastOutputAt : 0;
48584
+ } catch {
48585
+ return;
48586
+ }
48587
+ const anchor = lastOutputAt > 0 ? lastOutputAt : this.startedAt;
48588
+ if (this.meshStallAnchorAt === -1) {
48589
+ this.meshStallAnchorAt = anchor;
48590
+ this.meshStallEmittedForAnchor = false;
48591
+ return;
48592
+ }
48593
+ if (anchor > this.meshStallAnchorAt) {
48594
+ this.meshStallAnchorAt = anchor;
48595
+ this.meshStallEmittedForAnchor = false;
48596
+ return;
48597
+ }
48598
+ if (this.meshStallEmittedForAnchor) return;
48599
+ const stalledMs = now - this.meshStallAnchorAt;
48600
+ if (stalledMs < _CliProviderInstance.MESH_WORKER_STALL_THRESHOLD_MS) return;
48601
+ this.meshStallEmittedForAnchor = true;
48602
+ let observedStatus = "unknown";
48603
+ try {
48604
+ const s2 = this.adapter.getStatus({ allowParse: false })?.status;
48605
+ if (typeof s2 === "string" && s2) observedStatus = s2;
48606
+ } catch {
48607
+ }
48608
+ if (this.isMeshWorkerSession()) {
48609
+ traceMeshEventStage("fired", this.meshTraceCtx("monitor:no_progress"), "mesh_worker_stall_watchdog");
48610
+ }
48611
+ const stalledSec = Math.round(stalledMs / 1e3);
48612
+ this.pushEvent({
48613
+ event: "monitor:no_progress",
48614
+ agentKey: `${this.type}:cli`,
48615
+ elapsedSec: stalledSec,
48616
+ timestamp: now,
48617
+ // MESH-STALL-WATCH marker: buildMeshSystemMessage generalizes the
48618
+ // coordinator message (generating-specific → "PTY output unchanged")
48619
+ // when this is set, since this watchdog fires status-agnostically.
48620
+ meshWorkerStall: true,
48621
+ lastOutputAt: this.meshStallAnchorAt,
48622
+ stalledMs,
48623
+ observedStatus,
48624
+ taskId: this.completingTurnTaskId()
48625
+ });
48626
+ }
48069
48627
  /**
48070
48628
  * AUTOAPPROVE-FLAP-RECUR (Fix A+B): how long a busy blip / modal scroll-out may
48071
48629
  * persist before the in-progress settle gate is torn down. For a delegated
@@ -48371,7 +48929,13 @@ var CliProviderInstance = class _CliProviderInstance {
48371
48929
  // delegated session's inbox preview blank — or, for a LOCAL worktree session,
48372
48930
  // stuck on the dispatched user task. If the parser DID surface assistant text,
48373
48931
  // prefer it; only fall back to '' when no assistant summary can be derived.
48374
- finalSummary: blockReason.startsWith("parsed_status:") ? this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) ?? "" : this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt),
48932
+ // NOTIF Defect-B: completionFinalSummary is a point-sample of native-history/
48933
+ // screen at THIS instant; on a native-source provider (antigravity) it can be
48934
+ // empty at the forced-emit instant even though a prior poll already cached the
48935
+ // real answer (lastCompletionSummary). Fall back to the cache so the notification
48936
+ // carries the summary that mesh_read_chat.summary already shows — consistent with
48937
+ // completionDiagnostic.finalAssistantPresent being credited from the same cache.
48938
+ finalSummary: this.completionFinalSummary(this.adapter?.getScriptParsedStatus()?.messages, pending.turnStartedAt) || this.cachedCompletionSummaryContent() || (blockReason.startsWith("parsed_status:") ? "" : void 0),
48375
48939
  completionDiagnostic
48376
48940
  });
48377
48941
  this.completedDebouncePending = null;
@@ -49027,6 +49591,10 @@ ${buttons.join("\n")}`;
49027
49591
  this.completedDebouncePending = null;
49028
49592
  continue;
49029
49593
  }
49594
+ if (me.type === "monitor:no_progress" && this.isMeshWorkerSession()) {
49595
+ traceMeshEventDrop("mesh_worker_stall_watchdog_owns_no_progress", this.meshTraceCtx("monitor:no_progress"));
49596
+ continue;
49597
+ }
49030
49598
  this.pushEvent({ event: me.type, agentKey: me.agentKey, message: me.message, elapsedSec: me.elapsedSec, timestamp: me.timestamp });
49031
49599
  }
49032
49600
  }
@@ -60068,6 +60636,150 @@ ${e?.stderr || ""}`
60068
60636
  };
60069
60637
  }
60070
60638
  }
60639
+ async function classifyPatchEquivalenceFailure(repoRoot, baseHead, branchHead, summary, options = {}) {
60640
+ const targetBaseRef = options.targetBaseRef || baseHead;
60641
+ const autoPublish = options.autoPublishSubmoduleMainCommits;
60642
+ const evidence = {
60643
+ baseHead,
60644
+ branchHead,
60645
+ mergeBase: summary.mergeBase,
60646
+ expectedPatchId: summary.expectedPatchId,
60647
+ actualPatchId: summary.actualPatchId,
60648
+ patchIdEqual: !!summary.expectedPatchId && summary.expectedPatchId === summary.actualPatchId,
60649
+ ...autoPublish !== void 0 ? { autoPublishSubmoduleMainCommits: autoPublish } : {}
60650
+ };
60651
+ try {
60652
+ const git = (args) => execFileSync7(GIT2, args, {
60653
+ cwd: repoRoot,
60654
+ encoding: "utf8",
60655
+ maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES,
60656
+ windowsHide: true
60657
+ });
60658
+ const gitOk = (args) => {
60659
+ try {
60660
+ git(args);
60661
+ return true;
60662
+ } catch {
60663
+ return false;
60664
+ }
60665
+ };
60666
+ let ahead = 0;
60667
+ let behind = 0;
60668
+ try {
60669
+ const out = git(["rev-list", "--left-right", "--count", `${targetBaseRef}...${branchHead}`]).trim();
60670
+ const [left, right] = out.split(/\s+/).map((n) => Number.parseInt(n, 10));
60671
+ behind = Number.isFinite(left) ? left : 0;
60672
+ ahead = Number.isFinite(right) ? right : 0;
60673
+ } catch {
60674
+ }
60675
+ evidence.ahead = ahead;
60676
+ evidence.behind = behind;
60677
+ const baseIsAncestor = gitOk(["merge-base", "--is-ancestor", targetBaseRef, branchHead]);
60678
+ evidence.baseDiverged = !baseIsAncestor;
60679
+ let diffStat = "";
60680
+ try {
60681
+ if (summary.mergedTree) {
60682
+ diffStat = git(["diff", "--stat", baseHead, summary.mergedTree]).trim().split("\n").filter(Boolean).slice(-1)[0] || "";
60683
+ } else {
60684
+ diffStat = git(["diff", "--stat", baseHead, branchHead]).trim().split("\n").filter(Boolean).slice(-1)[0] || "";
60685
+ }
60686
+ } catch {
60687
+ }
60688
+ if (diffStat) evidence.diffStat = diffStat;
60689
+ const submoduleGitlinks = [];
60690
+ try {
60691
+ const nameStatus = git(["diff", "--name-only", "--diff-filter=d", baseHead, branchHead]).trim();
60692
+ const changedPaths = nameStatus ? nameStatus.split("\n").map((p) => p.trim()).filter(Boolean) : [];
60693
+ for (const p of changedPaths) {
60694
+ let baseCommit;
60695
+ let branchCommit;
60696
+ try {
60697
+ const baseLs = git(["ls-tree", baseHead, "--", p]).trim();
60698
+ const branchLs = git(["ls-tree", branchHead, "--", p]).trim();
60699
+ const isGitlink = /(^|\s)160000\s/.test(baseLs) || /(^|\s)160000\s/.test(branchLs);
60700
+ if (!isGitlink) continue;
60701
+ baseCommit = baseLs.split(/\s+/)[2];
60702
+ branchCommit = branchLs.split(/\s+/)[2];
60703
+ } catch {
60704
+ continue;
60705
+ }
60706
+ const submoduleRepo = pathJoin2(repoRoot, p);
60707
+ let fastForward;
60708
+ let reachableFromOriginMain;
60709
+ if (branchCommit) {
60710
+ if (baseCommit) {
60711
+ fastForward = execGitOk(submoduleRepo, ["merge-base", "--is-ancestor", baseCommit, branchCommit]);
60712
+ }
60713
+ reachableFromOriginMain = execGitOk(submoduleRepo, ["merge-base", "--is-ancestor", branchCommit, "refs/remotes/origin/main"]);
60714
+ }
60715
+ submoduleGitlinks.push({ path: p, baseCommit, branchCommit, fastForward, reachableFromOriginMain });
60716
+ }
60717
+ } catch {
60718
+ }
60719
+ if (submoduleGitlinks.length) evidence.submoduleGitlinks = submoduleGitlinks;
60720
+ const gitlinkFf = summary.gitlinkTrivialFastForward;
60721
+ const noResidualDiff = !evidence.diffStat && (!summary.actualPatchId || summary.actualPatchId === "");
60722
+ if (ahead === 0 && behind === 0 && noResidualDiff) {
60723
+ return {
60724
+ detailedReason: "already_converged",
60725
+ detailedReasonDescription: "Branch is already identical to the target base (ahead 0, behind 0, no residual diff); the merge would be a no-op.",
60726
+ 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).",
60727
+ evidence
60728
+ };
60729
+ }
60730
+ const unreachable = submoduleGitlinks.filter((g) => g.reachableFromOriginMain === false);
60731
+ if (unreachable.length > 0) {
60732
+ const paths = unreachable.map((g) => g.path).join(", ");
60733
+ return {
60734
+ detailedReason: "submodule_unreachable",
60735
+ detailedReasonDescription: `Submodule gitlink commit(s) not reachable from submodule origin/main (publish needed): ${paths}.`,
60736
+ recommendedAction: `Publish the submodule commit(s) to submodule origin/main, then retry mesh_refine_node (policy allowAutoPublishSubmoduleMainCommits=${autoPublish === void 0 ? "unknown" : autoPublish}).`,
60737
+ evidence
60738
+ };
60739
+ }
60740
+ const changedGitlinks = submoduleGitlinks.length > 0;
60741
+ const allGitlinksFf = changedGitlinks && submoduleGitlinks.every((g) => g.fastForward === true);
60742
+ const gateSawUnresolvedGitlinkFf = gitlinkFf?.resolved === false && Array.isArray(gitlinkFf.gitlinks) && gitlinkFf.gitlinks.some((g) => g.fastForward);
60743
+ if (baseIsAncestor && (evidence.patchIdEqual || allGitlinksFf || gateSawUnresolvedGitlinkFf)) {
60744
+ return {
60745
+ detailedReason: "trivial_ff_misjudgment",
60746
+ 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.",
60747
+ 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.",
60748
+ evidence
60749
+ };
60750
+ }
60751
+ if (!baseIsAncestor) {
60752
+ return {
60753
+ detailedReason: "base_divergence",
60754
+ detailedReasonDescription: `Worktree base has diverged from ${targetBaseRef} (HEAD is not a descendant; ahead ${ahead}, behind ${behind}).`,
60755
+ recommendedAction: `Rebase the branch onto ${targetBaseRef}, then retry mesh_refine_node.`,
60756
+ evidence
60757
+ };
60758
+ }
60759
+ return {
60760
+ detailedReason: "actual_patch_diff",
60761
+ detailedReasonDescription: "The merge introduces content not equivalent to the branch's cumulative patch (expected tree vs actual merge diff differ).",
60762
+ recommendedAction: "Manual review required \u2014 inspect the residual diff; the branch content is not patch-equivalent to a clean merge onto the base.",
60763
+ evidence
60764
+ };
60765
+ } catch (e) {
60766
+ evidence.classifierError = e?.message || String(e);
60767
+ return {
60768
+ detailedReason: "unclassified",
60769
+ detailedReasonDescription: "Patch-equivalence sub-cause could not be classified (git inspection failed); see classifierError.",
60770
+ recommendedAction: "Inspect the refineStages and patchEquivalence summary manually to determine the cause.",
60771
+ evidence
60772
+ };
60773
+ }
60774
+ }
60775
+ function execGitOk(cwd, args) {
60776
+ try {
60777
+ execFileSync7(GIT2, args, { cwd, encoding: "utf8", maxBuffer: REFINE_PATCH_EQUIVALENCE_OUTPUT_LIMIT_BYTES, windowsHide: true });
60778
+ return true;
60779
+ } catch {
60780
+ return false;
60781
+ }
60782
+ }
60071
60783
  async function checkWorktreeChangesPatchEquivalentInRef(repoRoot, ref, worktreeHead) {
60072
60784
  const startedAt = Date.now();
60073
60785
  try {
@@ -61387,9 +62099,23 @@ async function refineSyncBaseStage(self, ctx) {
61387
62099
  error: submoduleHintPatchEquivalence.error,
61388
62100
  actionableHint: submoduleHintPatchEquivalence.actionableHint
61389
62101
  });
62102
+ const classification = await classifyPatchEquivalenceFailure(
62103
+ repoRoot,
62104
+ baseHead,
62105
+ ctx.branchHead,
62106
+ submoduleHintPatchEquivalence,
62107
+ {
62108
+ targetBaseRef: baseHead,
62109
+ autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(ctx.mesh, node.workspace).enabled
62110
+ }
62111
+ );
61390
62112
  return { kind: "terminal", result: {
61391
62113
  success: false,
61392
62114
  code: "patch_equivalence_failed",
62115
+ detailedReason: classification.detailedReason,
62116
+ detailedReasonDescription: classification.detailedReasonDescription,
62117
+ recommendedAction: classification.recommendedAction,
62118
+ evidence: classification.evidence,
61393
62119
  convergenceStatus: "blocked_review",
61394
62120
  error: "Refinery patch-equivalence preflight failed (submodule gitlink conflict); merge/refine was not attempted.",
61395
62121
  branch,
@@ -61529,7 +62255,7 @@ ${tail}` : ""
61529
62255
  return { kind: "continue", ctx };
61530
62256
  }
61531
62257
  async function refinePatchEquivalenceStage(self, ctx) {
61532
- const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, validationSummary, refineStages } = ctx;
62258
+ const { meshId, nodeId, args, repoRoot, baseHead, branch, baseBranch, mesh, node, validationSummary, refineStages } = ctx;
61533
62259
  const branchHead = ctx.branchHead;
61534
62260
  const patchEquivalenceStarted = Date.now();
61535
62261
  const patchEquivalence = await runMeshRefinePatchEquivalenceGate(repoRoot, baseHead, branchHead);
@@ -61543,9 +62269,27 @@ async function refinePatchEquivalenceStage(self, ctx) {
61543
62269
  if (!patchEquivalence.equivalent) {
61544
62270
  const alreadyMergedViaOtherPath = !patchEquivalence.actualPatchId && !!patchEquivalence.expectedPatchId;
61545
62271
  if (!alreadyMergedViaOtherPath) {
62272
+ const classification = await classifyPatchEquivalenceFailure(
62273
+ repoRoot,
62274
+ baseHead,
62275
+ branchHead,
62276
+ patchEquivalence,
62277
+ {
62278
+ targetBaseRef: baseHead,
62279
+ autoPublishSubmoduleMainCommits: resolveRefineryAutoPublishSubmoduleMainCommits(mesh, node.workspace).enabled
62280
+ }
62281
+ );
62282
+ recordMeshRefineStage(refineStages, "patch_equivalence_classification", "failed", patchEquivalenceStarted, {
62283
+ detailedReason: classification.detailedReason,
62284
+ recommendedAction: classification.recommendedAction
62285
+ });
61546
62286
  return { kind: "terminal", result: {
61547
62287
  success: false,
61548
62288
  code: "patch_equivalence_failed",
62289
+ detailedReason: classification.detailedReason,
62290
+ detailedReasonDescription: classification.detailedReasonDescription,
62291
+ recommendedAction: classification.recommendedAction,
62292
+ evidence: classification.evidence,
61549
62293
  convergenceStatus: "blocked_review",
61550
62294
  error: "Refinery patch-equivalence preflight failed; merge/refine was not attempted.",
61551
62295
  branch,
@@ -62652,6 +63396,10 @@ async function finishMeshRefineJob(self, handle, args) {
62652
63396
  };
62653
63397
  if (typeof result.error === "string") ctx.error = result.error;
62654
63398
  if (typeof result.blockedReason === "string") ctx.blockedReason = result.blockedReason;
63399
+ if (typeof result.detailedReason === "string") ctx.detailedReason = result.detailedReason;
63400
+ if (typeof result.detailedReasonDescription === "string") ctx.detailedReasonDescription = result.detailedReasonDescription;
63401
+ if (typeof result.recommendedAction === "string") ctx.recommendedAction = result.recommendedAction;
63402
+ if (result.evidence && typeof result.evidence === "object") ctx.evidence = result.evidence;
62655
63403
  if (stage === "patch_equivalence" && result.patchEquivalence) {
62656
63404
  const pe = result.patchEquivalence;
62657
63405
  ctx.details = {
@@ -62659,7 +63407,10 @@ async function finishMeshRefineJob(self, handle, args) {
62659
63407
  actualPatchId: pe.actualPatchId,
62660
63408
  status: pe.status,
62661
63409
  actionableHint: pe.actionableHint,
62662
- error: pe.error
63410
+ error: pe.error,
63411
+ ...typeof result.detailedReason === "string" ? { detailedReason: result.detailedReason } : {},
63412
+ ...typeof result.recommendedAction === "string" ? { recommendedAction: result.recommendedAction } : {},
63413
+ ...result.evidence && typeof result.evidence === "object" ? { evidence: result.evidence } : {}
62663
63414
  };
62664
63415
  }
62665
63416
  if (stage === "submodule_reachability" && Array.isArray(result.unreachableSubmoduleCommits)) {
@@ -63629,7 +64380,22 @@ var MESH_FORWARDABLE_SESSION_COMMANDS = /* @__PURE__ */ new Set([
63629
64380
  // worker session that reaches the wrong daemon used to fuzzy-inject the task body into that
63630
64381
  // daemon's own CLI session (TASKECHO coordinator self-echo). Forwarding to the owning daemon
63631
64382
  // delivers it to the real worker instead. (findAdapter is also fail-closed as the backstop.)
63632
- "agent_command"
64383
+ "agent_command",
64384
+ // read_terminal (MESH-READ-TERMINAL feature 2): mesh_read_terminal reads the CURRENT
64385
+ // rendered PTY viewport of a specific worker session. The live viewport lives ONLY on the
64386
+ // OWNING session's adapter, so when the target is a REMOTE worker the coordinator has no
64387
+ // local instance and the handler would return 'Session not found' — the exact
64388
+ // remote-worker forwarding gap of mission 6938892f. Forward it to the owning worker daemon
64389
+ // so it reads its own live screen. (It is read-only; unlike the mutations above it makes no
64390
+ // state change, but it is session-scoped identically and must reach the owning daemon.)
64391
+ "read_terminal",
64392
+ // send_keys (MESH-SEND-KEYS feature 3): mesh_send_keys injects a structured key sequence
64393
+ // into a specific worker session's PTY. The live PTY lives ONLY on the OWNING session's
64394
+ // adapter, so a remote-worker target must be forwarded to the owning daemon or the handler
64395
+ // returns 'Session not found' (same class as mission 6938892f). Unlike read_terminal this
64396
+ // MUTATES the worker PTY, so forwarding to the real owner (not a wrong local session) is
64397
+ // doubly important. The daemon re-enforces the destructive-key confirm gate after the forward.
64398
+ "send_keys"
63633
64399
  ]);
63634
64400
  function normalizeCommandSource(source) {
63635
64401
  switch (source) {
@@ -65774,7 +66540,13 @@ var ProviderInstanceManager = class {
65774
66540
  if (this.tickTimer) return;
65775
66541
  this.tickInterval = intervalMs || this.tickInterval;
65776
66542
  this.tickTimer = setInterval(async () => {
66543
+ const now = Date.now();
65777
66544
  for (const [id, instance] of this.instances) {
66545
+ try {
66546
+ instance.checkMeshWorkerStall?.(now);
66547
+ } catch (e) {
66548
+ LOG.warn("InstanceMgr", `[InstanceManager] Mesh stall check failed for ${id}: ${e.message}`);
66549
+ }
65778
66550
  try {
65779
66551
  await instance.onTick();
65780
66552
  } catch (e) {
@@ -72920,6 +73692,7 @@ export {
72920
73692
  resetConfig,
72921
73693
  resetDebugRuntimeConfig,
72922
73694
  resetState,
73695
+ resolveAllowSendKeysDestructive,
72923
73696
  resolveAutoConvergeCodeChange,
72924
73697
  resolveChatMessageKind,
72925
73698
  resolveConvergeRequiredTags,