openmausbot 0.1.83 → 0.1.85
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/index-Ba9G44HI.js +310 -0
- package/dist/assets/{index-nqTFZTxg.js → index-CTNNoqSJ.js} +1 -1
- package/dist/assets/index-CkRNp7wX.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/routes.js +1 -0
- package/dist-server/container-mcp.js +212 -7
- package/dist-server/drivers/agents-proxy.js +161 -12
- package/dist-server/enterprise/server/index.js +1 -0
- package/dist-server/hooks/omb-hook.js +74 -0
- package/dist-server/index.js +8025 -5661
- package/dist-server/local-computer-proxy.js +83 -1
- package/dist-server/local-computer.js +14 -2
- package/dist-server/openmausbot.js +796 -309
- package/dist-server/pair-cli.js +796 -309
- package/dist-server/proxy-paths.js +1 -0
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/bot-package.js +2 -0
- package/dist-server/server/box.js +23 -4
- 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 +65 -10
- package/dist-server/server/checkpoints.js +71 -23
- package/dist-server/server/chief-of-staff.js +3 -0
- package/dist-server/server/cli-prompts.js +3 -1
- package/dist-server/server/commands.js +27 -0
- package/dist-server/server/compaction-summary.js +78 -0
- package/dist-server/server/computer-wait.js +33 -0
- package/dist-server/server/config.js +84 -3
- package/dist-server/server/context-budget.js +29 -0
- package/dist-server/server/context-rebuild.js +72 -0
- package/dist-server/server/delta-context.js +258 -0
- package/dist-server/server/digest.js +130 -0
- package/dist-server/server/drivers/acp/core.js +540 -229
- package/dist-server/server/drivers/agents-proxy.js +79 -12
- package/dist-server/server/drivers/agents-result.js +23 -0
- package/dist-server/server/drivers/claude.js +84 -15
- package/dist-server/server/drivers/codex.js +19 -5
- package/dist-server/server/drivers/openai-chat.js +66 -45
- package/dist-server/server/drivers/openai-compat.js +1 -0
- package/dist-server/server/hooks/omb-hook.js +103 -0
- package/dist-server/server/incidents.js +104 -0
- package/dist-server/server/index.js +1382 -318
- package/dist-server/server/local-computer.js +21 -2
- 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 +80 -21
- package/dist-server/server/message-file.js +3 -2
- package/dist-server/server/notify.js +3 -1
- package/dist-server/server/package-export.js +1 -0
- package/dist-server/server/peer-roster.js +4 -2
- package/dist-server/server/provider-icon.js +20 -0
- package/dist-server/server/proxy-paths.js +1 -0
- package/dist-server/server/request-auth.js +1 -0
- package/dist-server/server/room-handoffs.js +16 -2
- package/dist-server/server/routine-requests.js +39 -1
- package/dist-server/server/routines.js +43 -9
- package/dist-server/server/shared-computer-control.js +31 -4
- package/dist-server/server/steer-queue.js +6 -0
- package/dist-server/server/store.js +111 -14
- package/dist-server/server/system-prompt.js +4 -4
- package/dist-server/server/team-backup.js +9 -1
- 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/webhooks.js +28 -0
- 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/provider-icon.js +110 -0
- package/dist-server/shared/team-backup.js +3 -0
- package/dist-server/vps-container-mcp.js +217 -12
- package/enterprise/server/index.js +1 -0
- package/package.json +1 -1
- package/dist/assets/index-CKysBq-V.css +0 -1
- package/dist/assets/index-DSvqfBOf.js +0 -309
|
@@ -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,6 +28,7 @@
|
|
|
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";
|
|
31
|
+
import { createHash } from "node:crypto";
|
|
20
32
|
import { PROVIDER_CREDENTIAL_ENV, WORKSPACE_CREDENTIAL_ENV } from "../../config.js";
|
|
21
33
|
import { decodeInjectId } from "../local-inject.js";
|
|
22
34
|
import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../../procs.js";
|
|
@@ -32,11 +44,26 @@ 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";
|
|
35
48
|
const envOr = (key, fallback) => Number(process.env[key] ?? fallback);
|
|
36
49
|
const INIT_TIMEOUT = envOr("OPENMAUS_ACP_INIT_TIMEOUT_MS", 300_000);
|
|
37
50
|
const SESSION_CONFIG_TIMEOUT = envOr("OPENMAUS_ACP_SESSION_CONFIG_TIMEOUT_MS", 300_000); // configureSession's per-request default
|
|
38
51
|
const NEW_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_NEW_SESSION_TIMEOUT_MS", 300_000);
|
|
39
52
|
const LOAD_SESSION_TIMEOUT = envOr("OPENMAUS_ACP_LOAD_SESSION_TIMEOUT_MS", 120_000); // history replay on a long thread is slow
|
|
53
|
+
// Read lazily (not at import) so a test can shorten the window. Unlike the
|
|
54
|
+
// setup calls above, session/prompt legitimately streams for minutes, so a
|
|
55
|
+
// wall-clock deadline would false-positive: this guard only trips when the
|
|
56
|
+
// child sends nothing at all for the whole window (a wedged OpenCode turn
|
|
57
|
+
// streams thought chunks, then goes silent forever and never resolves). 0
|
|
58
|
+
// disables the guard, restoring the pre-fix "hang until the user cancels"
|
|
59
|
+
// behavior.
|
|
60
|
+
const promptIdleTimeoutMs = () => {
|
|
61
|
+
const raw = process.env.OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS;
|
|
62
|
+
if (raw === undefined)
|
|
63
|
+
return 180_000;
|
|
64
|
+
const ms = Number(raw);
|
|
65
|
+
return Number.isFinite(ms) && ms > 0 ? ms : 0;
|
|
66
|
+
};
|
|
40
67
|
const CLIENT_FILE_MAX_BYTES = 8 * 1024 * 1024;
|
|
41
68
|
function acpVariantOption(result) {
|
|
42
69
|
const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.type === "select" && typeof entry.id === "string"
|
|
@@ -198,6 +225,48 @@ export function createAcpDriver(support) {
|
|
|
198
225
|
await refreshModels();
|
|
199
226
|
const listeners = new Set();
|
|
200
227
|
const active = new Map();
|
|
228
|
+
// One live agent process per thread, kept across turns — the Claude
|
|
229
|
+
// driver's pool. The ACP handshake (initialize, authenticate) and the
|
|
230
|
+
// native session are established once; a later turn on the same spawn
|
|
231
|
+
// contract prompts the live session instead of paying the handshake
|
|
232
|
+
// again. An idle session closes after SESSION_IDLE_MS of quiet.
|
|
233
|
+
const sessions = new Map();
|
|
234
|
+
const configuredIdleMinimum = Number(process.env.OMB_ACP_SESSION_IDLE_MIN_MS);
|
|
235
|
+
const sessionIdleMinimum = Number.isFinite(configuredIdleMinimum) && configuredIdleMinimum > 0
|
|
236
|
+
? configuredIdleMinimum
|
|
237
|
+
: 10_000;
|
|
238
|
+
const SESSION_IDLE_MS = Math.max(sessionIdleMinimum, Number(process.env.OMB_ACP_SESSION_IDLE_MS) || 10 * 60_000);
|
|
239
|
+
const closeSession = (threadId, why) => {
|
|
240
|
+
const session = sessions.get(threadId);
|
|
241
|
+
if (!session || session.closing)
|
|
242
|
+
return;
|
|
243
|
+
session.closing = true;
|
|
244
|
+
if (session.idleTimer)
|
|
245
|
+
clearTimeout(session.idleTimer);
|
|
246
|
+
appendNative(threadId, { dir: "out", source: SOURCE, msg: { close: why } });
|
|
247
|
+
// a new turn must never adopt a closing session
|
|
248
|
+
sessions.delete(threadId);
|
|
249
|
+
session.acp.close();
|
|
250
|
+
// stdin EOF asks the agent to exit; EOF is not a guaranteed exit
|
|
251
|
+
// signal for ACP agents, so insist after a grace period
|
|
252
|
+
try {
|
|
253
|
+
session.child.stdin.end();
|
|
254
|
+
}
|
|
255
|
+
catch { }
|
|
256
|
+
const kill = setTimeout(() => {
|
|
257
|
+
void killCliTree(session.child);
|
|
258
|
+
}, 5_000);
|
|
259
|
+
kill.unref?.();
|
|
260
|
+
};
|
|
261
|
+
const armIdle = (threadId) => {
|
|
262
|
+
const session = sessions.get(threadId);
|
|
263
|
+
if (!session)
|
|
264
|
+
return;
|
|
265
|
+
if (session.idleTimer)
|
|
266
|
+
clearTimeout(session.idleTimer);
|
|
267
|
+
session.idleTimer = setTimeout(() => closeSession(threadId, "idle"), SESSION_IDLE_MS);
|
|
268
|
+
session.idleTimer.unref?.();
|
|
269
|
+
};
|
|
201
270
|
// "Always allow this session", remembered by the driver when the agent
|
|
202
271
|
// offered no `allow_always` of its own: the exact operations (kind,
|
|
203
272
|
// title, command, input, locations) a person allowed for the session,
|
|
@@ -297,109 +366,61 @@ export function createAcpDriver(support) {
|
|
|
297
366
|
}
|
|
298
367
|
return servers;
|
|
299
368
|
};
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
const
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
&& !skipSubscriptionAuthForLocalInject(turn.model)
|
|
323
|
-
&& !(await support.isAuthenticated(env, turnConfig, instanceId))) {
|
|
324
|
-
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
325
|
-
emit({ ...base(threadId, turnId), type: "runtime.error", message: support.loginNote, setup: true });
|
|
326
|
-
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "auth_required", cost: null });
|
|
327
|
-
return { turnId };
|
|
328
|
-
}
|
|
329
|
-
const resolvedModel = support.resolveTurnModel?.(turn.model, env);
|
|
330
|
-
support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
|
|
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 };
|
|
369
|
+
/** The one completion path for a turn: the prompt result, a crashed
|
|
370
|
+
* child, an unanswered cancel, or an rpc error the turn body throws.
|
|
371
|
+
* The child is NOT killed here — a clean settle leaves it pooled for
|
|
372
|
+
* the next turn on this contract. */
|
|
373
|
+
const settle = (threadId, session, ok, stopReason) => {
|
|
374
|
+
const current = session.current;
|
|
375
|
+
if (!current || current.state.settled)
|
|
376
|
+
return;
|
|
377
|
+
current.state.settled = true;
|
|
378
|
+
if (current.interruptTimer)
|
|
379
|
+
clearTimeout(current.interruptTimer);
|
|
380
|
+
for (const finish of current.asks.values())
|
|
381
|
+
finish("cancel", "system");
|
|
382
|
+
session.acp.failAll(new Error("turn settled"));
|
|
383
|
+
// detach before the final events: a listener that starts the next
|
|
384
|
+
// turn synchronously must find this session free
|
|
385
|
+
session.current = null;
|
|
386
|
+
active.delete(threadId);
|
|
387
|
+
current.flushAssistantText();
|
|
388
|
+
emit({ ...base(threadId, current.turnId), type: "turn.completed", ok, stopReason, cost: null });
|
|
389
|
+
if (session.child.exitCode === null && !session.closing && !session.dead) {
|
|
390
|
+
armIdle(threadId);
|
|
340
391
|
}
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
...base(threadId, turnId),
|
|
345
|
-
type: "runtime.error",
|
|
346
|
-
message: error instanceof Error ? error.message : String(error),
|
|
347
|
-
setup: true,
|
|
348
|
-
});
|
|
349
|
-
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "setup_required", cost: null });
|
|
350
|
-
return { turnId };
|
|
392
|
+
else if (session.dead && sessions.get(threadId) === session) {
|
|
393
|
+
// a dead session is never pooled; the next turn respawns
|
|
394
|
+
sessions.delete(threadId);
|
|
351
395
|
}
|
|
352
|
-
|
|
396
|
+
};
|
|
397
|
+
/** Spawn the agent process and everything that lives for its whole
|
|
398
|
+
* lifetime: the wire connection, native logging, stderr tailing, and
|
|
399
|
+
* the server-request/update dispatch. Per-turn state arrives through
|
|
400
|
+
* session.current, so a request that lands between turns is answered
|
|
401
|
+
* (never brokered) instead of left hanging. */
|
|
402
|
+
const openSession = (threadId, launch, argv, env, cwd, contractKey) => {
|
|
403
|
+
const child = spawnCli(launch.command, argv, {
|
|
353
404
|
cwd,
|
|
354
|
-
env
|
|
405
|
+
env,
|
|
355
406
|
stdio: ["pipe", "pipe", "pipe"],
|
|
356
407
|
});
|
|
357
|
-
const state = { settled: false, promptSent: false, text: "" };
|
|
358
|
-
const asks = new Map();
|
|
359
408
|
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
409
|
const rpcPending = new Map();
|
|
395
410
|
const send = (obj) => {
|
|
411
|
+
// A permission/file response resumes an agent that was waiting on us.
|
|
412
|
+
const message = obj;
|
|
413
|
+
if (message.id !== undefined && (message.result !== undefined || message.error !== undefined)) {
|
|
414
|
+
for (const pending of rpcPending.values())
|
|
415
|
+
pending.armIdle();
|
|
416
|
+
}
|
|
396
417
|
try {
|
|
397
418
|
child.stdin.write(JSON.stringify(obj) + "\n");
|
|
398
419
|
}
|
|
399
420
|
catch { }
|
|
400
421
|
appendNative(threadId, { dir: "out", source: SOURCE, msg: nativeLogMessage(obj) });
|
|
401
422
|
};
|
|
402
|
-
const request = (method, params, timeoutMs, receive) => new Promise((resolve, reject) => {
|
|
423
|
+
const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => new Promise((resolve, reject) => {
|
|
403
424
|
const id = nextId++;
|
|
404
425
|
let timer = null;
|
|
405
426
|
if (timeoutMs) {
|
|
@@ -409,16 +430,59 @@ export function createAcpDriver(support) {
|
|
|
409
430
|
}, timeoutMs);
|
|
410
431
|
timer.unref?.();
|
|
411
432
|
}
|
|
433
|
+
// Idle watchdog: unlike a wall-clock timeout, the deadline restarts
|
|
434
|
+
// on every inbound line (see the stdout handler), so a long-lived
|
|
435
|
+
// streaming agent is never cut off — only one that has gone fully
|
|
436
|
+
// silent trips it.
|
|
437
|
+
let idleTimer = null;
|
|
438
|
+
const armIdle = () => {
|
|
439
|
+
if (!(idleMs && idleMs > 0))
|
|
440
|
+
return;
|
|
441
|
+
if (idleTimer)
|
|
442
|
+
clearTimeout(idleTimer);
|
|
443
|
+
idleTimer = setTimeout(() => {
|
|
444
|
+
// Waiting for a person is not an unresponsive agent.
|
|
445
|
+
if (session.current?.asks.size) {
|
|
446
|
+
armIdle();
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
rpcPending.delete(id);
|
|
450
|
+
const error = new Error(idleMessage ?? `${method} stopped responding`);
|
|
451
|
+
Object.assign(error, { acpPromptStall: true });
|
|
452
|
+
reject(error);
|
|
453
|
+
}, idleMs);
|
|
454
|
+
idleTimer.unref?.();
|
|
455
|
+
};
|
|
456
|
+
armIdle();
|
|
412
457
|
rpcPending.set(id, {
|
|
413
458
|
// Consume configuration in wire order: an update following this
|
|
414
459
|
// response may arrive before the awaiting continuation resumes.
|
|
415
460
|
resolve: (result) => { receive?.(result); resolve(result); },
|
|
416
461
|
reject,
|
|
417
462
|
timer,
|
|
463
|
+
get idleTimer() { return idleTimer; },
|
|
464
|
+
armIdle,
|
|
418
465
|
});
|
|
419
466
|
send({ jsonrpc: "2.0", id, method, params });
|
|
420
467
|
});
|
|
421
|
-
const
|
|
468
|
+
const acp = {
|
|
469
|
+
send,
|
|
470
|
+
request,
|
|
471
|
+
failAll: (error) => {
|
|
472
|
+
for (const p of rpcPending.values()) {
|
|
473
|
+
if (p.timer)
|
|
474
|
+
clearTimeout(p.timer);
|
|
475
|
+
if (p.idleTimer)
|
|
476
|
+
clearTimeout(p.idleTimer);
|
|
477
|
+
p.reject(error);
|
|
478
|
+
}
|
|
479
|
+
rpcPending.clear();
|
|
480
|
+
},
|
|
481
|
+
close: () => {
|
|
482
|
+
acp.failAll(new Error("session closed"));
|
|
483
|
+
child.stdout.removeAllListeners("data");
|
|
484
|
+
},
|
|
485
|
+
};
|
|
422
486
|
const resolveClientPath = async (requestPath) => {
|
|
423
487
|
if (typeof requestPath !== "string" || !isAbsolute(requestPath)) {
|
|
424
488
|
throw new Error("ACP file paths must be absolute.");
|
|
@@ -469,8 +533,8 @@ export function createAcpDriver(support) {
|
|
|
469
533
|
send({ jsonrpc: "2.0", id: msg.id, result: { content } });
|
|
470
534
|
return;
|
|
471
535
|
}
|
|
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;
|
|
536
|
+
const line = typeof params.line === "number" && Number.isInteger(params.line) && params.line > 0 ? params.line : 1;
|
|
537
|
+
const limit = typeof params.limit === "number" && Number.isInteger(params.limit) && params.limit >= 0 ? params.limit : undefined;
|
|
474
538
|
const lines = content.split("\n");
|
|
475
539
|
const start = line - 1;
|
|
476
540
|
send({
|
|
@@ -491,35 +555,9 @@ export function createAcpDriver(support) {
|
|
|
491
555
|
fail(error);
|
|
492
556
|
}
|
|
493
557
|
};
|
|
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) => {
|
|
558
|
+
// server→client permission request → canonical request.opened,
|
|
559
|
+
// answered fail-closed for the running turn
|
|
560
|
+
const handleServerRequest = (msg, current) => {
|
|
523
561
|
if (msg.method === "fs/read_text_file" || msg.method === "fs/write_text_file") {
|
|
524
562
|
void handleClientFileRequest(msg);
|
|
525
563
|
return;
|
|
@@ -529,7 +567,7 @@ export function createAcpDriver(support) {
|
|
|
529
567
|
return send({ jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } });
|
|
530
568
|
}
|
|
531
569
|
const params = msg.params ?? {};
|
|
532
|
-
flushAssistantText();
|
|
570
|
+
current.flushAssistantText();
|
|
533
571
|
const options = Array.isArray(params.options) ? params.options : [];
|
|
534
572
|
const optionFor = (want) => options.find((o) => o.kind === `${want}_once` && typeof o.optionId === "string")?.optionId
|
|
535
573
|
?? options.find((o) => String(o.kind ?? "").startsWith(want) && typeof o.optionId === "string")?.optionId
|
|
@@ -537,13 +575,13 @@ export function createAcpDriver(support) {
|
|
|
537
575
|
const optionAlways = options.find((o) => o.kind === "allow_always" && typeof o.optionId === "string")?.optionId ?? null;
|
|
538
576
|
const cancelled = { outcome: { outcome: "cancelled" } };
|
|
539
577
|
const missing = (want) => emit({
|
|
540
|
-
...base(threadId, turnId),
|
|
578
|
+
...base(threadId, current.turnId),
|
|
541
579
|
type: "runtime.error",
|
|
542
580
|
message: `${DRIVER_KIND} offered no "${want}" permission option — cancelling the request instead of guessing`,
|
|
543
581
|
});
|
|
544
582
|
const toolCall = params.toolCall ?? {};
|
|
545
583
|
const isQuestion = String(toolCall.toolCallId ?? "").startsWith("interaction_");
|
|
546
|
-
if (turnConfig.fullAuto && turn.approvalMode === undefined && !isQuestion) {
|
|
584
|
+
if (current.turnConfig.fullAuto && current.turn.approvalMode === undefined && !isQuestion) {
|
|
547
585
|
const allow = optionFor("allow");
|
|
548
586
|
if (!allow)
|
|
549
587
|
missing("allow");
|
|
@@ -555,7 +593,7 @@ export function createAcpDriver(support) {
|
|
|
555
593
|
}
|
|
556
594
|
const kind = String(toolCall.kind ?? "");
|
|
557
595
|
// an earlier "Always allow this session" on this exact operation
|
|
558
|
-
const operationKey = isQuestion || controlsHost ? null : sessionOperationKey(toolCall);
|
|
596
|
+
const operationKey = isQuestion || current.controlsHost ? null : sessionOperationKey(toolCall);
|
|
559
597
|
if (operationKey && sessionAllows.get(threadId)?.has(operationKey)) {
|
|
560
598
|
const allow = optionFor("allow");
|
|
561
599
|
if (allow) {
|
|
@@ -566,11 +604,11 @@ export function createAcpDriver(support) {
|
|
|
566
604
|
const summary = String(toolCall.rawInput?.command ?? toolCall.title ?? tool).slice(0, 200);
|
|
567
605
|
const requestId = newId();
|
|
568
606
|
const finish = (behavior, source = "user", message, always) => {
|
|
569
|
-
if (!asks.delete(requestId))
|
|
607
|
+
if (!current.asks.delete(requestId))
|
|
570
608
|
return;
|
|
571
609
|
clearTimeout(timer);
|
|
572
610
|
const want = behavior === "allow" ? "allow" : "reject";
|
|
573
|
-
const forSession = want === "allow" && always === true && !isQuestion && !controlsHost;
|
|
611
|
+
const forSession = want === "allow" && always === true && !isQuestion && !current.controlsHost;
|
|
574
612
|
const named = isQuestion && behavior === "answer"
|
|
575
613
|
? options.filter((option) => option.optionId === message || option.name?.trim() === message)
|
|
576
614
|
: [];
|
|
@@ -596,22 +634,22 @@ export function createAcpDriver(support) {
|
|
|
596
634
|
result: optionId ? { outcome: { outcome: "selected", optionId } } : cancelled,
|
|
597
635
|
});
|
|
598
636
|
emit({
|
|
599
|
-
...base(threadId, turnId),
|
|
637
|
+
...base(threadId, current.turnId),
|
|
600
638
|
type: "request.resolved",
|
|
601
639
|
requestId,
|
|
602
640
|
behavior: optionId && isQuestion ? "answer" : optionId && behavior === "allow" ? "allow" : "deny",
|
|
603
641
|
source: optionId ? source : "system",
|
|
604
|
-
approvalScope: controlsHost ? "local-computer" : undefined,
|
|
642
|
+
approvalScope: current.controlsHost ? "local-computer" : undefined,
|
|
605
643
|
});
|
|
606
644
|
};
|
|
607
645
|
const timer = setTimeout(() => {
|
|
608
|
-
emit({ ...base(threadId, turnId), type: "runtime.error", message: DENY_TIMEOUT_NOTE });
|
|
646
|
+
emit({ ...base(threadId, current.turnId), type: "runtime.error", message: DENY_TIMEOUT_NOTE });
|
|
609
647
|
finish("deny", "timeout");
|
|
610
648
|
}, 15 * 60_000);
|
|
611
649
|
timer.unref?.();
|
|
612
|
-
asks.set(requestId, finish);
|
|
650
|
+
current.asks.set(requestId, finish);
|
|
613
651
|
emit({
|
|
614
|
-
...base(threadId, turnId),
|
|
652
|
+
...base(threadId, current.turnId),
|
|
615
653
|
type: "request.opened",
|
|
616
654
|
requestId,
|
|
617
655
|
requestType: isQuestion ? "question" : "permission",
|
|
@@ -620,9 +658,9 @@ export function createAcpDriver(support) {
|
|
|
620
658
|
choices: isQuestion
|
|
621
659
|
? options.flatMap((option) => typeof option.name === "string" && option.name.trim() ? [option.name.trim()] : [])
|
|
622
660
|
: undefined,
|
|
623
|
-
approvalScope: controlsHost ? "local-computer" : undefined,
|
|
661
|
+
approvalScope: current.controlsHost ? "local-computer" : undefined,
|
|
624
662
|
// the driver can honor a session-wide allow either way
|
|
625
|
-
allowSession: !isQuestion && !controlsHost ? true : undefined,
|
|
663
|
+
allowSession: !isQuestion && !current.controlsHost ? true : undefined,
|
|
626
664
|
});
|
|
627
665
|
};
|
|
628
666
|
const handleNotification = (msg) => {
|
|
@@ -633,12 +671,13 @@ export function createAcpDriver(support) {
|
|
|
633
671
|
const p = msg.params ?? {};
|
|
634
672
|
if (p._meta?.isReplay === true)
|
|
635
673
|
return;
|
|
674
|
+
const current = session.current;
|
|
636
675
|
if (support.modelVariants && p.update?.sessionUpdate === "config_option_update") {
|
|
637
|
-
if (!state.settled && sessionId && p.sessionId === sessionId)
|
|
638
|
-
receiveModelVariants(p.update);
|
|
676
|
+
if (current && !current.state.settled && session.sessionId && p.sessionId === session.sessionId)
|
|
677
|
+
current.receiveModelVariants(p.update);
|
|
639
678
|
return;
|
|
640
679
|
}
|
|
641
|
-
if (!state.promptSent)
|
|
680
|
+
if (!current || !current.state.promptSent)
|
|
642
681
|
return;
|
|
643
682
|
const u = p.update ?? {};
|
|
644
683
|
switch (u.sessionUpdate) {
|
|
@@ -646,9 +685,9 @@ export function createAcpDriver(support) {
|
|
|
646
685
|
const content = u.content;
|
|
647
686
|
const delta = content?.text;
|
|
648
687
|
if (content?.type === "image" && typeof content.data === "string" && content.data) {
|
|
649
|
-
flushAssistantText();
|
|
688
|
+
current.flushAssistantText();
|
|
650
689
|
emit({
|
|
651
|
-
...base(threadId, turnId),
|
|
690
|
+
...base(threadId, current.turnId),
|
|
652
691
|
type: "item.completed",
|
|
653
692
|
itemType: "assistant_image",
|
|
654
693
|
data: content.data,
|
|
@@ -656,22 +695,22 @@ export function createAcpDriver(support) {
|
|
|
656
695
|
});
|
|
657
696
|
}
|
|
658
697
|
else if (typeof delta === "string" && delta) {
|
|
659
|
-
state.text += delta;
|
|
660
|
-
emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "assistant_text", delta });
|
|
698
|
+
current.state.text += delta;
|
|
699
|
+
emit({ ...base(threadId, current.turnId), type: "content.delta", streamKind: "assistant_text", delta });
|
|
661
700
|
}
|
|
662
701
|
break;
|
|
663
702
|
}
|
|
664
703
|
case "agent_thought_chunk": {
|
|
665
704
|
const delta = u.content?.text;
|
|
666
705
|
if (typeof delta === "string" && delta) {
|
|
667
|
-
emit({ ...base(threadId, turnId), type: "content.delta", streamKind: "reasoning_text", delta });
|
|
706
|
+
emit({ ...base(threadId, current.turnId), type: "content.delta", streamKind: "reasoning_text", delta });
|
|
668
707
|
}
|
|
669
708
|
break;
|
|
670
709
|
}
|
|
671
710
|
case "tool_call": {
|
|
672
|
-
flushAssistantText();
|
|
711
|
+
current.flushAssistantText();
|
|
673
712
|
emit({
|
|
674
|
-
...base(threadId, turnId),
|
|
713
|
+
...base(threadId, current.turnId),
|
|
675
714
|
type: "item.started",
|
|
676
715
|
itemType: "tool",
|
|
677
716
|
itemId: u.toolCallId,
|
|
@@ -684,18 +723,38 @@ export function createAcpDriver(support) {
|
|
|
684
723
|
case "tool_call_update": {
|
|
685
724
|
if (u.status === "completed" || u.status === "failed") {
|
|
686
725
|
emit({
|
|
687
|
-
...base(threadId, turnId),
|
|
726
|
+
...base(threadId, current.turnId),
|
|
688
727
|
type: "item.completed",
|
|
689
728
|
itemType: "tool",
|
|
690
729
|
itemId: u.toolCallId,
|
|
691
730
|
ok: u.status !== "failed",
|
|
692
731
|
output: toolDetailPreview(u.rawOutput ?? u.content),
|
|
693
732
|
});
|
|
733
|
+
for (const img of extractMcpImages(u.content ?? u.rawOutput)) {
|
|
734
|
+
emit({ ...base(threadId, current.turnId), type: "item.completed", itemType: "assistant_image", data: img.data });
|
|
735
|
+
}
|
|
694
736
|
}
|
|
695
737
|
break;
|
|
696
738
|
}
|
|
697
739
|
}
|
|
698
740
|
};
|
|
741
|
+
const session = {
|
|
742
|
+
child,
|
|
743
|
+
acp,
|
|
744
|
+
launch,
|
|
745
|
+
cwd,
|
|
746
|
+
contractKey,
|
|
747
|
+
sessionKey: null,
|
|
748
|
+
sessionId: null,
|
|
749
|
+
sessionConfigResult: null,
|
|
750
|
+
initResult: null,
|
|
751
|
+
authenticated: false,
|
|
752
|
+
idleTimer: null,
|
|
753
|
+
closing: false,
|
|
754
|
+
dead: false,
|
|
755
|
+
stderr: "",
|
|
756
|
+
current: null,
|
|
757
|
+
};
|
|
699
758
|
let buf = "";
|
|
700
759
|
// decode as UTF-8 across chunk boundaries — a raw `buf += chunk` splits
|
|
701
760
|
// multibyte characters that straddle two reads and corrupts the text
|
|
@@ -716,12 +775,18 @@ export function createAcpDriver(support) {
|
|
|
716
775
|
continue;
|
|
717
776
|
}
|
|
718
777
|
appendNative(threadId, { dir: "in", source: SOURCE, msg: nativeLogMessage(msg) });
|
|
778
|
+
// Inbound traffic proves the child is alive and making progress,
|
|
779
|
+
// so every idle deadline restarts; only total silence trips it.
|
|
780
|
+
for (const p of rpcPending.values())
|
|
781
|
+
p.armIdle();
|
|
719
782
|
if (msg.id !== undefined && (msg.result !== undefined || msg.error !== undefined)) {
|
|
720
783
|
const pend = rpcPending.get(msg.id);
|
|
721
784
|
if (pend) {
|
|
722
785
|
rpcPending.delete(msg.id);
|
|
723
786
|
if (pend.timer)
|
|
724
787
|
clearTimeout(pend.timer);
|
|
788
|
+
if (pend.idleTimer)
|
|
789
|
+
clearTimeout(pend.idleTimer);
|
|
725
790
|
if (msg.error) {
|
|
726
791
|
const error = new Error(msg.error.message ?? JSON.stringify(msg.error));
|
|
727
792
|
Object.assign(error, { code: msg.error.code, data: msg.error.data });
|
|
@@ -733,112 +798,348 @@ export function createAcpDriver(support) {
|
|
|
733
798
|
}
|
|
734
799
|
}
|
|
735
800
|
else if (msg.id !== undefined && msg.method) {
|
|
736
|
-
|
|
801
|
+
const current = session.current;
|
|
802
|
+
if (!current) {
|
|
803
|
+
// between turns nothing is brokered: cancel a permission
|
|
804
|
+
// request and refuse anything else — the agent must never
|
|
805
|
+
// block on an unanswered request
|
|
806
|
+
send(msg.method === "session/request_permission"
|
|
807
|
+
? { jsonrpc: "2.0", id: msg.id, result: { outcome: { outcome: "cancelled" } } }
|
|
808
|
+
: { jsonrpc: "2.0", id: msg.id, error: { code: -32601, message: "method not found" } });
|
|
809
|
+
}
|
|
810
|
+
else {
|
|
811
|
+
handleServerRequest(msg, current);
|
|
812
|
+
}
|
|
737
813
|
}
|
|
738
814
|
else if (msg.method) {
|
|
739
815
|
handleNotification(msg);
|
|
740
816
|
}
|
|
741
817
|
}
|
|
742
818
|
});
|
|
743
|
-
let stderr = "";
|
|
744
819
|
child.stderr.on("data", (c) => {
|
|
745
820
|
if (!support.redactStderr)
|
|
746
|
-
stderr += c;
|
|
747
|
-
if (stderr.length > 8192)
|
|
748
|
-
stderr = stderr.slice(-8192);
|
|
821
|
+
session.stderr += c;
|
|
822
|
+
if (session.stderr.length > 8192)
|
|
823
|
+
session.stderr = session.stderr.slice(-8192);
|
|
749
824
|
});
|
|
750
825
|
child.on("error", (e) => {
|
|
751
|
-
|
|
752
|
-
|
|
826
|
+
session.dead = true;
|
|
827
|
+
const current = session.current;
|
|
828
|
+
if (!current)
|
|
829
|
+
return;
|
|
830
|
+
emit({ ...base(threadId, current.turnId), type: "runtime.error", ...describeSpawnFailure(e, launch.command) });
|
|
831
|
+
settle(threadId, session, false, "spawn_error");
|
|
753
832
|
});
|
|
754
833
|
child.on("close", (code) => {
|
|
755
|
-
|
|
834
|
+
session.dead = true;
|
|
835
|
+
const current = session.current;
|
|
836
|
+
if (current) {
|
|
756
837
|
emit({
|
|
757
|
-
...base(threadId, turnId),
|
|
838
|
+
...base(threadId, current.turnId),
|
|
758
839
|
type: "runtime.error",
|
|
759
|
-
message: `${DRIVER_KIND} exited ${code} before the prompt result${stderr ? `: ${stderr.trim().slice(-300)}` : ""}`,
|
|
840
|
+
message: `${DRIVER_KIND} exited ${code} before the prompt result${session.stderr ? `: ${session.stderr.trim().slice(-300)}` : ""}`,
|
|
760
841
|
});
|
|
761
|
-
settle(false, "exit_before_result");
|
|
842
|
+
settle(threadId, session, false, "exit_before_result");
|
|
843
|
+
}
|
|
844
|
+
else if (sessions.get(threadId) === session) {
|
|
845
|
+
// drop the record between turns; a later turn respawns. The
|
|
846
|
+
// identity check keeps an old child's exit from unlinking a
|
|
847
|
+
// session that already replaced this one.
|
|
848
|
+
sessions.delete(threadId);
|
|
762
849
|
}
|
|
763
850
|
});
|
|
851
|
+
return session;
|
|
852
|
+
};
|
|
853
|
+
const sendTurn = async (turn) => {
|
|
854
|
+
const { threadId } = turn;
|
|
855
|
+
if (active.has(threadId))
|
|
856
|
+
throw new Error("a turn is already running on this thread");
|
|
857
|
+
// Provider-instance `fullAuto` predates per-bot approval levels. Every
|
|
858
|
+
// harness turn now carries the bot's mode, so Ask/Auto must explicitly
|
|
859
|
+
// put the native agent back into its interactive mode. Otherwise a
|
|
860
|
+
// legacy Grok bypassPermissions / Cursor --force / Droid auto-high /
|
|
861
|
+
// Antigravity yolo setting would silently outrank the selector. Calls
|
|
862
|
+
// that omit approvalMode retain the old adapter-level behavior for
|
|
863
|
+
// embedders and tests outside the harness.
|
|
864
|
+
const turnConfig = turn.approvalMode === undefined
|
|
865
|
+
? config
|
|
866
|
+
: { ...config, fullAuto: turn.approvalMode === "full" && supportsApprovalMode(DRIVER_KIND, "full") };
|
|
867
|
+
const controlsHost = turn.integrations?.localComputer?.scope === "local-computer";
|
|
868
|
+
if (controlsHost && turnConfig.fullAuto && turn.approvalMode !== "full") {
|
|
869
|
+
throw new Error("local computer control requires interactive provider approvals");
|
|
870
|
+
}
|
|
871
|
+
const turnId = newId();
|
|
872
|
+
const cwd = turn.cwd ?? turnConfig.workspace ?? homedir();
|
|
873
|
+
const env = childEnv(turnConfig);
|
|
874
|
+
if (support.requireAuthenticationBeforeSpawn
|
|
875
|
+
&& !skipSubscriptionAuthForLocalInject(turn.model)
|
|
876
|
+
&& !(await support.isAuthenticated(env, turnConfig, instanceId))) {
|
|
877
|
+
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
878
|
+
emit({ ...base(threadId, turnId), type: "runtime.error", message: support.loginNote, setup: true });
|
|
879
|
+
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "auth_required", cost: null });
|
|
880
|
+
return { turnId };
|
|
881
|
+
}
|
|
882
|
+
const resolvedModel = support.resolveTurnModel?.(turn.model, env);
|
|
883
|
+
support.applyTurnEnv?.(env, { model: resolvedModel, requestedModel: turn.model, fullAuto: turnConfig.fullAuto === true });
|
|
884
|
+
const cliTurn = resolvedModel !== undefined && resolvedModel !== turn.model
|
|
885
|
+
? { ...turn, model: resolvedModel }
|
|
886
|
+
: turn;
|
|
887
|
+
const mcpServers = acpMcpServers(turn);
|
|
888
|
+
let launch;
|
|
889
|
+
try {
|
|
890
|
+
launch = support.resolveCommand
|
|
891
|
+
? await support.resolveCommand(env, turnConfig, instanceId)
|
|
892
|
+
: { command: turnConfig.cli };
|
|
893
|
+
}
|
|
894
|
+
catch (error) {
|
|
895
|
+
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
896
|
+
emit({
|
|
897
|
+
...base(threadId, turnId),
|
|
898
|
+
type: "runtime.error",
|
|
899
|
+
message: error instanceof Error ? error.message : String(error),
|
|
900
|
+
setup: true,
|
|
901
|
+
});
|
|
902
|
+
emit({ ...base(threadId, turnId), type: "turn.completed", ok: false, stopReason: "setup_required", cost: null });
|
|
903
|
+
return { turnId };
|
|
904
|
+
}
|
|
905
|
+
// The spawn contract: everything that changes what process a turn
|
|
906
|
+
// gets. The model rides argv where a support passes -m, and a
|
|
907
|
+
// selectModel support re-applies it over the wire on the live
|
|
908
|
+
// session, so the model is not a separate axis; fullAuto covers the
|
|
909
|
+
// transformEnv-policy supports (opencode). mcpServers are session
|
|
910
|
+
// establishment inputs — they ride session/new and session/load over
|
|
911
|
+
// the wire — and the harness mints fresh integration bearer tokens
|
|
912
|
+
// every turn, so they must not respawn the process; a change instead
|
|
913
|
+
// re-establishes the session below (see sessionKey).
|
|
914
|
+
// The env the spawned child actually receives is part of the
|
|
915
|
+
// contract too, and arrives hashed as envFingerprint for the same
|
|
916
|
+
// reason.
|
|
917
|
+
const spawnArgs = support.spawnArgs(turnConfig, cliTurn);
|
|
918
|
+
const spawnEnv = launch.env ?? env;
|
|
919
|
+
// Env is part of the spawn contract: a turn that changes auth env
|
|
920
|
+
// (FACTORY_API_KEY placeholder, a fresh login file) must not keep
|
|
921
|
+
// riding a child spawned under the old env. Hash it so secrets
|
|
922
|
+
// never sit in the key itself.
|
|
923
|
+
const envFingerprint = createHash("sha256").update(JSON.stringify(spawnEnv)).digest("hex").slice(0, 16);
|
|
924
|
+
const contractKey = JSON.stringify([launch.command, launch.args ?? [], spawnArgs, cwd, turnConfig.fullAuto === true, envFingerprint]);
|
|
925
|
+
const sessionKey = JSON.stringify(mcpServers);
|
|
926
|
+
const pooled = sessions.get(threadId);
|
|
927
|
+
let session;
|
|
928
|
+
if (pooled && !pooled.dead && !pooled.closing && pooled.contractKey === contractKey) {
|
|
929
|
+
// adoption cancels the idle countdown — a running turn is not quiet
|
|
930
|
+
if (pooled.idleTimer)
|
|
931
|
+
clearTimeout(pooled.idleTimer);
|
|
932
|
+
pooled.idleTimer = null;
|
|
933
|
+
session = pooled;
|
|
934
|
+
}
|
|
935
|
+
else {
|
|
936
|
+
if (pooled) {
|
|
937
|
+
// a dead child already exited — just drop the record; a live one
|
|
938
|
+
// gets the full close (contract changed)
|
|
939
|
+
if (pooled.dead)
|
|
940
|
+
sessions.delete(threadId);
|
|
941
|
+
else
|
|
942
|
+
closeSession(threadId, "contract");
|
|
943
|
+
}
|
|
944
|
+
session = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], spawnEnv, cwd, contractKey);
|
|
945
|
+
sessions.set(threadId, session);
|
|
946
|
+
}
|
|
947
|
+
// `session` rebinds mid-turn: when the establishment retry below
|
|
948
|
+
// respawns the child, every wire call must reach the live record, so
|
|
949
|
+
// nothing captures the connection off it.
|
|
950
|
+
const request = (method, params, timeoutMs, receive, idleMs, idleMessage) => session.acp.request(method, params, timeoutMs, receive, idleMs, idleMessage);
|
|
951
|
+
const state = { settled: false, promptSent: false, text: "" };
|
|
952
|
+
const asks = new Map();
|
|
953
|
+
const modelOf = (result) => {
|
|
954
|
+
const option = (Array.isArray(result?.configOptions) ? result.configOptions : []).find((entry) => entry?.id === (support.selectModel?.configId ?? "model"));
|
|
955
|
+
return typeof option?.currentValue === "string" ? option.currentValue : null;
|
|
956
|
+
};
|
|
957
|
+
const receiveModelVariants = (result) => {
|
|
958
|
+
session.sessionConfigResult = result;
|
|
959
|
+
if (!support.modelVariants)
|
|
960
|
+
return;
|
|
961
|
+
const nativeModel = modelOf(result) ?? cliTurn.model;
|
|
962
|
+
if (!nativeModel)
|
|
963
|
+
return;
|
|
964
|
+
const option = acpVariantOption(result);
|
|
965
|
+
emit({
|
|
966
|
+
...base(threadId, turnId),
|
|
967
|
+
type: "session.model-variants",
|
|
968
|
+
model: nativeModel === cliTurn.model ? (turn.model ?? nativeModel) : nativeModel,
|
|
969
|
+
variants: {
|
|
970
|
+
options: option?.options ?? [],
|
|
971
|
+
...(option?.currentValue !== undefined ? { currentValue: option.currentValue } : {}),
|
|
972
|
+
},
|
|
973
|
+
});
|
|
974
|
+
};
|
|
975
|
+
const requestedVariantOption = () => {
|
|
976
|
+
if (!support.modelVariants)
|
|
977
|
+
throw new Error(`${support.displayName} does not support model variants`);
|
|
978
|
+
const option = acpVariantOption(session.sessionConfigResult);
|
|
979
|
+
if (!option || !option.options.some((entry) => entry.id === turn.variant)) {
|
|
980
|
+
throw new Error(`${support.displayName} does not advertise variant ${turn.variant} for this model`);
|
|
981
|
+
}
|
|
982
|
+
return option;
|
|
983
|
+
};
|
|
984
|
+
/** Emit buffered assistant text as its own item, then clear it. */
|
|
985
|
+
const flushAssistantText = () => {
|
|
986
|
+
const text = state.text;
|
|
987
|
+
state.text = "";
|
|
988
|
+
if (!text.trim())
|
|
989
|
+
return;
|
|
990
|
+
emit({ ...base(threadId, turnId), type: "item.completed", itemType: "assistant_text", text });
|
|
991
|
+
};
|
|
992
|
+
const current = {
|
|
993
|
+
turnId,
|
|
994
|
+
turn: cliTurn,
|
|
995
|
+
turnConfig,
|
|
996
|
+
controlsHost,
|
|
997
|
+
state,
|
|
998
|
+
asks,
|
|
999
|
+
interruptTimer: null,
|
|
1000
|
+
flushAssistantText,
|
|
1001
|
+
receiveModelVariants,
|
|
1002
|
+
};
|
|
764
1003
|
const interrupt = () => {
|
|
765
|
-
if (sessionId)
|
|
766
|
-
send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId } });
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
1004
|
+
if (session.sessionId) {
|
|
1005
|
+
session.acp.send({ jsonrpc: "2.0", method: "session/cancel", params: { sessionId: session.sessionId } });
|
|
1006
|
+
if (current.interruptTimer)
|
|
1007
|
+
clearTimeout(current.interruptTimer);
|
|
1008
|
+
current.interruptTimer = setTimeout(() => {
|
|
1009
|
+
// an agent that ignores session/cancel must not stay pooled
|
|
1010
|
+
closeSession(threadId, "cancel-timeout");
|
|
1011
|
+
settle(threadId, session, true, "cancelled");
|
|
1012
|
+
}, 5_000);
|
|
1013
|
+
current.interruptTimer.unref?.();
|
|
1014
|
+
}
|
|
1015
|
+
else {
|
|
1016
|
+
// no native session to cancel — the close handler settles
|
|
1017
|
+
closeSession(threadId, "stop");
|
|
1018
|
+
}
|
|
773
1019
|
};
|
|
774
|
-
active.set(threadId, { stop, interrupt, turnId, asks });
|
|
1020
|
+
active.set(threadId, { stop: () => closeSession(threadId, "stop"), interrupt, turnId, asks });
|
|
775
1021
|
emit({ ...base(threadId, turnId), type: "turn.started" });
|
|
1022
|
+
session.current = current;
|
|
776
1023
|
(async () => {
|
|
777
1024
|
try {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1025
|
+
// The handshake is paid once per process, not once per turn. It
|
|
1026
|
+
// is a function so the establishment retry below can pay it
|
|
1027
|
+
// again on a replacement child.
|
|
1028
|
+
// Returns whether this runtime accepts image prompts, for the
|
|
1029
|
+
// prompt phase below.
|
|
1030
|
+
const handshake = async () => {
|
|
1031
|
+
if (!session.initResult) {
|
|
1032
|
+
session.initResult = await request("initialize", {
|
|
1033
|
+
protocolVersion: 1,
|
|
1034
|
+
clientInfo: { name: "openmausbot", version: "0.0.0" },
|
|
1035
|
+
clientCapabilities: {
|
|
1036
|
+
fs: {
|
|
1037
|
+
readTextFile: support.clientFileSystem === true,
|
|
1038
|
+
writeTextFile: support.clientFileSystem === true,
|
|
1039
|
+
},
|
|
1040
|
+
terminal: false,
|
|
1041
|
+
},
|
|
1042
|
+
}, INIT_TIMEOUT);
|
|
1043
|
+
}
|
|
1044
|
+
// authenticate is once per process; a turn that skips
|
|
1045
|
+
// subscription auth neither checks nor marks the flag
|
|
1046
|
+
if (!skipSubscriptionAuthForLocalInject(turn.model) && !session.authenticated) {
|
|
1047
|
+
const methods = Array.isArray(session.initResult?.authMethods)
|
|
1048
|
+
? session.initResult.authMethods
|
|
1049
|
+
: [];
|
|
1050
|
+
const methodId = support.pickAuthMethod(methods);
|
|
1051
|
+
if (methodId) {
|
|
1052
|
+
try {
|
|
1053
|
+
await request("authenticate", { methodId }, INIT_TIMEOUT);
|
|
1054
|
+
session.authenticated = true;
|
|
1055
|
+
}
|
|
1056
|
+
catch {
|
|
1057
|
+
if (support.authFailure === "fail")
|
|
1058
|
+
throw new Error(support.loginNote);
|
|
1059
|
+
// else: proceed on an ambient login
|
|
1060
|
+
}
|
|
795
1061
|
}
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
throw new Error(support.loginNote);
|
|
799
|
-
// else: proceed on an ambient login
|
|
1062
|
+
else if (support.authFailure === "fail") {
|
|
1063
|
+
throw new Error(support.loginNote);
|
|
800
1064
|
}
|
|
801
1065
|
}
|
|
802
|
-
|
|
803
|
-
|
|
1066
|
+
const images = turn.images ?? [];
|
|
1067
|
+
const accepts = session.initResult?.agentCapabilities?.promptCapabilities?.image === true ||
|
|
1068
|
+
support.acceptsUnadvertisedImages?.(session.initResult) === true;
|
|
1069
|
+
if (images.length && support.images === true && !accepts) {
|
|
1070
|
+
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.`);
|
|
804
1071
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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);
|
|
1072
|
+
return accepts;
|
|
1073
|
+
};
|
|
1074
|
+
let runtimeAcceptsImages = await handshake();
|
|
1075
|
+
let init = session.initResult;
|
|
816
1076
|
const cursor = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
|
|
817
|
-
// a fresh native session forgets what the previous one allowed
|
|
818
|
-
if (!cursor)
|
|
819
|
-
sessionAllows.delete(threadId);
|
|
820
1077
|
let sessionResult = null;
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
1078
|
+
for (;;) {
|
|
1079
|
+
const liveSessionId = session.sessionId;
|
|
1080
|
+
if (liveSessionId !== null && session.sessionKey === sessionKey && (cursor === null || cursor === liveSessionId)) {
|
|
1081
|
+
// the pooled session still answers the cursor (or the cursor's
|
|
1082
|
+
// absence) and was established with these exact session inputs:
|
|
1083
|
+
// prompt it directly — no session/load replay, no session/new,
|
|
1084
|
+
// no fresh session bookkeeping
|
|
1085
|
+
break;
|
|
1086
|
+
}
|
|
1087
|
+
// stdio is every agent's baseline; a url server rides only with
|
|
1088
|
+
// an agent that advertised its transport, so an agent without
|
|
1089
|
+
// http/sse never sees an entry it would refuse the session over
|
|
1090
|
+
const sessionServers = mcpServers.filter((server) => !("type" in server) || init?.agentCapabilities?.mcpCapabilities?.[server.type] === true);
|
|
1091
|
+
let loaded = false;
|
|
1092
|
+
if (cursor) {
|
|
1093
|
+
try {
|
|
1094
|
+
await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers: sessionServers }, LOAD_SESSION_TIMEOUT, (result) => {
|
|
1095
|
+
if (result) {
|
|
1096
|
+
loaded = true;
|
|
1097
|
+
session.sessionId = cursor;
|
|
1098
|
+
session.sessionKey = sessionKey;
|
|
1099
|
+
receiveModelVariants(result);
|
|
1100
|
+
}
|
|
1101
|
+
});
|
|
1102
|
+
}
|
|
1103
|
+
catch {
|
|
1104
|
+
/* session gone, load unsupported, or too slow — the
|
|
1105
|
+
* fallbacks below choose between one fresh process and a
|
|
1106
|
+
* genuinely new session */
|
|
1107
|
+
}
|
|
829
1108
|
}
|
|
830
|
-
|
|
831
|
-
|
|
1109
|
+
if (loaded)
|
|
1110
|
+
break;
|
|
1111
|
+
if (cursor && liveSessionId === cursor) {
|
|
1112
|
+
// The agent refused (or never answered) re-establishing its
|
|
1113
|
+
// own live session on this process. Continuity outranks the
|
|
1114
|
+
// saved handshake: close the pooled child and resume the
|
|
1115
|
+
// recorded session on a fresh one — the pre-pool path every
|
|
1116
|
+
// agent already supports. A load that fails there too means
|
|
1117
|
+
// the session is genuinely gone; the loop falls through to
|
|
1118
|
+
// session/new on the replacement child.
|
|
1119
|
+
session.current = null;
|
|
1120
|
+
closeSession(threadId, "reestablish");
|
|
1121
|
+
session = openSession(threadId, launch, [...(launch.args ?? []), ...spawnArgs], spawnEnv, cwd, contractKey);
|
|
1122
|
+
sessions.set(threadId, session);
|
|
1123
|
+
session.current = current;
|
|
1124
|
+
runtimeAcceptsImages = await handshake();
|
|
1125
|
+
init = session.initResult;
|
|
1126
|
+
continue;
|
|
832
1127
|
}
|
|
833
|
-
|
|
834
|
-
|
|
1128
|
+
// a genuinely fresh native session forgets what the previous
|
|
1129
|
+
// one allowed
|
|
1130
|
+
if (!cursor)
|
|
1131
|
+
sessionAllows.delete(threadId);
|
|
835
1132
|
sessionResult = await request("session/new", { cwd, mcpServers: sessionServers }, NEW_SESSION_TIMEOUT, (result) => {
|
|
836
|
-
sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
|
|
1133
|
+
session.sessionId = typeof result?.sessionId === "string" ? result.sessionId : null;
|
|
1134
|
+
session.sessionKey = sessionKey;
|
|
837
1135
|
receiveModelVariants(result);
|
|
838
1136
|
});
|
|
839
|
-
|
|
840
|
-
throw new Error("session/new returned no sessionId");
|
|
1137
|
+
break;
|
|
841
1138
|
}
|
|
1139
|
+
// every establishment path leaves a native session id behind
|
|
1140
|
+
const sessionId = session.sessionId;
|
|
1141
|
+
if (sessionId === null)
|
|
1142
|
+
throw new Error("session/new returned no sessionId");
|
|
842
1143
|
let selectedModel = null;
|
|
843
1144
|
let sessionStarted = false;
|
|
844
1145
|
const emitSessionStarted = () => {
|
|
@@ -855,10 +1156,10 @@ export function createAcpDriver(support) {
|
|
|
855
1156
|
try {
|
|
856
1157
|
if (support.selectModel) {
|
|
857
1158
|
const { configId } = support.selectModel;
|
|
858
|
-
selectedModel = modelOf(sessionConfigResult);
|
|
1159
|
+
selectedModel = modelOf(session.sessionConfigResult);
|
|
859
1160
|
if (cliTurn.model && cliTurn.model !== selectedModel) {
|
|
860
1161
|
sessionResult = await request("session/set_config_option", { sessionId, configId, value: cliTurn.model }, INIT_TIMEOUT, receiveModelVariants);
|
|
861
|
-
selectedModel = modelOf(sessionConfigResult);
|
|
1162
|
+
selectedModel = modelOf(session.sessionConfigResult);
|
|
862
1163
|
// an agent that answers OK but keeps its old model is worse than
|
|
863
1164
|
// one that errors: it burns a paid turn on the wrong thing
|
|
864
1165
|
if (selectedModel !== cliTurn.model) {
|
|
@@ -903,19 +1204,18 @@ export function createAcpDriver(support) {
|
|
|
903
1204
|
? `${turn.system}\n\n${turn.text}`
|
|
904
1205
|
: turn.text;
|
|
905
1206
|
const imageBlocks = support.images === true && runtimeAcceptsImages
|
|
906
|
-
? await readAcpImageBlocks(images)
|
|
1207
|
+
? await readAcpImageBlocks(turn.images ?? [])
|
|
907
1208
|
: [];
|
|
908
|
-
if (support.modelVariants && cliTurn.model && modelOf(sessionConfigResult) !== cliTurn.model) {
|
|
1209
|
+
if (support.modelVariants && cliTurn.model && modelOf(session.sessionConfigResult) !== cliTurn.model) {
|
|
909
1210
|
throw new Error(`${support.displayName} changed model before the prompt`);
|
|
910
1211
|
}
|
|
911
1212
|
if (turn.variant !== undefined && requestedVariantOption().currentValue !== turn.variant) {
|
|
912
1213
|
throw new Error(`${support.displayName} changed variant before the prompt`);
|
|
913
1214
|
}
|
|
914
1215
|
state.promptSent = true;
|
|
915
|
-
const
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
});
|
|
1216
|
+
const promptIdleMs = promptIdleTimeoutMs();
|
|
1217
|
+
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. ` +
|
|
1218
|
+
"Raise OPENMAUS_ACP_PROMPT_IDLE_TIMEOUT_MS if this model legitimately takes longer to answer.");
|
|
919
1219
|
// opencode 1.18.18 reports usage at the result root; grok and
|
|
920
1220
|
// gemini put it under _meta. Read both rather than lose the count.
|
|
921
1221
|
const usage = result?.usage ?? result?._meta ?? {};
|
|
@@ -929,9 +1229,9 @@ export function createAcpDriver(support) {
|
|
|
929
1229
|
}
|
|
930
1230
|
const reason = result?.stopReason;
|
|
931
1231
|
if (reason === "end_turn")
|
|
932
|
-
settle(true, null);
|
|
1232
|
+
settle(threadId, session, true, null);
|
|
933
1233
|
else if (reason === "cancelled")
|
|
934
|
-
settle(true, "cancelled");
|
|
1234
|
+
settle(threadId, session, true, "cancelled");
|
|
935
1235
|
else {
|
|
936
1236
|
const errorMessage = typeof result?.error === "string" && result.error
|
|
937
1237
|
? result.error
|
|
@@ -943,7 +1243,7 @@ export function createAcpDriver(support) {
|
|
|
943
1243
|
type: "runtime.error",
|
|
944
1244
|
message: errorMessage,
|
|
945
1245
|
});
|
|
946
|
-
settle(false, reason ?? "failed");
|
|
1246
|
+
settle(threadId, session, false, reason ?? "failed");
|
|
947
1247
|
}
|
|
948
1248
|
}
|
|
949
1249
|
catch (e) {
|
|
@@ -961,7 +1261,13 @@ export function createAcpDriver(support) {
|
|
|
961
1261
|
message,
|
|
962
1262
|
...(needsAuth ? { setup: true } : {}),
|
|
963
1263
|
});
|
|
964
|
-
|
|
1264
|
+
// A prompt that went idle has a wedged child under the RPC — it
|
|
1265
|
+
// will never answer the next prompt either. Do not leave it
|
|
1266
|
+
// pooled: close it so the next turn spawns a fresh agent.
|
|
1267
|
+
if (e?.acpPromptStall === true && session.child.exitCode === null && !session.closing) {
|
|
1268
|
+
closeSession(threadId, "prompt-stall");
|
|
1269
|
+
}
|
|
1270
|
+
settle(threadId, session, false, needsAuth ? "auth_required" : "rpc_error");
|
|
965
1271
|
}
|
|
966
1272
|
}
|
|
967
1273
|
})();
|
|
@@ -1024,6 +1330,9 @@ export function createAcpDriver(support) {
|
|
|
1024
1330
|
stopAll: async () => {
|
|
1025
1331
|
for (const { stop } of active.values())
|
|
1026
1332
|
stop();
|
|
1333
|
+
// idle pooled sessions have no running turn — close them too
|
|
1334
|
+
for (const threadId of Array.from(sessions.keys()))
|
|
1335
|
+
closeSession(threadId, "stopAll");
|
|
1027
1336
|
},
|
|
1028
1337
|
onEvent: (listener) => {
|
|
1029
1338
|
listeners.add(listener);
|
|
@@ -1033,6 +1342,8 @@ export function createAcpDriver(support) {
|
|
|
1033
1342
|
dispose: async () => {
|
|
1034
1343
|
for (const { stop } of active.values())
|
|
1035
1344
|
stop();
|
|
1345
|
+
for (const threadId of Array.from(sessions.keys()))
|
|
1346
|
+
closeSession(threadId, "dispose");
|
|
1036
1347
|
listeners.clear();
|
|
1037
1348
|
},
|
|
1038
1349
|
};
|