openmausbot 0.1.87 → 0.1.88

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 (149) hide show
  1. package/README.md +2 -1
  2. package/dist/assets/{abnfDiagram-O67JEVCF-BTiuL79F.js → abnfDiagram-O67JEVCF-qd9VD3GV.js} +1 -1
  3. package/dist/assets/{arc-C88Blc2v.js → arc-3RwkUjRe.js} +1 -1
  4. package/dist/assets/{architectureDiagram-NJMV4G6O-BZsJIZrb.js → architectureDiagram-NJMV4G6O-CFKSJS7a.js} +1 -1
  5. package/dist/assets/{blockDiagram-BEXU5L5S-CVeMe9Ah.js → blockDiagram-BEXU5L5S-CG1rEfWD.js} +1 -1
  6. package/dist/assets/{c4Diagram-YGBWAQC7-Cedk3Bhx.js → c4Diagram-YGBWAQC7-CBW9N664.js} +1 -1
  7. package/dist/assets/channel-CnRnO3tF.js +1 -0
  8. package/dist/assets/{chunk-2Q5K7J3B-D-VRqzTB.js → chunk-2Q5K7J3B-2mRfTksB.js} +1 -1
  9. package/dist/assets/{chunk-3FUC2YCW-BChov01Y.js → chunk-3FUC2YCW-Cv1dpErj.js} +2 -2
  10. package/dist/assets/{chunk-5VM5RSS4-C-TfwvHy.js → chunk-5VM5RSS4-B7IACJp-.js} +1 -1
  11. package/dist/assets/{chunk-GTNCS2PH-Db7CGj5Y.js → chunk-GTNCS2PH-An0hZtYa.js} +1 -1
  12. package/dist/assets/{chunk-GWA4HPMP-CNvIfqKT.js → chunk-GWA4HPMP-BcJB2nEm.js} +1 -1
  13. package/dist/assets/{chunk-JWPE2WC7-C_bEp5tq.js → chunk-JWPE2WC7-CV_W7TN7.js} +1 -1
  14. package/dist/assets/{chunk-NETBCI7D-Coi4BOls.js → chunk-NETBCI7D-Bcs_kwnA.js} +1 -1
  15. package/dist/assets/{chunk-WEXAMYUT-CzylEuL3.js → chunk-WEXAMYUT-lrx_DxFP.js} +1 -1
  16. package/dist/assets/{chunk-XXDRQBXY-m7p7ztaD.js → chunk-XXDRQBXY-DabHlLGM.js} +1 -1
  17. package/dist/assets/{classDiagram-v2-NBCMYWYE-BMy6o9x2.js → classDiagram-v2-NBCMYWYE-BnHj6P7d.js} +1 -1
  18. package/dist/assets/{cose-bilkent-JH36ORCC-BDQ__0_J.js → cose-bilkent-JH36ORCC-CfLp6UDi.js} +1 -1
  19. package/dist/assets/{cynefin-OW5HDTMX-Djv4ghUb.js → cynefin-OW5HDTMX-Dm4dbVA1.js} +1 -1
  20. package/dist/assets/{cynefinDiagram-VND7K2PF-BULrlGYH.js → cynefinDiagram-VND7K2PF-B9mH-PID.js} +1 -1
  21. package/dist/assets/{dagre-6A5THRUB-D-jrT_hU.js → dagre-6A5THRUB-DDc_NVQJ.js} +1 -1
  22. package/dist/assets/{diagram-22UHCM2B-p2UhQmfL.js → diagram-22UHCM2B-lwr8Ip8-.js} +1 -1
  23. package/dist/assets/{diagram-3UASUU5V-DHvGWZ7q.js → diagram-3UASUU5V-BLLe6dmI.js} +1 -1
  24. package/dist/assets/{diagram-ATOU4E4O-ChhKDot-.js → diagram-ATOU4E4O-6eUIq4lz.js} +1 -1
  25. package/dist/assets/{diagram-CDSNMT55-B-T7A43R.js → diagram-CDSNMT55-O-pRN7VQ.js} +1 -1
  26. package/dist/assets/{diagram-MLGK6HIB-CJm8ENNY.js → diagram-MLGK6HIB-BietvGAI.js} +1 -1
  27. package/dist/assets/{diagram-MPIPVDR6-510tlza8.js → diagram-MPIPVDR6-Bd2Cl62X.js} +1 -1
  28. package/dist/assets/{ebnfDiagram-ZINNZB2B-PkvRPHel.js → ebnfDiagram-ZINNZB2B-B6iDy69W.js} +1 -1
  29. package/dist/assets/{elk-276RUBZZ-BVBVPoX3.js → elk-276RUBZZ-DQVHfKe0.js} +1 -1
  30. package/dist/assets/{erDiagram-OPXOYQCR-BgYMVm7L.js → erDiagram-OPXOYQCR-BWdVp2tQ.js} +1 -1
  31. package/dist/assets/{flowDiagram-KWPJA3E3-BGqsnc_3.js → flowDiagram-KWPJA3E3-WLE6rKB4.js} +1 -1
  32. package/dist/assets/{ganttDiagram-FUAMR5RP-D_-UeReH.js → ganttDiagram-FUAMR5RP-BbjS-4gn.js} +1 -1
  33. package/dist/assets/{gitGraphDiagram-X574FWY7-D0uooONx.js → gitGraphDiagram-X574FWY7-Dpy2QSGq.js} +1 -1
  34. package/dist/assets/index-BIMdIbaw.css +1 -0
  35. package/dist/assets/index-CHnODCr2.js +583 -0
  36. package/dist/assets/{index-zRsJERGJ.js → index-CIaHn5NS.js} +1 -1
  37. package/dist/assets/{infoDiagram-VRGFBTTK-B9e1z77f.js → infoDiagram-VRGFBTTK-D_jKWlKe.js} +1 -1
  38. package/dist/assets/{ishikawaDiagram-OU5B5YK6-BB4vNwvD.js → ishikawaDiagram-OU5B5YK6-DLJohSuK.js} +1 -1
  39. package/dist/assets/{journeyDiagram-ZHPQQLJL-DpkosOTB.js → journeyDiagram-ZHPQQLJL-DPOFj0qN.js} +1 -1
  40. package/dist/assets/{kanban-definition-PNTS6WVX-DbDP4kUu.js → kanban-definition-PNTS6WVX-BI7v-xpK.js} +1 -1
  41. package/dist/assets/{linear-DBJGPmjT.js → linear-C2pWPSxO.js} +1 -1
  42. package/dist/assets/{mermaid.core-DWPRi-ue.js → mermaid.core-CR-j5WQU.js} +5 -5
  43. package/dist/assets/{mindmap-definition-NLK3R4M7-nszBbJmx.js → mindmap-definition-NLK3R4M7-lz9GvXRh.js} +1 -1
  44. package/dist/assets/{pegDiagram-GJSIUBJH-CWykJWu_.js → pegDiagram-GJSIUBJH-Dl4_ia61.js} +1 -1
  45. package/dist/assets/{pieDiagram-5QR66LMP-g3Da-Vnv.js → pieDiagram-5QR66LMP-DWuT3n88.js} +1 -1
  46. package/dist/assets/{quadrantDiagram-O4NWA36T-Wzrlpzgh.js → quadrantDiagram-O4NWA36T-DXWe9Jx7.js} +1 -1
  47. package/dist/assets/{railroadDiagram-XR7U4H2S-moHTFMC0.js → railroadDiagram-XR7U4H2S-COY1IBVY.js} +1 -1
  48. package/dist/assets/{requirementDiagram-PLB6GJNP-CwfXdFH8.js → requirementDiagram-PLB6GJNP-DnyVuhsQ.js} +1 -1
  49. package/dist/assets/{sankeyDiagram-IPEJSGJF-3ZROYvnq.js → sankeyDiagram-IPEJSGJF-BSc4NdGK.js} +1 -1
  50. package/dist/assets/{sequenceDiagram-PO4LG4MO-E1TBebP8.js → sequenceDiagram-PO4LG4MO-kqEU2EGq.js} +1 -1
  51. package/dist/assets/{sizeCapture-INFHLROL-DHKEU5pu.js → sizeCapture-INFHLROL-BvB4bkWD.js} +1 -1
  52. package/dist/assets/{stateDiagram-v2-GCMORJYK-BguEj09D.js → stateDiagram-v2-GCMORJYK-BIrqNv7y.js} +1 -1
  53. package/dist/assets/{swimlanes-2SLR337P-C7IDYnhd.js → swimlanes-2SLR337P-CFnalQUE.js} +1 -1
  54. package/dist/assets/swimlanesDiagram-TC7HE7FX-C4a8qNGj.js +8 -0
  55. package/dist/assets/{timeline-definition-EJHVYXUP-DCW-F5PW.js → timeline-definition-EJHVYXUP-CsSWJnSQ.js} +1 -1
  56. package/dist/assets/{usecaseDiagram-POWQR4AR-xBMFtWYV.js → usecaseDiagram-POWQR4AR-DkAmJ1EE.js} +1 -1
  57. package/dist/assets/{vennDiagram-UO4OBE2U-B5nIkuH8.js → vennDiagram-UO4OBE2U-C3vd9drL.js} +1 -1
  58. package/dist/assets/{wardleyDiagram-VNRHLVJA-DYFAhdbr.js → wardleyDiagram-VNRHLVJA-j26CM-pi.js} +1 -1
  59. package/dist/assets/{xychartDiagram-PMCCYNJV-DeXwX6Cz.js → xychartDiagram-PMCCYNJV-BYu1Q_at.js} +1 -1
  60. package/dist/index.html +2 -2
  61. package/dist-server/companion/src/routes.js +8 -1
  62. package/dist-server/connector-proxy.js +105 -1
  63. package/dist-server/container-mcp.js +3 -1
  64. package/dist-server/drivers/agents-proxy.js +277 -22
  65. package/dist-server/index.js +23150 -19947
  66. package/dist-server/local-computer-proxy.js +2 -0
  67. package/dist-server/mcp-gate.js +2 -0
  68. package/dist-server/mcp-server.js +5 -3
  69. package/dist-server/message-search.worker.js +52 -0
  70. package/dist-server/openmausbot.js +540 -298
  71. package/dist-server/pair-cli.js +540 -298
  72. package/dist-server/server/admission.js +81 -0
  73. package/dist-server/server/aside-queue.js +294 -0
  74. package/dist-server/server/attachments.js +43 -1
  75. package/dist-server/server/bot-attachment.js +107 -0
  76. package/dist-server/server/bot-overview.js +19 -0
  77. package/dist-server/server/cli-api-setup.js +1 -0
  78. package/dist-server/server/composio.js +297 -1
  79. package/dist-server/server/config.js +25 -3
  80. package/dist-server/server/connector-advertisement.js +165 -0
  81. package/dist-server/server/connector-proxy.js +14 -2
  82. package/dist-server/server/connector-verdict.js +31 -4
  83. package/dist-server/server/container-computer.js +60 -0
  84. package/dist-server/server/contracts.js +22 -0
  85. package/dist-server/server/drivers/acp/core.js +24 -6
  86. package/dist-server/server/drivers/acp/cursor.js +3 -7
  87. package/dist-server/server/drivers/acp/droid.js +4 -7
  88. package/dist-server/server/drivers/acp/gemini.js +4 -4
  89. package/dist-server/server/drivers/acp/grok.js +4 -9
  90. package/dist-server/server/drivers/acp/hermes.js +3 -5
  91. package/dist-server/server/drivers/acp/opencode-go.js +2 -5
  92. package/dist-server/server/drivers/acp/qwen.js +9 -15
  93. package/dist-server/server/drivers/agents-call.js +137 -10
  94. package/dist-server/server/drivers/agents-catalog.js +100 -6
  95. package/dist-server/server/drivers/boxagent.js +2 -16
  96. package/dist-server/server/drivers/claude.js +31 -17
  97. package/dist-server/server/drivers/codex-catalog.js +14 -6
  98. package/dist-server/server/drivers/codex-identity.js +2 -1
  99. package/dist-server/server/drivers/codex-release.js +48 -0
  100. package/dist-server/server/drivers/codex.js +7 -25
  101. package/dist-server/server/drivers/openai-chat.js +26 -2
  102. package/dist-server/server/drivers/pi.js +237 -30
  103. package/dist-server/server/drivers/prompt-split.js +32 -9
  104. package/dist-server/server/drivers/session-idle.js +29 -0
  105. package/dist-server/server/env-path.js +15 -0
  106. package/dist-server/server/guarded-requests.js +2 -2
  107. package/dist-server/server/harness-capabilities.js +46 -0
  108. package/dist-server/server/index.js +1037 -209
  109. package/dist-server/server/message-db.js +22 -42
  110. package/dist-server/server/message-file.js +9 -1
  111. package/dist-server/server/message-search-query.js +43 -0
  112. package/dist-server/server/message-search-worker.js +80 -0
  113. package/dist-server/server/message-search.worker.js +21 -0
  114. package/dist-server/server/model-requests.js +248 -0
  115. package/dist-server/server/options-card.js +21 -0
  116. package/dist-server/server/org-library.js +15 -1
  117. package/dist-server/server/peer-delivery.js +60 -0
  118. package/dist-server/server/peer-roster.js +57 -0
  119. package/dist-server/server/presets.js +32 -32
  120. package/dist-server/server/profile-requests.js +66 -39
  121. package/dist-server/server/profile-revision.js +9 -1
  122. package/dist-server/server/profile-versions.js +6 -3
  123. package/dist-server/server/routine-requests.js +29 -7
  124. package/dist-server/server/skill-fetch.js +72 -14
  125. package/dist-server/server/skills.js +1 -0
  126. package/dist-server/server/store.js +32 -2
  127. package/dist-server/server/system-prompt.js +26 -2
  128. package/dist-server/server/team-setup-requests.js +124 -23
  129. package/dist-server/server/thread-events.js +3 -1
  130. package/dist-server/server/tightening-requests.js +405 -0
  131. package/dist-server/server/tts/chatterbox.js +27 -5
  132. package/dist-server/server/usage-ledger.js +7 -1
  133. package/dist-server/server/workspace-backup.js +40 -3
  134. package/dist-server/server/workspace-backup.worker.js +10 -0
  135. package/dist-server/shared/ask-question.js +19 -0
  136. package/dist-server/shared/credential-request.js +5 -1
  137. package/dist-server/shared/line-diff.js +23 -0
  138. package/dist-server/shared/model-request.js +1 -0
  139. package/dist-server/shared/options-card.js +51 -0
  140. package/dist-server/shared/profile-request.js +5 -4
  141. package/dist-server/shared/tightening-request.js +87 -0
  142. package/dist-server/src/lib/composer-attachments.js +25 -3
  143. package/dist-server/vps-container-mcp.js +3 -1
  144. package/dist-server/workspace-backup.worker.js +19474 -0
  145. package/package.json +1 -1
  146. package/dist/assets/channel-taCNRlUU.js +0 -1
  147. package/dist/assets/index-BwdMAQ6p.css +0 -1
  148. package/dist/assets/index-ByJennr7.js +0 -581
  149. package/dist/assets/swimlanesDiagram-TC7HE7FX-BpEdoB2e.js +0 -8
