wayang-ai 0.9.3 → 0.9.4

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/cli.js CHANGED
@@ -1457,7 +1457,7 @@ function validateWorkerEntry(id, worker) {
1457
1457
  return `${at}.emoji must be a string (got ${JSON.stringify(worker.emoji)})`;
1458
1458
  }
1459
1459
  if (type === "claude-code") {
1460
- for (const key of ["command", "args", "env", "protocol", "trustUpdatesAfterCancel"]) {
1460
+ for (const key of ["command", "args", "env", "protocol", "trustUpdatesAfterCancel", "mcp"]) {
1461
1461
  if (worker[key] !== void 0) {
1462
1462
  return `${at}.${key} is not supported for type "claude-code" (claude-code uses cliPath)`;
1463
1463
  }
@@ -1473,6 +1473,22 @@ function validateWorkerEntry(id, worker) {
1473
1473
  if (worker.trustUpdatesAfterCancel !== void 0 && typeof worker.trustUpdatesAfterCancel !== "boolean") {
1474
1474
  return `${at}.trustUpdatesAfterCancel must be a boolean (got ${JSON.stringify(worker.trustUpdatesAfterCancel)})`;
1475
1475
  }
1476
+ const mcp = worker.mcp;
1477
+ if (mcp !== void 0) {
1478
+ if (typeof mcp !== "object" || mcp === null || Array.isArray(mcp)) {
1479
+ return `${at}.mcp must be an object (got ${JSON.stringify(mcp)})`;
1480
+ }
1481
+ const record = mcp;
1482
+ for (const key of Object.keys(record)) {
1483
+ if (key !== "enabled" && key !== "fallbackMs") return `${at}.mcp.${key} is not a known option (allowed: enabled, fallbackMs)`;
1484
+ }
1485
+ if (record.enabled !== void 0 && typeof record.enabled !== "boolean") {
1486
+ return `${at}.mcp.enabled must be a boolean (got ${JSON.stringify(record.enabled)})`;
1487
+ }
1488
+ if (record.fallbackMs !== void 0 && !(typeof record.fallbackMs === "number" && Number.isFinite(record.fallbackMs) && record.fallbackMs > 0)) {
1489
+ return `${at}.mcp.fallbackMs must be a positive number (got ${JSON.stringify(record.fallbackMs)})`;
1490
+ }
1491
+ }
1476
1492
  if (worker.args !== void 0 && (!Array.isArray(worker.args) || worker.args.some((a) => typeof a !== "string"))) {
1477
1493
  return `${at}.args must be an array of strings (got ${JSON.stringify(worker.args)})`;
1478
1494
  }
@@ -5694,7 +5710,7 @@ function AppContent() {
5694
5710
  // package.json
5695
5711
  var package_default = {
5696
5712
  name: "wayang-ai",
5697
- version: "0.9.3",
5713
+ version: "0.9.4",
5698
5714
  description: "Multi-agent orchestration platform",
5699
5715
  type: "module",
5700
5716
  license: "MIT",
@@ -8051,7 +8067,8 @@ function buildWorkerSystemPrompt(ctx, skills, workspaceDir2) {
8051
8067
  var RULES = [
8052
8068
  "<Rules>",
8053
8069
  "Output plain text only. No Markdown formatting.",
8054
- "Report meaningful progress as you work (entering a new phase, completing a sub-step, recovering from an error).",
8070
+ "Report meaningful progress as you work (entering a new phase, completing a sub-step, recovering from an error). When an update_progress tool is available it is the primary channel \u2014 call it with a concise message; plain assistant text is only a degraded fallback.",
8071
+ "If no update_progress is reported for 2 minutes, the system reports your latest complete reply automatically (capped at 20000 characters), so keep reporting progress yourself to stay in control of what the controller sees.",
8055
8072
  "Do NOT output a final summary right before completing \u2014 the system already captures the result separately.",
8056
8073
  "Match the language of the task description.",
8057
8074
  "Keep scratch files and intermediate artifacts in `.agents/tmp/` inside the task workspace \u2014 never `/tmp`, the home directory, or the repository root.",
@@ -12389,7 +12406,7 @@ var AcpClient = class {
12389
12406
  () => new AcpError("agent-error", `authenticate(${methodId}): no response within ${this.timeouts.handshakeMs}ms`)
12390
12407
  );
12391
12408
  }
12392
- /** mcpServers is required by the v1 schema; 0.9.0 always passes []. Unknown response fields are ignored. */
12409
+ /** mcpServers is required by the v1 schema; the worker layer passes its injection (or [] on degrade). Unknown response fields are ignored. */
12393
12410
  async sessionNew(input2) {
12394
12411
  const raw = await this.rpcRequest(
12395
12412
  "session/new",
@@ -12658,10 +12675,7 @@ function mapSessionUpdate(update, acc) {
12658
12675
  const { content, messageId } = update;
12659
12676
  const text = content && content.type === "text" ? content.text : "";
12660
12677
  if (!text) return [{ kind: "ignored", variant, reason: "empty-chunk" }];
12661
- return [
12662
- { kind: "agent-text", text, messageId: messageId ?? null },
12663
- { kind: "progress", text }
12664
- ];
12678
+ return [{ kind: "agent-text", text, messageId: messageId ?? null }];
12665
12679
  }
12666
12680
  if (variant === "agent_thought_chunk") {
12667
12681
  return [{ kind: "ignored", variant, reason: "thought" }];
@@ -12709,6 +12723,48 @@ function mapSessionUpdate(update, acc) {
12709
12723
  return [{ kind: "ignored", variant, reason: "unknown-variant" }];
12710
12724
  }
12711
12725
 
12726
+ // src/services/agents/acp/mcp-schemas.ts
12727
+ var MCP_SERVER_NAME = "wayang";
12728
+ var MCP_PROTOCOL_VERSION = "2025-06-18";
12729
+ var MAX_PROGRESS_CHARS = 2e4;
12730
+ var MAX_QUESTION_CHARS = 500;
12731
+ var MAX_CONTEXT_CHARS = 1e3;
12732
+ var TRUNCATION_MARKER = "\n\u2026[truncated]";
12733
+ var PROGRESS_FALLBACK_MS = 6e4;
12734
+ var MCP_PROGRESS_TIMEOUT_MS = 5e3;
12735
+ var MCP_ASK_TIMEOUT_MS = 31e4;
12736
+ var MCP_TOOLS = [
12737
+ {
12738
+ name: "update_progress",
12739
+ description: "Report progress for the current task. This is the PRIMARY progress channel: call it when entering a new phase, finishing a sub-deliverable, recovering from an error. Do not rely on session/update text \u2014 it is only a degraded fallback.",
12740
+ inputSchema: {
12741
+ type: "object",
12742
+ properties: {
12743
+ message: { type: "string", maxLength: MAX_PROGRESS_CHARS, description: "Progress description" }
12744
+ },
12745
+ required: ["message"],
12746
+ additionalProperties: false
12747
+ }
12748
+ },
12749
+ {
12750
+ name: "ask_controller",
12751
+ description: "Ask the controller a question and WAIT for the answer. Use ONLY when blocked: execution cannot continue as instructed (contradiction, missing resource, unexpected state), or continuing would risk significant wasted work. Do NOT use it for decisions you can make yourself \u2014 decide those autonomously and report via update_progress.",
12752
+ inputSchema: {
12753
+ type: "object",
12754
+ properties: {
12755
+ question: { type: "string", maxLength: MAX_QUESTION_CHARS, description: "The specific question you need answered to proceed. Be concise and self-contained." },
12756
+ context: { type: "string", maxLength: MAX_CONTEXT_CHARS, description: "What you have tried and where you are stuck, so the controller can answer precisely." }
12757
+ },
12758
+ required: ["question"],
12759
+ additionalProperties: false
12760
+ }
12761
+ }
12762
+ ];
12763
+ var MCP_TOOL_NAMES = MCP_TOOLS.map((tool2) => tool2.name);
12764
+ function capText(text, max = MAX_PROGRESS_CHARS) {
12765
+ return text.length > max ? `${text.slice(0, max)}${TRUNCATION_MARKER}` : text;
12766
+ }
12767
+
12712
12768
  // src/services/agents/acp/permission-bridge.ts
12713
12769
  var ACP_PERMISSION_TIMEOUT_MS = 3e5;
12714
12770
  function pathFromSnapshot(snapshot) {
@@ -12733,7 +12789,37 @@ function commandFromSnapshot(snapshot) {
12733
12789
  if (Array.isArray(cmd)) return cmd.every((part) => typeof part === "string") ? cmd.join(" ") : void 0;
12734
12790
  return void 0;
12735
12791
  }
12792
+ var SAFE_WAYANG_MCP_TOOLS = /* @__PURE__ */ new Set([
12793
+ "update_progress",
12794
+ "ask_controller"
12795
+ ]);
12796
+ function mcpToolIdentity(name) {
12797
+ const lower = name.trim().toLowerCase();
12798
+ if (lower.startsWith("mcp__")) {
12799
+ const rest = lower.slice("mcp__".length);
12800
+ const sep2 = rest.lastIndexOf("__");
12801
+ if (sep2 <= 0 || sep2 + 2 >= rest.length) return void 0;
12802
+ return { server: rest.slice(0, sep2), tool: rest.slice(sep2 + 2) };
12803
+ }
12804
+ const qualified = /^([a-z0-9_]+)[.-]([a-z0-9_]+)$/.exec(lower);
12805
+ return qualified ? { server: qualified[1], tool: qualified[2] } : void 0;
12806
+ }
12807
+ function safeOwnMcpTool(snapshot) {
12808
+ const raw = snapshot.rawInput;
12809
+ const declared = raw && typeof raw === "object" && !Array.isArray(raw) ? raw.toolName : void 0;
12810
+ for (const candidate of [snapshot.name, declared]) {
12811
+ if (typeof candidate !== "string" || candidate.length === 0) continue;
12812
+ const identity = mcpToolIdentity(candidate);
12813
+ if (!identity || identity.server !== MCP_SERVER_NAME) continue;
12814
+ const tool2 = identity.tool;
12815
+ if (SAFE_WAYANG_MCP_TOOLS.has(tool2)) return tool2;
12816
+ }
12817
+ return void 0;
12818
+ }
12736
12819
  function classifyAcpToolCall(snapshot) {
12820
+ if (safeOwnMcpTool(snapshot) !== void 0) {
12821
+ return { type: "direct-allow", reason: "wayang MCP controller-facing tool" };
12822
+ }
12737
12823
  const kind = snapshot.kind;
12738
12824
  if (kind === "fetch" || kind === "think" || kind === "switch_mode") {
12739
12825
  return { type: "direct-allow", reason: `non-path kind "${kind}"` };
@@ -13033,6 +13119,14 @@ var AcpTurnWriter = class {
13033
13119
  lastText() {
13034
13120
  return this.lastFlushedText;
13035
13121
  }
13122
+ /**
13123
+ * Read-only view of the OPEN text segment: every `agent-text` chunk the run
13124
+ * has aggregated since the last flush. This is the single text source for
13125
+ * progress reporting (the degraded path must never keep a second copy).
13126
+ */
13127
+ openText() {
13128
+ return this.openTextParts.join("");
13129
+ }
13036
13130
  /**
13037
13131
  * Buffer a tool call. A same-id record (running → terminal evolution of one
13038
13132
  * call) replaces the previous one inside its owning step instead of piling
@@ -13418,9 +13512,10 @@ var BaseAcpWorker = class {
13418
13512
  _grants = new ApprovalMemory();
13419
13513
  _approvalMemory = "task";
13420
13514
  _currentTurnEnv = null;
13421
- // --- Progress debounce ---
13422
- _progressTimer = null;
13423
- _pendingProgress = null;
13515
+ // --- Progress (update_progress is primary; the idle tracker is the net) ---
13516
+ _onProgress = null;
13517
+ /** Engine-backed inquiry channel, exposed to the agent as ask_controller. */
13518
+ _askController = null;
13424
13519
  // --- ACP runtime (per run) ---
13425
13520
  _accumulator = createAcpToolAccumulator();
13426
13521
  _turnWriter;
@@ -13558,6 +13653,14 @@ var BaseAcpWorker = class {
13558
13653
  this._grants = deps.grants ?? new ApprovalMemory();
13559
13654
  this._approvalMemory = deps.approvalMemory ?? "task";
13560
13655
  }
13656
+ /**
13657
+ * Wire the engine-backed ask_controller implementation. The per-run Wayang
13658
+ * MCP bridge serves it over a Unix socket (member presence is what the
13659
+ * engine's `wireWorker` probe detects, as with ClaudeCodeWorker).
13660
+ */
13661
+ setInquirySupport(ask) {
13662
+ this._askController = ask;
13663
+ }
13561
13664
  getState() {
13562
13665
  return this.state;
13563
13666
  }
@@ -13620,13 +13723,6 @@ var BaseAcpWorker = class {
13620
13723
  const prev = this.state.get("runtimeState") ?? {};
13621
13724
  this.state.set("runtimeState", { ...prev, acp: { ...prev.acp ?? {}, ...fields } });
13622
13725
  }
13623
- clearPendingProgress() {
13624
- if (this._progressTimer) {
13625
- clearTimeout(this._progressTimer);
13626
- this._progressTimer = null;
13627
- }
13628
- this._pendingProgress = null;
13629
- }
13630
13726
  };
13631
13727
 
13632
13728
  // src/services/agents/acp/acp-worker-run.ts
@@ -13670,13 +13766,678 @@ function makeTerminalResult(category, verdictReason, denials, summary, stopReaso
13670
13766
  return { status: "failed", error };
13671
13767
  }
13672
13768
 
13769
+ // src/services/agents/acp/mcp-transport.ts
13770
+ import { createServer } from "net";
13771
+ import { chmodSync, existsSync as existsSync11, mkdirSync as mkdirSync8, unlinkSync as unlinkSync4 } from "fs";
13772
+ import { dirname as dirname9, join as join18 } from "path";
13773
+ import { fileURLToPath } from "url";
13774
+ import { randomBytes, timingSafeEqual } from "crypto";
13775
+
13776
+ // src/services/agents/acp/mcp-protocol.ts
13777
+ function mcpResult(id, result2) {
13778
+ return { jsonrpc: "2.0", id, result: result2 };
13779
+ }
13780
+ function mcpError(id, code, message) {
13781
+ return { jsonrpc: "2.0", id, error: { code, message } };
13782
+ }
13783
+ function mcpToolResultPayload(call) {
13784
+ return {
13785
+ content: [{ type: "text", text: call.text }],
13786
+ ...call.isError ? { isError: true } : {}
13787
+ };
13788
+ }
13789
+ function createMcpDispatcher(invoker, serverInfo) {
13790
+ return async (frame) => {
13791
+ const method = typeof frame.method === "string" ? frame.method : "";
13792
+ if (frame.id === void 0 || frame.id === null || method.startsWith("notifications/")) return null;
13793
+ const id = frame.id;
13794
+ switch (method) {
13795
+ case "initialize":
13796
+ return mcpResult(id, {
13797
+ protocolVersion: MCP_PROTOCOL_VERSION,
13798
+ capabilities: { tools: {} },
13799
+ serverInfo
13800
+ });
13801
+ case "ping":
13802
+ return mcpResult(id, {});
13803
+ case "tools/list":
13804
+ return mcpResult(id, { tools: MCP_TOOLS });
13805
+ case "tools/call": {
13806
+ const params = frame.params ?? {};
13807
+ const name = typeof params.name === "string" ? params.name : "";
13808
+ if (!MCP_TOOL_NAMES.includes(name)) {
13809
+ return mcpError(id, -32602, `unknown tool "${name}" (available: ${MCP_TOOL_NAMES.join(", ")})`);
13810
+ }
13811
+ const args = params.arguments && typeof params.arguments === "object" ? params.arguments : {};
13812
+ try {
13813
+ const call = await invoker(name, args);
13814
+ return mcpResult(id, mcpToolResultPayload(call));
13815
+ } catch (err) {
13816
+ const message = err instanceof Error ? err.message : String(err);
13817
+ return mcpResult(id, mcpToolResultPayload({ text: `[MCP ERROR] ${message}`, isError: true }));
13818
+ }
13819
+ }
13820
+ default:
13821
+ return mcpError(id, -32601, `method not found: ${method || "(missing)"}`);
13822
+ }
13823
+ };
13824
+ }
13825
+ var DEFAULT_MCP_SERVER_INFO = { name: MCP_SERVER_NAME, version: "wayang" };
13826
+
13827
+ // src/services/agents/acp/mcp-transport.ts
13828
+ var MCP_ENV_SOCKET = "WAYANG_MCP_SOCK";
13829
+ var MCP_ENV_TOKEN = "WAYANG_MCP_TOKEN";
13830
+ var MCP_ENV_WORKER_ID = "WAYANG_MCP_WORKER_ID";
13831
+ var MCP_HANDSHAKE_TIMEOUT_MS = 1e4;
13832
+ var MCP_MAX_CONNECTIONS = 4;
13833
+ function generateMcpToken() {
13834
+ return randomBytes(32).toString("hex");
13835
+ }
13836
+ function mcpSocketPathFor(sessionDir, workerId, suffix) {
13837
+ return join18(sessionDir, ".mcp", `acp-mcp-${workerId}-${suffix}.sock`);
13838
+ }
13839
+ function resolveMcpBridgeEntryPath(importMetaUrl) {
13840
+ const dir = dirname9(fileURLToPath(importMetaUrl));
13841
+ const candidates = [
13842
+ join18(dir, "mcp-bridge-entry.js"),
13843
+ join18(dir, "services/agents/acp/mcp-bridge-entry.js"),
13844
+ join18(dir, "mcp-bridge-entry.ts")
13845
+ ];
13846
+ return candidates.find((candidate) => existsSync11(candidate)) ?? candidates[0];
13847
+ }
13848
+ function safeEqualToken(expected, actual) {
13849
+ if (typeof actual !== "string") return false;
13850
+ const a = Buffer.from(expected, "utf8");
13851
+ const b = Buffer.from(actual, "utf8");
13852
+ return a.length === b.length && timingSafeEqual(a, b);
13853
+ }
13854
+ function stopBridgeProcesses(pids, logger, workerId) {
13855
+ const alive = [...pids].filter((pid) => pid > 0 && pid !== process.pid);
13856
+ if (alive.length === 0) return;
13857
+ for (const pid of alive) {
13858
+ try {
13859
+ process.kill(pid, "SIGTERM");
13860
+ } catch {
13861
+ }
13862
+ }
13863
+ const ladder = setTimeout(() => {
13864
+ for (const pid of alive) {
13865
+ try {
13866
+ process.kill(pid, 0);
13867
+ process.kill(pid, "SIGKILL");
13868
+ } catch {
13869
+ }
13870
+ }
13871
+ }, 2e3);
13872
+ ladder.unref?.();
13873
+ logger.debug({ workerId, count: alive.length, reason: "bridge-cleanup" }, "ACP MCP bridge processes signalled");
13874
+ }
13875
+ async function startMcpHost(opts) {
13876
+ const dir = dirname9(opts.socketPath);
13877
+ mkdirSync8(dir, { recursive: true, mode: 448 });
13878
+ chmodSync(dir, 448);
13879
+ try {
13880
+ unlinkSync4(opts.socketPath);
13881
+ } catch (err) {
13882
+ if (err.code !== "ENOENT") throw err;
13883
+ }
13884
+ let closed = false;
13885
+ let ready = false;
13886
+ let resolveReady = () => void 0;
13887
+ const readyPromise = new Promise((resolve15) => {
13888
+ resolveReady = resolve15;
13889
+ });
13890
+ const connections = /* @__PURE__ */ new Set();
13891
+ const bridgePids = /* @__PURE__ */ new Set();
13892
+ const handshakeTimer = setTimeout(() => {
13893
+ if (!closed && !ready) {
13894
+ opts.logger.warn({ workerId: opts.workerId, reason: "mcp-handshake-timeout" }, "ACP MCP bridge did not connect");
13895
+ resolveReady(false);
13896
+ }
13897
+ }, opts.handshakeTimeoutMs ?? MCP_HANDSHAKE_TIMEOUT_MS);
13898
+ handshakeTimer.unref?.();
13899
+ const dispatcher = createMcpDispatcher(
13900
+ (name, args) => opts.invoker.invoke(name, args),
13901
+ opts.serverInfo ?? DEFAULT_MCP_SERVER_INFO
13902
+ );
13903
+ const handleFrame = async (frame, socket) => {
13904
+ if (closed || typeof frame.method !== "string") return;
13905
+ try {
13906
+ const response = await dispatcher(frame);
13907
+ if (response && !socket.destroyed) {
13908
+ socket.write(`${JSON.stringify(response)}
13909
+ `);
13910
+ }
13911
+ } catch (err) {
13912
+ opts.logger.debug({ workerId: opts.workerId, err, reason: "mcp-dispatch" }, "ACP MCP request failed");
13913
+ }
13914
+ };
13915
+ const accept = (socket) => {
13916
+ if (closed || connections.size >= MCP_MAX_CONNECTIONS) {
13917
+ socket.destroy();
13918
+ return;
13919
+ }
13920
+ connections.add(socket);
13921
+ socket.unref?.();
13922
+ const codec = new LineCodec();
13923
+ let helloChecked = false;
13924
+ socket.setEncoding("utf8");
13925
+ socket.on("data", (chunk) => {
13926
+ const frames = codec.push(chunk);
13927
+ for (const frame of frames) {
13928
+ if (!helloChecked) {
13929
+ helloChecked = true;
13930
+ const params = frame?.params ?? {};
13931
+ const valid = frame?.method === "hello" && params.workerId === opts.workerId && safeEqualToken(opts.token, params.token);
13932
+ if (!valid) {
13933
+ opts.logger.warn({ workerId: opts.workerId, reason: "mcp-hello-rejected" }, "ACP MCP bridge hello rejected");
13934
+ socket.destroy();
13935
+ return;
13936
+ }
13937
+ const pid = Number(params.pid);
13938
+ if (Number.isInteger(pid) && pid > 0) bridgePids.add(pid);
13939
+ ready = true;
13940
+ clearTimeout(handshakeTimer);
13941
+ resolveReady(true);
13942
+ continue;
13943
+ }
13944
+ void handleFrame(frame, socket);
13945
+ }
13946
+ });
13947
+ socket.on("close", () => connections.delete(socket));
13948
+ socket.on("error", () => {
13949
+ });
13950
+ };
13951
+ const server = createServer(accept);
13952
+ await new Promise((resolve15, reject) => {
13953
+ const onError = (err) => reject(err);
13954
+ server.once("error", onError);
13955
+ server.listen(opts.socketPath, () => {
13956
+ server.off("error", onError);
13957
+ resolve15();
13958
+ });
13959
+ });
13960
+ chmodSync(opts.socketPath, 384);
13961
+ server.unref();
13962
+ const close = async () => {
13963
+ if (closed) return;
13964
+ closed = true;
13965
+ clearTimeout(handshakeTimer);
13966
+ if (!ready) resolveReady(false);
13967
+ opts.invoker.settlePending();
13968
+ await new Promise((resolve15) => setImmediate(resolve15));
13969
+ for (const socket of connections) socket.destroy();
13970
+ connections.clear();
13971
+ await new Promise((resolve15) => {
13972
+ if (!server.listening) return resolve15();
13973
+ server.close(() => resolve15());
13974
+ });
13975
+ try {
13976
+ unlinkSync4(opts.socketPath);
13977
+ } catch {
13978
+ }
13979
+ stopBridgeProcesses(bridgePids, opts.logger, opts.workerId);
13980
+ };
13981
+ return { ready: readyPromise, isReady: () => ready && !closed, close };
13982
+ }
13983
+ function buildMcpServerDescriptor(opts) {
13984
+ return {
13985
+ name: MCP_SERVER_NAME,
13986
+ command: opts.nodeExecutable ?? process.execPath,
13987
+ args: [opts.bridgeEntryPath],
13988
+ env: [
13989
+ { name: MCP_ENV_SOCKET, value: opts.socketPath },
13990
+ { name: MCP_ENV_TOKEN, value: opts.token },
13991
+ { name: MCP_ENV_WORKER_ID, value: opts.workerId }
13992
+ ]
13993
+ };
13994
+ }
13995
+ async function startStdioMcpTransport(opts) {
13996
+ const socketPath = mcpSocketPathFor(opts.sessionDir, opts.workerId, generateMcpToken().slice(0, 12));
13997
+ const token = generateMcpToken();
13998
+ const bridgeEntryPath = resolveMcpBridgeEntryPath(import.meta.url);
13999
+ const host = await startMcpHost({
14000
+ socketPath,
14001
+ token,
14002
+ workerId: opts.workerId,
14003
+ invoker: opts.invoker,
14004
+ logger: opts.logger,
14005
+ serverInfo: opts.serverInfo
14006
+ });
14007
+ return {
14008
+ descriptor: buildMcpServerDescriptor({ socketPath, token, workerId: opts.workerId, bridgeEntryPath }),
14009
+ ready: host.ready,
14010
+ isReady: host.isReady,
14011
+ close: host.close
14012
+ };
14013
+ }
14014
+
14015
+ // src/services/agents/acp/mcp-progress-fallback.ts
14016
+ function isAfter(a, b) {
14017
+ return a.turn > b.turn || a.turn === b.turn && a.chars > b.chars;
14018
+ }
14019
+ var McpProgressTracker = class {
14020
+ canFire;
14021
+ emit;
14022
+ now;
14023
+ idleMs;
14024
+ maxChars;
14025
+ timer = null;
14026
+ lastProgressAt = 0;
14027
+ /** The latest complete entry handed by the run layer (single slot). */
14028
+ reply = null;
14029
+ /**
14030
+ * Everything up to here counts as reported — by a real `update_progress`
14031
+ * (to the CURRENT position) or by the fallback itself (to the entry it
14032
+ * emitted). A candidate at/before this line is stale.
14033
+ */
14034
+ watermark = { turn: 0, chars: 0 };
14035
+ constructor(opts) {
14036
+ this.canFire = opts.canFire;
14037
+ this.emit = opts.emit;
14038
+ this.idleMs = opts.idleMs ?? PROGRESS_FALLBACK_MS;
14039
+ this.maxChars = opts.maxChars ?? MAX_PROGRESS_CHARS;
14040
+ this.now = opts.now ?? (() => Date.now());
14041
+ }
14042
+ setIdleMs(ms) {
14043
+ this.idleMs = ms > 0 ? ms : PROGRESS_FALLBACK_MS;
14044
+ }
14045
+ /** Task start: fresh state and a fresh idle window. */
14046
+ start() {
14047
+ this.stop();
14048
+ this.reply = null;
14049
+ this.watermark = { turn: 0, chars: 0 };
14050
+ this.lastProgressAt = this.now();
14051
+ this.arm();
14052
+ }
14053
+ stop() {
14054
+ if (this.timer) {
14055
+ clearTimeout(this.timer);
14056
+ this.timer = null;
14057
+ }
14058
+ }
14059
+ /**
14060
+ * A real update_progress resets the idle window and advances the watermark
14061
+ * to the current position: everything produced so far counts as reported,
14062
+ * so a later window must not re-announce it. The MCP channel carries no
14063
+ * message id and takes no part in any dedupe.
14064
+ */
14065
+ noteProgress(position) {
14066
+ if (isAfter(position, this.watermark)) this.watermark = position;
14067
+ this.lastProgressAt = this.now();
14068
+ this.arm();
14069
+ }
14070
+ /** New prompt / turn start: new idle window. Emits nothing by itself. */
14071
+ noteNewMessage() {
14072
+ this.lastProgressAt = this.now();
14073
+ this.arm();
14074
+ }
14075
+ /** Turn end resets the idle window. Emits nothing by itself. */
14076
+ noteTurnEnd() {
14077
+ this.lastProgressAt = this.now();
14078
+ this.arm();
14079
+ }
14080
+ /**
14081
+ * One complete entry flushed to the transcript (a tool call closed the open
14082
+ * segment, or the turn ended). The text IS the writer's own aggregation —
14083
+ * this tracker never joins chunks and never keeps more than the latest
14084
+ * entry. Sealing never emits anything.
14085
+ */
14086
+ noteReplySealed(text, position) {
14087
+ this.reply = { text, position };
14088
+ }
14089
+ /**
14090
+ * Report the latest complete, never-reported entry NOW (turn end on the
14091
+ * degraded path — no need to wait out the idle window). Verbatim, capped at
14092
+ * maxChars; never a join, never a summary. Returns whether anything was
14093
+ * emitted; either way the idle window restarts.
14094
+ */
14095
+ flush() {
14096
+ if (!this.canFire()) return false;
14097
+ const reply = this.reply;
14098
+ this.lastProgressAt = this.now();
14099
+ if (!reply || !isAfter(reply.position, this.watermark)) {
14100
+ this.arm();
14101
+ return false;
14102
+ }
14103
+ this.watermark = reply.position;
14104
+ this.emit(capText(reply.text, this.maxChars), "fallback");
14105
+ this.arm();
14106
+ return true;
14107
+ }
14108
+ arm() {
14109
+ this.stop();
14110
+ if (!this.canFire()) return;
14111
+ const remaining = Math.max(0, this.idleMs - (this.now() - this.lastProgressAt));
14112
+ this.timer = setTimeout(() => this.fire(), remaining);
14113
+ this.timer.unref?.();
14114
+ }
14115
+ fire() {
14116
+ this.timer = null;
14117
+ if (!this.canFire()) return;
14118
+ if (this.now() - this.lastProgressAt < this.idleMs) {
14119
+ this.arm();
14120
+ return;
14121
+ }
14122
+ this.flush();
14123
+ }
14124
+ };
14125
+
14126
+ // src/services/agents/acp/acp-progress.ts
14127
+ var PROGRESS_FILLER_RE = /[\s\p{P}]/gu;
14128
+ function hasProgressSubstance(text) {
14129
+ return text.replace(PROGRESS_FILLER_RE, "").length > 0;
14130
+ }
14131
+ var AcpProgress = class {
14132
+ tracker;
14133
+ deps;
14134
+ constructor(deps) {
14135
+ this.deps = deps;
14136
+ this.tracker = new McpProgressTracker({
14137
+ canFire: deps.canFire,
14138
+ emit: (message) => deps.onProgress(message),
14139
+ ...deps.idleMs !== void 0 && { idleMs: deps.idleMs }
14140
+ });
14141
+ }
14142
+ /** Task start: fresh tracker state and a fresh idle window. */
14143
+ start() {
14144
+ this.tracker.start();
14145
+ }
14146
+ /** Run finally / abort / loop exit: never report after this. */
14147
+ stop() {
14148
+ this.tracker.stop();
14149
+ }
14150
+ /** New prompt / turn start: new idle window. */
14151
+ noteNewMessage() {
14152
+ this.tracker.noteNewMessage();
14153
+ }
14154
+ /** Turn end resets the idle window. */
14155
+ noteTurnEnd() {
14156
+ this.tracker.noteTurnEnd();
14157
+ }
14158
+ /**
14159
+ * Single choke point for a real `update_progress` (the MCP tool layer calls
14160
+ * this). Guards FIRST — a late tool call must not re-arm the idle timer
14161
+ * either — then advance the watermark to the current position: everything
14162
+ * produced so far counts as announced.
14163
+ */
14164
+ report(message) {
14165
+ if (!this.deps.canFire()) return;
14166
+ this.tracker.noteProgress(this.deps.position());
14167
+ this.deps.onProgress(capText(message));
14168
+ }
14169
+ /**
14170
+ * One complete entry flushed to the transcript (a tool call closed the open
14171
+ * segment, or the turn ended and finishTurn wrote it): hand it to the
14172
+ * tracker as the fallback candidate. Recording never emits anything.
14173
+ */
14174
+ recordReply() {
14175
+ if (!this.deps.canFire()) return;
14176
+ const text = this.deps.writer.lastText();
14177
+ if (text === null || text.length === 0) return;
14178
+ if (!hasProgressSubstance(text)) return;
14179
+ this.tracker.noteReplySealed(text, this.deps.position());
14180
+ }
14181
+ /**
14182
+ * Turn-end catch-up on the degraded path: report the latest complete,
14183
+ * never-reported entry NOW instead of waiting out the idle window.
14184
+ */
14185
+ flush() {
14186
+ return this.tracker.flush();
14187
+ }
14188
+ };
14189
+
14190
+ // src/services/agents/acp/mcp-tools.ts
14191
+ var TIMED_OUT = /* @__PURE__ */ Symbol("wayang-mcp-tool-timeout");
14192
+ var ABORTED = /* @__PURE__ */ Symbol("wayang-mcp-tool-aborted");
14193
+ function withTimeout(work, ms, pending) {
14194
+ return new Promise((resolve15, reject) => {
14195
+ let done = false;
14196
+ const finish = (value) => {
14197
+ if (done) return;
14198
+ done = true;
14199
+ clearTimeout(timer);
14200
+ pending.delete(cancel);
14201
+ resolve15(value);
14202
+ };
14203
+ const fail = (err) => {
14204
+ if (done) return;
14205
+ done = true;
14206
+ clearTimeout(timer);
14207
+ pending.delete(cancel);
14208
+ reject(err);
14209
+ };
14210
+ const timer = setTimeout(() => finish(TIMED_OUT), ms);
14211
+ timer.unref?.();
14212
+ const cancel = () => finish(ABORTED);
14213
+ pending.add(cancel);
14214
+ work.then(finish, fail);
14215
+ });
14216
+ }
14217
+ function createWayangMcpToolInvoker(deps) {
14218
+ const pending = /* @__PURE__ */ new Set();
14219
+ const invoke = async (name, args) => {
14220
+ const started = Date.now();
14221
+ if (name === "update_progress") {
14222
+ if (typeof args.message !== "string" || args.message.length === 0) {
14223
+ return { text: '[MCP ERROR] update_progress requires a non-empty string "message"', isError: true };
14224
+ }
14225
+ const message = capText(args.message);
14226
+ const settled = await withTimeout(
14227
+ Promise.resolve().then(() => deps.onProgress(message)),
14228
+ deps.progressTimeoutMs ?? MCP_PROGRESS_TIMEOUT_MS,
14229
+ pending
14230
+ );
14231
+ if (settled === ABORTED) return { text: "[ABORTED] Worker is shutting down.", isError: true };
14232
+ if (settled === TIMED_OUT) {
14233
+ return { text: "[TIMEOUT] The progress report was not delivered within 5s.", isError: true };
14234
+ }
14235
+ deps.logger.debug(
14236
+ { workerId: deps.workerId, toolName: name, charLen: message.length, durationMs: Date.now() - started, mcpAvailable: true, reason: "progress" },
14237
+ "ACP MCP tool served"
14238
+ );
14239
+ return { text: `Progress reported: ${message}` };
14240
+ }
14241
+ if (name === "ask_controller") {
14242
+ const question = capText(typeof args.question === "string" ? args.question : "", MAX_QUESTION_CHARS);
14243
+ if (!question) return { text: '[MCP ERROR] ask_controller requires a non-empty string "question"', isError: true };
14244
+ const context = args.context === void 0 ? void 0 : capText(String(args.context), MAX_CONTEXT_CHARS);
14245
+ const settled = await withTimeout(
14246
+ deps.ask(question, context),
14247
+ deps.askTimeoutMs ?? MCP_ASK_TIMEOUT_MS,
14248
+ pending
14249
+ );
14250
+ const answer = settled === TIMED_OUT ? "[TIMEOUT] No answer from the controller within 5 minutes. Use your best judgment to continue." : settled === ABORTED ? "[ABORTED] Worker is shutting down." : settled;
14251
+ deps.logger.debug(
14252
+ { workerId: deps.workerId, toolName: name, charLen: answer.length, durationMs: Date.now() - started, mcpAvailable: true, reason: "ask" },
14253
+ "ACP MCP tool served"
14254
+ );
14255
+ return { text: answer, isError: settled === TIMED_OUT || settled === ABORTED };
14256
+ }
14257
+ return { text: `Unknown tool: ${name}`, isError: true };
14258
+ };
14259
+ return {
14260
+ invoke,
14261
+ settlePending: () => {
14262
+ for (const cancel of [...pending]) cancel();
14263
+ pending.clear();
14264
+ }
14265
+ };
14266
+ }
14267
+
14268
+ // src/services/agents/acp/acp-mcp-host.ts
14269
+ var AcpMcpHost = class {
14270
+ deps;
14271
+ transport = null;
14272
+ handshakeTimer = null;
14273
+ available = false;
14274
+ warned = false;
14275
+ /** Set by close(): a watchdog already on the timer queue must stay inert. */
14276
+ stopped = false;
14277
+ constructor(deps) {
14278
+ this.deps = deps;
14279
+ }
14280
+ /** Whether the injected server is live this run (false once degraded). */
14281
+ get mcpAvailable() {
14282
+ return this.available;
14283
+ }
14284
+ /**
14285
+ * Spawn the bridge transport (unless disabled) and return the mcpServers
14286
+ * descriptor for session/new. A failed start degrades immediately and
14287
+ * returns [] — session/new must still happen.
14288
+ */
14289
+ async prepare() {
14290
+ if (!this.deps.enabled()) return [];
14291
+ const invoker = createWayangMcpToolInvoker({
14292
+ workerId: this.deps.workerId,
14293
+ logger: this.deps.logger,
14294
+ onProgress: (message) => this.deps.onProgress(message),
14295
+ ask: (question, context) => this.deps.ask(question, context)
14296
+ });
14297
+ try {
14298
+ const transport = await this.deps.spawnTransport({
14299
+ sessionDir: this.deps.sessionDir,
14300
+ workerId: this.deps.workerId,
14301
+ invoker,
14302
+ logger: this.deps.logger,
14303
+ serverInfo: { name: "wayang", version: package_default.version }
14304
+ });
14305
+ this.transport = transport;
14306
+ this.available = true;
14307
+ this.armHandshakeWatchdog(transport);
14308
+ this.deps.recordRuntime({ mcpAvailable: true });
14309
+ this.deps.logger.debug({ workerId: this.deps.workerId, reason: "mcp-injected" }, "ACP MCP server injected");
14310
+ return [transport.descriptor];
14311
+ } catch (err) {
14312
+ this.warnDegraded("transport-start-failed", err);
14313
+ return [];
14314
+ }
14315
+ }
14316
+ /**
14317
+ * Disable MCP, stop the watchdog and shut the transport (and thus the
14318
+ * bridge) down. The idle fallback stays armed: after degradation
14319
+ * session/update text is the only progress source.
14320
+ */
14321
+ async degrade(reason, err) {
14322
+ if (this.stopped) return;
14323
+ if (this.available) {
14324
+ this.available = false;
14325
+ this.deps.onDegraded();
14326
+ this.deps.recordRuntime({ mcpAvailable: false });
14327
+ }
14328
+ await this.close();
14329
+ this.warnDegraded(reason, err);
14330
+ }
14331
+ /** Close the transport (idempotent) and stop the handshake watchdog. */
14332
+ async close() {
14333
+ this.stopped = true;
14334
+ this.stopWatchdog();
14335
+ const transport = this.transport;
14336
+ this.transport = null;
14337
+ if (!transport) return;
14338
+ try {
14339
+ await transport.close();
14340
+ } catch (err) {
14341
+ this.deps.logger.debug({ workerId: this.deps.workerId, err, reason: "mcp-transport-close" }, "ACP MCP transport close failed");
14342
+ }
14343
+ }
14344
+ /** Stop the handshake watchdog (run finally / abort path). */
14345
+ stopWatchdog() {
14346
+ if (this.handshakeTimer) {
14347
+ clearTimeout(this.handshakeTimer);
14348
+ this.handshakeTimer = null;
14349
+ }
14350
+ }
14351
+ /** A session/new that succeeded with injection but no hello within 10s degrades. */
14352
+ armHandshakeWatchdog(transport) {
14353
+ this.stopWatchdog();
14354
+ const timer = setTimeout(() => {
14355
+ this.handshakeTimer = null;
14356
+ if (this.stopped || !this.available || transport.isReady()) return;
14357
+ void this.degrade("handshake-timeout");
14358
+ }, MCP_HANDSHAKE_TIMEOUT_MS);
14359
+ timer.unref?.();
14360
+ this.handshakeTimer = timer;
14361
+ }
14362
+ /** Metadata-only warning: no token, path, question, answer or reply text. */
14363
+ warnDegraded(reason, err) {
14364
+ if (this.warned) return;
14365
+ this.warned = true;
14366
+ const rpcCode = err instanceof AcpError ? err.detail?.rpc?.code : void 0;
14367
+ this.deps.logger.warn(
14368
+ { provider: this.deps.provider, workerId: this.deps.workerId, reason, ...rpcCode !== void 0 && { rpcCode }, mcpAvailable: false },
14369
+ "ACP MCP injection degraded \u2014 session/update progress is the fallback"
14370
+ );
14371
+ }
14372
+ };
14373
+
14374
+ // src/services/agents/acp/acp-session-open.ts
14375
+ async function trySessionNew(client, cwd, mcpServers, setAuthHit) {
14376
+ const authHit = new Promise((_, reject) => {
14377
+ setAuthHit((line) => reject(new AcpError("auth-required", `auth keyword on stderr during session/new: ${line}`, { stderrTail: [line] })));
14378
+ });
14379
+ authHit.catch(() => void 0);
14380
+ try {
14381
+ const { sessionId } = await Promise.race([
14382
+ client.sessionNew({ cwd, mcpServers }),
14383
+ authHit
14384
+ ]);
14385
+ return sessionId;
14386
+ } finally {
14387
+ setAuthHit(null);
14388
+ }
14389
+ }
14390
+ function mcpRetryable(err, deps) {
14391
+ const readiness = deps.isReady({
14392
+ handshake: deps.handshake,
14393
+ phase: "session-new",
14394
+ error: err,
14395
+ stderrTail: deps.transport.stderrTail()
14396
+ });
14397
+ if (!readiness.ready) return false;
14398
+ if (err instanceof AcpError && ["auth-required", "cancelled", "transport-closed"].includes(err.code)) return false;
14399
+ return true;
14400
+ }
14401
+ function attributeSessionNewError(err, deps) {
14402
+ const readiness = deps.isReady({
14403
+ handshake: deps.handshake,
14404
+ phase: "session-new",
14405
+ error: err,
14406
+ stderrTail: deps.transport.stderrTail()
14407
+ });
14408
+ if (!readiness.ready) {
14409
+ throw new AcpError(readiness.kind ?? "agent-error", readiness.message ?? "agent not ready after session/new");
14410
+ }
14411
+ throw deps.normalizeError(err, "session-new");
14412
+ }
14413
+ async function openAcpSession(deps) {
14414
+ try {
14415
+ return await trySessionNew(deps.client, deps.cwd, deps.mcpServers, deps.setAuthHit);
14416
+ } catch (firstError) {
14417
+ if (deps.mcpServers.length > 0 && mcpRetryable(firstError, deps)) {
14418
+ await deps.degrade("session-new-mcp-error", firstError);
14419
+ try {
14420
+ return await trySessionNew(deps.client, deps.cwd, [], deps.setAuthHit);
14421
+ } catch (secondError) {
14422
+ attributeSessionNewError(secondError, deps);
14423
+ }
14424
+ }
14425
+ attributeSessionNewError(firstError, deps);
14426
+ }
14427
+ }
14428
+
13673
14429
  // src/services/agents/acp/acp-worker-run.ts
13674
- var PROGRESS_DEBOUNCE_MS = 500;
13675
14430
  var AcpWorkerRun = class extends BaseAcpWorker {
13676
14431
  /** Per-turn work counters feeding the guard's progressSig (#56). */
13677
14432
  _turnTextChars = 0;
13678
14433
  _turnToolCalls = 0;
13679
14434
  _turnToolResults = 0;
14435
+ /** Turn counter: the turn component of the progress position. */
14436
+ _turnSeq = 0;
14437
+ /** Progress wiring (update_progress primary, idle fallback net). */
14438
+ _progress = null;
14439
+ /** Per-run MCP host lifecycle (injection / watchdog / degradation). */
14440
+ _mcpHost = null;
13680
14441
  async run(task, _tools, onProgress) {
13681
14442
  this._terminalResult = null;
13682
14443
  this._streamEnded = false;
@@ -13692,6 +14453,18 @@ var AcpWorkerRun = class extends BaseAcpWorker {
13692
14453
  this._turnGuard = resetTurnGuard(Date.now());
13693
14454
  this._accumulator = createAcpToolAccumulator();
13694
14455
  this._turnWriter.reset();
14456
+ this._onProgress = onProgress ?? null;
14457
+ this._turnSeq = 0;
14458
+ this._progress = new AcpProgress({
14459
+ canFire: () => this.progressGuardsOpen(),
14460
+ onProgress: (message) => {
14461
+ this._onProgress?.(message);
14462
+ },
14463
+ writer: this._turnWriter,
14464
+ position: () => this.progressPosition(),
14465
+ idleMs: this.acpUserConfig().mcp?.fallbackMs
14466
+ });
14467
+ this._progress.start();
13695
14468
  this.state.set("runtimeState.task", { id: task.id, description: task.description });
13696
14469
  const turnEnv = this.buildTurnEnv(task);
13697
14470
  this._currentTurnEnv = turnEnv;
@@ -13746,7 +14519,32 @@ var AcpWorkerRun = class extends BaseAcpWorker {
13746
14519
  if (!postHandshake.ready) {
13747
14520
  throw new AcpError(postHandshake.kind ?? "agent-error", postHandshake.message ?? "agent not ready");
13748
14521
  }
13749
- const sessionId = await this.openSession(client, handshake, transport);
14522
+ this._mcpHost = new AcpMcpHost({
14523
+ workerId: this.id,
14524
+ provider: this.config.type,
14525
+ sessionDir: this.sessionDir,
14526
+ logger: this.logger,
14527
+ enabled: () => this.acpUserConfig().mcp?.enabled !== false,
14528
+ spawnTransport: (opts) => this.createMcpTransport(opts),
14529
+ onProgress: (message) => this._progress.report(message),
14530
+ ask: (question, context) => this.mcpAsk(question, context),
14531
+ recordRuntime: (fields) => this.recordAcpRuntime(fields),
14532
+ onDegraded: () => this._progress?.noteNewMessage()
14533
+ });
14534
+ const mcpServers = await this._mcpHost.prepare();
14535
+ const sessionId = await openAcpSession({
14536
+ client,
14537
+ handshake,
14538
+ transport,
14539
+ cwd: this.workspaceDir,
14540
+ mcpServers,
14541
+ isReady: (ctx) => this.isReady(ctx),
14542
+ normalizeError: (err, phase) => this.normalizeError(err, phase),
14543
+ degrade: (reason, err) => this._mcpHost.degrade(reason, err),
14544
+ setAuthHit: (handler) => {
14545
+ this._sessionAuthHit = handler;
14546
+ }
14547
+ });
13750
14548
  this._sessionId = sessionId;
13751
14549
  this.recordAcpRuntime({ pid: transport.pid, command: launch.command, provider: this.config.type });
13752
14550
  const unregisterPermission = client.onPermissionRequest(async (req) => {
@@ -13786,7 +14584,7 @@ var AcpWorkerRun = class extends BaseAcpWorker {
13786
14584
  };
13787
14585
  return this.mapPermission(req, acpCtx);
13788
14586
  });
13789
- const unregisterUpdates = client.onUpdate((update) => this.handleSessionUpdate(update, onProgress));
14587
+ const unregisterUpdates = client.onUpdate((update) => this.handleSessionUpdate(update));
13790
14588
  try {
13791
14589
  let promptText = `${buildThirdPartyPrompt(this.skills)}${task.description}`;
13792
14590
  const initialQueued = this._messageQueue.splice(0, this._messageQueue.length);
@@ -13796,9 +14594,13 @@ ${joinControllerMessages(initialQueued)}`;
13796
14594
  while (!this.abortController.signal.aborted) {
13797
14595
  const cancelBaseline = client.cancelSentCount;
13798
14596
  this.resetTurnProgress();
14597
+ this._turnSeq += 1;
14598
+ this._progress.noteNewMessage();
13799
14599
  const result2 = await client.sessionPrompt(sessionId, [{ type: "text", text: promptText }]);
13800
14600
  this._resultReceived = true;
13801
14601
  this.settleTurn();
14602
+ this._progress.recordReply();
14603
+ this._progress.noteTurnEnd();
13802
14604
  const denials = this.takeTurnDenials();
13803
14605
  const cancelFact = client.cancelSentCount > cancelBaseline ? client.lastCancelReason : null;
13804
14606
  const hasQueuedMessages = this._messageQueue.length > 0;
@@ -13821,6 +14623,7 @@ ${joinControllerMessages(initialQueued)}`;
13821
14623
  );
13822
14624
  break;
13823
14625
  }
14626
+ if (!this._mcpHost.mcpAvailable) this._progress.flush();
13824
14627
  const queued = this._messageQueue.splice(0, this._messageQueue.length);
13825
14628
  if (verdict.action === "reissue") {
13826
14629
  this._turnGuard.denialReissues += 1;
@@ -13832,6 +14635,7 @@ ${joinControllerMessages(initialQueued)}`;
13832
14635
  this.state.append("conversation", applyTurnEnvelope(makeInjectedEntry(promptText, injectedKind, this.ctx.sessionId), this._currentTurnEnv ?? void 0));
13833
14636
  }
13834
14637
  } finally {
14638
+ this._progress?.stop();
13835
14639
  unregisterUpdates();
13836
14640
  unregisterPermission();
13837
14641
  }
@@ -13848,6 +14652,7 @@ ${joinControllerMessages(initialQueued)}`;
13848
14652
  } finally {
13849
14653
  this._streamEnded = true;
13850
14654
  this.clearPendingProgress();
14655
+ await this._mcpHost?.close();
13851
14656
  if (this._cancelGraceTimer) {
13852
14657
  clearTimeout(this._cancelGraceTimer);
13853
14658
  this._cancelGraceTimer = null;
@@ -13882,6 +14687,7 @@ ${joinControllerMessages(initialQueued)}`;
13882
14687
  abort() {
13883
14688
  this.abortController.abort();
13884
14689
  this.clearPendingProgress();
14690
+ void this._mcpHost?.close();
13885
14691
  this.permissionHandler.cleanup();
13886
14692
  const client = this._client;
13887
14693
  if (!client || !this._sessionId || this._terminalResult) return;
@@ -13899,31 +14705,50 @@ ${joinControllerMessages(initialQueued)}`;
13899
14705
  }, policy.cancelGraceMs);
13900
14706
  }
13901
14707
  // --- Internals ---
13902
- /**
13903
- * session/new with the stderr login short-circuit: a stderr line hitting the
13904
- * auth pattern rejects the race immediately (03 §2.2 — do not wait out the
13905
- * timeout); every failure is then attributed through the isReady hook.
13906
- */
13907
- async openSession(client, handshake, transport) {
13908
- const authHit = new Promise((_, reject) => {
13909
- this._sessionAuthHit = (line) => reject(new AcpError("auth-required", `auth keyword on stderr during session/new: ${line}`, { stderrTail: [line] }));
13910
- });
13911
- authHit.catch(() => void 0);
14708
+ /** Clear progress state: stop the idle tracker and the MCP handshake watchdog. */
14709
+ clearPendingProgress() {
14710
+ this._progress?.stop();
14711
+ this._mcpHost?.stopWatchdog();
14712
+ }
14713
+ /** The four guards: no progress after turn end / cancel / abort / agent exit. */
14714
+ progressGuardsOpen() {
14715
+ return this._runInFlight && !this._inputClosed && !this.abortController.signal.aborted && !this._streamEnded && this._terminalResult === null && this._cancelledAt === null;
14716
+ }
14717
+ /** Where the run currently is in its own text stream (turn + chars so far). */
14718
+ progressPosition() {
14719
+ return { turn: this._turnSeq, chars: this._turnTextChars };
14720
+ }
14721
+ /** ask_controller: same blocking engine channel as ClaudeCodeWorker, with an audit entry. */
14722
+ async mcpAsk(question, context) {
14723
+ const ask = this._askController;
14724
+ if (!ask || !this._mcpHost?.mcpAvailable) {
14725
+ return "[UNAVAILABLE] The controller inquiry channel is not available for this worker.";
14726
+ }
14727
+ const q = capText(question, MAX_QUESTION_CHARS);
14728
+ const c = context === void 0 ? void 0 : capText(context, MAX_CONTEXT_CHARS);
14729
+ let answer;
13912
14730
  try {
13913
- const { sessionId } = await Promise.race([
13914
- client.sessionNew({ cwd: this.workspaceDir, mcpServers: [] }),
13915
- authHit
13916
- ]);
13917
- return sessionId;
14731
+ answer = await ask(q, c);
13918
14732
  } catch (err) {
13919
- const readiness = this.isReady({ handshake, phase: "session-new", error: err, stderrTail: transport.stderrTail() });
13920
- if (!readiness.ready) {
13921
- throw new AcpError(readiness.kind ?? "agent-error", readiness.message ?? "agent not ready after session/new");
13922
- }
13923
- throw this.normalizeError(err, "session-new");
13924
- } finally {
13925
- this._sessionAuthHit = null;
14733
+ answer = `[UNAVAILABLE] ${err instanceof Error ? err.message : String(err)}`;
13926
14734
  }
14735
+ this.recordInquiryAudit(q, answer);
14736
+ return answer;
14737
+ }
14738
+ recordInquiryAudit(question, answer) {
14739
+ const ts = nowISO();
14740
+ this.state.append("conversation", applyTurnEnvelope({
14741
+ type: "system" /* System */,
14742
+ uuid: generateId("s"),
14743
+ parentUuid: null,
14744
+ sessionId: this.ctx.sessionId,
14745
+ timestamp: ts,
14746
+ subtype: "inquiry" /* Inquiry */,
14747
+ content: `ask_controller: ${question}
14748
+ controller: ${answer}`,
14749
+ startedAt: ts,
14750
+ endedAt: ts
14751
+ }, this._currentTurnEnv ?? void 0));
13927
14752
  }
13928
14753
  handleStderrLine(line) {
13929
14754
  this._stderrLines.push(line);
@@ -13934,7 +14759,7 @@ ${joinControllerMessages(initialQueued)}`;
13934
14759
  }
13935
14760
  }
13936
14761
  /** Route one session/update through the mapper and persist/forward the result. */
13937
- handleSessionUpdate(update, onProgress) {
14762
+ handleSessionUpdate(update) {
13938
14763
  if (this._cancelledAt !== null && !this.stopPolicy().trustUpdatesAfterCancel) {
13939
14764
  this._discardedAfterCancel += 1;
13940
14765
  this.logger.debug(
@@ -13952,17 +14777,16 @@ ${joinControllerMessages(initialQueued)}`;
13952
14777
  }
13953
14778
  for (const event of events) {
13954
14779
  switch (event.kind) {
13955
- case "agent-text":
14780
+ case "agent-text": {
13956
14781
  this._turnWriter.addText(event.text);
13957
14782
  this._turnTextChars += event.text.length;
13958
14783
  break;
13959
- case "progress":
13960
- this.emitDebouncedProgress(event.text, onProgress);
13961
- break;
14784
+ }
13962
14785
  case "tool-call":
13963
14786
  this._sawToolCall = true;
13964
14787
  this._turnWriter.addToolCall(event.record);
13965
14788
  this._turnToolCalls += 1;
14789
+ this._progress?.recordReply();
13966
14790
  break;
13967
14791
  case "tool-result":
13968
14792
  this._turnWriter.addToolResult(event.record);
@@ -13992,22 +14816,6 @@ ${joinControllerMessages(initialQueued)}`;
13992
14816
  }
13993
14817
  this._turnWriter.finishTurn();
13994
14818
  }
13995
- /** Progress debounce (02 §5): 500ms, dropped once the run is over. */
13996
- emitDebouncedProgress(text, onProgress) {
13997
- if (!onProgress) return;
13998
- this._pendingProgress = text.slice(0, 200);
13999
- if (this._progressTimer) clearTimeout(this._progressTimer);
14000
- this._progressTimer = setTimeout(() => {
14001
- this._progressTimer = null;
14002
- if (this._pendingProgress === null || this.abortController.signal.aborted || this._streamEnded || this._terminalResult !== null) {
14003
- this._pendingProgress = null;
14004
- return;
14005
- }
14006
- const pending = this._pendingProgress;
14007
- this._pendingProgress = null;
14008
- onProgress(pending);
14009
- }, PROGRESS_DEBOUNCE_MS);
14010
- }
14011
14819
  /** Display-ready error text with the full context (frozen §2.5 acpUserMessage). */
14012
14820
  userFacingError(err) {
14013
14821
  return acpUserMessage(err.code, {
@@ -14052,6 +14860,15 @@ ${joinControllerMessages(initialQueued)}`;
14052
14860
  createTransport(config) {
14053
14861
  return new StdioAcpTransport(config);
14054
14862
  }
14863
+ /**
14864
+ * MCP transport factory — the ONLY seam between the worker run and the MCP
14865
+ * plumbing. Swapping the stdio bridge for an ACP v2 `acp` tunnel means
14866
+ * replacing this one call (plus capability detection), not the tool layer.
14867
+ * Overridable in tests to inject a fake transport.
14868
+ */
14869
+ createMcpTransport(opts) {
14870
+ return startStdioMcpTransport(opts);
14871
+ }
14055
14872
  /** Capability observation snapshot for diagnostics/tests (02 §6). */
14056
14873
  get observedCapabilities() {
14057
14874
  return this.getCapabilities();
@@ -14236,13 +15053,13 @@ function formatApiCallError(err) {
14236
15053
  }
14237
15054
 
14238
15055
  // src/services/tools/add-task.ts
14239
- import { mkdirSync as mkdirSync8 } from "fs";
15056
+ import { mkdirSync as mkdirSync9 } from "fs";
14240
15057
  import * as path8 from "path";
14241
15058
  import { z as z3 } from "zod";
14242
15059
 
14243
15060
  // src/services/tools/workspace-rules.ts
14244
15061
  import { readFileSync as readFileSync10, statSync as statSync6 } from "fs";
14245
- import { join as join18 } from "path";
15062
+ import { join as join19 } from "path";
14246
15063
  var RULE_FILE_NAMES2 = ["AGENTS.md", "CLAUDE.md"];
14247
15064
  var WORKSPACE_RULES_MAX_LINES = 40;
14248
15065
  var WORKSPACE_RULES_MAX_CHARS = 2e3;
@@ -14250,7 +15067,7 @@ function buildWorkspaceRulesSummary(input2) {
14250
15067
  const workspacePath = input2.workspacePath;
14251
15068
  if (!workspacePath) return "";
14252
15069
  for (const name of RULE_FILE_NAMES2) {
14253
- const rulesPath = join18(workspacePath, name);
15070
+ const rulesPath = join19(workspacePath, name);
14254
15071
  if (!isRegularFile(rulesPath)) continue;
14255
15072
  const header = `Workspace rules (from ${rulesPath}):`;
14256
15073
  const refusal = describeRefusal(rulesPath, workspacePath, input2);
@@ -14325,7 +15142,7 @@ function readTextFile(p) {
14325
15142
  // src/services/tools/add-task.ts
14326
15143
  function ensureWorkspaceDir(dirPath) {
14327
15144
  try {
14328
- mkdirSync8(dirPath, { recursive: true });
15145
+ mkdirSync9(dirPath, { recursive: true });
14329
15146
  return null;
14330
15147
  } catch (err) {
14331
15148
  return `Cannot create workspace ${dirPath}: ${err.message}. The task was NOT created.`;
@@ -15033,8 +15850,8 @@ function readFileTool(deps = {}) {
15033
15850
 
15034
15851
  // src/services/tools/write-file.ts
15035
15852
  import { z as z15 } from "zod";
15036
- import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync9, existsSync as existsSync11 } from "fs";
15037
- import { dirname as dirname9, resolve as resolve9 } from "path";
15853
+ import { writeFileSync as writeFileSync5, mkdirSync as mkdirSync10, existsSync as existsSync12 } from "fs";
15854
+ import { dirname as dirname10, resolve as resolve9 } from "path";
15038
15855
  function writeFileTool(deps = {}) {
15039
15856
  const workspace = deps.cwd ?? process.cwd();
15040
15857
  return defineTool({
@@ -15045,10 +15862,10 @@ function writeFileTool(deps = {}) {
15045
15862
  }),
15046
15863
  execute: safeExecute("write_file", async ({ path: path10, content }) => {
15047
15864
  const resolved = resolve9(workspace, expandHome(path10).path);
15048
- if (existsSync11(resolved)) {
15865
+ if (existsSync12(resolved)) {
15049
15866
  return `[ERROR] write_file: File already exists: ${path10}. Use edit_file to modify existing files.`;
15050
15867
  }
15051
- mkdirSync9(dirname9(resolved), { recursive: true });
15868
+ mkdirSync10(dirname10(resolved), { recursive: true });
15052
15869
  writeFileSync5(resolved, content, "utf-8");
15053
15870
  return `Written ${content.length} chars to ${resolved}`;
15054
15871
  })
@@ -15503,12 +16320,12 @@ function askControllerTool(deps) {
15503
16320
  import { resolve as resolve12 } from "path";
15504
16321
 
15505
16322
  // src/services/task/task-pool-state.ts
15506
- import { join as join19 } from "path";
16323
+ import { join as join20 } from "path";
15507
16324
  var TaskPoolState = class extends BaseWayangState {
15508
16325
  jsonFile;
15509
16326
  logger;
15510
16327
  constructor(ctx) {
15511
- const jsonFile = new JSONFileHelper(join19(ctx.sessionDir, "tasks.json"));
16328
+ const jsonFile = new JSONFileHelper(join20(ctx.sessionDir, "tasks.json"));
15512
16329
  super(
15513
16330
  {
15514
16331
  tasks: {
@@ -16339,7 +17156,7 @@ var WorkerWiringEngine = class extends WorkerTrackingEngine {
16339
17156
  // (accessScope) judges expanded paths with — no injection needed.
16340
17157
  tavilyApiKey: this.ctx.config.tavilyApiKey,
16341
17158
  registry: this.skills,
16342
- reportProgress: (msg, _percent) => {
17159
+ reportProgress: (msg) => {
16343
17160
  const activity = this.getWorkerActivity(workerId);
16344
17161
  this.enqueueWorkerSignal(workerId, {
16345
17162
  source: "worker",
@@ -16871,7 +17688,7 @@ var ControllerLoop = class {
16871
17688
  };
16872
17689
 
16873
17690
  // src/services/agents/controller-state.ts
16874
- import { join as join20 } from "path";
17691
+ import { join as join21 } from "path";
16875
17692
 
16876
17693
  // src/services/conversation/conversation-index.ts
16877
17694
  function emptyUsage() {
@@ -17095,8 +17912,8 @@ var ControllerAgentState = class extends BaseWayangState {
17095
17912
  /** In-memory resolve function for the current inquiry — never persisted. */
17096
17913
  inquiryResolver = null;
17097
17914
  constructor(ctx) {
17098
- const jsonFile = new JSONFileHelper(join20(ctx.sessionDir, "runtime-state.json"));
17099
- const jsonlFile = new JSONLFileHelper(join20(ctx.sessionDir, "conversation.jsonl"));
17915
+ const jsonFile = new JSONFileHelper(join21(ctx.sessionDir, "runtime-state.json"));
17916
+ const jsonlFile = new JSONLFileHelper(join21(ctx.sessionDir, "conversation.jsonl"));
17100
17917
  const initialData = {
17101
17918
  runtimeState: {
17102
17919
  session: { id: "", startedAt: 0 },
@@ -18086,7 +18903,7 @@ var ControllerAgent = class _ControllerAgent extends ControllerTurnRun {
18086
18903
 
18087
18904
  // src/services/skills/registry.ts
18088
18905
  import { readFileSync as readFileSync13, readdirSync as readdirSync2, statSync as statSync7 } from "fs";
18089
- import { join as join21 } from "path";
18906
+ import { join as join22 } from "path";
18090
18907
  import { parse as parseYaml } from "yaml";
18091
18908
  var SKILL_FILE = "SKILL.md";
18092
18909
  function parseFrontmatter(raw) {
@@ -18121,7 +18938,7 @@ function listResources(skillDir) {
18121
18938
  const walk = (relDir) => {
18122
18939
  let entries;
18123
18940
  try {
18124
- entries = readdirSync2(join21(skillDir, relDir), { withFileTypes: true });
18941
+ entries = readdirSync2(join22(skillDir, relDir), { withFileTypes: true });
18125
18942
  } catch {
18126
18943
  return;
18127
18944
  }
@@ -18168,7 +18985,7 @@ var SkillRegistry = class {
18168
18985
  if (existing) return existing;
18169
18986
  const desc = this.descriptors.get(name);
18170
18987
  if (!desc) return void 0;
18171
- const filePath = join21(desc.dir, SKILL_FILE);
18988
+ const filePath = join22(desc.dir, SKILL_FILE);
18172
18989
  let raw;
18173
18990
  try {
18174
18991
  raw = readFileSync13(filePath, "utf-8");
@@ -18199,8 +19016,8 @@ var SkillRegistry = class {
18199
19016
  for (const entry of entries) {
18200
19017
  if (!entry.isDirectory()) continue;
18201
19018
  if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
18202
- const skillDir = join21(dir, entry.name);
18203
- const skillFile = join21(skillDir, SKILL_FILE);
19019
+ const skillDir = join22(dir, entry.name);
19020
+ const skillFile = join22(skillDir, SKILL_FILE);
18204
19021
  if (!existsAsFile(skillFile)) continue;
18205
19022
  const { frontmatter } = safeReadFrontmatter(skillFile);
18206
19023
  const description = asString(frontmatter.description);
@@ -18229,12 +19046,12 @@ function safeReadFrontmatter(path10) {
18229
19046
  }
18230
19047
 
18231
19048
  // src/services/skills/skill-dir.ts
18232
- import { existsSync as existsSync12 } from "fs";
18233
- import { join as join22, resolve as resolve13 } from "path";
19049
+ import { existsSync as existsSync13 } from "fs";
19050
+ import { join as join23, resolve as resolve13 } from "path";
18234
19051
  function resolveSkillDirs(dataDir, workspaceDir2, configDirs) {
18235
19052
  const candidates = [
18236
- join22(dataDir, "skills"),
18237
- join22(workspaceDir2, ".wayang", "skills"),
19053
+ join23(dataDir, "skills"),
19054
+ join23(workspaceDir2, ".wayang", "skills"),
18238
19055
  ...(configDirs ?? []).map((d) => resolve13(d))
18239
19056
  ];
18240
19057
  const seen = /* @__PURE__ */ new Set();
@@ -18242,14 +19059,14 @@ function resolveSkillDirs(dataDir, workspaceDir2, configDirs) {
18242
19059
  for (const dir of candidates) {
18243
19060
  if (seen.has(dir)) continue;
18244
19061
  seen.add(dir);
18245
- if (existsSync12(dir)) dirs.push(dir);
19062
+ if (existsSync13(dir)) dirs.push(dir);
18246
19063
  }
18247
19064
  return dirs;
18248
19065
  }
18249
19066
 
18250
19067
  // src/services/supervisor.ts
18251
19068
  import { homedir as homedir8 } from "os";
18252
- import { join as join23 } from "path";
19069
+ import { join as join24 } from "path";
18253
19070
  var Supervisor = class {
18254
19071
  ctx;
18255
19072
  signalQueue;
@@ -18298,7 +19115,7 @@ var Supervisor = class {
18298
19115
  );
18299
19116
  this.signalQueue = new SignalQueue(this.ctx);
18300
19117
  this.skills = new SkillRegistry(
18301
- resolveSkillDirs(options.dataDir ?? join23(homedir8(), ".wayang"), workspaceDir2, config.skillsDirs),
19118
+ resolveSkillDirs(options.dataDir ?? join24(homedir8(), ".wayang"), workspaceDir2, config.skillsDirs),
18302
19119
  this.ctx.logger
18303
19120
  );
18304
19121
  this.engine = new TaskExecuteEngine(this.ctx, this.signalQueue, this.skills, {