openmausbot 0.1.92 → 0.1.93

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (104) hide show
  1. package/dist/assets/{DesktopViewer-Do6lBa4C.js → DesktopViewer-CKNR3SEW.js} +1 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-Cd3X7Hxc.js → abnfDiagram-O67JEVCF-Ckrm1I1X.js} +1 -1
  3. package/dist/assets/{arc-DIYc7Ymx.js → arc-BBdBuCgB.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-DBmRGVyP.js → architectureDiagram-NJMV4G6O-CDdA3Hjs.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CPFePSEE.js → blockDiagram-BEXU5L5S-AUrsx1YR.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-DD_DO1qy.js → c4Diagram-YGBWAQC7-BEqQ0Unh.js} +1 -1
  7. package/dist/assets/channel-BU94VpxE.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-BNEeNSjX.js → chunk-2Q5K7J3B-CEEuvd4C.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BbinLaqH.js → chunk-3FUC2YCW-DyANCiHl.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-BBgYrgDR.js → chunk-5VM5RSS4-Dx_Slpw9.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-ezXj-kA-.js → chunk-GTNCS2PH-Dl7h7iiD.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-9FxZ3diV.js → chunk-GWA4HPMP-BLx07SPb.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-CO-40JpI.js → chunk-JWPE2WC7-BMxDj3n7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-BEhjslYE.js → chunk-NETBCI7D-CN66PT4H.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CNeq4n7_.js → chunk-WEXAMYUT-6HwhsDKw.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-OsCY7KSi.js → chunk-XXDRQBXY-gWnLt7SN.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-CloRUEtk.js → classDiagram-v2-NBCMYWYE-CMS5l2ea.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-D1BGZhp4.js → cose-bilkent-JH36ORCC-CV7sQwIG.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-CUf4T2CN.js → cynefin-OW5HDTMX-CSyl-7vl.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-DPEZrFGA.js → cynefinDiagram-VND7K2PF-BCLKL6az.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-bDe6GIv5.js → dagre-6A5THRUB-DiaZs6S1.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-CdcUQz55.js → diagram-22UHCM2B-BeTtmt3I.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-BZpqGg7c.js → diagram-3UASUU5V-CsdBG-QQ.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-DvGM6ugp.js → diagram-ATOU4E4O-BczvdbR1.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-DSTmMm0s.js → diagram-CDSNMT55-DHTTTMhi.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-15cucnMn.js → diagram-MLGK6HIB-RTjHyd8z.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-tMbuaE6n.js → diagram-MPIPVDR6-BDenHEH_.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-BYxYzjHI.js → ebnfDiagram-ZINNZB2B-BSiRSbtL.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-v6bj852W.js → elk-276RUBZZ-BCAElss-.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-5lBBlnd2.js → erDiagram-OPXOYQCR-C4_1ZPJe.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-CZyl1zIj.js → flowDiagram-KWPJA3E3-Bcb4Z75S.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-BiAI-ltZ.js → ganttDiagram-FUAMR5RP-BiPOzxzb.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-BAZISkj3.js → gitGraphDiagram-X574FWY7-B1wtF3ZS.js} +1 -1
  34. package/dist/assets/index-Biji-bfL.css +1 -0
  35. package/dist/assets/{index-DGBaEXrM.js → index-BkYwilkG.js} +1 -1
  36. package/dist/assets/index-DZlhoES0.js +597 -0
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B6ooOy-Z.js → infoDiagram-VRGFBTTK-A19No71U.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BZ9ksi-B.js → ishikawaDiagram-OU5B5YK6-BQsqJ_cV.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DWqlzIEe.js → journeyDiagram-ZHPQQLJL-DAO0pVan.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-JX6piDwL.js → kanban-definition-PNTS6WVX-NepmKEe2.js} +1 -1
  41. package/dist/assets/{linear-CmOWzX0P.js → linear-bq1PTDTW.js} +1 -1
  42. package/dist/assets/{mermaid.core-CF1zwoQ-.js → mermaid.core-s4LTbQle.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-CGh2K4fW.js → mindmap-definition-NLK3R4M7-LtdbgHFH.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CDwYN0s7.js → pegDiagram-GJSIUBJH-tdbaCxiK.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-1rfjJocB.js → pieDiagram-5QR66LMP-DvkbpW8l.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-tKPvPiok.js → quadrantDiagram-O4NWA36T-Do4KaH-E.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-BHLm5vXR.js → railroadDiagram-XR7U4H2S-BQWULOAs.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CB45STp7.js → requirementDiagram-PLB6GJNP-w9kei1FU.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-55QA_huC.js → sankeyDiagram-IPEJSGJF-D2gRy0e8.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-BckFmQHn.js → sequenceDiagram-PO4LG4MO-a2sO5_Eo.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-BFCOJF11.js → sizeCapture-INFHLROL-CiPJmFEB.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-CZvmiVVN.js → stateDiagram-v2-GCMORJYK-DAkUUlL7.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-BpDUCEKy.js → swimlanes-2SLR337P-DANf2R8Y.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-CdHxG8aM.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-BpxHq6IK.js → timeline-definition-EJHVYXUP-BKpQmzrT.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-Cj66vm02.js → usecaseDiagram-POWQR4AR-BtI7qLN-.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-C9nRkWE_.js → vennDiagram-UO4OBE2U-gXJXrAG_.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-D-me_nWj.js → wardleyDiagram-VNRHLVJA-GU8Po5-n.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-Dc_fL4mk.js → xychartDiagram-PMCCYNJV-BF4J30g6.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +24 -1
  62. package/dist-server/container-mcp.js +6 -1
  63. package/dist-server/drivers/agents-proxy.js +2 -0
  64. package/dist-server/index.js +1722 -906
  65. package/dist-server/local-computer-proxy.js +5 -1
  66. package/dist-server/mcp-gate.js +5 -1
  67. package/dist-server/openmausbot.js +763 -93
  68. package/dist-server/pair-cli.js +763 -93
  69. package/dist-server/server/boat.js +3 -3
  70. package/dist-server/server/cloud-owner.js +1 -1
  71. package/dist-server/server/computer-control.js +6 -0
  72. package/dist-server/server/config.js +14 -1
  73. package/dist-server/server/container-computer.js +43 -32
  74. package/dist-server/server/digest.js +12 -2
  75. package/dist-server/server/drivers/acp/core.js +172 -12
  76. package/dist-server/server/drivers/acp/cursor.js +13 -1
  77. package/dist-server/server/drivers/acp/quiet-status.js +257 -0
  78. package/dist-server/server/drivers/acp/qwen.js +18 -0
  79. package/dist-server/server/drivers/builtIn.js +2 -0
  80. package/dist-server/server/drivers/cerebras.js +108 -0
  81. package/dist-server/server/drivers/chat-mcp-tools.js +20 -3
  82. package/dist-server/server/drivers/claude.js +17 -3
  83. package/dist-server/server/drivers/openai-chat.js +26 -2
  84. package/dist-server/server/drivers/prompt-split.js +2 -0
  85. package/dist-server/server/env-path.js +6 -2
  86. package/dist-server/server/index.js +210 -57
  87. package/dist-server/server/local-vm-stop-reason.js +26 -0
  88. package/dist-server/server/provider-key-check.js +2 -1
  89. package/dist-server/server/routes/desktop-viewer.js +50 -6
  90. package/dist-server/server/routines.js +6 -3
  91. package/dist-server/server/store.js +54 -0
  92. package/dist-server/server/vps-computer.js +1 -1
  93. package/dist-server/server/workspace-backup.js +22 -0
  94. package/dist-server/server/workspace.js +1 -1
  95. package/dist-server/shared/approval-mode.js +1 -1
  96. package/dist-server/shared/redact.js +1 -0
  97. package/dist-server/shared/tool-name.js +22 -0
  98. package/dist-server/vps-container-mcp.js +6 -1
  99. package/dist-server/workspace-backup.worker.js +14 -0
  100. package/package.json +1 -1
  101. package/dist/assets/channel-Dtk5Mtic.js +0 -1
  102. package/dist/assets/index-Cdmjrl28.css +0 -1
  103. package/dist/assets/index-DR7YPMkW.js +0 -597
  104. package/dist/assets/swimlanesDiagram-TC7HE7FX-M0GYW9Y3.js +0 -8
@@ -0,0 +1,257 @@
1
+ // What an ACP agent is doing while it sends nothing.
2
+ //
3
+ // ACP has no "still working" message, and agents go quiet for minutes in
4
+ // normal work: Qwen Code 0.24 says nothing over the wire while it waits out
5
+ // a rate limit, while its SDK retries a request, or while one slow model
6
+ // call runs. Silence alone cannot tell that from a wedged process, so a
7
+ // quiet turn is probed from outside: the agent's own debug log where it
8
+ // keeps one, and the process tree's open connections and CPU time. The
9
+ // result becomes a plain-words notice in the chat, so the person is never
10
+ // left watching a spinner without knowing why.
11
+ import { execFile } from "node:child_process";
12
+ import { closeSync, fstatSync, openSync, readSync } from "node:fs";
13
+ /** Qwen Code's debug log (QWEN_DEBUG_LOG_FILE=1), lines like
14
+ * `2026-10-02T02:50:41.757Z [WARN] [RETRY] Attempt 1 failed with status 429. Retrying after explicit delay of 20000ms...`.
15
+ * Only the facts worth telling a person are parsed; every other line still
16
+ * counts as proof the process is alive. */
17
+ export function parseQwenLogLine(line) {
18
+ if (line.includes("[RETRY]") && /\bAttempt \d+ failed\b/.test(line)) {
19
+ const attempt = /\bAttempt (\d+) failed/.exec(line);
20
+ const status = /\bwith status (\d{3})\b/.exec(line);
21
+ const explicit = /explicit delay of (\d+)\s*ms/.exec(line);
22
+ const seconds = /Retrying in (\d+)\s*s\b/.exec(line);
23
+ return {
24
+ kind: "retry",
25
+ attempt: attempt ? Number(attempt[1]) : null,
26
+ status: status ? Number(status[1]) : null,
27
+ delayMs: explicit ? Number(explicit[1]) : seconds ? Number(seconds[1]) * 1000 : null,
28
+ };
29
+ }
30
+ if (/\[compaction\] hard-tier rescue triggered|Reactive compression|tryCompress/.test(line)
31
+ && !/succeeded|failed|skipped|stopped/.test(line)) {
32
+ return { kind: "compressing" };
33
+ }
34
+ const gate = /\[compaction\] cheap-gate NOOP: effectiveTokens=(\d+), auto=(\d+)/.exec(line);
35
+ if (gate)
36
+ return { kind: "context", tokens: Number(gate[1]), threshold: Number(gate[2]) };
37
+ return null;
38
+ }
39
+ /** Reads the lines appended to a log file since the last read. A file that
40
+ * does not exist yet is simply empty; the first read skips what was already
41
+ * there, so an old conversation's log is not replayed as news. */
42
+ export class LogTail {
43
+ offset = null;
44
+ partial = "";
45
+ path;
46
+ maxBytes;
47
+ constructor(path, maxBytes = 256 * 1024) {
48
+ this.path = path;
49
+ this.maxBytes = maxBytes;
50
+ }
51
+ read() {
52
+ let fd;
53
+ try {
54
+ fd = openSync(this.path, "r");
55
+ }
56
+ catch {
57
+ if (this.offset === null)
58
+ this.offset = 0;
59
+ return [];
60
+ }
61
+ try {
62
+ const size = fstatSync(fd).size;
63
+ if (this.offset === null) {
64
+ this.offset = size;
65
+ return [];
66
+ }
67
+ // truncated or replaced: start over from its beginning
68
+ if (size < this.offset) {
69
+ this.offset = 0;
70
+ this.partial = "";
71
+ }
72
+ if (size === this.offset)
73
+ return [];
74
+ // a burst bigger than the cap keeps only its tail
75
+ const start = Math.max(this.offset, size - this.maxBytes);
76
+ if (start > this.offset)
77
+ this.partial = "";
78
+ const buf = Buffer.alloc(size - start);
79
+ readSync(fd, buf, 0, buf.length, start);
80
+ this.offset = size;
81
+ const text = this.partial + buf.toString("utf8");
82
+ const lines = text.split(/\r?\n/);
83
+ this.partial = lines.pop() ?? "";
84
+ return lines.filter((l) => l.trim());
85
+ }
86
+ catch {
87
+ return [];
88
+ }
89
+ finally {
90
+ try {
91
+ closeSync(fd);
92
+ }
93
+ catch { }
94
+ }
95
+ }
96
+ }
97
+ const run = (file, args, timeoutMs = 4_000) => new Promise((resolve) => {
98
+ execFile(file, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024, windowsHide: true }, (err, stdout) => {
99
+ // lsof exits 1 when it finds nothing to list; its stdout is still the answer
100
+ if (err && !(typeof stdout === "string" && err.code === 1))
101
+ resolve(null);
102
+ else
103
+ resolve(String(stdout));
104
+ });
105
+ });
106
+ /** `ps` TIME: [[dd-]hh:]mm:ss[.cc] */
107
+ function psTimeMs(raw) {
108
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+(?:\.\d+)?)$/.exec(raw.trim());
109
+ if (!m)
110
+ return null;
111
+ const [, d, h, min, s] = m;
112
+ return ((Number(d ?? 0) * 24 + Number(h ?? 0)) * 60 + Number(min)) * 60_000 + Number(s) * 1000;
113
+ }
114
+ /** The root and every descendant, from a `pid ppid ...` table. */
115
+ export function treePids(rows, root) {
116
+ const kids = new Map();
117
+ for (const r of rows) {
118
+ const list = kids.get(r.ppid);
119
+ if (list)
120
+ list.push(r.pid);
121
+ else
122
+ kids.set(r.ppid, [r.pid]);
123
+ }
124
+ const out = [];
125
+ const queue = [root];
126
+ while (queue.length && out.length < 512) {
127
+ const pid = queue.shift();
128
+ if (out.includes(pid))
129
+ continue;
130
+ out.push(pid);
131
+ queue.push(...(kids.get(pid) ?? []));
132
+ }
133
+ return out;
134
+ }
135
+ async function samplePosix(root) {
136
+ const table = await run("ps", ["-A", "-o", "pid=,ppid=,time="]);
137
+ if (table === null)
138
+ return { cpuMs: null, connections: null };
139
+ const rows = [];
140
+ for (const line of table.split("\n")) {
141
+ const [pid, ppid, time] = line.trim().split(/\s+/);
142
+ if (!pid || !ppid || !time)
143
+ continue;
144
+ rows.push({ pid: Number(pid), ppid: Number(ppid), cpuMs: psTimeMs(time) });
145
+ }
146
+ const pids = treePids(rows, root);
147
+ if (!rows.some((r) => r.pid === root))
148
+ return { cpuMs: null, connections: null };
149
+ let cpuMs = 0;
150
+ for (const r of rows)
151
+ if (pids.includes(r.pid))
152
+ cpuMs += r.cpuMs ?? 0;
153
+ const lsof = await run("lsof", ["-nP", "-a", "-p", pids.join(","), "-iTCP", "-sTCP:ESTABLISHED", "-Fn"]);
154
+ // -Fn prints one `n<addr>` line per connection
155
+ const connections = lsof === null ? null : lsof.split("\n").filter((l) => l.startsWith("n")).length;
156
+ return { cpuMs, connections };
157
+ }
158
+ async function sampleWindows(root) {
159
+ const script = "$ErrorActionPreference='SilentlyContinue';" +
160
+ "$all=Get-CimInstance Win32_Process|Select-Object ProcessId,ParentProcessId,KernelModeTime,UserModeTime;" +
161
+ `$ids=@(${root});$q=@(${root});` +
162
+ "while($q.Count){$p=$q[0];$q=@($q|Select-Object -Skip 1);foreach($c in $all|Where-Object{$_.ParentProcessId -eq $p}){if($ids -notcontains $c.ProcessId){$ids+=$c.ProcessId;$q+=$c.ProcessId}}};" +
163
+ "$cpu=($all|Where-Object{$ids -contains $_.ProcessId}|ForEach-Object{[double]$_.KernelModeTime+[double]$_.UserModeTime}|Measure-Object -Sum).Sum/10000;" +
164
+ "$n=@(Get-NetTCPConnection -State Established|Where-Object{$ids -contains $_.OwningProcess}).Count;" +
165
+ "Write-Output \"$cpu $n\"";
166
+ const out = await run("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], 8_000);
167
+ const [cpu, n] = (out ?? "").trim().split(/\s+/);
168
+ const cpuMs = Number(cpu);
169
+ const connections = Number(n);
170
+ return {
171
+ cpuMs: cpu !== undefined && Number.isFinite(cpuMs) ? cpuMs : null,
172
+ connections: n !== undefined && Number.isFinite(connections) ? connections : null,
173
+ };
174
+ }
175
+ /** CPU time and open connections of a process and its children. Best
176
+ * effort: a missing tool or a refused query reads as null (unknown), never
177
+ * as "idle". */
178
+ export function sampleProcessTree(pid) {
179
+ return (process.platform === "win32" ? sampleWindows(pid) : samplePosix(pid))
180
+ .catch(() => ({ cpuMs: null, connections: null }));
181
+ }
182
+ /** CPU a process tree can burn on bookkeeping while doing no real work. */
183
+ const BUSY_CPU_MS_PER_MIN = 3_000;
184
+ export function classifyQuiet(input) {
185
+ const { logged, sample, previous } = input;
186
+ if (logged?.kind === "retry")
187
+ return { kind: "retrying", status: logged.status, delayMs: logged.delayMs, attempt: logged.attempt };
188
+ if (logged?.kind === "compressing")
189
+ return { kind: "compressing" };
190
+ const nearFullContext = input.context !== null && input.context.threshold > 0
191
+ && input.context.tokens >= input.context.threshold * 0.9;
192
+ if (sample.connections !== null && sample.connections > 0)
193
+ return { kind: "waiting-model", nearFullContext };
194
+ const cpuDelta = sample.cpuMs !== null && previous?.cpuMs != null ? sample.cpuMs - previous.cpuMs : null;
195
+ if (cpuDelta !== null && input.sinceMs > 0 && cpuDelta > BUSY_CPU_MS_PER_MIN * (input.sinceMs / 60_000))
196
+ return { kind: "busy" };
197
+ if (sample.connections === 0 && cpuDelta !== null)
198
+ return { kind: "no-signs" };
199
+ return { kind: "unknown" };
200
+ }
201
+ /** Two states read the same to a person when this matches; a notice is
202
+ * sent only when it changes. */
203
+ export function quietKey(state) {
204
+ if (state.kind === "retrying")
205
+ return `retrying:${state.attempt ?? "?"}:${state.status ?? "?"}`;
206
+ if (state.kind === "waiting-model")
207
+ return `waiting-model:${state.nearFullContext}`;
208
+ return state.kind;
209
+ }
210
+ const minutes = (ms) => {
211
+ if (ms < 90_000)
212
+ return `${Math.max(1, Math.round(ms / 1000))} s`;
213
+ return `${Math.round(ms / 60_000)} min`;
214
+ };
215
+ /** The chat notice for a quiet state. */
216
+ export function describeQuiet(name, state, quietMs, stopAfterMs) {
217
+ const quiet = minutes(quietMs);
218
+ switch (state.kind) {
219
+ case "retrying": {
220
+ const why = state.status === 429 ? "hit a rate limit (HTTP 429)"
221
+ : state.status !== null && state.status >= 500 ? `got a server error from its model provider (HTTP ${state.status})`
222
+ : state.status !== null ? `got an error from its model provider (HTTP ${state.status})`
223
+ : "had a failed model request";
224
+ const next = state.delayMs !== null ? ` Next try in ${Math.max(1, Math.round(state.delayMs / 1000))} s.` : "";
225
+ const attempt = state.attempt !== null ? ` (attempt ${state.attempt + 1})` : "";
226
+ return `${name} ${why} and is retrying${attempt}.${next} It is still working.`;
227
+ }
228
+ case "compressing":
229
+ return `${name} is compressing its conversation history to make room. This can take a few minutes; it is still working.`;
230
+ case "waiting-model":
231
+ return state.nearFullContext
232
+ ? `${name} is waiting on its model: no reply for ${quiet}, but its request is still open. Its history is nearly full, so it may be compressing it.`
233
+ : `${name} is waiting on its model: no reply for ${quiet}, but its request is still open. Slow models and rate limits look like this.`;
234
+ case "busy":
235
+ return `${name} is busy on this computer but has sent nothing for ${quiet}.`;
236
+ case "between-requests":
237
+ return `${name} has sent nothing for ${quiet} and has no request open to its model right now. ` +
238
+ "It may be waiting before a retry. Still waiting; press Stop to end the turn.";
239
+ case "no-signs":
240
+ return `${name} has sent nothing for ${quiet}, has no open request to its model, and is not using the CPU. It may be stuck. ` +
241
+ `Press Stop to end the turn; otherwise it is stopped after ${minutes(stopAfterMs)} of silence.`;
242
+ case "unknown":
243
+ return `${name} has sent nothing for ${quiet}. Still waiting; press Stop to end the turn.`;
244
+ }
245
+ }
246
+ /** A short phrase for the final stuck error: what the agent looked like last. */
247
+ export function lastSeenPhrase(state) {
248
+ switch (state.kind) {
249
+ case "retrying": return "retrying a failed model request";
250
+ case "compressing": return "compressing its history";
251
+ case "waiting-model": return "waiting on its model with a request open";
252
+ case "busy": return "busy on this computer";
253
+ case "between-requests": return "no request open to its model";
254
+ case "no-signs": return "no open request and no CPU use";
255
+ case "unknown": return "no readable process state";
256
+ }
257
+ }
@@ -6,6 +6,7 @@ import { createHash } from "node:crypto";
6
6
  import { join } from "node:path";
7
7
  import { qualifiedModelLabel } from "../../contracts.js";
8
8
  import { harnessHome } from "../../env-path.js";
9
+ import { parseQwenLogLine } from "./quiet-status.js";
9
10
  import { decodeInjectId, hostApiKey, localHost, mergeLocalInject } from "../local-inject.js";
10
11
  import { createAcpDriver } from "./core.js";
11
12
  const EMPTY = { default: "", options: [] };
@@ -199,6 +200,15 @@ export function resolveQwenTurnModel(model, env) {
199
200
  throw new Error("This Qwen model is no longer configured. Refresh models and select it again.");
200
201
  return matches[0].id;
201
202
  }
203
+ /** Where Qwen Code 0.24 writes a session's debug log: `<runtime dir>/debug/<session id>.txt`,
204
+ * the runtime dir being QWEN_RUNTIME_DIR, else QWEN_HOME, else ~/.qwen. */
205
+ export function qwenDebugLogPath(env, sessionId) {
206
+ const off = ["", "0", "false", "off", "no"].includes((env.QWEN_DEBUG_LOG_FILE ?? "").trim().toLowerCase());
207
+ if (off || !/^[\w-]{1,128}$/.test(sessionId))
208
+ return null;
209
+ const base = env.QWEN_RUNTIME_DIR || env.QWEN_HOME || harnessHome("qwen", env);
210
+ return join(base, "debug", `${sessionId}.txt`);
211
+ }
202
212
  /** Qwen Code's own approval ladder, passed through (qwen --help, 0.24):
203
213
  * `--approval-mode default` asks, `auto-edit` approves file edits, `auto`
204
214
  * runs Qwen's LLM classifier that approves safe actions and blocks risky
@@ -243,5 +253,13 @@ const support = {
243
253
  authFailure: "continue",
244
254
  isAuthenticated: () => true,
245
255
  buildPromptText: (turn) => (turn.system ? `${turn.system}\n\n${turn.text}` : turn.text),
256
+ // Qwen says nothing over ACP while it retries a rate limit or compresses
257
+ // its history, but its debug log does. Turn the log on (unless the person
258
+ // turned it off) and read it while a prompt is quiet.
259
+ transformEnv: (env) => {
260
+ if (env.QWEN_DEBUG_LOG_FILE === undefined)
261
+ env.QWEN_DEBUG_LOG_FILE = "1";
262
+ },
263
+ statusLog: { path: qwenDebugLogPath, parse: parseQwenLogLine },
246
264
  };
247
265
  export const QwenAgentDriver = createAcpDriver(support);
@@ -15,6 +15,7 @@ import { HermesAgentDriver } from "./acp/hermes.js";
15
15
  import { OpenAICompatDriver } from "./openai-compat.js";
16
16
  import { PiDriver } from "./pi.js";
17
17
  import { MistralDriver } from "./mistral.js";
18
+ import { CerebrasDriver } from "./cerebras.js";
18
19
  import { MinimaxDriver } from "./minimax.js";
19
20
  export const BUILT_IN_DRIVERS = [
20
21
  GrokDriver,
@@ -35,4 +36,5 @@ export const BUILT_IN_DRIVERS = [
35
36
  BoatAgentDriver,
36
37
  MinimaxDriver,
37
38
  MistralDriver,
39
+ CerebrasDriver,
38
40
  ];
@@ -0,0 +1,108 @@
1
+ import { z } from "zod";
2
+ import { createOpenAIChatRuntime } from "./openai-chat.js";
3
+ // Cerebras serves open models on wafer-scale chips: same OpenAI chat
4
+ // contract, several times the tokens per second of a GPU cloud.
5
+ const DEFAULT_URL = "https://api.cerebras.ai/v1";
6
+ // The account tier is unknown: use the shared free-tier limit, not paid 128K.
7
+ // https://inference-docs.cerebras.ai/models/overview
8
+ const DEFAULT_MODELS = {
9
+ default: "gpt-oss-120b",
10
+ options: [
11
+ { id: "gpt-oss-120b", label: "GPT OSS 120B", contextWindow: 65536 },
12
+ { id: "qwen-3.8-27b", label: "Qwen3.8 27B", contextWindow: 65536 },
13
+ ],
14
+ };
15
+ const configSchema = z.object({
16
+ url: z.string().trim().url().default(DEFAULT_URL),
17
+ model: z.string().trim().min(1).optional(),
18
+ tools: z.boolean().optional(),
19
+ });
20
+ function decodeConfig(raw) {
21
+ const config = configSchema.parse(raw ?? {});
22
+ config.url = config.url.replace(/\/+$/, "");
23
+ const url = new URL(config.url);
24
+ if (url.protocol !== "https:" && !(url.protocol === "http:" &&
25
+ ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname))) {
26
+ throw new Error("Cerebras requires HTTPS, except for a local test endpoint.");
27
+ }
28
+ return config;
29
+ }
30
+ const modelCard = z.object({ id: z.string().min(1) });
31
+ /** Cerebras answers "Only 'text' content type can be used" for these. */
32
+ const TEXT_ONLY_MODELS = /^(?:gpt-oss|llama3\.1-8b|llama-3\.3-70b)/i;
33
+ export const CerebrasDriver = {
34
+ driverKind: "cerebras",
35
+ metadata: { displayName: "Cerebras (API)", supportsMultipleInstances: true, access: "api" },
36
+ models: DEFAULT_MODELS,
37
+ install: {
38
+ docsUrl: "https://cloud.cerebras.ai/",
39
+ settings: "connections",
40
+ signInCommand: "Save a Cerebras API key in Settings → Connections, or set CEREBRAS_API_KEY on the server.",
41
+ },
42
+ decodeConfig,
43
+ defaultConfig: () => decodeConfig({}),
44
+ async create(input) {
45
+ const { config } = input;
46
+ const apiKey = (input.environment.CEREBRAS_API_KEY ?? process.env.CEREBRAS_API_KEY ?? "").trim();
47
+ const known = new Map(DEFAULT_MODELS.options.map((option) => [option.id, option]));
48
+ const withConfiguredModel = (options) => {
49
+ const preferred = config.model ?? DEFAULT_MODELS.default;
50
+ if (config.model && !options.some((option) => option.id === config.model)) {
51
+ options = [{ id: config.model, label: config.model }, ...options];
52
+ }
53
+ return { default: options.some((option) => option.id === preferred) ? preferred : options[0].id, options };
54
+ };
55
+ let catalog = withConfiguredModel(DEFAULT_MODELS.options);
56
+ const refreshModels = async () => {
57
+ if (!apiKey)
58
+ return;
59
+ try {
60
+ const response = await fetch(`${config.url}/models`, {
61
+ headers: { authorization: `Bearer ${apiKey}` },
62
+ redirect: "error",
63
+ signal: AbortSignal.timeout(8_000),
64
+ });
65
+ if (!response.ok)
66
+ return;
67
+ const json = await response.json();
68
+ const rows = Array.isArray(json) ? json : z.object({ data: z.array(z.unknown()) }).parse(json).data;
69
+ const options = [];
70
+ const seen = new Set();
71
+ for (const row of rows) {
72
+ const parsed = modelCard.safeParse(row);
73
+ if (!parsed.success || !parsed.data.id.trim() || seen.has(parsed.data.id))
74
+ continue;
75
+ seen.add(parsed.data.id);
76
+ options.push(known.get(parsed.data.id) ?? { id: parsed.data.id, label: parsed.data.id });
77
+ }
78
+ if (options.length)
79
+ catalog = withConfiguredModel(options);
80
+ }
81
+ catch {
82
+ // A failed refresh keeps the last usable catalog, including custom models.
83
+ }
84
+ };
85
+ if (apiKey)
86
+ void refreshModels();
87
+ return createOpenAIChatRuntime({
88
+ input, driverKind: "cerebras", apiKey, apiUrl: config.url,
89
+ tools: config.tools, models: () => catalog, refreshModels, reasoning: true,
90
+ reasoningReplayField: "reasoning",
91
+ computerUse: true, imageInput: (model) => !TEXT_ONLY_MODELS.test(model),
92
+ nudgeAnnouncedAction: true,
93
+ requestBody: (model, messages, stream) => ({
94
+ model, messages, stream,
95
+ ...(stream ? { stream_options: { include_usage: true } } : {}),
96
+ }),
97
+ httpErrorLabel: "Cerebras",
98
+ missingKeyError: "Save a Cerebras API key in Settings → Connections, or set CEREBRAS_API_KEY.",
99
+ unavailableReason: "No Cerebras API key — open Settings → Connections.",
100
+ timeoutMs: 180_000, billing: "metered", includeUsageInCompleted: true,
101
+ nativeLog: {
102
+ source: "cerebras.chat.completions",
103
+ outgoing: (_turn, messages, model) => ({ model, messageCount: messages.length }),
104
+ incoming: ({ text, reasoning, usage }) => ({ textLength: text.length, reasoningLength: reasoning.length, usage }),
105
+ },
106
+ });
107
+ },
108
+ };
@@ -266,6 +266,22 @@ function boundedText(value) {
266
266
  end -= 1;
267
267
  return `${bytes.subarray(0, end).toString()}\n[MCP result truncated at 50KB; request less output.]`;
268
268
  }
269
+ /** Optional fields a tool documents as "omit to use the default", where a
270
+ * blank string is an error instead. Smaller models fill optional fields with
271
+ * "" rather than leaving them out; dropping the blank restores the documented
272
+ * call. agent_browser_read: "Omit url to read the active tab." */
273
+ const BUILT_IN_BROWSER_BLANK_MEANS_OMITTED = {
274
+ agent_browser_read: ["url"],
275
+ };
276
+ function omitBlankDefaults(builtInBrowser, tool, args) {
277
+ if (!builtInBrowser || !object(args))
278
+ return;
279
+ for (const field of BUILT_IN_BROWSER_BLANK_MEANS_OMITTED[tool] ?? []) {
280
+ const value = args[field];
281
+ if (typeof value === "string" && !value.trim())
282
+ delete args[field];
283
+ }
284
+ }
269
285
  export async function mountChatTools(integrations, signal, computerUse = false) {
270
286
  const servers = [];
271
287
  if (computerUse && integrations?.computer)
@@ -316,12 +332,12 @@ export async function mountChatTools(integrations, signal, computerUse = false)
316
332
  // image delivery, including custom servers. Text stays bounded below.
317
333
  const client = "boxId" in descriptor ? new ChatBoatClient(descriptor) : new ChatMcpClient(descriptor, computerUse);
318
334
  clients.push(client);
319
- return { name, client, tools: await client.tools(signal) };
335
+ return { name, client, builtInBrowser: descriptor === integrations?.browser, tools: await client.tools(signal) };
320
336
  }));