@@ -3,6 +3,7 @@
3
3
  // old file's email. Only the protocol's display email leaves this helper.
4
4
  import { homedir } from "node:os";
5
5
  import { isAbsolute, join, resolve } from "node:path";
6
+ import { userHome } from "../env-path.js";
6
7
  import { killCliTree, spawnCli } from "../procs.js";
7
8
  const MAX_OUTPUT = 16_384;
8
9
  const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
@@ -11,7 +12,7 @@ const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
11
12
  export function codexHome(env) {
12
13
  if (env.CODEX_HOME)
13
14
  return isAbsolute(env.CODEX_HOME) ? resolve(env.CODEX_HOME) : null;
14
- const home = env.HOME || env.USERPROFILE || homedir();
15
+ const home = userHome(env);
15
16
  return isAbsolute(home) ? join(home, ".codex") : null;
16
17
  }
17
18
  /** Read-only account metadata. Unsupported CLIs and uncertain responses stay
@@ -0,0 +1,48 @@
1
+ // Release freshness is independent of account-specific model availability.
2
+ function parseCodexReleaseVersion(value) {
3
+ const match = /^(?:.*\bcodex-cli\s+)?v?(\d+)\.(\d+)\.(\d+)(-[0-9a-z.-]+)?(?:\+[0-9a-z.-]+)?$/i.exec(value.trim());
4
+ return match ? { parts: match.slice(1, 4).map(Number), prerelease: Boolean(match[4]) } : null;
5
+ }
6
+ export function codexVersionBehind(installed, latest) {
7
+ const a = parseCodexReleaseVersion(installed);
8
+ const b = parseCodexReleaseVersion(latest);
9
+ if (!a || !b || b.prerelease)
10
+ return false;
11
+ for (let i = 0; i < 3; i++) {
12
+ if (a.parts[i] !== b.parts[i])
13
+ return a.parts[i] < b.parts[i];
14
+ }
15
+ return a.prerelease;
16
+ }
17
+ export function createCodexReleaseReader(fetchImpl = fetch, now = Date.now) {
18
+ let cached;
19
+ let expires = 0;
20
+ return () => {
21
+ if (cached && now() < expires)
22
+ return cached;
23
+ expires = now() + 60 * 60 * 1000;
24
+ cached = (async () => {
25
+ try {
26
+ const response = await fetchImpl("https://registry.npmjs.org/@openai/codex/latest", {
27
+ signal: AbortSignal.timeout(3000),
28
+ headers: { Accept: "application/json" },
29
+ });
30
+ if (!response.ok)
31
+ throw new Error("Release lookup failed");
32
+ const payload = await response.json();
33
+ const version = typeof payload.version === "string" ? payload.version : "";
34
+ const parsed = parseCodexReleaseVersion(version);
35
+ if (!parsed || parsed.prerelease)
36
+ throw new Error("Invalid stable release");
37
+ return version;
38
+ }
39
+ catch {
40
+ // Offline checks must not make an otherwise usable engine unavailable.
41
+ expires = now() + 5 * 60 * 1000;
42
+ return null;
43
+ }
44
+ })();
45
+ return cached;
46
+ };
47
+ }
48
+ export const readLatestCodexRelease = createCodexReleaseReader();
@@ -31,10 +31,9 @@ import { codexAccountEmail } from "./codex-identity.js";
31
31
  import { classifyResumeFailure, mayReplay, recoveryPromptFor } from "../resume-recovery.js";
32
32
  import { extractMcpImages } from "../mcp-tool-images.js";
33
33
  import { parseProtocolAskQuestions, questionAnswersById, questionChoices } from "../../shared/ask-question.js";
34
+ import { codexVersionBehind, readLatestCodexRelease } from "./codex-release.js";
34
35
  export { decodeCodexSelection, readCodexModelCatalog, STATIC_CODEX_MODELS } from "./codex-catalog.js";
35
36
  const DRIVER_KIND = "codex";
36
- const ASTRA_MODEL_ID = "gpt-6-astra";
37
- const ASTRA_MIN_CODEX_VERSION = [0, 153, 1];
38
37
  class CodexRpcError extends Error {
39
38
  code;
40
39
  constructor(error) {
@@ -49,23 +48,6 @@ function missingNativeCodexThread(error, cursor) {
49
48
  return error instanceof CodexRpcError && error.code === -32600 &&
50
49
  error.message === `no rollout found for thread id ${cursor}`;
51
50
  }
52
- /** Whether an installed Codex predates the release that exposes GPT-6 Astra
53
- * through app-server. Unknown version formats stay quiet: a bad guess should
54
- * never nag someone whose custom build may already support the model. */
55
- export function codexPredatesAstra(version) {
56
- const value = version.trim();
57
- const match = /\bcodex-cli\s+v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9a-z.-]+)?(?![\d.])\b/i.exec(value)
58
- ?? /^v?(\d+)\.(\d+)\.(\d+)(?:[-+][0-9a-z.-]+)?$/i.exec(value);
59
- if (!match)
60
- return false;
61
- const installed = match.slice(1, 4).map(Number);
62
- for (let i = 0; i < ASTRA_MIN_CODEX_VERSION.length; i += 1) {
63
- if (installed[i] !== ASTRA_MIN_CODEX_VERSION[i]) {
64
- return installed[i] < ASTRA_MIN_CODEX_VERSION[i];
65
- }
66
- }
67
- return false;
68
- }
69
51
  /** Ask the configured executable to update itself. This matters when the user
70
52
  * selected a non-PATH Codex: installing a second global copy would leave
71
53
  * OpenMausBot pointing at the old binary. */
