openmausbot 0.1.84 → 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.
Files changed (71) hide show
  1. package/dist/assets/index-Ba9G44HI.js +310 -0
  2. package/dist/assets/{index-DNa2umw-.js → index-CTNNoqSJ.js} +1 -1
  3. package/dist/assets/index-CkRNp7wX.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/routes.js +1 -0
  6. package/dist-server/container-mcp.js +212 -7
  7. package/dist-server/drivers/agents-proxy.js +135 -12
  8. package/dist-server/enterprise/server/index.js +1 -0
  9. package/dist-server/hooks/omb-hook.js +74 -0
  10. package/dist-server/index.js +8051 -6252
  11. package/dist-server/local-computer-proxy.js +83 -1
  12. package/dist-server/local-computer.js +14 -2
  13. package/dist-server/openmausbot.js +759 -301
  14. package/dist-server/pair-cli.js +759 -301
  15. package/dist-server/proxy-paths.js +1 -0
  16. package/dist-server/server/agent-tool-policy.js +1 -0
  17. package/dist-server/server/bot-package.js +2 -0
  18. package/dist-server/server/box.js +23 -4
  19. package/dist-server/server/browser-engine.js +87 -10
  20. package/dist-server/server/browser-live.js +7 -5
  21. package/dist-server/server/browser-runtime.js +65 -10
  22. package/dist-server/server/checkpoints.js +62 -18
  23. package/dist-server/server/cli-prompts.js +3 -1
  24. package/dist-server/server/commands.js +27 -0
  25. package/dist-server/server/compaction-summary.js +78 -0
  26. package/dist-server/server/config.js +59 -2
  27. package/dist-server/server/context-budget.js +29 -0
  28. package/dist-server/server/context-rebuild.js +72 -0
  29. package/dist-server/server/digest.js +130 -0
  30. package/dist-server/server/drivers/acp/core.js +540 -229
  31. package/dist-server/server/drivers/agents-proxy.js +51 -12
  32. package/dist-server/server/drivers/agents-result.js +23 -0
  33. package/dist-server/server/drivers/claude.js +62 -10
  34. package/dist-server/server/drivers/codex.js +6 -0
  35. package/dist-server/server/drivers/openai-chat.js +66 -45
  36. package/dist-server/server/drivers/openai-compat.js +1 -0
  37. package/dist-server/server/hooks/omb-hook.js +103 -0
  38. package/dist-server/server/index.js +1127 -365
  39. package/dist-server/server/local-computer.js +21 -2
  40. package/dist-server/server/mcp-bridge.js +10 -2
  41. package/dist-server/server/mcp-tool-images.js +37 -0
  42. package/dist-server/server/mcp-tool-schema.js +108 -0
  43. package/dist-server/server/message-db.js +80 -21
  44. package/dist-server/server/message-file.js +3 -2
  45. package/dist-server/server/package-export.js +1 -0
  46. package/dist-server/server/peer-roster.js +4 -2
  47. package/dist-server/server/provider-icon.js +20 -0
  48. package/dist-server/server/proxy-paths.js +1 -0
  49. package/dist-server/server/request-auth.js +1 -0
  50. package/dist-server/server/room-handoffs.js +16 -2
  51. package/dist-server/server/routine-requests.js +39 -1
  52. package/dist-server/server/routines.js +43 -9
  53. package/dist-server/server/shared-computer-control.js +31 -4
  54. package/dist-server/server/steer-queue.js +6 -0
  55. package/dist-server/server/store.js +94 -14
  56. package/dist-server/server/system-prompt.js +4 -4
  57. package/dist-server/server/team-backup.js +9 -1
  58. package/dist-server/server/tool-results.js +72 -0
  59. package/dist-server/server/tts/grok.js +74 -0
  60. package/dist-server/server/tts/index.js +19 -1
  61. package/dist-server/server/webhooks.js +28 -0
  62. package/dist-server/shared/approval-mode.js +7 -1
  63. package/dist-server/shared/digest.js +1 -0
  64. package/dist-server/shared/markdown-windows-paths.js +36 -0
  65. package/dist-server/shared/provider-icon.js +110 -0
  66. package/dist-server/shared/team-backup.js +3 -0
  67. package/dist-server/vps-container-mcp.js +217 -12
  68. package/enterprise/server/index.js +1 -0
  69. package/package.json +1 -1
  70. package/dist/assets/index-BbU5REzd.js +0 -310
  71. package/dist/assets/index-CLGfYlx_.css +0 -1
@@ -5,6 +5,17 @@
5
5
  // argv, auth method, model catalog, sign-in check) live in a small support
6
6
  // object. Adding a harness = write server/drivers/acp/<name>.ts.
7
7
  //
8
+ // One live agent process per (thread, spawn contract): the ACP handshake
9
+ // (initialize, authenticate) and the native session are established once,
10
+ // and later turns prompt the live session directly instead of paying the
11
+ // full handshake per message. The pool mirrors the Claude driver: a session
12
+ // closes after OMB_ACP_SESSION_IDLE_MS of quiet (default 10 minutes, floored
13
+ // by OMB_ACP_SESSION_IDLE_MIN_MS default 10s), when the spawn contract
14
+ // changes, when the child crashes, when an interrupt's cancel goes
15
+ // unanswered, and on stopAll/dispose. A resume cursor left by an earlier
16
+ // session resumes through session/load|resume when the process had to
17
+ // respawn.
18
+ //
8
19
  // ACP has no `turn/completed` notification: the `session/prompt` RPC *result*