321
337
  for (const mount of mounts) {
322
338
  if (mount.status === "rejected")
323
339
  throw mount.reason;
324
- const { name: server, client, tools } = mount.value;
340
+ const { name: server, client, builtInBrowser, tools } = mount.value;
325
341
  const originalNames = new Set();
326
342
  for (const tool of tools) {
327
343
  if (!object(tool) || typeof tool.name !== "string" || !tool.name.trim() || originalNames.has(tool.name))
@@ -352,7 +368,7 @@ export async function mountChatTools(integrations, signal, computerUse = false)
352
368
  const suffix = `_${index}`;
353
369
  name = base.slice(0, 64 - suffix.length) + suffix;
354
370
  }
355
- registered.set(name, { client, name: tool.name, schema });
371
+ registered.set(name, { client, builtInBrowser, name: tool.name, schema });
356
372
  definitions.push({ type: "function", function: { name, description, parameters } });
357
373
  if (Buffer.byteLength(JSON.stringify(definitions)) > CATALOG_BYTES)
358
374
  throw new Error("MCP tool catalog exceeds the 1MB limit");
@@ -371,6 +387,7 @@ export async function mountChatTools(integrations, signal, computerUse = false)
371
387
  const tool = registered.get(name);
372
388
  if (!tool)
373
389
  throw new Error("The requested tool was not advertised for this turn");
390
+ omitBlankDefaults(tool.builtInBrowser, tool.name, args);
374
391
  if (!object(args) || !tool.schema(args))
375
392
  throw new Error("Tool arguments do not match the advertised input schema; use its required fields and types");
376
393
  };