@@ -85,13 +67,13 @@ export function codexUpdateCommand(cli, platform = process.platform) {
85
67
  const command = (tokens.length > 0 ? tokens : [trimmed]).map(quote).join(" ");
86
68
  return platform === "win32" ? `& ${command} update` : `${command} update`;
87
69
  }
88
- function codexAstraUpdate(version, models, cli) {
89
- if (models.options.some((model) => model.id === ASTRA_MODEL_ID) || !codexPredatesAstra(version)) {
70
+ async function codexReleaseUpdate(version, cli) {
71
+ const latest = await readLatestCodexRelease();
72
+ if (!latest || !codexVersionBehind(version, latest))
90
73
  return undefined;
91
- }
92
74
  return {
93
- title: "Update Codex for GPT-6 Astra",
94
- message: "This Codex version predates Astra support. Update it, then refresh models. Astra must also be available to your signed-in ChatGPT account.",
75
+ title: `Update Codex to ${latest}`,
76
+ message: `A newer stable Codex CLI is available (installed: ${version}). Update it, then refresh models. Model availability also depends on your signed-in account.`,
95
77
  command: codexUpdateCommand(cli),
96
78
  };
97
79
  }
@@ -1579,7 +1561,7 @@ export const CodexDriver = {
1579
1561
  version,
1580
1562
  authenticated,
1581
1563
  ...(email ? { account: { email } } : {}),
1582
- update: codexAstraUpdate(version, models, config.cli),
1564
+ update: await codexReleaseUpdate(version, config.cli),
1583
1565
  billing: "subscription",
1584
1566
  };
1585
1567
  };