9
20
  // is the completion signal (it carries stopReason + usage). Permission
10
21
  // requests arrive as server→client `session/request_permission` and surface
@@ -17,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
- const sendTurn = async (turn) => {
301
- const { threadId } = turn;
302
- if (active.has(threadId))
303
- throw new Error("a turn is already running on this thread");
304
- // Provider-instance `fullAuto` predates per-bot approval levels. Every
305
- // harness turn now carries the bot's mode, so Ask/Auto must explicitly
306
- // put the native agent back into its interactive mode. Otherwise a
307
- // legacy Grok bypassPermissions / Cursor --force / Droid auto-high /
308
- // Antigravity yolo setting would silently outrank the selector. Calls
309
- // that omit approvalMode retain the old adapter-level behavior for
310
- // embedders and tests outside the harness.
311
- const turnConfig = turn.approvalMode === undefined
312
- ? config
313
- : { ...config, fullAuto: turn.approvalMode === "full" && supportsApprovalMode(DRIVER_KIND, "full") };
314
- const controlsHost = turn.integrations?.localComputer?.scope === "local-computer";
315
- if (controlsHost && turnConfig.fullAuto && turn.approvalMode !== "full") {
316
- throw new Error("local computer control requires interactive provider approvals");
317
- }
318
- const turnId = newId();
319
- const cwd = turn.cwd ?? turnConfig.workspace ?? homedir();
320
- const env = childEnv(turnConfig);
321
- if (support.requireAuthenticationBeforeSpawn
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
- catch (error) {
342
- emit({ ...base(threadId, turnId), type: "turn.started" });
343
- emit({
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
- const child = spawnCli(launch.command, [...(launch.args ?? []), ...support.spawnArgs(turnConfig, cliTurn)], {
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: launch.env ?? 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 stop = () => killCliTree(child);
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
- /** Emit buffered assistant text as its own item, then clear it. */
495
- const flushAssistantText = () => {
496
- const text = state.text;
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
- handleServerRequest(msg);
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
- emit({ ...base(threadId, turnId), type: "runtime.error", ...describeSpawnFailure(e, launch.command) });
752
- settle(false, "spawn_error");
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
- if (!state.settled) {
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
- else
768
- stop();
769
- if (interruptTimer)
770
- clearTimeout(interruptTimer);
771
- interruptTimer = setTimeout(() => settle(true, "cancelled"), 5_000);
772
- interruptTimer.unref?.();
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
- const init = await request("initialize", {
779
- protocolVersion: 1,
780
- clientInfo: { name: "openmausbot", version: "0.0.0" },
781
- clientCapabilities: {
782
- fs: {
783
- readTextFile: support.clientFileSystem === true,
784
- writeTextFile: support.clientFileSystem === true,
785
- },
786
- terminal: false,
787
- },
788
- }, INIT_TIMEOUT);
789
- const methods = Array.isArray(init?.authMethods) ? init.authMethods : [];
790
- const methodId = support.pickAuthMethod(methods);
791
- if (!skipSubscriptionAuthForLocalInject(turn.model)) {
792
- if (methodId) {
793
- try {
794
- await request("authenticate", { methodId }, INIT_TIMEOUT);
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
- catch {
797
- if (support.authFailure === "fail")
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
- else if (support.authFailure === "fail") {
803
- throw new Error(support.loginNote);
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
- const images = turn.images ?? [];
807
- const runtimeAcceptsImages = init?.agentCapabilities?.promptCapabilities?.image === true ||
808
- support.acceptsUnadvertisedImages?.(init) === true;
809
- if (images.length && support.images === true && !runtimeAcceptsImages) {
810
- throw new Error(`${support.displayName} is configured for image attachments, but this installed runtime does not advertise ACP image input. Update the ${support.displayName} CLI or send the message without an image.`);
811
- }
812
- // stdio is every agent's baseline; a url server rides only with
813
- // an agent that advertised its transport, so an agent without
814
- // http/sse never sees an entry it would refuse the session over
815
- const sessionServers = mcpServers.filter((server) => !("type" in server) || init?.agentCapabilities?.mcpCapabilities?.[server.type] === true);
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
- if (cursor) {
822
- try {
823
- sessionResult = await request(support.resumeMethod === "resume" ? "session/resume" : "session/load", { sessionId: cursor, cwd, mcpServers: sessionServers }, LOAD_SESSION_TIMEOUT, (result) => {
824
- if (result) {
825
- sessionId = cursor;
826
- receiveModelVariants(result);
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
- catch {
831
- /* session gone, load unsupported, or too slow — start fresh */
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
- if (!sessionId) {
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
- if (!sessionId)
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 result = await request("session/prompt", {
916
- sessionId,
917
- prompt: [{ type: "text", text }, ...imageBlocks],
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
- settle(false, needsAuth ? "auth_required" : "rpc_error");
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
  };