@@ -1032,11 +1032,18 @@ export const ClaudeDriver = {
1032
1032
  // harness snapshots every instance whenever it describes them — app
1033
1033
  // load, the Engines page, and right after `claude update`, which is
1034
1034
  // exactly when the answer changes — so a turn normally finds it filled.
1035
- // Most turns before any snapshot assume a current CLI. A coordinated
1036
- // turn checks first because the snapshot-refresh flag is newer than the
1035
+ // Most flags before any snapshot assume a current CLI; autocompact
1036
+ // requires confirmed help support. A coordinated turn checks first
1037
+ // because the snapshot-refresh flag is newer than the
1037
1038
  // other context controls and an unknown flag would reject that request.
1038
1039
  let cliVersion = null;
1039
1040
  let cliVersionChecked = false;
1041
+ // Whether `claude --help` lists --autocompact, read once per CLI version
1042
+ // by snapshot(). The flag is not in every build above its version floor
1043
+ // (2.1.129 rejects it), so the listing wins over the floor; null until
1044
+ // probed, or when the probe fails.
1045
+ let cliHasAutocompact = null;
1046
+ let cliHelpVersion = null;
1040
1047
  const readCliVersion = (env) => new Promise((resolve) => {
1041
1048
  execCli(config.cli, ["--version"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout.trim() || null));
1042
1049
  });
@@ -1230,7 +1237,7 @@ export const ClaudeDriver = {
1230
1237
  args.push("--setting-sources", "project");
1231
1238
  }
1232
1239
  const compactWindow = autoCompactWindow(turnEnvironment);
1233
- if (compactWindow && claudeCliSupports(cliVersion, "--autocompact")) {
1240
+ if (compactWindow && cliHasAutocompact === true) {
1234
1241
  args.push("--autocompact", compactWindow);
1235
1242
  }
1236
1243
  // An old pair conversation can still carry its first assignment in
@@ -2263,6 +2270,13 @@ export const ClaudeDriver = {
2263
2270
  return { state: "unavailable", reason: `\`${config.cli}\` CLI not found` };
2264
2271
  cliVersion = parseClaudeCliVersion(version);
2265
2272
  cliVersionChecked = true;
2273
+ if (version !== cliHelpVersion) {
2274
+ const help = await new Promise((resolve) => {
2275
+ execCli(config.cli, ["--help"], { timeout: 8000, env }, (err, stdout) => resolve(err ? null : stdout));
2276
+ });
2277
+ cliHasAutocompact = help === null ? null : /^\s*--autocompact\b/m.test(help);
2278
+ cliHelpVersion = version;
2279
+ }
2266
2280
  const update = claudeCliUpdate(version, config.cli);
2267
2281
  const warning = claudeInheritWarning(env);
2268
2282
  if (config.requireApiKey) {
@@ -30,6 +30,17 @@ const usageFrom = (usage) => usage
30
30
  const asError = (value) => value instanceof Error ? value : new Error(String(value));
31
31
  class UnsupportedChatToolsError extends Error {
32
32
  }
33
+ const NUDGE_ANNOUNCED_ACTION = "You said what you would do next but called no tool. Do it now with your tools, or reply with your final answer if nothing is left to do.";
34
+ /** A short reply that only announces a next step, with nothing to answer. */
35
+ export function announcesAction(text) {
36
+ const reply = text.trim();
37
+ if (!reply || reply.length > 400 || reply.includes("?"))
38
+ return false;
39
+ return /(?:[:\u2014\u2013]|\.\.\.|\u2026)$/.test(reply) ||
40
+ /^(?:checking|let me|i'll|i will|i'm going to|i am going to|opening|pulling|looking|searching|navigating|reading|fetching|loading|now (?:i'll|let me|opening|checking|pulling|reading))\b/i.test(reply);
41
+ }
42
+ const TEXT_ONLY_ATTACHMENT_NOTE = "[The person attached image(s), but this model cannot see images. Say so if the request depends on them.]";
43
+ const TEXT_ONLY_SCREENSHOT_NOTE = "[Screenshot not shown: this model cannot see images. Read the page with a snapshot or a text tool instead.]";
33
44
  function rejectsToolsParameter(status, body) {
34
45
  if (status !== 400 && status !== 422)
35
46
  return false;
@@ -282,6 +293,7 @@ export function createOpenAIChatRuntime(options) {
282
293
  clearTimeout(idleTimer);
283
294
  }
284
295
  };
296
+ const seesImages = (turn) => options.imageInput?.(turn.model || options.models().default) ?? true;
285
297
  const messagesFor = (turn) => {
286
298
  // The system message is the head of the resent prefix, so only the
287
299
  // stable half belongs there: a volatile edit must not re-price the
@@ -302,7 +314,9 @@ export function createOpenAIChatRuntime(options) {
302
314
  role: message.role,
303
315
  content: message.text,
304
316
  })),
305
- { role: "user", content: options.computerUse ? chatUserContent(userTurn) : userTurn.text },
317
+ { role: "user", content: !options.computerUse ? userTurn.text
318
+ : seesImages(turn) ? chatUserContent(userTurn)
319
+ : userTurn.images?.length ? `${userTurn.text}\n\n${TEXT_ONLY_ATTACHMENT_NOTE}` : userTurn.text },
306
320
  ];
307
321
  };
308
322
  const sendTurn = async (turn) => {
@@ -381,6 +395,7 @@ export function createOpenAIChatRuntime(options) {
381
395
  let toolFailed = false;
382
396
  const denials = [];
383
397
  const seenCalls = new Set();
398
+ let nudged = false;
384
399
  try {
385
400
  tools = await mountChatTools(options.tools === false ? undefined : turn.integrations, abort.signal, options.computerUse);
386
401
  let optionalQuestionOnly = options.tools !== false && tools.definitions.length === 0;
@@ -490,6 +505,11 @@ export function createOpenAIChatRuntime(options) {
490
505
  if (completion.finishReason && completion.finishReason !== "stop") {
491
506
  throw new ChatProtocolError(`provider did not finish the response (${completion.finishReason})`);
492
507
  }
508
+ if (options.nudgeAnnouncedAction && !nudged && tools.definitions.length && announcesAction(completion.text)) {
509
+ nudged = true;
510
+ messages.push({ role: "assistant", content: completion.text }, { role: "user", content: NUDGE_ANNOUNCED_ACTION });
511
+ continue;
512
+ }
493
513
  if (toolFailed) {
494
514
  stopReason = "tool_error";
495
515
  throw new ChatProtocolError("One or more tool operations failed or were denied. See the tool results; the final response is not an execution receipt.");
@@ -508,7 +528,7 @@ export function createOpenAIChatRuntime(options) {
508
528
  if (seenCalls.size > MAX_CHAT_TOOL_CALLS)
509
529
  throw new ChatProtocolError("tool-call limit reached");
510
530
  messages.push({ role: "assistant", content: completion.text || null, tool_calls: completion.toolCalls,
511
- ...(completion.protocolReasoning && !reasoningReplayRejected.has(model) ? { reasoning_content: completion.protocolReasoning } : {}),
531
+ ...(completion.protocolReasoning && !reasoningReplayRejected.has(model) ? { [options.reasoningReplayField ?? "reasoning_content"]: completion.protocolReasoning } : {}),
512
532
  ...(completion.protocolReasoningDetails.length ? { reasoning_details: completion.protocolReasoningDetails } : {}),
513
533
  });
514
534
  const screenshotParts = [];
@@ -573,6 +593,10 @@ export function createOpenAIChatRuntime(options) {
573
593
  started = true;
574
594
  if (allowed) {
575
595
  result = await tools.execute(call.function.name, args, abort.signal);
596
+ if (result.images?.length && !seesImages(turn)) {
597
+ const { images: _dropped, ...rest } = result;
598
+ result = { ...rest, text: `${rest.text}\n${TEXT_ONLY_SCREENSHOT_NOTE}` };
599
+ }
576
600
  if (result.images?.length) {
577
601
  try {
578
602
  assertImageTransport(options.apiUrl);
@@ -57,6 +57,8 @@ export function readPromptSplitReceipt(scope, key) {
57
57
  stable: record.stable,
58
58
  volatile: record.volatile,
59
59
  ...(typeof record.turnsSinceFull === "number" ? { turnsSinceFull: record.turnsSinceFull } : {}),
60
+ ...(typeof record.lastUsed === "number" && record.lastUsed > 0 ? { lastUsed: record.lastUsed } : {}),
61
+ ...(typeof record.peakUsed === "number" && record.peakUsed > 0 ? { peakUsed: record.peakUsed } : {}),
60
62
  };
61
63
  }
62
64
  }
@@ -64,8 +64,9 @@ function knownDirs() {
64
64
  * invisible until it restarts, because Windows never pushes PATH changes
65
65
  * into a live process. Scanning the standard install locations recovers
66
66
  * those without a restart — `~/.grok/bin` (the x.ai installer) and
67
- * `%APPDATA%\npm` (global npm shims), plus `%LOCALAPPDATA%\agy\bin`, cover
68
- * every engine we ship an install command for. */
67
+ * `%APPDATA%\npm` (global npm shims), plus `%LOCALAPPDATA%\agy\bin` and
68
+ * `%LOCALAPPDATA%\cursor-agent`, cover every engine we ship an install
69
+ * command for. */
69
70
  function windowsKnownDirs() {
70
71
  const home = homedir();
71
72
  const appData = process.env.APPDATA ?? join(home, "AppData", "Roaming");
@@ -74,12 +75,15 @@ function windowsKnownDirs() {
74
75
  join(appData, "npm"), // npm -g shims: claude, codex
75
76
  join(home, ".grok", "bin"), // x.ai installer
76
77
  join(localAppData, "agy", "bin"), // Antigravity installer
78
+ // Cursor installer: cursor-agent.* and its `agent` copies (MOCA-272)
79
+ join(localAppData, "cursor-agent"),
77
80
  join(home, ".local", "bin"), // claude native installer
78
81
  join(home, ".claude", "local"),
79
82
  join(home, "bin"), // Factory droid installer (%USERPROFILE%\bin)
80
83
  join(home, ".bun", "bin"),
81
84
  join(home, ".deno", "bin"),
82
85
  join(home, "go", "bin"),
86
+ join(process.env.ProgramFiles ?? "C:\\Program Files", "Docker", "Docker", "resources", "bin"), // Docker Desktop installer
83
87
  ];
84
88
  }
85
89
  let cached = null;