@@ -332,7 +332,8 @@ export function createOpenAIChatRuntime(options) {
332
332
  });
333
333
  let resolveDone;
334
334
  const done = new Promise((resolve) => { resolveDone = resolve; });
335
- active.set(turn.threadId, { abort, turnId, done, approval });
335
+ const turnEntry = { abort, turnId, done, approval, asides: [] };
336
+ active.set(turn.threadId, turnEntry);
336
337
  emit({ ...base(turn.threadId, turnId), type: "turn.started" });
337
338
  emit({ ...base(turn.threadId, turnId), type: "session.started", sessionId: null, model });
338
339
  void (async () => {
@@ -358,6 +359,13 @@ export function createOpenAIChatRuntime(options) {
358
359
  }
359
360
  for (let round = 0; round < 16; round++) {
360
361
  abort.signal.throwIfAborted();
362
+ // Drain parked mid-turn input here, before the next completion
363
+ // request: the previous round's tool results are complete, so a
364
+ // user message lands on a consistent array (never inside a tool
365
+ // batch) and the provider sees it as the newest input.
366
+ const parked = turnEntry.asides.splice(0);
367
+ if (parked.length)
368
+ messages.push({ role: "user", content: parked.join("\n\n") });
361
369
  native("out", options.nativeLog.outgoing(turn, messages, model));
362
370
  let attempt = 0;
363
371
  let completion;
@@ -611,7 +619,10 @@ export function createOpenAIChatRuntime(options) {
611
619
  provider: options.driverKind,
612
620
  capabilities: { ...(options.computerUse ? { computerMcp: options.tools !== false, cloudComputerMcp: options.tools !== false, localComputerMcp: options.tools !== false,
613
621
  browserMcp: options.tools !== false, nativeImageInput: true, images: true } : {}),
614
- sessionModelSwitch: "in-session", customMcp: options.tools !== false, agentsMcp: options.tools !== false, composioMcp: options.tools !== false },
622
+ sessionModelSwitch: "in-session", customMcp: options.tools !== false, agentsMcp: options.tools !== false, composioMcp: options.tools !== false,
623
+ // The runtime owns the whole tool loop, so it can always take a
624
+ // user message mid-turn: park it, deliver before the next completion.
625
+ queueing: true },
615
626
  sendTurn,
616
627
  interruptTurn: async (threadId, turnId) => {
617
628
  const turn = active.get(threadId);
@@ -621,6 +632,19 @@ export function createOpenAIChatRuntime(options) {
621
632
  await turn.done;
622
633
  },
623
634
  respondToRequest: async (threadId, requestId, decision) => active.get(threadId)?.approval.answer(requestId, decision.behavior, decision.message) ?? "unavailable",
635
+ steer: async (threadId, text) => {
636
+ // This engine has no external session to respect — parking the
637
+ // words in the live turn's entry IS delivery into the loop, so a
638
+ // successful park is "steered" and a missing turn is the only
639
+ // refusal. The words ride the array at the next round boundary;
640
+ // if the turn settles first, the harness's transcript record
641
+ // keeps them for the next turn (they are never re-sent here).
642
+ const running = active.get(threadId);
643
+ if (!running)
644
+ return "refused";
645
+ running.asides.push(text);
646
+ return "steered";
647
+ },
624
648
  hasSession: (threadId) => active.has(threadId),
625
649
  stopAll: async () => {
626
650
  const turns = [...active.values()];
@@ -26,6 +26,7 @@ import { join } from "node:path";
26
26
  import { PROVIDER_CREDENTIAL_ENV, stripWorkspaceCredentialEnv } from "../config.js";
27
27
  import { augmentedPath } from "../env-path.js";
28
28
  import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
29
+ import { deletePromptSplitReceipt, promptHalves, readPromptSplitReceipt, splitSessionPrompt, writePromptSplitReceipt, } from "./prompt-split.js";
29
30
  import { SPAWNED_PROXIES } from "../proxy-paths.js";
30
31
  import { commandSummary, toolDetailPreview } from "../tool-summary.js";
31
32
  import { EFFORT_LEVELS } from "../../shared/wire.js";
@@ -37,6 +38,21 @@ const DRIVER_KIND = "piAgent";
37
38
  const PI_ARGS = ["--mode", "rpc", "--no-session"];
38
39
  const PI_MODEL_UPDATE_ARGS = ["update", "--models", "--no-approve"];
39
40
  const NODE_ENV_FLAG = { ELECTRON_RUN_AS_NODE: "1" };
41
+ /** After this many bare turns on one pi session the full prompt rides
42
+ * again even without a compaction event: any history rewrite the events
43
+ * miss (an older pi, a missed line) still loses at most this many turns
44
+ * of standing instructions instead of the rest of the session. */
45
+ const PI_PROMPT_RE_ANCHOR_TURNS = 8;
46
+ /** How long the driver keeps listening after a finished turn_end for the
47
+ * run's terminal agent_end before settling anyway: a runtime that never
48
+ * emits agent_end still completes, while one that does cancels the wait. */
49
+ const PI_AGENT_END_GRACE_MS = 60_000;
50
+ /** pi answered a command with success:false — an explicit refusal from a
51
+ * live runtime, not a transport failure. Only steer branches on the
52
+ * difference today ("refused": provably not delivered, safe to re-queue);
53
+ * the handshake commands keep their catch-all ignore. */
54
+ class PiRpcRefusalError extends Error {
55
+ }
40
56
  function readPiPromptImages(turn) {
41
57
  return (turn.images ?? []).map((image) => ({
42
58
  type: "image",
@@ -431,6 +447,13 @@ export const PiDriver = {
431
447
  // closure (send, child) alive until it fires.
432
448
  const askTimers = new Set();
433
449
  let settled = false;
450
+ // the last turn_end's outcome, replayed when the run's terminal
451
+ // agent_end (or the grace fallback) settles the turn
452
+ let endStopReason;
453
+ let endUsage;
454
+ let endErrorMessage;
455
+ // hang insurance for runtimes with no agent_end frame at all
456
+ let agentEndTimer = null;
434
457
  // pi's RPC surface accepts image content directly. Read before spawning
435
458
  // so an attachment that disappeared produces one clear dispatch error
436
459
  // instead of starting a child that can never receive its prompt.
@@ -493,8 +516,17 @@ export const PiDriver = {
493
516
  })();
494
517
  let buf = "";
495
518
  let assistantText = "";
519
+ // set when a compaction event arrives this turn; gates the receipt
520
+ // write below so a compacted-around delivery is never claimed
521
+ let compactionObserved = false;
496
522
  // resolve one-shot RPC responses (new_session / switch_session / set_model)
497
523
  const responseWaiters = new Map();
524
+ // Steer frames correlate by a unique per-request id, not the shared
525
+ // "steer" command name: two concurrent steers would otherwise overwrite
526
+ // each other's waiter, and a refusal response could reject the wrong
527
+ // caller — requeueing words pi already accepted. Every other command
528
+ // keeps its command-name key.
529
+ let steerSeq = 0;
498
530
  const rejectWaiters = (err) => {
499
531
  for (const waiter of responseWaiters.values()) {
500
532
  clearTimeout(waiter.timer);
@@ -502,13 +534,13 @@ export const PiDriver = {
502
534
  }
503
535
  responseWaiters.clear();
504
536
  };
505
- const awaitResponse = (command, timeoutMs = 20_000) => new Promise((resolve, reject) => {
537
+ const awaitResponse = (command, timeoutMs = 20_000, key = command) => new Promise((resolve, reject) => {
506
538
  const timer = setTimeout(() => {
507
- responseWaiters.delete(command);
539
+ responseWaiters.delete(key);
508
540
  reject(new Error(`pi ${command} timed out`));
509
541
  }, timeoutMs);
510
542
  timer.unref?.();
511
- responseWaiters.set(command, { resolve, reject, timer });
543
+ responseWaiters.set(key, { resolve, reject, timer });
512
544
  });
513
545
  child.stdin.on("error", () => rejectWaiters(new Error("pi stdin closed")));
514
546
  const send = (obj) => {
@@ -527,6 +559,10 @@ export const PiDriver = {
527
559
  if (settled)
528
560
  return;
529
561
  settled = true;
562
+ if (agentEndTimer) {
563
+ clearTimeout(agentEndTimer);
564
+ agentEndTimer = null;
565
+ }
530
566
  // The turn is over: drop unanswered asks so their 15-minute
531
567
  // fail-safe timers are cancelled outright instead of no-oping on a
532
568
  // dead child while holding the ask closure alive.
@@ -564,6 +600,23 @@ export const PiDriver = {
564
600
  }
565
601
  active.delete(threadId);
566
602
  };
603
+ /** Settle from the last turn_end's recorded outcome. Errors surface
604
+ * here, at run completion, so a failed run reports exactly once and
605
+ * only after any scheduled maintenance events had their chance to
606
+ * arrive. */
607
+ const finishRun = () => {
608
+ const sr = endStopReason;
609
+ if (sr === "error" || sr === "failed") {
610
+ emit({
611
+ ...base(threadId, turnId),
612
+ type: "runtime.error",
613
+ message: String(endErrorMessage ?? "pi turn failed").slice(0, 2_000),
614
+ });
615
+ settle(false, "failed", endUsage);
616
+ return;
617
+ }
618
+ settle(true, sr === "cancelled" || sr === "aborted" ? "cancelled" : "end_turn", endUsage);
619
+ };
567
620
  const stop = () => {
568
621
  try {
569
622
  send({ type: "abort" });
@@ -579,19 +632,53 @@ export const PiDriver = {
579
632
  }
580
633
  settle(true, "cancelled");
581
634
  };
582
- active.set(threadId, { stop, turnId, pending, child });
635
+ // Mid-turn input rides pi's native steer frame — the runtime queues
636
+ // it into the running agent without aborting the in-flight step, and
637
+ // answers success only after session.steer() accepted it. A success:
638
+ // false response is an explicit refusal (nothing consumed the words:
639
+ // safe to re-queue), while a death or timeout after the frame was
640
+ // written leaves the outcome unknowable — those are "indeterminate"
641
+ // so the caller can never run the words twice. Each frame carries a
642
+ // unique id that pi echoes on its response, so concurrent steers on
643
+ // the same turn never share or overwrite a waiter.
644
+ const steerActiveTurn = async (text) => {
645
+ if (settled || child.exitCode !== null || child.signalCode !== null)
646
+ return "refused";
647
+ let ack;
648
+ try {
649
+ const id = `steer:${++steerSeq}`;
650
+ ack = awaitResponse("steer", 20_000, id);
651
+ send({ type: "steer", id, message: text });
652
+ }
653
+ catch {
654
+ // stdin closed before the frame was written: provably undelivered.
655
+ return "refused";
656
+ }
657
+ try {
658
+ await ack;
659
+ return "steered";
660
+ }
661
+ catch (error) {
662
+ return error instanceof PiRpcRefusalError ? "refused" : "indeterminate";
663
+ }
664
+ };
665
+ active.set(threadId, { stop, turnId, pending, child, steer: steerActiveTurn });
583
666
  const onEvent = (evt) => {
584
667
  appendNative(threadId, { dir: "in", source: "pi.rpc", msg: evt });
585
668
  switch (evt.type) {
586
669
  case "response": {
587
- if (evt.command && responseWaiters.has(evt.command)) {
588
- const waiter = responseWaiters.get(evt.command);
589
- responseWaiters.delete(evt.command);
670
+ // Resolve the echoed request id first (unique per steer);
671
+ // fall back to the command name for the handshake-style call
672
+ // sites, whose responses carry no id.
673
+ const key = typeof evt.id === "string" && responseWaiters.has(evt.id) ? evt.id : evt.command;
674
+ if (key && responseWaiters.has(key)) {
675
+ const waiter = responseWaiters.get(key);
676
+ responseWaiters.delete(key);
590
677
  clearTimeout(waiter.timer);
591
678
  if (evt.success)
592
679
  waiter.resolve(evt.data);
593
680
  else
594
- waiter.reject(new Error(`pi ${evt.command} failed`));
681
+ waiter.reject(new PiRpcRefusalError(`pi ${evt.command} failed`));
595
682
  }
596
683
  return;
597
684
  }
@@ -715,24 +802,81 @@ export const PiDriver = {
715
802
  }
716
803
  return;
717
804
  }
805
+ case "compaction_start":
806
+ case "compaction_end":
807
+ case "auto_compaction_start":
808
+ case "auto_compaction_end": {
809
+ // pi compaction (manual or automatic) summarizes older
810
+ // messages, which can absorb the turn that carried this
811
+ // session's standing prompt. Both events ride the RPC surface
812
+ // for every compaction kind, so the moment either arrives the
813
+ // receipt is dropped and the next turn re-establishes the full
814
+ // prompt. The rest of the compacting turn may still answer
815
+ // without its standing instructions; that single turn is the
816
+ // unavoidable cost of summarizing history mid-session.
817
+ if (sessionReady && sessionFile) {
818
+ compactionObserved = true;
819
+ deletePromptSplitReceipt("pi", JSON.stringify([threadId, sessionFile]));
820
+ }
821
+ return;
822
+ }
718
823
  case "turn_end":
719
- case "agent_end": {
720
- const sr = evt.message?.stopReason;
721
- // toolUse means pi ran a tool and auto-continues next turn to
722
- // answer — settling now would drop the final reply.
723
- if (sr === "toolUse" || sr === "tool_use" || sr === "tool_calls")
824
+ {
825
+ // a killed child can still flush one buffered frame after the
826
+ // turn settled (the abort path's cancelled turn_end is the
827
+ // common one); arming the grace timer then would leak it past
828
+ // a turn that is already complete.
829
+ if (settled)
830
+ return;
831
+ const sr = evt.message?.stopReason;
832
+ // toolUse means pi ran a tool and auto-continues next turn to
833
+ // answer — the run is not over, and the next turn_end carries
834
+ // the real outcome.
835
+ if (sr === "toolUse" || sr === "tool_use" || sr === "tool_calls")
836
+ return;
837
+ endStopReason = sr;
838
+ endUsage = evt.usage ?? evt.message?.usage;
839
+ endErrorMessage = evt.message?.errorMessage;
840
+ // agent_end, not turn_end, closes the run: pi can schedule
841
+ // post-run maintenance (overflow-recovery compaction) after
842
+ // the final turn_end, and only the terminal agent_end proves
843
+ // the session is done rewriting itself. The grace timer
844
+ // settles a runtime that never sends agent_end at all.
845
+ if (agentEndTimer)
846
+ clearTimeout(agentEndTimer);
847
+ agentEndTimer = setTimeout(finishRun, PI_AGENT_END_GRACE_MS);
848
+ agentEndTimer.unref?.();
724
849
  return;
725
- const usage = evt.usage ?? evt.message?.usage;
726
- if (sr === "error" || sr === "failed") {
727
- emit({
728
- ...base(threadId, turnId),
729
- type: "runtime.error",
730
- message: String(evt.message?.errorMessage ?? "pi turn failed").slice(0, 2_000),
731
- });
732
- settle(false, "failed", usage);
850
+ }
851
+ case "agent_end": {
852
+ // Two dialects mark a non-terminal agent_end: upstream pi sets
853
+ // willRetry: true when an automatic retry follows (overflow
854
+ // recovery runs compaction and retries as a fresh run), and the
855
+ // omp fork sets isTerminal: false while maintenance or async
856
+ // delivery has more work scheduled. Either marker means the
857
+ // session may still rewrite the turn that carried this
858
+ // session's standing prompt — keep listening so those events
859
+ // can drop the prompt-split receipt.
860
+ if (evt.isTerminal === false || evt.willRetry === true)
733
861
  return;
862
+ if (agentEndTimer) {
863
+ clearTimeout(agentEndTimer);
864
+ agentEndTimer = null;
865
+ }
866
+ finishRun();
867
+ return;
868
+ }
869
+ case "agent_settled": {
870
+ // upstream pi's explicit fully-settled guarantee: no automatic
871
+ // retry, compaction retry, or queued continuation remains.
872
+ // Usually settles one frame after the final agent_end already
873
+ // did; kept as its own terminal signal so the driver never
874
+ // depends on which dialect the configured cli speaks.
875
+ if (agentEndTimer) {
876
+ clearTimeout(agentEndTimer);
877
+ agentEndTimer = null;
734
878
  }
735
- settle(true, sr === "cancelled" || sr === "aborted" ? "cancelled" : "end_turn", usage);
879
+ finishRun();
736
880
  return;
737
881
  }
738
882
  default:
@@ -774,6 +918,7 @@ export const PiDriver = {
774
918
  // sessionFile, which switch_session expects as `sessionPath`.
775
919
  const sessionPath = typeof turn.resumeCursor === "string" ? turn.resumeCursor : null;
776
920
  let sessionFile = sessionPath;
921
+ let sessionReady = false;
777
922
  try {
778
923
  const command = sessionPath ? "switch_session" : "new_session";
779
924
  const hsPromise = awaitResponse(command);
@@ -781,6 +926,7 @@ export const PiDriver = {
781
926
  const hs = (await hsPromise);
782
927
  if (hs?.sessionFile)
783
928
  sessionFile = hs.sessionFile;
929
+ sessionReady = true;
784
930
  emit({
785
931
  ...base(threadId, turnId),
786
932
  type: "session.started",
@@ -816,17 +962,71 @@ export const PiDriver = {
816
962
  /* keep going on the engine default */
817
963
  }
818
964
  }
819
- // pi compacts long sessions by summarizing older user messages, and
820
- // this driver delivers the prompt as the leading user message: a
821
- // receipt-based split would let a compacted session keep running
822
- // bare, without its standing instructions. Re-deliver the full prompt
823
- // every turn until pi exposes a compaction signal the harness can
824
- // watch (its extension API has session_before_compact).
825
- const message = turn.system ? `${turn.system}\n\n${turn.text}` : turn.text;
965
+ // The stable/volatile split: the full prompt rides only the turn that
966
+ // establishes - or re-instructs, after a soul edit - this pi session,
967
+ // which previously re-sent the whole system prompt on every turn and
968
+ // let it accumulate in the session file once per turn. pi compacts
969
+ // long sessions by summarizing older messages, which can absorb the
970
+ // turn that carried the prompt, so the driver watches pi's
971
+ // compaction events and drops the receipt the moment one arrives,
972
+ // and re-anchors the full prompt every PI_PROMPT_RE_ANCHOR_TURNS
973
+ // bare turns as a backstop for anything the events miss. Receipts
974
+ // are durable because the session file outlives both the per-turn
975
+ // child and this process. Without a session the prompt is the
976
+ // model's only context, so that turn keeps the full block and writes
977
+ // no receipt.
978
+ const halves = promptHalves(turn);
979
+ let message;
980
+ let pendingReceipt = null;
981
+ if (halves.stable !== null && sessionReady && sessionFile) {
982
+ const receiptKey = JSON.stringify([threadId, sessionFile]);
983
+ const composed = splitSessionPrompt(halves.stable, halves.volatile, readPromptSplitReceipt("pi", receiptKey), turn.system, turn.text, Boolean(turn.mentionTurn), PI_PROMPT_RE_ANCHOR_TURNS);
984
+ message = composed.text;
985
+ pendingReceipt = { key: receiptKey, receipt: composed.receipt };
986
+ }
987
+ else {
988
+ message = turn.system ? `${turn.system}\n\n${turn.text}` : turn.text;
989
+ }
990
+ // events cannot interleave with this synchronous stretch, so any
991
+ // compaction recorded above already invalidated what compose read
826
992
  try {
993
+ // pi answers the prompt with an authoritative response only after
994
+ // preflight accepts it - and rejects it outright while a
995
+ // compaction runs - so the receipt commits only after that
996
+ // acceptance: a rejected or compacted-around send redelivers on
997
+ // the next turn. The wait is long enough to survive a compaction
998
+ // queue; a dead child rejects it immediately.
999
+ // sendTurn still resolves once the prompt is written: a child that
1000
+ // dies before replying settles the turn through the close handler,
1001
+ // whose waiter sweep can precede this registration.
1002
+ const accepted = awaitResponse("prompt", 120_000);
827
1003
  send({ type: "prompt", message, ...(images.length ? { images } : {}) });
1004
+ void accepted.then(() => {
1005
+ if (!pendingReceipt || compactionObserved)
1006
+ return;
1007
+ try {
1008
+ writePromptSplitReceipt("pi", pendingReceipt.key, pendingReceipt.receipt);
1009
+ }
1010
+ catch {
1011
+ /* an unwritten receipt only re-delivers the full prompt next turn */
1012
+ }
1013
+ }, (err) => {
1014
+ if (settled)
1015
+ return;
1016
+ emit({
1017
+ ...base(threadId, turnId),
1018
+ type: "runtime.error",
1019
+ message: String(err?.message ?? err).slice(0, 2_000),
1020
+ });
1021
+ settle(false);
1022
+ });
828
1023
  }
829
- catch {
1024
+ catch (err) {
1025
+ emit({
1026
+ ...base(threadId, turnId),
1027
+ type: "runtime.error",
1028
+ message: String(err?.message ?? err).slice(0, 2_000),
1029
+ });
830
1030
  settle(false);
831
1031
  }
832
1032
  return { turnId };
@@ -899,6 +1099,9 @@ export const PiDriver = {
899
1099
  // xhigh/max only land on models that expose them; pi rejects an
900
1100
  // unsupported level and the turn keeps the engine default.
901
1101
  effortLevels: EFFORT_LEVELS,
1102
+ // pi's RPC mode takes a mid-turn steer frame the runtime queues
1103
+ // into the running agent without aborting the current step.
1104
+ queueing: true,
902
1105
  },
903
1106
  sendTurn,
904
1107
  interruptTurn: async (threadId) => active.get(threadId)?.stop(),
@@ -918,6 +1121,10 @@ export const PiDriver = {
918
1121
  });
919
1122
  return decision.behavior === "allow" ? "allowed-once" : decision.behavior === "answer" ? "answered" : "rejected";
920
1123
  },
1124
+ steer: async (threadId, text) => {
1125
+ const entry = active.get(threadId);
1126
+ return entry?.steer ? await entry.steer(text) : "refused";
1127
+ },
921
1128
  hasSession: (threadId) => active.has(threadId),
922
1129
  stopAll: async () => {
923
1130
  for (const { stop } of active.values())
@@ -5,7 +5,7 @@
5
5
  // work, recent work) legitimately changes mid-conversation and reaches
6
6
  // the model inside the turn that changed it, after the cacheable prefix.
7
7
  import { createHash } from "node:crypto";
8
- import { mkdirSync, readFileSync } from "node:fs";
8
+ import { mkdirSync, readFileSync, rmSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { DATA_DIR } from "../config.js";
11
11
  import { writeFileAtomic } from "../atomic.js";
@@ -53,7 +53,11 @@ export function readPromptSplitReceipt(scope, key) {
53
53
  if (raw && typeof raw === "object" && !Array.isArray(raw)) {
54
54
  const record = raw;
55
55
  if (typeof record.stable === "string" && typeof record.volatile === "string") {
56
- return { stable: record.stable, volatile: record.volatile };
56
+ return {
57
+ stable: record.stable,
58
+ volatile: record.volatile,
59
+ ...(typeof record.turnsSinceFull === "number" ? { turnsSinceFull: record.turnsSinceFull } : {}),
60
+ };
57
61
  }
58
62
  }
59
63
  }
@@ -66,6 +70,18 @@ export function writePromptSplitReceipt(scope, key, receipt) {
66
70
  mkdirSync(join(DATA_DIR, "prompt-split"), { recursive: true });
67
71
  writeFileAtomic(receiptPath(scope, key), JSON.stringify(receipt), { mode: 0o600 });
68
72
  }
73
+ /** Drop a session's receipt: its history was rewritten (pi compaction
74
+ * summarizes older messages), so what it last carried is unknown and the
75
+ * next turn re-delivers the full prompt. A missing receipt already reads
76
+ * as unknown, so deleting one is always safe. */
77
+ export function deletePromptSplitReceipt(scope, key) {
78
+ try {
79
+ rmSync(receiptPath(scope, key));
80
+ }
81
+ catch {
82
+ /* a missing receipt is already unknown */
83
+ }
84
+ }
69
85
  /** Compose the prompt for a session that persists its own history (ACP
70
86
  * agents, pi): the full system block rides only when this native session
71
87
  * has not carried it - or carried a different one, which re-delivers the
@@ -74,14 +90,21 @@ export function writePromptSplitReceipt(scope, key, receipt) {
74
90
  * through bare, so an ordinary memory write neither appends a second copy
75
91
  * of the prompt to the session nor re-prices its cached prefix.
76
92
  * perTurnVolatile marks a turn whose volatile half describes this very
77
- * turn (a mention): its note is delivered even when the text is unchanged. */
78
- export function splitSessionPrompt(stable, volatile, previous, fullSystem, text, perTurnVolatile = false) {
79
- const receipt = promptSplitFingerprints(stable, volatile);
80
- if (previous === null || previous.stable !== receipt.stable) {
81
- return { text: fullSystem ? fullSystem + "\n\n" + text : text, receipt };
93
+ * turn (a mention): its note is delivered even when the text is unchanged.
94
+ * reAnchorAfterTurns, when positive, re-sends the full prompt after that
95
+ * many consecutive bare turns: a session that rewrites its own history
96
+ * without an observable event (an older pi, a missed compaction) still
97
+ * gets its standing instructions back within a bounded number of turns. */
98
+ export function splitSessionPrompt(stable, volatile, previous, fullSystem, text, perTurnVolatile = false, reAnchorAfterTurns = 0) {
99
+ const fingerprints = promptSplitFingerprints(stable, volatile);
100
+ // Legacy receipts predate the turn counter; count them from zero.
101
+ const turnsSinceFull = typeof previous?.turnsSinceFull === "number" ? previous.turnsSinceFull : 0;
102
+ const reAnchor = reAnchorAfterTurns > 0 && previous !== null && turnsSinceFull >= reAnchorAfterTurns;
103
+ if (previous === null || previous.stable !== fingerprints.stable || reAnchor) {
104
+ return { text: fullSystem ? fullSystem + "\n\n" + text : text, receipt: { ...fingerprints, turnsSinceFull: 0 } };
82
105
  }
83
- const note = previous.volatile === receipt.volatile && !perTurnVolatile
106
+ const note = previous.volatile === fingerprints.volatile && !perTurnVolatile
84
107
  ? ""
85
108
  : volatileContextNote(volatile, previous.volatile !== EMPTY_FINGERPRINT);
86
- return { text: withContextNote(note, text), receipt };
109
+ return { text: withContextNote(note, text), receipt: { ...fingerprints, turnsSinceFull: turnsSinceFull + 1 } };
87
110
  }