openmausbot 0.1.84 → 0.1.86
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/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- package/dist/assets/abnfDiagram-O67JEVCF-DpUZNnFD.js +1 -0
- package/dist/assets/arc-DW7mrI4p.js +1 -0
- package/dist/assets/architectureDiagram-NJMV4G6O-CyeZ5WLZ.js +36 -0
- package/dist/assets/blockDiagram-BEXU5L5S-CBVaNjO7.js +140 -0
- package/dist/assets/c4Diagram-YGBWAQC7-sf8d41uT.js +38 -0
- package/dist/assets/channel-BXFkgus9.js +1 -0
- package/dist/assets/chunk-2Q5K7J3B-BOGvy7Y_.js +1 -0
- package/dist/assets/chunk-3FUC2YCW-BiF9zyiz.js +2 -0
- package/dist/assets/chunk-5VM5RSS4-EFuZ1SBZ.js +15 -0
- package/dist/assets/chunk-GTNCS2PH-pJLNSAT7.js +88 -0
- package/dist/assets/chunk-GWA4HPMP-Cb-3_3IT.js +1 -0
- package/dist/assets/chunk-JWPE2WC7-DAf5aogn.js +1 -0
- package/dist/assets/chunk-NETBCI7D-DR3kiwZz.js +1 -0
- package/dist/assets/chunk-WEXAMYUT-BmU8PeyM.js +1 -0
- package/dist/assets/chunk-XXDRQBXY-Buh8g8OT.js +1 -0
- package/dist/assets/classDiagram-v2-NBCMYWYE-BurfmMWM.js +217 -0
- package/dist/assets/cose-bilkent-JH36ORCC-KkGZUEkU.js +1 -0
- package/dist/assets/cynefin-OW5HDTMX-C3PpV96d.js +166 -0
- package/dist/assets/cynefinDiagram-VND7K2PF-Dt8JwJaI.js +62 -0
- package/dist/assets/cytoscape.esm-BB4DxJjf.js +321 -0
- package/dist/assets/dagre-6A5THRUB-DkjN8266.js +4 -0
- package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
- package/dist/assets/diagram-22UHCM2B-DUAJS0JV.js +200 -0
- package/dist/assets/diagram-3UASUU5V-Dw4KhF1a.js +24 -0
- package/dist/assets/diagram-ATOU4E4O-Dd1gmQqf.js +3 -0
- package/dist/assets/diagram-CDSNMT55-CjwGs0Yw.js +30 -0
- package/dist/assets/diagram-MLGK6HIB-BL-69Zrq.js +24 -0
- package/dist/assets/diagram-MPIPVDR6-5Zi2N7wo.js +41 -0
- package/dist/assets/ebnfDiagram-ZINNZB2B-D9NekWUX.js +1 -0
- package/dist/assets/elk-276RUBZZ-CkaSQjxw.js +27 -0
- package/dist/assets/erDiagram-OPXOYQCR-CdAVBV8-.js +99 -0
- package/dist/assets/flowDiagram-KWPJA3E3-Bm_lWWH7.js +213 -0
- package/dist/assets/ganttDiagram-FUAMR5RP-FZ8ejYWJ.js +292 -0
- package/dist/assets/gitGraphDiagram-X574FWY7-Dlv9rizZ.js +106 -0
- package/dist/assets/graph-D8UmR1vK.js +1 -0
- package/dist/assets/index-BGgqlWPJ.css +1 -0
- package/dist/assets/index-BtHo8ZKl.js +577 -0
- package/dist/assets/{index-DNa2umw-.js → index-h9FFNwTu.js} +1 -1
- package/dist/assets/infoDiagram-VRGFBTTK-BdQ_gW5B.js +2 -0
- package/dist/assets/init-Gi6I4Gst.js +1 -0
- package/dist/assets/ishikawaDiagram-OU5B5YK6-EwssEp2X.js +70 -0
- package/dist/assets/journeyDiagram-ZHPQQLJL-BX1G1vOd.js +139 -0
- package/dist/assets/kanban-definition-PNTS6WVX-BB4kAsyC.js +89 -0
- package/dist/assets/linear-3su2TG93.js +1 -0
- package/dist/assets/mermaid.core-CDD1E0s9.js +311 -0
- package/dist/assets/mindmap-definition-NLK3R4M7-Ba50MsNK.js +96 -0
- package/dist/assets/ordinal-Cboi1Yqb.js +1 -0
- package/dist/assets/pegDiagram-GJSIUBJH-DZLkkZlM.js +1 -0
- package/dist/assets/pieDiagram-5QR66LMP-DaTz77Uq.js +39 -0
- package/dist/assets/quadrantDiagram-O4NWA36T-CpaiidpR.js +7 -0
- package/dist/assets/railroadDiagram-XR7U4H2S-mN4JrMeI.js +1 -0
- package/dist/assets/requirementDiagram-PLB6GJNP-BSNDRpg7.js +84 -0
- package/dist/assets/sankeyDiagram-IPEJSGJF-FngzGNug.js +40 -0
- package/dist/assets/sequenceDiagram-PO4LG4MO-CM4pCC3L.js +169 -0
- package/dist/assets/sizeCapture-INFHLROL-CRICHXkw.js +1 -0
- package/dist/assets/stateDiagram-v2-GCMORJYK-oyTWpxH2.js +291 -0
- package/dist/assets/swimlanes-2SLR337P-D9h9mPBq.js +1 -0
- package/dist/assets/swimlanesDiagram-TC7HE7FX-BWrvB2ak.js +8 -0
- package/dist/assets/timeline-definition-EJHVYXUP-BJUlN9bW.js +120 -0
- package/dist/assets/usecaseDiagram-POWQR4AR-3uBNkbAb.js +370 -0
- package/dist/assets/vennDiagram-UO4OBE2U-D2odalzT.js +34 -0
- package/dist/assets/wardleyDiagram-VNRHLVJA-DHgFaB9S.js +78 -0
- package/dist/assets/xychartDiagram-PMCCYNJV-w5GYM2h0.js +7 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +1 -0
- package/dist-server/container-mcp.js +231 -17
- package/dist-server/drivers/agents-proxy.js +16097 -1411
- package/dist-server/enterprise/LICENSE +32 -0
- package/dist-server/enterprise/server/index.js +6 -5
- package/dist-server/hooks/omb-hook.js +74 -0
- package/dist-server/index.js +12495 -7584
- package/dist-server/local-computer-proxy.js +83 -1
- package/dist-server/local-computer.js +14 -2
- package/dist-server/mcp-server.js +3 -1
- package/dist-server/openmausbot.js +5458 -4587
- package/dist-server/pair-cli.js +5458 -4587
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/admin-activity.js +410 -0
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/bot-package.js +2 -0
- package/dist-server/server/bot-visibility.js +462 -0
- package/dist-server/server/box.js +25 -5
- package/dist-server/server/browser-engine.js +87 -10
- package/dist-server/server/browser-live.js +7 -5
- package/dist-server/server/browser-runtime.js +66 -11
- package/dist-server/server/channel-queue.js +2 -0
- package/dist-server/server/checkpoints.js +62 -18
- package/dist-server/server/cli-prompts.js +26 -2
- package/dist-server/server/cli.js +79 -10
- package/dist-server/server/commands.js +27 -0
- package/dist-server/server/compaction-summary.js +78 -0
- package/dist-server/server/composio.js +49 -11
- package/dist-server/server/config.js +137 -14
- package/dist-server/server/context-budget.js +29 -0
- package/dist-server/server/context-rebuild.js +72 -0
- package/dist-server/server/decision-log.js +209 -48
- package/dist-server/server/default-model-selection.js +20 -3
- package/dist-server/server/delegations.js +11 -9
- package/dist-server/server/digest.js +130 -0
- package/dist-server/server/drivers/acp/core.js +637 -238
- package/dist-server/server/drivers/acp/grok.js +4 -3
- package/dist-server/server/drivers/agents-call.js +1123 -0
- package/dist-server/server/drivers/agents-catalog.js +659 -0
- package/dist-server/server/drivers/agents-client.js +28 -0
- package/dist-server/server/drivers/agents-proxy.js +18 -1683
- package/dist-server/server/drivers/agents-result.js +23 -0
- package/dist-server/server/drivers/chat-mcp-tools.js +17 -2
- package/dist-server/server/drivers/claude.js +83 -16
- package/dist-server/server/drivers/codex-mcp-names.js +44 -0
- package/dist-server/server/drivers/codex.js +32 -4
- package/dist-server/server/drivers/grok.js +2 -1
- package/dist-server/server/drivers/openai-chat.js +66 -45
- package/dist-server/server/drivers/openai-compat.js +16 -8
- package/dist-server/server/enterprise.js +97 -16
- package/dist-server/server/external-runtime.js +83 -0
- package/dist-server/server/guarded-requests.js +60 -0
- package/dist-server/server/harness/http.js +61 -0
- package/dist-server/server/hooks/omb-hook.js +103 -0
- package/dist-server/server/hosted-models.js +151 -0
- package/dist-server/server/hosted-slack.js +11 -0
- package/dist-server/server/index.js +2810 -678
- package/dist-server/server/local-computer.js +21 -2
- package/dist-server/server/managed-desktop.js +79 -14
- package/dist-server/server/managed-policy.js +153 -0
- package/dist-server/server/mcp-bridge.js +10 -2
- package/dist-server/server/mcp-tool-images.js +37 -0
- package/dist-server/server/mcp-tool-schema.js +108 -0
- package/dist-server/server/message-db.js +108 -21
- package/dist-server/server/message-file.js +3 -2
- package/dist-server/server/model-context-window.js +7 -0
- package/dist-server/server/model-prices.js +121 -0
- package/dist-server/server/notify.js +10 -1
- package/dist-server/server/package-export.js +1 -0
- package/dist-server/server/peer-roster.js +17 -4
- package/dist-server/server/provider-icon.js +20 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/recent-work.js +15 -0
- package/dist-server/server/request-auth.js +97 -0
- package/dist-server/server/room-handoffs.js +41 -13
- package/dist-server/server/routes/hosted-slack.js +20 -0
- package/dist-server/server/routes/table.js +14 -0
- package/dist-server/server/routine-requests.js +39 -1
- package/dist-server/server/routines.js +65 -9
- package/dist-server/server/shared-computer-control.js +31 -4
- package/dist-server/server/spend.js +111 -16
- package/dist-server/server/steer-queue.js +12 -1
- package/dist-server/server/store.js +384 -33
- package/dist-server/server/system-prompt.js +4 -4
- package/dist-server/server/team-backup.js +19 -2
- package/dist-server/server/thread-starters.js +53 -0
- package/dist-server/server/tool-results.js +72 -0
- package/dist-server/server/tts/grok.js +74 -0
- package/dist-server/server/tts/index.js +19 -1
- package/dist-server/server/usage-ledger.js +31 -8
- package/dist-server/server/webhooks.js +28 -0
- package/dist-server/server/workspace-backup-policy.js +9 -2
- package/dist-server/server/workspace-backup.js +7 -4
- package/dist-server/shared/approval-mode.js +7 -1
- package/dist-server/shared/digest.js +1 -0
- package/dist-server/shared/markdown-windows-paths.js +36 -0
- package/dist-server/shared/mention-boundary.js +15 -0
- package/dist-server/shared/provider-icon.js +110 -0
- package/dist-server/shared/routines.js +7 -1
- package/dist-server/shared/team-backup.js +5 -0
- package/dist-server/src/lib/composer-attachments.js +16 -0
- package/dist-server/vps-container-mcp.js +236 -22
- package/enterprise/README.md +33 -12
- package/enterprise/server/index.js +6 -5
- package/package.json +1 -1
- package/dist/assets/index-BbU5REzd.js +0 -310
- package/dist/assets/index-CLGfYlx_.css +0 -1
|
@@ -5,6 +5,17 @@
|
|
|
5
5
|
// argv, auth method, model catalog, sign-in check) live in a small support
|
|
6
6
|
// object. Adding a harness = write server/drivers/acp/<name>.ts.
|
|
7
7
|
//
|
|
8
|
+
// One live agent process per (thread, spawn contract): the ACP handshake
|
|
9
|
+
// (initialize, authenticate) and the native session are established once,
|
|
10
|
+
// and later turns prompt the live session directly instead of paying the
|
|
11
|
+
// full handshake per message. The pool mirrors the Claude driver: a session
|
|
12
|
+
// closes after OMB_ACP_SESSION_IDLE_MS of quiet (default 10 minutes, floored
|
|
13
|
+
// by OMB_ACP_SESSION_IDLE_MIN_MS default 10s), when the spawn contract
|
|
14
|
+
// changes, when the child crashes, when an interrupt's cancel goes
|
|
15
|
+
// unanswered, and on stopAll/dispose. A resume cursor left by an earlier
|
|
16
|
+
// session resumes through session/load|resume when the process had to
|
|
17
|
+
// respawn.
|
|
18
|
+
//
|
|
8
19
|
// ACP has no `turn/completed` notification: the `session/prompt` RPC *result*
|
|
9
20
|
// is the completion signal (it carries stopReason + usage). Permission
|
|
10
21
|
// requests arrive as server→client `session/request_permission` and surface
|
|
@@ -17,7 +28,8 @@
|
|
|
17
28
|
import { homedir } from "node:os";
|
|
18
29
|
import { lstat, mkdir, readFile, realpath, stat, writeFile } from "node:fs/promises";
|
|
19
30
|
import { basename, dirname, isAbsolute, relative, resolve } from "node:path";
|
|
20
|
-
import {
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
32
|
+
import { PROVIDER_CREDENTIAL_ENV, stripControlPlaneEnv, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
|
|
21
33
|
import { decodeInjectId } from "../local-inject.js";
|
|
22
34
|
import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.js";
|
|
23
35
|
/**
|
|
@@ -32,11 +44,53 @@ import { augmentedPath } from "../../env-path.js";
|
|
|
32
44
|
import { supportsApprovalMode } from "../../../shared/approval-mode.js";
|
|
33
45
|
import { appendNative } from "../native.js";
|
|
34
46
|
import { commandSummary, toolDetailPreview } from "../../tool-summary.js";
|
|
47
|
+
import { extractMcpImages } from "../../mcp-tool-images.js";
|
|
48
|
+
import { redactSecretsInText } from "../../redact.js";
|
|
49
|
+
import { recoveryPromptFor } from "../../resume-recovery.js";
|
|
50
|
+
/** ACP vendors put the actionable cause in error.data while keeping the
|
|
51
|
+
* JSON-RPC message generic. Only surface known text fields, never a response
|
|
52
|
+
* body/config dump, and redact before bounding the displayed diagnostic. */
|
|
53
|
+
function acpRpcError(value, method) {
|
|
54
|
+
const message = typeof value?.message === "string" ? value.message : "ACP request failed";
|
|
55
|
+
const data = value?.data;
|
|
56
|
+
const detail = typeof data === "string" ? data
|
|
57
|
+
: typeof data?.details === "string" ? data.details
|
|
58
|
+
: typeof data?.message === "string" ? data.message
|
|
59
|
+
: typeof data?.error?.message === "string" ? data.error.message
|
|
60
|
+
: "";
|
|
61
|
+
const context = [
|
|
62
|
+
method,
|
|
63
|
+
typeof data?.service === "string" ? `service: ${data.service}` : "",
|
|
64
|
+
typeof data?.errorName === "string" ? data.errorName : "",
|
|
65
|
+
].filter(Boolean).join(", ");
|
|
66
|
+
const diagnostic = `${detail && detail !== message ? `${message}: ${detail}` : message} (${context})`;
|
|
67
|
+
const error = new Error(redactSecretsInText(diagnostic).slice(0, 1500));
|
|
68
|
+
return Object.assign(error, { code: value?.code, data,
|
|
69
|
+
// -32603 is JSON-RPC's internal error. Invalid params, unsupported
|
|
70
|
+
// methods and auth refusals are user/configuration issues, not evidence
|
|
71
|
+
// of a broken process. Preserve their session instead of retrying them.
|
|
72
|
+
acpSessionFailure: value?.code === -32603,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
35
75
|
const envOr = (key, fallback) => Number(process.env[key] ?? fallback);
|
|
36
76
|
const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
|
|
37
77
|
const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 300_000); // configureSession's per-request default
|
|
38
78
|
const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
|
|
39
79
|
const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
|
|
80
|
+
// Read lazily (not at import) so a test can shorten the window. Unlike the
|
|
81
|
+
// setup calls above, session/prompt legitimately streams for minutes, so a
|
|
82
|
+
// wall-clock deadline would false-positive: this guard only trips when the
|
|
83
|
+
// child sends nothing at all for the whole window (a wedged OpenCode turn
|
|
84
|
+
// streams thought chunks, then goes silent forever and never resolves). 0
|
|
85
|
+
// disables the guard, restoring the pre-fix "hang until the user cancels"
|
|
86
|
+
// behavior.
|
|
87
|
+
const promptIdleTimeoutMs = () => {
|
|
88
|
+
const raw = process.env.OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS;
|
|
89
|
+
if (raw === undefined)
|
|
90
|
+
return 180_000;
|
|
91
|
+
const ms = Number(raw);
|
|
92
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
93
|
+
};
|
|
40
94
|
const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024;
|
|
41
95
|
function acpVariantOption(result) {
|
|
42
96
|
const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.type === "select" && typeof entry.id === "string"
|
|
@@ -144,6 +198,17 @@ function sessionOperationKey(toolCall) {
|
|
|
144
198
|
locations: toolCall?.locations,
|
|
145
199
|
}));
|
|
146
200
|
}
|
|
201
|
+
/** The banner an ACP CLI prints for `--version`. Hermes writes its whole banner
|
|
202
|
+
* to stderr with an empty stdout, so an stdout-only read reports a perfectly
|
|
203
|
+
* good install as "CLI not found". Prefer stdout; fall back to the first
|
|
204
|
+
* stderr line; null when both are empty. */
|
|
205
|
+
export function versionFromProbe(stdout, stderr) {
|
|
206
|
+
const out = (stdout ?? "").trim();
|
|
207
|
+
if (out)
|
|
208
|
+
return out;
|
|
209
|
+
const err = (stderr ?? "").trim().split(/\r\n|\n|\r/, 1)[0]?.trim() ?? "";
|
|
210
|
+
return err || null;
|
|
211
|
+
}
|
|
147
212
|
export function createAcpDriver(support) {
|
|
148
213
|
const DRIVER_KIND = support.driverKind;
|
|
149
214
|
const SOURCE = support.nativeSource;
|
|
@@ -178,6 +243,8 @@ export function createAcpDriver(support) {
|
|
|
178
243
|
if (!allowedCredentials.has(key))
|
|
179
244
|
delete env[key];
|
|
180
245
|
}
|
|
246
|
+
// The operator's own secrets are outside any driver's allowlist.
|
|
247
|
+
stripControlPlaneEnv(env);
|
|
181
248
|
support.transformEnv?.(env, activeConfig, instanceId);
|
|
182
249
|
return env;
|
|
183
250
|
};
|
|
@@ -198,6 +265,48 @@ export function createAcpDriver(support) {
|
|
|
198
265
|
await refreshModels();
|
|
199
266
|
const listeners = new Set();
|
|
200
267
|
const active = new Map();
|
|
268
|
+
// One live agent process per thread, kept across turns — the Claude
|
|
269
|
+
// driver's pool. The ACP handshake (initialize, authenticate) and the
|
|
270
|
+
// native session are established once; a later turn on the same spawn
|
|
271
|
+
// contract prompts the live session instead of paying the handshake
|
|
272
|
+
// again. An idle session closes after SESSION_IDLE_MS of quiet.
|
|
273
|
+
const sessions = new Map();
|
|
274
|
+
const configuredIdleMinimum = Number(process.env.OMB_ACP_SESSION_IDLE_MIN_MS);
|
|
275
|
+
const sessionIdleMinimum = Number.isFinite(configuredIdleMinimum) && configuredIdleMinimum > 0
|
|
276
|
+
? configuredIdleMinimum
|
|
277
|
+
: 10_000;
|
|
278
|
+
const SESSION_IDLE_MS = Math.max(sessionIdleMinimum, Number(process.env.OMB_ACP_SESSION_IDLE_MS) || 10 * 60_000);
|
|
279
|
+
const closeSession = (threadId, why) => {
|
|
280
|
+
const session = sessions.get(threadId);
|
|
281
|
+
if (!session || session.closing)
|
|
282
|
+
return;
|
|
283
|
+
session.closing = true;
|
|
284
|
+
if (session.idleTimer)
|
|
285
|
+
clearTimeout(session.idleTimer);
|
|
286
|
+
appendNative(threadId, { dir: "out", source: SOURCE, msg: { close: why } });
|
|
287
|
+
// a new turn must never adopt a closing session
|
|
288
|
+
sessions.delete(threadId);
|
|
289
|
+
session.acp.close();
|
|
290
|
+
// stdin EOF asks the agent to exit; EOF is not a guaranteed exit
|
|
291
|
+
// signal for ACP agents, so insist after a grace period
|
|
292
|
+
try {
|
|
293
|
+
session.child.stdin.end();
|
|
294
|
+
}
|
|
295
|
+
catch { }
|
|
296
|
+
const kill = setTimeout(() => {
|
|
297
|
+
void killCliTree(session.child);
|
|
298
|
+
}, 5_000);
|
|
299
|
+
kill.unref?.();
|
|
300
|
+
};
|
|
301
|
+
const armIdle = (threadId) => {
|
|
302
|
+
const session = sessions.get(threadId);
|
|
303
|
+
if (!session)
|
|
304
|
+
return;
|
|
305
|
+
if (session.idleTimer)
|
|
306
|
+
clearTimeout(session.idleTimer);
|
|
307
|
+
session.idleTimer = setTimeout(() => closeSession(threadId, "idle"), SESSION_IDLE_MS);
|
|
308
|
+
session.idleTimer.unref?.();
|
|
309
|
+
};
|
|
201
310
|
// "Always allow this session", remembered by the driver when the agent
|
|
202
311
|
// offered no `allow_always` of its own: the exact operations (kind,
|
|
203
312
|
// title, command, input, locations) a person allowed for the session,
|
|
@@ -297,128 +406,142 @@ export function createAcpDriver(support) {
|
|
|
297
406
|
}
|
|
298
407
|
return servers;
|
|
299
408
|
};
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
const cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model
|
|
332
|
-
? { ...turn, model: resolvedModel }
|
|
333
|
-
: turn;
|
|
334
|
-
const mcpServers = acpMcpServers(turn);
|
|
335
|
-
let launch;
|
|
336
|
-
try {
|
|
337
|
-
launch = support.resolveCommand
|
|
338
|
-
? await support.resolveCommand(env, turnConfig, instanceId)
|
|
339
|
-
: { command: turnConfig.cli };
|
|
340
|
-
}
|
|
341
|
-
catch (error) {
|
|
342
|
-
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
409
|
+
/** The one completion path for a turn: the prompt result, a crashed
|
|
410
|
+
* child, an unanswered cancel, or an rpc error the turn body throws.
|
|
411
|
+
* The child is NOT killed here — a clean settle leaves it pooled for
|
|
412
|
+
* the next turn on this contract. */
|
|
413
|
+
const settle = (threadId, session, ok, stopReason) => {
|
|
414
|
+
const current = session.current;
|
|
415
|
+
if (!current || current.state.settled)
|
|
416
|
+
return;
|
|
417
|
+
current.state.settled = true;
|
|
418
|
+
if (current.interruptTimer)
|
|
419
|
+
clearTimeout(current.interruptTimer);
|
|
420
|
+
for (const finish of current.asks.values())
|
|
421
|
+
finish("cancel", "system");
|
|
422
|
+
session.acp.failAll(new Error("turn settled"));
|
|
423
|
+
// detach before the final events: a listener that starts the next
|
|
424
|
+
// turn synchronously must find this session free
|
|
425
|
+
session.current = null;
|
|
426
|
+
active.delete(threadId);
|
|
427
|
+
current.flushAssistantText();
|
|
428
|
+
// `end_turn` with nothing to show for it — no reply, no image, no
|
|
429
|
+
// tool result — is a lost turn, not a success. An engine can report
|
|
430
|
+
// exactly that (a provider may cut a reasoning-only stream and
|
|
431
|
+
// still answer end_turn), and ok:true would end the thread quietly
|
|
432
|
+
// while the person's message went unanswered. Keep the completion,
|
|
433
|
+
// but report it as a failure so terminal chips, incidents and
|
|
434
|
+
// follow-ups see what happened.
|
|
435
|
+
let finalOk = ok;
|
|
436
|
+
let finalStopReason = stopReason;
|
|
437
|
+
if (finalOk && finalStopReason === null && !current.state.producedItem) {
|
|
438
|
+
finalOk = false;
|
|
439
|
+
finalStopReason = "empty_turn";
|
|
343
440
|
emit({
|
|
344
|
-
...base(threadId, turnId),
|
|
441
|
+
...base(threadId, current.turnId),
|
|
345
442
|
type: "runtime.error",
|
|
346
|
-
message:
|
|
347
|
-
setup: true,
|
|
443
|
+
message: `${DRIVER_KIND} ended the turn with no reply, image, or tool result`,
|
|
348
444
|
});
|
|
349
|
-
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "setup_required", cost: null });
|
|
350
|
-
return { turnId };
|
|
351
445
|
}
|
|
352
|
-
|
|
446
|
+
emit({ ...base(threadId, current.turnId), type: "turn.completed", ok: finalOk, stopReason: finalStopReason, cost: null });
|
|
447
|
+
if (session.child.exitCode === null && !session.closing && !session.dead) {
|
|
448
|
+
armIdle(threadId);
|
|
449
|
+
}
|
|
450
|
+
else if (session.dead && sessions.get(threadId) === session) {
|
|
451
|
+
// a dead session is never pooled; the next turn respawns
|
|
452
|
+
sessions.delete(threadId);
|
|
453
|
+
}
|
|
454
|
+
};
|
|
455
|
+
/** Spawn the agent process and everything that lives for its whole
|
|
456
|
+
* lifetime: the wire connection, native logging, stderr tailing, and
|
|
457
|
+
* the server-request/update dispatch. Per-turn state arrives through
|
|
458
|
+
* session.current, so a request that lands between turns is answered
|
|
459
|
+
* (never brokered) instead of left hanging. */
|
|
460
|
+
const openSession = (threadId, launch, argv, env, cwd, contractKey) => {
|
|
461
|
+
const child = spawnCli(launch.command, argv, {
|
|
353
462
|
cwd,
|
|
354
|
-
env
|
|
463
|
+
env,
|
|
355
464
|
stdio: ["pipe", "pipe", "pipe"],
|
|
356
465
|
});
|
|
357
|
-
const state = { settled: false, promptSent: false, text: "" };
|
|
358
|
-
const asks = new Map();
|
|
359
466
|
let nextId = 1;
|
|
360
|
-
let sessionId = null;
|
|
361
|
-
let sessionConfigResult = null;
|
|
362
|
-
const modelOf = (result) => {
|
|
363
|
-
const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
|
|
364
|
-
return typeof option?.currentValue === "string" ? option.currentValue : null;
|
|
365
|
-
};
|
|
366
|
-
const receiveModelVariants = (result) => {
|
|
367
|
-
sessionConfigResult = result;
|
|
368
|
-
if (!support.modelVariants)
|
|
369
|
-
return;
|
|
370
|
-
const nativeModel = modelOf(result) ?? cliTurn.model;
|
|
371
|
-
if (!nativeModel)
|
|
372
|
-
return;
|
|
373
|
-
const option = acpVariantOption(result);
|
|
374
|
-
emit({
|
|
375
|
-
...base(threadId, turnId),
|
|
376
|
-
type: "session.model-variants",
|
|
377
|
-
model: nativeModel === cliTurn.model ? (turn.model ?? nativeModel) : nativeModel,
|
|
378
|
-
variants: {
|
|
379
|
-
options: option?.options ?? [],
|
|
380
|
-
...(option?.currentValue !== undefined ? { currentValue: option.currentValue } : {}),
|
|
381
|
-
},
|
|
382
|
-
});
|
|
383
|
-
};
|
|
384
|
-
const requestedVariantOption = () => {
|
|
385
|
-
if (!support.modelVariants)
|
|
386
|
-
throw new Error(`${support.displayName} does not support model variants`);
|
|
387
|
-
const option = acpVariantOption(sessionConfigResult);
|
|
388
|
-
if (!option || !option.options.some((entry) => entry.id === turn.variant)) {
|
|
389
|
-
throw new Error(`${support.displayName} does not advertise variant ${turn.variant} for this model`);
|
|
390
|
-
}
|
|
391
|
-
return option;
|
|
392
|
-
};
|
|
393
|
-
let interruptTimer = null;
|
|
394
467
|
const rpcPending = new Map();
|
|
395
468
|
const send = (obj) => {
|
|
469
|
+
// A permission/file response resumes an agent that was waiting on us.
|
|
470
|
+
const message = obj;
|
|
471
|
+
if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
|
|
472
|
+
for (const pending of rpcPending.values())
|
|
473
|
+
pending.armIdle();
|
|
474
|
+
}
|
|
396
475
|
try {
|
|
397
476
|
child.stdin.write(JSON.stringify(obj) + "\n");
|
|
398
477
|
}
|
|
399
478
|
catch { }
|
|
400
479
|
appendNative(threadId, { dir: "out", source: SOURCE, msg: nativeLogMessage(obj) });
|
|
401
480
|
};
|
|
402
|
-
const request = (method, params, timeoutMs, receive) => new Promise((resolve, reject) => {
|
|
481
|
+
const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => new Promise((resolve, reject) => {
|
|
403
482
|
const id = nextId++;
|
|
404
483
|
let timer = null;
|
|
405
484
|
if (timeoutMs) {
|
|
406
485
|
timer = setTimeout(() => {
|
|
407
486
|
rpcPending.delete(id);
|
|
408
|
-
reject(new Error(`${method} timed out`));
|
|
487
|
+
reject(Object.assign(new Error(`${method} timed out`), { acpSessionFailure: true }));
|
|
409
488
|
}, timeoutMs);
|
|
410
489
|
timer.unref?.();
|
|
411
490
|
}
|
|
491
|
+
// Idle watchdog: unlike a wall-clock timeout, the deadline restarts
|
|
492
|
+
// on every inbound line (see the stdout handler), so a long-lived
|
|
493
|
+
// streaming agent is never cut off — only one that has gone fully
|
|
494
|
+
// silent trips it.
|
|
495
|
+
let idleTimer = null;
|
|
496
|
+
const armIdle = () => {
|
|
497
|
+
if (!(idleMs && idleMs > 0))
|
|
498
|
+
return;
|
|
499
|
+
if (idleTimer)
|
|
500
|
+
clearTimeout(idleTimer);
|
|
501
|
+
idleTimer = setTimeout(() => {
|
|
502
|
+
// Waiting for a person is not an unresponsive agent.
|
|
503
|
+
if (session.current?.asks.size) {
|
|
504
|
+
armIdle();
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
rpcPending.delete(id);
|
|
508
|
+
const error = new Error(idleMessage ?? `${method} stopped responding`);
|
|
509
|
+
Object.assign(error, { acpPromptStall: true });
|
|
510
|
+
reject(error);
|
|
511
|
+
}, idleMs);
|
|
512
|
+
idleTimer.unref?.();
|
|
513
|
+
};
|
|
514
|
+
armIdle();
|
|
412
515
|
rpcPending.set(id, {
|
|
516
|
+
method,
|
|
413
517
|
// Consume configuration in wire order: an update following this
|
|
414
518
|
// response may arrive before the awaiting continuation resumes.
|
|
415
519
|
resolve: (result) => { receive?.(result); resolve(result); },
|
|
416
520
|
reject,
|
|
417
521
|
timer,
|
|
522
|
+
get idleTimer() { return idleTimer; },
|
|
523
|
+
armIdle,
|
|
418
524
|
});
|
|
419
525
|
send({ jsonrpc: "2.0", id, method, params });
|
|
420
526
|
});
|
|
421
|
-
const
|
|
527
|
+
const acp = {
|
|
528
|
+
send,
|
|
529
|
+
request,
|
|
530
|
+
failAll: (error) => {
|
|
531
|
+
for (const p of rpcPending.values()) {
|
|
532
|
+
if (p.timer)
|
|
533
|
+
clearTimeout(p.timer);
|
|
534
|
+
if (p.idleTimer)
|
|
535
|
+
clearTimeout(p.idleTimer);
|
|
536
|
+
p.reject(error);
|
|
537
|
+
}
|
|
538
|
+
rpcPending.clear();
|
|
539
|
+
},
|
|
540
|
+
close: () => {
|
|
541
|
+
acp.failAll(new Error("session closed"));
|
|
542
|
+
child.stdout.removeAllListeners("data");
|
|
543
|
+
},
|
|
544
|
+
};
|
|
422
545
|
const resolveClientPath = async (requestPath) => {
|
|
423
546
|
if (typeof requestPath !== "string" || !isAbsolute(requestPath)) {
|
|
424
547
|
throw new Error("ACP file paths must be absolute.");
|
|
@@ -469,8 +592,8 @@ export function createAcpDriver(support) {
|
|
|
469
592
|
send({ jsonrpc: "2.0", id: msg.id, result: { content } });
|
|
470
593
|
return;
|
|
471
594
|
}
|
|
472
|
-
const line = Number.isInteger(params.line) && params.line > 0 ? params.line : 1;
|
|
473
|
-
const limit = Number.isInteger(params.limit) && params.limit >= 0 ? params.limit : undefined;
|
|
595
|
+
const line = typeof params.line === "number" && Number.isInteger(params.line) && params.line > 0 ? params.line : 1;
|
|
596
|
+
const limit = typeof params.limit === "number" && Number.isInteger(params.limit) && params.limit >= 0 ? params.limit : undefined;
|
|
474
597
|
const lines = content.split("\n");
|
|
475
598
|
const start = line - 1;
|
|
476
599
|
send({
|
|
@@ -491,35 +614,9 @@ export function createAcpDriver(support) {
|
|
|
491
614
|
fail(error);
|
|
492
615
|
}
|
|
493
616
|
};
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
state.text = "";
|
|
498
|
-
if (!text.trim())
|
|
499
|
-
return;
|
|
500
|
-
emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text });
|
|
501
|
-
};
|
|
502
|
-
const settle = (ok, stopReason) => {
|
|
503
|
-
if (state.settled)
|
|
504
|
-
return;
|
|
505
|
-
state.settled = true;
|
|
506
|
-
if (interruptTimer)
|
|
507
|
-
clearTimeout(interruptTimer);
|
|
508
|
-
for (const finish of asks.values())
|
|
509
|
-
finish("cancel", "system");
|
|
510
|
-
for (const p of rpcPending.values()) {
|
|
511
|
-
if (p.timer)
|
|
512
|
-
clearTimeout(p.timer);
|
|
513
|
-
p.reject(new Error("turn settled"));
|
|
514
|
-
}
|
|
515
|
-
rpcPending.clear();
|
|
516
|
-
active.delete(threadId);
|
|
517
|
-
flushAssistantText();
|
|
518
|
-
emit({ ...base(threadId, turnId), type: "turn.completed", ok, stopReason, cost: null });
|
|
519
|
-
stop(); // the agent process does not exit on its own
|
|
520
|
-
};
|
|
521
|
-
// server→client permission request → canonical request.opened
|
|
522
|
-
const handleServerRequest = (msg) => {
|
|
617
|
+
// server→client permission request → canonical request.opened,
|
|
618
|
+
// answered fail-closed for the running turn
|
|
619
|
+
const handleServerRequest = (msg, current) => {
|
|
523
620
|
if (msg.method === "fs/read_text_file" || msg.method === "fs/write_text_file") {
|
|
524
621
|
void handleClientFileRequest(msg);
|
|
525
622
|
return;
|
|
@@ -529,7 +626,7 @@ export function createAcpDriver(support) {
|
|
|
529
626
|
return send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } });
|
|
530
627
|
}
|
|
531
628
|
const params = msg.params ?? {};
|
|
532
|
-
flushAssistantText();
|
|
629
|
+
current.flushAssistantText();
|
|
533
630
|
const options = Array.isArray(params.options) ? params.options : [];
|
|
534
631
|
const optionFor = (want) => options.find((o) => o.kind === `${want}_once` && typeof o.optionId === "string")?.optionId
|
|
535
632
|
?? options.find((o) => String(o.kind ?? "").startsWith(want) && typeof o.optionId === "string")?.optionId
|
|
@@ -537,13 +634,13 @@ export function createAcpDriver(support) {
|
|
|
537
634
|
const optionAlways = options.find((o) => o.kind === "allow_always" && typeof o.optionId === "string")?.optionId ?? null;
|
|
538
635
|
const cancelled = { outcome: { outcome: "cancelled" } };
|
|
539
636
|
const missing = (want) => emit({
|
|
540
|
-
...base(threadId, turnId),
|
|
637
|
+
...base(threadId, current.turnId),
|
|
541
638
|
type: "runtime.error",
|
|
542
639
|
message: `${DRIVER_KIND} offered no "${want}" permission option — cancelling the request instead of guessing`,
|
|
543
640
|
});
|
|
544
641
|
const toolCall = params.toolCall ?? {};
|
|
545
642
|
const isQuestion = String(toolCall.toolCallId ?? "").startsWith("interaction_");
|
|
546
|
-
if (turnConfig.fullAuto && turn.approvalMode === undefined && !isQuestion) {
|
|
643
|
+
if (current.turnConfig.fullAuto && current.turn.approvalMode === undefined && !isQuestion) {
|
|
547
644
|
const allow = optionFor("allow");
|
|
548
645
|
if (!allow)
|
|
549
646
|
missing("allow");
|
|
@@ -555,7 +652,7 @@ export function createAcpDriver(support) {
|
|
|
555
652
|
}
|
|
556
653
|
const kind = String(toolCall.kind ?? "");
|
|
557
654
|
// an earlier "Always allow this session" on this exact operation
|
|
558
|
-
const operationKey = isQuestion || controlsHost ? null : sessionOperationKey(toolCall);
|
|
655
|
+
const operationKey = isQuestion || current.controlsHost ? null : sessionOperationKey(toolCall);
|
|
559
656
|
if (operationKey && sessionAllows.get(threadId)?.has(operationKey)) {
|
|
560
657
|
const allow = optionFor("allow");
|
|
561
658
|
if (allow) {
|
|
@@ -566,11 +663,11 @@ export function createAcpDriver(support) {
|
|
|
566
663
|
const summary = String(toolCall.rawInput?.command ?? toolCall.title ?? tool).slice(0, 200);
|
|
567
664
|
const requestId = newId();
|
|
568
665
|
const finish = (behavior, source = "user", message, always) => {
|
|
569
|
-
if (!asks.delete(requestId))
|
|
666
|
+
if (!current.asks.delete(requestId))
|
|
570
667
|
return;
|
|
571
668
|
clearTimeout(timer);
|
|
572
669
|
const want = behavior === "allow" ? "allow" : "reject";
|
|
573
|
-
const forSession = want === "allow" && always === true && !isQuestion && !controlsHost;
|
|
670
|
+
const forSession = want === "allow" && always === true && !isQuestion && !current.controlsHost;
|
|
574
671
|
const named = isQuestion && behavior === "answer"
|
|
575
672
|
? options.filter((option) => option.optionId === message || option.name?.trim() === message)
|
|
576
673
|
: [];
|
|
@@ -596,22 +693,22 @@ export function createAcpDriver(support) {
|
|
|
596
693
|
result: optionId ? { outcome: { outcome: "selected", optionId } } : cancelled,
|
|
597
694
|
});
|
|
598
695
|
emit({
|
|
599
|
-
...base(threadId, turnId),
|
|
696
|
+
...base(threadId, current.turnId),
|
|
600
697
|
type: "request.resolved",
|
|
601
698
|
requestId,
|
|
602
699
|
behavior: optionId && isQuestion ? "answer" : optionId && behavior === "allow" ? "allow" : "deny",
|
|
603
700
|
source: optionId ? source : "system",
|
|
604
|
-
approvalScope: controlsHost ? "local-computer" : undefined,
|
|
701
|
+
approvalScope: current.controlsHost ? "local-computer" : undefined,
|
|
605
702
|
});
|
|
606
703
|
};
|
|
607
704
|
const timer = setTimeout(() => {
|
|
608
|
-
emit({ ...base(threadId, turnId), type: "runtime.error", message: DENY_TIMEOUT_NOTE });
|
|
705
|
+
emit({ ...base(threadId, current.turnId), type: "runtime.error", message: DENY_TIMEOUT_NOTE });
|
|
609
706
|
finish("deny", "timeout");
|
|
610
707
|
}, 15 * 60_000);
|
|
611
708
|
timer.unref?.();
|
|
612
|
-
asks.set(requestId, finish);
|
|
709
|
+
current.asks.set(requestId, finish);
|
|
613
710
|
emit({
|
|
614
|
-
...base(threadId, turnId),
|
|
711
|
+
...base(threadId, current.turnId),
|
|
615
712
|
type: "request.opened",
|
|
616
713
|
requestId,
|
|
617
714
|
requestType: isQuestion ? "question" : "permission",
|
|
@@ -620,9 +717,9 @@ export function createAcpDriver(support) {
|
|
|
620
717
|
choices: isQuestion
|
|
621
718
|
? options.flatMap((option) => typeof option.name === "string" && option.name.trim() ? [option.name.trim()] : [])
|
|
622
719
|
: undefined,
|
|
623
|
-
approvalScope: controlsHost ? "local-computer" : undefined,
|
|
720
|
+
approvalScope: current.controlsHost ? "local-computer" : undefined,
|
|
624
721
|
// the driver can honor a session-wide allow either way
|
|
625
|
-
allowSession: !isQuestion && !controlsHost ? true : undefined,
|
|
722
|
+
allowSession: !isQuestion && !current.controlsHost ? true : undefined,
|
|
626
723
|
});
|
|
627
724
|
};
|
|
628
725
|
const handleNotification = (msg) => {
|
|
@@ -633,12 +730,13 @@ export function createAcpDriver(support) {
|
|
|
633
730
|
const p = msg.params ?? {};
|
|
634
731
|
if (p._meta?.isReplay === true)
|
|
635
732
|
return;
|
|
733
|
+
const current = session.current;
|
|
636
734
|
if (support.modelVariants && p.update?.sessionUpdate === "config_option_update") {
|
|
637
|
-
if (!state.settled && sessionId && p.sessionId === sessionId)
|
|
638
|
-
receiveModelVariants(p.update);
|
|
735
|
+
if (current && !current.state.settled && session.sessionId && p.sessionId === session.sessionId)
|
|
736
|
+
current.receiveModelVariants(p.update);
|
|
639
737
|
return;
|
|
640
738
|
}
|
|
641
|
-
if (!state.promptSent)
|
|
739
|
+
if (!current || !current.state.promptSent)
|
|
642
740
|
return;
|
|
643
741
|
const u = p.update ?? {};
|
|
644
742
|
switch (u.sessionUpdate) {
|
|
@@ -646,9 +744,10 @@ export function createAcpDriver(support) {
|
|
|
646
744
|
const content = u.content;
|
|
647
745
|
const delta = content?.text;
|
|
648
746
|
if (content?.type === "image" && typeof content.data === "string" && content.data) {
|
|
649
|
-
flushAssistantText();
|
|
747
|
+
current.flushAssistantText();
|
|
748
|
+
current.state.producedItem = true;
|
|
650
749
|
emit({
|
|
651
|
-
...base(threadId, turnId),
|
|
750
|
+
...base(threadId, current.turnId),
|
|
652
751
|
type: "item.completed",
|
|
653
752
|
itemType: "assistant_image",
|
|
654
753
|
data: content.data,
|
|
@@ -656,22 +755,22 @@ export function createAcpDriver(support) {
|
|
|
656
755
|
});
|
|
657
756
|
}
|
|
658
757
|
else if (typeof delta === "string" && delta) {
|
|
659
|
-
state.text += delta;
|
|
660
|
-
emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "assistant_text", delta });
|
|
758
|
+
current.state.text += delta;
|
|
759
|
+
emit({ ...base(threadId, current.turnId), type: "content.delta", streamKind: "assistant_text", delta });
|
|
661
760
|
}
|
|
662
761
|
break;
|
|
663
762
|
}
|
|
664
763
|
case "agent_thought_chunk": {
|
|
665
764
|
const delta = u.content?.text;
|
|
666
765
|
if (typeof delta === "string" && delta) {
|
|
667
|
-
emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "reasoning_text", delta });
|
|
766
|
+
emit({ ...base(threadId, current.turnId), type: "content.delta", streamKind: "reasoning_text", delta });
|
|
668
767
|
}
|
|
669
768
|
break;
|
|
670
769
|
}
|
|
671
770
|
case "tool_call": {
|
|
672
|
-
flushAssistantText();
|
|
771
|
+
current.flushAssistantText();
|
|
673
772
|
emit({
|
|
674
|
-
...base(threadId, turnId),
|
|
773
|
+
...base(threadId, current.turnId),
|
|
675
774
|
type: "item.started",
|
|
676
775
|
itemType: "tool",
|
|
677
776
|
itemId: u.toolCallId,
|
|
@@ -683,19 +782,40 @@ export function createAcpDriver(support) {
|
|
|
683
782
|
}
|
|
684
783
|
case "tool_call_update": {
|
|
685
784
|
if (u.status === "completed" || u.status === "failed") {
|
|
785
|
+
current.state.producedItem = true;
|
|
686
786
|
emit({
|
|
687
|
-
...base(threadId, turnId),
|
|
787
|
+
...base(threadId, current.turnId),
|
|
688
788
|
type: "item.completed",
|
|
689
789
|
itemType: "tool",
|
|
690
790
|
itemId: u.toolCallId,
|
|
691
791
|
ok: u.status !== "failed",
|
|
692
792
|
output: toolDetailPreview(u.rawOutput ?? u.content),
|
|
693
793
|
});
|
|
794
|
+
for (const img of extractMcpImages(u.content ?? u.rawOutput)) {
|
|
795
|
+
emit({ ...base(threadId, current.turnId), type: "item.completed", itemType: "assistant_image", data: img.data });
|
|
796
|
+
}
|
|
694
797
|
}
|
|
695
798
|
break;
|
|
696
799
|
}
|
|
697
800
|
}
|
|
698
801
|
};
|
|
802
|
+
const session = {
|
|
803
|
+
child,
|
|
804
|
+
acp,
|
|
805
|
+
launch,
|
|
806
|
+
cwd,
|
|
807
|
+
contractKey,
|
|
808
|
+
sessionKey: null,
|
|
809
|
+
sessionId: null,
|
|
810
|
+
sessionConfigResult: null,
|
|
811
|
+
initResult: null,
|
|
812
|
+
authenticated: false,
|
|
813
|
+
idleTimer: null,
|
|
814
|
+
closing: false,
|
|
815
|
+
dead: false,
|
|
816
|
+
stderr: "",
|
|
817
|
+
current: null,
|
|
818
|
+
};
|
|
699
819
|
let buf = "";
|
|
700
820
|
// decode as UTF-8 across chunk boundaries — a raw `buf += chunk` splits
|
|
701
821
|
// multibyte characters that straddle two reads and corrupts the text
|
|
@@ -716,16 +836,20 @@ export function createAcpDriver(support) {
|
|
|
716
836
|
continue;
|
|
717
837
|
}
|
|
718
838
|
appendNative(threadId, { dir: "in", source: SOURCE, msg: nativeLogMessage(msg) });
|
|
839
|
+
// Inbound traffic proves the child is alive and making progress,
|
|
840
|
+
// so every idle deadline restarts; only total silence trips it.
|
|
841
|
+
for (const p of rpcPending.values())
|
|
842
|
+
p.armIdle();
|
|
719
843
|
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
720
844
|
const pend = rpcPending.get(msg.id);
|
|
721
845
|
if (pend) {
|
|
722
846
|
rpcPending.delete(msg.id);
|
|
723
847
|
if (pend.timer)
|
|
724
848
|
clearTimeout(pend.timer);
|
|
849
|
+
if (pend.idleTimer)
|
|
850
|
+
clearTimeout(pend.idleTimer);
|
|
725
851
|
if (msg.error) {
|
|
726
|
-
|
|
727
|
-
Object.assign(error, { code: msg.error.code, data: msg.error.data });
|
|
728
|
-
pend.reject(error);
|
|
852
|
+
pend.reject(acpRpcError(msg.error, pend.method));
|
|
729
853
|
}
|
|
730
854
|
else {
|
|
731
855
|
pend.resolve(msg.result);
|
|
@@ -733,112 +857,373 @@ export function createAcpDriver(support) {
|
|
|
733
857
|
}
|
|
734
858
|
}
|
|
735
859
|
else if (msg.id !== undefined && msg.method) {
|
|
736
|
-
|
|
860
|
+
const current = session.current;
|
|
861
|
+
if (!current) {
|
|
862
|
+
// between turns nothing is brokered: cancel a permission
|
|
863
|
+
// request and refuse anything else — the agent must never
|
|
864
|
+
// block on an unanswered request
|
|
865
|
+
send(msg.method === "session/request_permission"
|
|
866
|
+
? { jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }
|
|
867
|
+
: { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } });
|
|
868
|
+
}
|
|
869
|
+
else {
|
|
870
|
+
handleServerRequest(msg, current);
|
|
871
|
+
}
|
|
737
872
|
}
|
|
738
873
|
else if (msg.method) {
|
|
739
874
|
handleNotification(msg);
|
|
740
875
|
}
|
|
741
876
|
}
|
|
742
877
|
});
|
|
743
|
-
let stderr = "";
|
|
744
878
|
child.stderr.on("data", (c) => {
|
|
745
879
|
if (!support.redactStderr)
|
|
746
|
-
stderr += c;
|
|
747
|
-
if (stderr.length > 8192)
|
|
748
|
-
stderr = stderr.slice(-8192);
|
|
880
|
+
session.stderr += c;
|
|
881
|
+
if (session.stderr.length > 8192)
|
|
882
|
+
session.stderr = session.stderr.slice(-8192);
|
|
749
883
|
});
|
|
750
884
|
child.on("error", (e) => {
|
|
751
|
-
|
|
752
|
-
|
|
885
|
+
session.dead = true;
|
|
886
|
+
const current = session.current;
|
|
887
|
+
if (!current)
|
|
888
|
+
return;
|
|
889
|
+
emit({ ...base(threadId, current.turnId), type: "runtime.error", ...describeSpawnFailure(e, launch.command) });
|
|
890
|
+
settle(threadId, session, false, "spawn_error");
|
|
753
891
|
});
|
|
754
892
|
child.on("close", (code) => {
|
|
755
|
-
|
|
893
|
+
session.dead = true;
|
|
894
|
+
const current = session.current;
|
|
895
|
+
if (current) {
|
|
756
896
|
emit({
|
|
757
|
-
...base(threadId, turnId),
|
|
897
|
+
...base(threadId, current.turnId),
|
|
758
898
|
type: "runtime.error",
|
|
759
|
-
message: `${DRIVER_KIND} exited ${code} before the prompt result${stderr ? `: ${stderr.trim().slice(-300)}` : ""}`,
|
|
899
|
+
message: `${DRIVER_KIND} exited ${code} before the prompt result${session.stderr ? `: ${session.stderr.trim().slice(-300)}` : ""}`,
|
|
760
900
|
});
|
|
761
|
-
settle(false, "exit_before_result");
|
|
901
|
+
settle(threadId, session, false, "exit_before_result");
|
|
902
|
+
}
|
|
903
|
+
else if (sessions.get(threadId) === session) {
|
|
904
|
+
// drop the record between turns; a later turn respawns. The
|
|
905
|
+
// identity check keeps an old child's exit from unlinking a
|
|
906
|
+
// session that already replaced this one.
|
|
907
|
+
sessions.delete(threadId);
|
|
762
908
|
}
|
|
763
909
|
});
|
|
910
|
+
return session;
|
|
911
|
+
};
|
|
912
|
+
const sendTurn = async (turn) => {
|
|
913
|
+
const { threadId } = turn;
|
|
914
|
+
if (active.has(threadId))
|
|
915
|
+
throw new Error("a turn is already running on this thread");
|
|
916
|
+
// Provider-instance `fullAuto` predates per-bot approval levels. Every
|
|
917
|
+
// harness turn now carries the bot's mode, so Ask/Auto must explicitly
|
|
918
|
+
// put the native agent back into its interactive mode. Otherwise a
|
|
919
|
+
// legacy Grok bypassPermissions / Cursor --force / Droid auto-high /
|
|
920
|
+
// Antigravity yolo setting would silently outrank the selector. Calls
|
|
921
|
+
// that omit approvalMode retain the old adapter-level behavior for
|
|
922
|
+
// embedders and tests outside the harness.
|
|
923
|
+
const turnConfig = turn.approvalMode === undefined
|
|
924
|
+
? config
|
|
925
|
+
: { ...config, fullAuto: turn.approvalMode === "full" && supportsApprovalMode(DRIVER_KIND, "full") };
|
|
926
|
+
const controlsHost = turn.integrations?.localComputer?.scope === "local-computer";
|
|
927
|
+
if (controlsHost && turnConfig.fullAuto && turn.approvalMode !== "full") {
|
|
928
|
+
throw new Error("local computer control requires interactive provider approvals");
|
|
929
|
+
}
|
|
930
|
+
const turnId = newId();
|
|
931
|
+
const cwd = turn.cwd ?? turnConfig.workspace ?? homedir();
|
|
932
|
+
const env = childEnv(turnConfig);
|
|
933
|
+
if (support.requireAuthenticationBeforeSpawn
|
|
934
|
+
&& !skipSubscriptionAuthForLocalInject(turn.model)
|
|
935
|
+
&& !(await support.isAuthenticated(env, turnConfig, instanceId))) {
|
|
936
|
+
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
937
|
+
emit({ ...base(threadId, turnId), type: "runtime.error", message: support.loginNote, setup: true });
|
|
938
|
+
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "auth_required", cost: null });
|
|
939
|
+
return { turnId };
|
|
940
|
+
}
|
|
941
|
+
const resolvedModel = support.resolveTurnModel?.(turn.model, env);
|
|
942
|
+
support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
|
|
943
|
+
const cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model
|
|
944
|
+
? { ...turn, model: resolvedModel }
|
|
945
|
+
: turn;
|
|
946
|
+
const mcpServers = acpMcpServers(turn);
|
|
947
|
+
let launch;
|
|
948
|
+
try {
|
|
949
|
+
launch = support.resolveCommand
|
|
950
|
+
? await support.resolveCommand(env, turnConfig, instanceId)
|
|
951
|
+
: { command: turnConfig.cli };
|
|
952
|
+
}
|
|
953
|
+
catch (error) {
|
|
954
|
+
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
955
|
+
emit({
|
|
956
|
+
...base(threadId, turnId),
|
|
957
|
+
type: "runtime.error",
|
|
958
|
+
message: error instanceof Error ? error.message : String(error),
|
|
959
|
+
setup: true,
|
|
960
|
+
});
|
|
961
|
+
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "setup_required", cost: null });
|
|
962
|
+
return { turnId };
|
|
963
|
+
}
|
|
964
|
+
// The spawn contract: everything that changes what process a turn
|
|
965
|
+
// gets. The model rides argv where a support passes -m, and a
|
|
966
|
+
// selectModel support re-applies it over the wire on the live
|
|
967
|
+
// session, so the model is not a separate axis; fullAuto covers the
|
|
968
|
+
// transformEnv-policy supports (opencode). mcpServers are session
|
|
969
|
+
// establishment inputs — they ride session/new and session/load over
|
|
970
|
+
// the wire — and the harness mints fresh integration bearer tokens
|
|
971
|
+
// every turn, so they must not respawn the process; a change instead
|
|
972
|
+
// re-establishes the session below (see sessionKey).
|
|
973
|
+
// The env the spawned child actually receives is part of the
|
|
974
|
+
// contract too, and arrives hashed as envFingerprint for the same
|
|
975
|
+
// reason.
|
|
976
|
+
const spawnArgs = support.spawnArgs(turnConfig, cliTurn);
|
|
977
|
+
const spawnEnv = launch.env ?? env;
|
|
978
|
+
// Env is part of the spawn contract: a turn that changes auth env
|
|
979
|
+
// (FACTORY_API_KEY placeholder, a fresh login file) must not keep
|
|
980
|
+
// riding a child spawned under the old env. Hash it so secrets
|
|
981
|
+
// never sit in the key itself.
|
|
982
|
+
const envFingerprint = createHash("sha256").update(JSON.stringify(spawnEnv)).digest("hex").slice(0, 16);
|
|
983
|
+
const contractKey = JSON.stringify([launch.command, launch.args ?? [], spawnArgs, cwd, turnConfig.fullAuto === true, envFingerprint]);
|
|
984
|
+
const sessionKey = JSON.stringify(mcpServers);
|
|
985
|
+
if (turn.sessionReset) {
|
|
986
|
+
closeSession(threadId, "reset");
|
|
987
|
+
sessionAllows.delete(threadId);
|
|
988
|
+
}
|
|
989
|
+
const pooled = sessions.get(threadId);
|
|
990
|
+
let session;
|
|
991
|
+
if (pooled && !pooled.dead && !pooled.closing && pooled.contractKey === contractKey) {
|
|
992
|
+
// adoption cancels the idle countdown — a running turn is not quiet
|
|
993
|
+
if (pooled.idleTimer)
|
|
994
|
+
clearTimeout(pooled.idleTimer);
|
|
995
|
+
pooled.idleTimer = null;
|
|
996
|
+
session = pooled;
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
if (pooled) {
|
|
1000
|
+
// a dead child already exited — just drop the record; a live one
|
|
1001
|
+
// gets the full close (contract changed)
|
|
1002
|
+
if (pooled.dead)
|
|
1003
|
+
sessions.delete(threadId);
|
|
1004
|
+
else
|
|
1005
|
+
closeSession(threadId, "contract");
|
|
1006
|
+
}
|
|
1007
|
+
session = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], spawnEnv, cwd, contractKey);
|
|
1008
|
+
sessions.set(threadId, session);
|
|
1009
|
+
}
|
|
1010
|
+
// `session` rebinds mid-turn: when the establishment retry below
|
|
1011
|
+
// respawns the child, every wire call must reach the live record, so
|
|
1012
|
+
// nothing captures the connection off it.
|
|
1013
|
+
const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => session.acp.request(method, params, timeoutMs, receive, idleMs, idleMessage);
|
|
1014
|
+
const state = { settled: false, promptSent: false, text: "", producedItem: false };
|
|
1015
|
+
const asks = new Map();
|
|
1016
|
+
const modelOf = (result) => {
|
|
1017
|
+
const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
|
|
1018
|
+
return typeof option?.currentValue === "string" ? option.currentValue : null;
|
|
1019
|
+
};
|
|
1020
|
+
const receiveModelVariants = (result) => {
|
|
1021
|
+
session.sessionConfigResult = result;
|
|
1022
|
+
if (!support.modelVariants)
|
|
1023
|
+
return;
|
|
1024
|
+
const nativeModel = modelOf(result) ?? cliTurn.model;
|
|
1025
|
+
if (!nativeModel)
|
|
1026
|
+
return;
|
|
1027
|
+
const option = acpVariantOption(result);
|
|
1028
|
+
emit({
|
|
1029
|
+
...base(threadId, turnId),
|
|
1030
|
+
type: "session.model-variants",
|
|
1031
|
+
model: nativeModel === cliTurn.model ? (turn.model ?? nativeModel) : nativeModel,
|
|
1032
|
+
variants: {
|
|
1033
|
+
options: option?.options ?? [],
|
|
1034
|
+
...(option?.currentValue !== undefined ? { currentValue: option.currentValue } : {}),
|
|
1035
|
+
},
|
|
1036
|
+
});
|
|
1037
|
+
};
|
|
1038
|
+
const requestedVariantOption = () => {
|
|
1039
|
+
if (!support.modelVariants)
|
|
1040
|
+
throw new Error(`${support.displayName} does not support model variants`);
|
|
1041
|
+
const option = acpVariantOption(session.sessionConfigResult);
|
|
1042
|
+
if (!option || !option.options.some((entry) => entry.id === turn.variant)) {
|
|
1043
|
+
throw new Error(`${support.displayName} does not advertise variant ${turn.variant} for this model`);
|
|
1044
|
+
}
|
|
1045
|
+
return option;
|
|
1046
|
+
};
|
|
1047
|
+
/** Emit buffered assistant text as its own item, then clear it. */
|
|
1048
|
+
const flushAssistantText = () => {
|
|
1049
|
+
const text = state.text;
|
|
1050
|
+
state.text = "";
|
|
1051
|
+
if (!text.trim())
|
|
1052
|
+
return;
|
|
1053
|
+
state.producedItem = true;
|
|
1054
|
+
emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text });
|
|
1055
|
+
};
|
|
1056
|
+
const current = {
|
|
1057
|
+
turnId,
|
|
1058
|
+
turn: cliTurn,
|
|
1059
|
+
turnConfig,
|
|
1060
|
+
controlsHost,
|
|
1061
|
+
state,
|
|
1062
|
+
asks,
|
|
1063
|
+
interruptTimer: null,
|
|
1064
|
+
flushAssistantText,
|
|
1065
|
+
receiveModelVariants,
|
|
1066
|
+
};
|
|
764
1067
|
const interrupt = () => {
|
|
765
|
-
if (sessionId)
|
|
766
|
-
send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1068
|
+
if (session.sessionId) {
|
|
1069
|
+
session.acp.send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId: session.sessionId } });
|
|
1070
|
+
if (current.interruptTimer)
|
|
1071
|
+
clearTimeout(current.interruptTimer);
|
|
1072
|
+
current.interruptTimer = setTimeout(() => {
|
|
1073
|
+
// an agent that ignores session/cancel must not stay pooled
|
|
1074
|
+
closeSession(threadId, "cancel-timeout");
|
|
1075
|
+
settle(threadId, session, true, "cancelled");
|
|
1076
|
+
}, 5_000);
|
|
1077
|
+
current.interruptTimer.unref?.();
|
|
1078
|
+
}
|
|
1079
|
+
else {
|
|
1080
|
+
// no native session to cancel — the close handler settles
|
|
1081
|
+
closeSession(threadId, "stop");
|
|
1082
|
+
}
|
|
773
1083
|
};
|
|
774
|
-
active.set(threadId, { stop, interrupt, turnId, asks });
|
|
1084
|
+
active.set(threadId, { stop: () => closeSession(threadId, "stop"), interrupt, turnId, asks });
|
|
775
1085
|
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
1086
|
+
session.current = current;
|
|
776
1087
|
(async () => {
|
|
777
1088
|
try {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
1089
|
+
// The handshake is paid once per process, not once per turn. It
|
|
1090
|
+
// is a function so the establishment retry below can pay it
|
|
1091
|
+
// again on a replacement child.
|
|
1092
|
+
// Returns whether this runtime accepts image prompts, for the
|
|
1093
|
+
// prompt phase below.
|
|
1094
|
+
const handshake = async () => {
|
|
1095
|
+
if (!session.initResult) {
|
|
1096
|
+
session.initResult = await request("initialize", {
|
|
1097
|
+
protocolVersion: 1,
|
|
1098
|
+
clientInfo: { name: "openmausbot", version: "0.0.0" },
|
|
1099
|
+
clientCapabilities: {
|
|
1100
|
+
fs: {
|
|
1101
|
+
readTextFile: support.clientFileSystem === true,
|
|
1102
|
+
writeTextFile: support.clientFileSystem === true,
|
|
1103
|
+
},
|
|
1104
|
+
terminal: false,
|
|
1105
|
+
},
|
|
1106
|
+
}, INIT_TIMEOUT);
|
|
1107
|
+
}
|
|
1108
|
+
// authenticate is once per process; a turn that skips
|
|
1109
|
+
// subscription auth neither checks nor marks the flag
|
|
1110
|
+
if (!skipSubscriptionAuthForLocalInject(turn.model) && !session.authenticated) {
|
|
1111
|
+
const methods = Array.isArray(session.initResult?.authMethods)
|
|
1112
|
+
? session.initResult.authMethods
|
|
1113
|
+
: [];
|
|
1114
|
+
const methodId = support.pickAuthMethod(methods);
|
|
1115
|
+
if (methodId) {
|
|
1116
|
+
try {
|
|
1117
|
+
await request("authenticate", { methodId }, INIT_TIMEOUT);
|
|
1118
|
+
session.authenticated = true;
|
|
1119
|
+
}
|
|
1120
|
+
catch {
|
|
1121
|
+
if (support.authFailure === "fail")
|
|
1122
|
+
throw new Error(support.loginNote);
|
|
1123
|
+
// else: proceed on an ambient login
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
else if (support.authFailure === "fail") {
|
|
1127
|
+
throw new Error(support.loginNote);
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
const images = turn.images ?? [];
|
|
1131
|
+
const accepts = session.initResult?.agentCapabilities?.promptCapabilities?.image === true ||
|
|
1132
|
+
support.acceptsUnadvertisedImages?.(session.initResult) === true;
|
|
1133
|
+
if (images.length && support.images === true && !accepts) {
|
|
1134
|
+
throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
|
|
1135
|
+
}
|
|
1136
|
+
return accepts;
|
|
1137
|
+
};
|
|
1138
|
+
let runtimeAcceptsImages = await handshake();
|
|
1139
|
+
let init = session.initResult;
|
|
1140
|
+
const cursor = !turn.sessionReset && typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
|
|
1141
|
+
let sessionResult = null;
|
|
1142
|
+
let promptTurn = turn;
|
|
1143
|
+
let rebuiltFromReplay = false;
|
|
1144
|
+
for (;;) {
|
|
1145
|
+
const liveSessionId = session.sessionId;
|
|
1146
|
+
if (liveSessionId !== null && session.sessionKey === sessionKey && (cursor === null || cursor === liveSessionId)) {
|
|
1147
|
+
// the pooled session still answers the cursor (or the cursor's
|
|
1148
|
+
// absence) and was established with these exact session inputs:
|
|
1149
|
+
// prompt it directly — no session/load replay, no session/new,
|
|
1150
|
+
// no fresh session bookkeeping
|
|
1151
|
+
break;
|
|
1152
|
+
}
|
|
1153
|
+
// stdio is every agent's baseline; a url server rides only with
|
|
1154
|
+
// an agent that advertised its transport, so an agent without
|
|
1155
|
+
// http/sse never sees an entry it would refuse the session over
|
|
1156
|
+
const sessionServers = mcpServers.filter((server) => !("type" in server) || init?.agentCapabilities?.mcpCapabilities?.[server.type] === true);
|
|
1157
|
+
let loaded = false;
|
|
1158
|
+
if (cursor) {
|
|
793
1159
|
try {
|
|
794
|
-
await request("
|
|
1160
|
+
await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers: sessionServers }, LOAD_SESSION_TIMEOUT, (result) => {
|
|
1161
|
+
if (result) {
|
|
1162
|
+
loaded = true;
|
|
1163
|
+
session.sessionId = cursor;
|
|
1164
|
+
session.sessionKey = sessionKey;
|
|
1165
|
+
receiveModelVariants(result);
|
|
1166
|
+
}
|
|
1167
|
+
});
|
|
795
1168
|
}
|
|
796
|
-
catch {
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
//
|
|
1169
|
+
catch (error) {
|
|
1170
|
+
const classification = support.classifyError?.(error);
|
|
1171
|
+
// OpenCode encodes ACPSessionNotFoundError as invalidParams
|
|
1172
|
+
// with just the rejected sessionId. Other invalidParams
|
|
1173
|
+
// responses (model/config errors) must not erase history.
|
|
1174
|
+
const data = error?.data;
|
|
1175
|
+
const missingSession = data && typeof data === "object" && !Array.isArray(data)
|
|
1176
|
+
&& data.sessionId === cursor && Object.keys(data).length === 1;
|
|
1177
|
+
if (classification === "invalid_credentials" || classification === "inactive_subscription"
|
|
1178
|
+
|| (error?.code === -32602 && !missingSession))
|
|
1179
|
+
throw error;
|
|
1180
|
+
/* session gone, load unsupported, or too slow — the
|
|
1181
|
+
* fallbacks below choose between one fresh process and a
|
|
1182
|
+
* genuinely new session */
|
|
800
1183
|
}
|
|
801
1184
|
}
|
|
802
|
-
|
|
803
|
-
|
|
1185
|
+
if (loaded)
|
|
1186
|
+
break;
|
|
1187
|
+
if (cursor && liveSessionId === cursor) {
|
|
1188
|
+
// The agent refused (or never answered) re-establishing its
|
|
1189
|
+
// own live session on this process. Continuity outranks the
|
|
1190
|
+
// saved handshake: close the pooled child and resume the
|
|
1191
|
+
// recorded session on a fresh one — the pre-pool path every
|
|
1192
|
+
// agent already supports. A load that fails there too means
|
|
1193
|
+
// the session is genuinely gone; the loop falls through to
|
|
1194
|
+
// session/new on the replacement child.
|
|
1195
|
+
session.current = null;
|
|
1196
|
+
closeSession(threadId, "reestablish");
|
|
1197
|
+
session = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], spawnEnv, cwd, contractKey);
|
|
1198
|
+
sessions.set(threadId, session);
|
|
1199
|
+
session.current = current;
|
|
1200
|
+
runtimeAcceptsImages = await handshake();
|
|
1201
|
+
init = session.initResult;
|
|
1202
|
+
continue;
|
|
804
1203
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true ||
|
|
808
|
-
support.acceptsUnadvertisedImages?.(init) === true;
|
|
809
|
-
if (images.length && support.images === true && !runtimeAcceptsImages) {
|
|
810
|
-
throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
|
|
811
|
-
}
|
|
812
|
-
// stdio is every agent's baseline; a url server rides only with
|
|
813
|
-
// an agent that advertised its transport, so an agent without
|
|
814
|
-
// http/sse never sees an entry it would refuse the session over
|
|
815
|
-
const sessionServers = mcpServers.filter((server) => !("type" in server) || init?.agentCapabilities?.mcpCapabilities?.[server.type] === true);
|
|
816
|
-
const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
|
|
817
|
-
// a fresh native session forgets what the previous one allowed
|
|
818
|
-
if (!cursor)
|
|
1204
|
+
// a genuinely fresh native session forgets what the previous
|
|
1205
|
+
// one allowed
|
|
819
1206
|
sessionAllows.delete(threadId);
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
sessionId = cursor;
|
|
826
|
-
receiveModelVariants(result);
|
|
827
|
-
}
|
|
1207
|
+
if (cursor) {
|
|
1208
|
+
const recovery = recoveryPromptFor({
|
|
1209
|
+
recoveryText: turn.recoveryText,
|
|
1210
|
+
currentText: turn.text,
|
|
1211
|
+
failure: "before-accept",
|
|
828
1212
|
});
|
|
1213
|
+
promptTurn = { ...turn, text: recovery.text };
|
|
1214
|
+
rebuiltFromReplay = recovery.replayed;
|
|
829
1215
|
}
|
|
830
|
-
catch {
|
|
831
|
-
/* session gone, load unsupported, or too slow — start fresh */
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
if (!sessionId) {
|
|
835
1216
|
sessionResult = await request("session/new", { cwd, mcpServers: sessionServers }, NEW_SESSION_TIMEOUT, (result) => {
|
|
836
|
-
sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
|
|
1217
|
+
session.sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
|
|
1218
|
+
session.sessionKey = sessionKey;
|
|
837
1219
|
receiveModelVariants(result);
|
|
838
1220
|
});
|
|
839
|
-
|
|
840
|
-
throw new Error("session/new returned no sessionId");
|
|
1221
|
+
break;
|
|
841
1222
|
}
|
|
1223
|
+
// every establishment path leaves a native session id behind
|
|
1224
|
+
const sessionId = session.sessionId;
|
|
1225
|
+
if (sessionId === null)
|
|
1226
|
+
throw new Error("session/new returned no sessionId");
|
|
842
1227
|
let selectedModel = null;
|
|
843
1228
|
let sessionStarted = false;
|
|
844
1229
|
const emitSessionStarted = () => {
|
|
@@ -850,15 +1235,16 @@ export function createAcpDriver(support) {
|
|
|
850
1235
|
type: "session.started",
|
|
851
1236
|
sessionId,
|
|
852
1237
|
model: selectedModel ?? init?._meta?.modelState?.currentModelId ?? cliTurn.model ?? null,
|
|
1238
|
+
...(rebuiltFromReplay ? { rebuilt: true } : {}),
|
|
853
1239
|
});
|
|
854
1240
|
};
|
|
855
1241
|
try {
|
|
856
1242
|
if (support.selectModel) {
|
|
857
1243
|
const { configId } = support.selectModel;
|
|
858
|
-
selectedModel = modelOf(sessionConfigResult);
|
|
1244
|
+
selectedModel = modelOf(session.sessionConfigResult);
|
|
859
1245
|
if (cliTurn.model && cliTurn.model !== selectedModel) {
|
|
860
1246
|
sessionResult = await request("session/set_config_option", { sessionId, configId, value: cliTurn.model }, INIT_TIMEOUT, receiveModelVariants);
|
|
861
|
-
selectedModel = modelOf(sessionConfigResult);
|
|
1247
|
+
selectedModel = modelOf(session.sessionConfigResult);
|
|
862
1248
|
// an agent that answers OK but keeps its old model is worse than
|
|
863
1249
|
// one that errors: it burns a paid turn on the wrong thing
|
|
864
1250
|
if (selectedModel !== cliTurn.model) {
|
|
@@ -876,7 +1262,7 @@ export function createAcpDriver(support) {
|
|
|
876
1262
|
? sessionResult.models.availableModels
|
|
877
1263
|
: [],
|
|
878
1264
|
});
|
|
879
|
-
// initialize's currentModelId is the CLI default
|
|
1265
|
+
// initialize's currentModelId is the CLI default,
|
|
880
1266
|
// not the model this turn asked for. After a successful pin,
|
|
881
1267
|
// report the slug we set so the UI does not claim otherwise.
|
|
882
1268
|
if (!selectedModel && cliTurn.model)
|
|
@@ -898,24 +1284,23 @@ export function createAcpDriver(support) {
|
|
|
898
1284
|
}
|
|
899
1285
|
emitSessionStarted();
|
|
900
1286
|
const text = support.buildPromptText
|
|
901
|
-
? support.buildPromptText(
|
|
902
|
-
:
|
|
903
|
-
? `${
|
|
904
|
-
:
|
|
1287
|
+
? support.buildPromptText(promptTurn)
|
|
1288
|
+
: promptTurn.system
|
|
1289
|
+
? `${promptTurn.system}\n\n${promptTurn.text}`
|
|
1290
|
+
: promptTurn.text;
|
|
905
1291
|
const imageBlocks = support.images === true && runtimeAcceptsImages
|
|
906
|
-
? await readAcpImageBlocks(images)
|
|
1292
|
+
? await readAcpImageBlocks(turn.images ?? [])
|
|
907
1293
|
: [];
|
|
908
|
-
if (support.modelVariants && cliTurn.model && modelOf(sessionConfigResult) !== cliTurn.model) {
|
|
1294
|
+
if (support.modelVariants && cliTurn.model && modelOf(session.sessionConfigResult) !== cliTurn.model) {
|
|
909
1295
|
throw new Error(`${support.displayName} changed model before the prompt`);
|
|
910
1296
|
}
|
|
911
1297
|
if (turn.variant !== undefined && requestedVariantOption().currentValue !== turn.variant) {
|
|
912
1298
|
throw new Error(`${support.displayName} changed variant before the prompt`);
|
|
913
1299
|
}
|
|
914
1300
|
state.promptSent = true;
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
});
|
|
1301
|
+
const promptIdleMs = promptIdleTimeoutMs();
|
|
1302
|
+
const result = await request("session/prompt", { sessionId, prompt: [{ type: "text", text }, ...imageBlocks] }, undefined, undefined, promptIdleMs, `${DRIVER_KIND} went fully silent ${Math.round(promptIdleMs / 1000)} s after the message and the turn was stopped. ` +
|
|
1303
|
+
"Raise OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS if this model legitimately takes longer to answer.");
|
|
919
1304
|
// opencode 1.18.18 reports usage at the result root; grok and
|
|
920
1305
|
// gemini put it under _meta. Read both rather than lose the count.
|
|
921
1306
|
const usage = result?.usage ?? result?._meta ?? {};
|
|
@@ -929,9 +1314,9 @@ export function createAcpDriver(support) {
|
|
|
929
1314
|
}
|
|
930
1315
|
const reason = result?.stopReason;
|
|
931
1316
|
if (reason === "end_turn")
|
|
932
|
-
settle(true, null);
|
|
1317
|
+
settle(threadId, session, true, null);
|
|
933
1318
|
else if (reason === "cancelled")
|
|
934
|
-
settle(true, "cancelled");
|
|
1319
|
+
settle(threadId, session, true, "cancelled");
|
|
935
1320
|
else {
|
|
936
1321
|
const errorMessage = typeof result?.error === "string" && result.error
|
|
937
1322
|
? result.error
|
|
@@ -943,7 +1328,7 @@ export function createAcpDriver(support) {
|
|
|
943
1328
|
type: "runtime.error",
|
|
944
1329
|
message: errorMessage,
|
|
945
1330
|
});
|
|
946
|
-
settle(false, reason ?? "failed");
|
|
1331
|
+
settle(threadId, session, false, reason ?? "failed");
|
|
947
1332
|
}
|
|
948
1333
|
}
|
|
949
1334
|
catch (e) {
|
|
@@ -961,7 +1346,16 @@ export function createAcpDriver(support) {
|
|
|
961
1346
|
message,
|
|
962
1347
|
...(needsAuth ? { setup: true } : {}),
|
|
963
1348
|
});
|
|
964
|
-
|
|
1349
|
+
// Internal RPC failures can leave a live child poisoned just
|
|
1350
|
+
// like a silent prompt. Evict before completion listeners can
|
|
1351
|
+
// start the next turn. Never replay this accepted prompt: it
|
|
1352
|
+
// may already have executed tools. The next explicit turn can
|
|
1353
|
+
// resume the recorded session on a fresh process.
|
|
1354
|
+
if (!needsAuth && (e?.acpPromptStall === true || e?.acpSessionFailure === true
|
|
1355
|
+
|| code === "upstream_outage") && session.child.exitCode === null && !session.closing) {
|
|
1356
|
+
closeSession(threadId, e?.acpPromptStall === true ? "prompt-stall" : "rpc-failure");
|
|
1357
|
+
}
|
|
1358
|
+
settle(threadId, session, false, needsAuth ? "auth_required" : "rpc_error");
|
|
965
1359
|
}
|
|
966
1360
|
}
|
|
967
1361
|
})();
|
|
@@ -972,7 +1366,7 @@ export function createAcpDriver(support) {
|
|
|
972
1366
|
if (support.snapshot)
|
|
973
1367
|
return support.snapshot(env, config, instanceId);
|
|
974
1368
|
const version = await new Promise((resolve) => {
|
|
975
|
-
execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout
|
|
1369
|
+
execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout, stderr) => resolve(err ? null : versionFromProbe(stdout, stderr)));
|
|
976
1370
|
});
|
|
977
1371
|
if (!version)
|
|
978
1372
|
return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
|
|
@@ -1024,6 +1418,9 @@ export function createAcpDriver(support) {
|
|
|
1024
1418
|
stopAll: async () => {
|
|
1025
1419
|
for (const { stop } of active.values())
|
|
1026
1420
|
stop();
|
|
1421
|
+
// idle pooled sessions have no running turn — close them too
|
|
1422
|
+
for (const threadId of Array.from(sessions.keys()))
|
|
1423
|
+
closeSession(threadId, "stopAll");
|
|
1027
1424
|
},
|
|
1028
1425
|
onEvent: (listener) => {
|
|
1029
1426
|
listeners.add(listener);
|
|
@@ -1033,6 +1430,8 @@ export function createAcpDriver(support) {
|
|
|
1033
1430
|
dispose: async () => {
|
|
1034
1431
|
for (const { stop } of active.values())
|
|
1035
1432
|
stop();
|
|
1433
|
+
for (const threadId of Array.from(sessions.keys()))
|
|
1434
|
+
closeSession(threadId, "dispose");
|
|
1036
1435
|
listeners.clear();
|
|
1037
1436
|
},
|
|
1038
1437
|
};
|