@mono-agent/agent-runtime 0.14.0 → 0.15.1

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 (41) hide show
  1. package/MIGRATION.md +147 -69
  2. package/README.md +83 -20
  3. package/package.json +3 -6
  4. package/src/agent/compaction.js +0 -11
  5. package/src/agent/prompt/skill-index.js +5 -1
  6. package/src/ai/index.js +7 -1
  7. package/src/ai/observer.js +48 -13
  8. package/src/ai/pi-interop.js +156 -0
  9. package/src/ai/providers/claude-cli.js +2 -13
  10. package/src/ai/providers/claude-sdk.js +14 -8
  11. package/src/ai/providers/codex-app.js +235 -56
  12. package/src/ai/providers/opencode-app.js +168 -3
  13. package/src/ai/providers/pi-messages.js +0 -8
  14. package/src/ai/providers/pi-native/compaction-driver.js +106 -10
  15. package/src/ai/providers/pi-native/result-builder.js +2 -14
  16. package/src/ai/providers/pi-native/stream-subscriber.js +20 -2
  17. package/src/ai/providers/pi-native/turn-runner.js +31 -8
  18. package/src/ai/providers/pi-native.js +2 -5
  19. package/src/ai/runtime/live-input-events.js +94 -0
  20. package/src/ai/runtime/registry.js +8 -1
  21. package/src/ai/runtime/router.js +21 -0
  22. package/src/ai/types.js +2 -1
  23. package/src/runtime.js +3 -0
  24. package/types/agent/compaction.d.ts +0 -2
  25. package/types/agent/prompt/skill-index.d.ts +3 -0
  26. package/types/ai/index.d.ts +1 -1
  27. package/types/ai/observer.d.ts +4 -2
  28. package/types/ai/pi-interop.d.ts +113 -0
  29. package/types/ai/providers/claude-cli.d.ts +6 -30
  30. package/types/ai/providers/claude-sdk.d.ts +2 -9
  31. package/types/ai/providers/codex-app.d.ts +4 -10
  32. package/types/ai/providers/pi-messages.d.ts +0 -1
  33. package/types/ai/providers/pi-native/result-builder.d.ts +3 -11
  34. package/types/ai/providers/pi-native/stream-subscriber.d.ts +4 -2
  35. package/types/ai/providers/pi-native/turn-runner.d.ts +1 -1
  36. package/types/ai/runtime/live-input-events.d.ts +22 -0
  37. package/types/ai/types.d.ts +10 -2
  38. package/src/ai/backend.js +0 -17
  39. package/src/ai/registry.js +0 -5
  40. package/types/ai/backend.d.ts +0 -57
  41. package/types/ai/registry.d.ts +0 -1
@@ -1048,6 +1048,27 @@ function usageFromTokenUsage(tokenUsage) {
1048
1048
  };
1049
1049
  }
1050
1050
 
1051
+ function contextUsageFromTokenUsage(tokenUsage) {
1052
+ const last = tokenUsage?.last;
1053
+ const total = Number(last?.totalTokens);
1054
+ if (!last || !Number.isFinite(total) || total <= 0) return null;
1055
+ const input = Number(last.inputTokens) || 0;
1056
+ const cachedInput = Number(last.cachedInputTokens) || 0;
1057
+ const output = Number(last.outputTokens) || 0;
1058
+ const reasoning = Number(last.reasoningOutputTokens) || 0;
1059
+ const contextWindow = Number(tokenUsage?.modelContextWindow) || 0;
1060
+ return {
1061
+ tokens: {
1062
+ input: Math.max(0, input - cachedInput),
1063
+ cachedInput,
1064
+ output,
1065
+ reasoning,
1066
+ total,
1067
+ },
1068
+ ...(contextWindow > 0 ? { contextWindow } : {}),
1069
+ };
1070
+ }
1071
+
1051
1072
  const noopNotificationHandler = () => {};
1052
1073
 
1053
1074
  async function closeCodexClient(client) {
@@ -1076,6 +1097,7 @@ const codexLiveness = createSessionLiveness(codexSessions);
1076
1097
  export async function generateCodexAppResponse(systemPrompt, options = {}) {
1077
1098
  const start = Date.now();
1078
1099
  const resolved = options.model;
1100
+ const requestedReference = resolved?.reference || `codex:${resolved?.model || ""}`;
1079
1101
  // Resolve every credential-bearing value before the app-server client is
1080
1102
  // constructed. The same set protects transport errors and provider events,
1081
1103
  // including MCP servers whose custom env/header names are not recognizable
@@ -1105,8 +1127,13 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1105
1127
  const events = [];
1106
1128
  const texts = [];
1107
1129
  const agentTextByItem = new Map();
1130
+ const compactionStatuses = new Map();
1131
+ const activeCompactions = new Map();
1132
+ const nativeCompactionTurnKeys = new Set();
1133
+ const legacyCompactionTurnKeys = new Set();
1108
1134
  let threadId = null;
1109
1135
  let activeTurnId = null;
1136
+ let actualModel = resolved?.model || null;
1110
1137
  let turnCompleted = false;
1111
1138
  let errorMessage = null;
1112
1139
  let failureKind = null;
@@ -1116,7 +1143,9 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1116
1143
  let serverRequestViolation = null;
1117
1144
  let resolveTurn;
1118
1145
  let resolveTurnReady;
1146
+ let resolveLiveInputStop;
1119
1147
  let turnReadyResolved = false;
1148
+ let liveInputStopped = false;
1120
1149
  const fileChangeSnapshots = new Map();
1121
1150
  const codexItemContext = {
1122
1151
  fileChangePayload: (raw) => createFileChangePayload(raw, {
@@ -1126,6 +1155,13 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1126
1155
  };
1127
1156
  const turnDone = new Promise((resolve) => { resolveTurn = resolve; });
1128
1157
  const turnReady = new Promise((resolve) => { resolveTurnReady = resolve; });
1158
+ const liveInputStop = new Promise((resolve) => { resolveLiveInputStop = resolve; });
1159
+
1160
+ function stopLiveInput() {
1161
+ if (liveInputStopped) return;
1162
+ liveInputStopped = true;
1163
+ resolveLiveInputStop();
1164
+ }
1129
1165
 
1130
1166
  function setActiveTurnId(turnId, { steerReady = false } = {}) {
1131
1167
  activeTurnId = turnId || activeTurnId;
@@ -1148,6 +1184,92 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1148
1184
  options.onEvent?.(safeEvent);
1149
1185
  }
1150
1186
 
1187
+ function invokeLiveInputCallback(message, callbackName, ...args) {
1188
+ const callback = message?.[callbackName];
1189
+ if (typeof callback !== "function") return;
1190
+ try {
1191
+ callback.apply(message, args);
1192
+ } catch (err) {
1193
+ const detail = safeDiagnostic(err, 512);
1194
+ emitEvent({
1195
+ type: "runtime_warning",
1196
+ warning_kind: "live_input_callback_failed",
1197
+ message: safeDiagnostic(`Live-input ${callbackName} callback failed: ${detail}`, 1_024),
1198
+ });
1199
+ }
1200
+ }
1201
+
1202
+ const compactionTurnKey = (params = {}) => `${params.threadId || threadId || "thread"}:${params.turnId || activeTurnId || "turn"}`;
1203
+
1204
+ /**
1205
+ * @param {{operationId: string, status: string, turnKey?: string, reason?: string, message?: string}} event
1206
+ */
1207
+ function emitCompaction({
1208
+ operationId,
1209
+ status,
1210
+ turnKey,
1211
+ reason,
1212
+ message,
1213
+ }) {
1214
+ const previous = compactionStatuses.get(operationId);
1215
+ if (previous === status || previous === "succeeded" || previous === "failed" || previous === "skipped") return;
1216
+ compactionStatuses.set(operationId, status);
1217
+ if (status === "running") activeCompactions.set(operationId, { turnKey: turnKey || compactionTurnKey() });
1218
+ else activeCompactions.delete(operationId);
1219
+ emitEvent({
1220
+ type: "context_compaction",
1221
+ operationId,
1222
+ status,
1223
+ sdk: "codex",
1224
+ trigger: "automatic",
1225
+ timestamp: Date.now(),
1226
+ model: actualModel ? `codex:${actualModel}` : requestedReference,
1227
+ ...(reason ? { reason } : {}),
1228
+ ...(message ? { message } : {}),
1229
+ });
1230
+ }
1231
+
1232
+ function finalizeOpenCompactions(reason, message) {
1233
+ for (const [operationId, active] of [...activeCompactions]) {
1234
+ emitCompaction({
1235
+ operationId,
1236
+ status: "failed",
1237
+ turnKey: active.turnKey,
1238
+ reason,
1239
+ message,
1240
+ });
1241
+ }
1242
+ }
1243
+
1244
+ function handleContextCompactionItem(method, params) {
1245
+ const item = params.item;
1246
+ const turnKey = compactionTurnKey(params);
1247
+ nativeCompactionTurnKeys.add(turnKey);
1248
+ if (legacyCompactionTurnKeys.has(turnKey)) return;
1249
+ const operationId = `codex:${item.id}`;
1250
+ emitCompaction({
1251
+ operationId,
1252
+ status: method === "item/started" ? "running" : "succeeded",
1253
+ turnKey,
1254
+ });
1255
+ }
1256
+
1257
+ function handleLegacyCompaction(params) {
1258
+ const turnKey = compactionTurnKey(params);
1259
+ const active = [...activeCompactions].find(([, value]) => value.turnKey === turnKey);
1260
+ if (active) {
1261
+ emitCompaction({ operationId: active[0], status: "succeeded", turnKey });
1262
+ return;
1263
+ }
1264
+ if (nativeCompactionTurnKeys.has(turnKey) || legacyCompactionTurnKeys.has(turnKey)) return;
1265
+ legacyCompactionTurnKeys.add(turnKey);
1266
+ emitCompaction({
1267
+ operationId: `codex:${turnKey}:legacy`,
1268
+ status: "succeeded",
1269
+ turnKey,
1270
+ });
1271
+ }
1272
+
1151
1273
  function handleAgentText(text) {
1152
1274
  const safeText = redactCodexDiagnostic(text, sensitiveValues);
1153
1275
  pushUniqueText(texts, safeText);
@@ -1171,6 +1293,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1171
1293
  client?.request("turn/interrupt", { threadId, turnId: activeTurnId }).catch(() => {});
1172
1294
  }
1173
1295
  turnCompleted = true;
1296
+ stopLiveInput();
1174
1297
  resolveTurn({ id: activeTurnId, status: "interrupted" });
1175
1298
  }
1176
1299
 
@@ -1195,6 +1318,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1195
1318
  message: errorMessage,
1196
1319
  });
1197
1320
  turnCompleted = true;
1321
+ stopLiveInput();
1198
1322
  resolveTurn({ id: activeTurnId, status: "interrupted" });
1199
1323
  }
1200
1324
 
@@ -1225,10 +1349,18 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1225
1349
  if (method === "turn/completed") {
1226
1350
  setActiveTurnId(params.turn?.id);
1227
1351
  turnCompleted = true;
1352
+ stopLiveInput();
1228
1353
  if (params.turn?.status === "failed") {
1229
1354
  errorMessage = safeDiagnostic(params.turn?.error?.message || params.turn?.error || "Codex turn failed");
1230
1355
  failureKind = "provider_unavailable";
1231
1356
  }
1357
+ if (activeCompactions.size > 0) {
1358
+ const cancelled = params.turn?.status === "cancelled" || params.turn?.status === "interrupted";
1359
+ finalizeOpenCompactions(
1360
+ cancelled ? "cancelled" : "incomplete",
1361
+ cancelled ? "Compaction was interrupted." : "Compaction ended without a completion event.",
1362
+ );
1363
+ }
1232
1364
  const safeTurn = params.turn?.error === undefined
1233
1365
  ? params.turn
1234
1366
  : { ...params.turn, error: safeResponseError(params.turn.error) };
@@ -1238,6 +1370,27 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1238
1370
  }
1239
1371
  if (method === "thread/tokenUsage/updated") {
1240
1372
  usage = usageFromTokenUsage(params.tokenUsage);
1373
+ const contextUsage = contextUsageFromTokenUsage(params.tokenUsage);
1374
+ if (contextUsage) {
1375
+ emitEvent({
1376
+ type: "context_usage",
1377
+ sdk: "codex",
1378
+ model: actualModel ? `codex:${actualModel}` : requestedReference,
1379
+ timestamp: Date.now(),
1380
+ ...(typeof params.turnId === "string" && params.turnId.length > 0
1381
+ ? { measurementId: params.turnId }
1382
+ : {}),
1383
+ ...contextUsage,
1384
+ });
1385
+ }
1386
+ return;
1387
+ }
1388
+ if (method === "model/rerouted") {
1389
+ if (typeof params.toModel === "string" && params.toModel.trim().length > 0) actualModel = params.toModel;
1390
+ return;
1391
+ }
1392
+ if (method === "thread/compacted") {
1393
+ handleLegacyCompaction(params);
1241
1394
  return;
1242
1395
  }
1243
1396
  if (method === "item/agentMessage/delta") {
@@ -1258,6 +1411,10 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1258
1411
  return;
1259
1412
  }
1260
1413
  if (method === "item/started" || method === "item/completed") {
1414
+ if (params.item?.type === "contextCompaction") {
1415
+ handleContextCompactionItem(method, params);
1416
+ return;
1417
+ }
1261
1418
  const raw = mapThreadItem(method, params.item);
1262
1419
  if (params.item?.type === "agentMessage") {
1263
1420
  const text = params.item.text || agentTextByItem.get(params.item.id) || "";
@@ -1352,6 +1509,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1352
1509
 
1353
1510
  const abortHandler = () => {
1354
1511
  abortRequested = true;
1512
+ stopLiveInput();
1355
1513
  if (threadId && activeTurnId && !interruptSent) {
1356
1514
  interruptSent = true;
1357
1515
  client?.request("turn/interrupt", { threadId, turnId: activeTurnId }).catch(() => {});
@@ -1363,60 +1521,77 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1363
1521
 
1364
1522
  async function steerLiveInput() {
1365
1523
  if (!options.liveInput) return;
1366
- for await (const message of options.liveInput) {
1367
- if (turnCompleted) break;
1368
- if (!threadId || !activeTurnId || !turnReadyResolved) {
1369
- await Promise.race([
1370
- turnReady,
1371
- turnDone,
1372
- client.closed.then((err) => { throw err; }),
1524
+ const iterator = options.liveInput[Symbol.asyncIterator]();
1525
+ try {
1526
+ while (!turnCompleted && !liveInputStopped) {
1527
+ const next = await Promise.race([
1528
+ iterator.next(),
1529
+ liveInputStop.then(() => ({ done: true, value: undefined })),
1373
1530
  ]);
1374
- if (turnCompleted || !turnReadyResolved) break;
1375
- }
1376
- const input = userTextInput(formatLiveInputGuidance(message.body, options.prompts));
1377
- try {
1378
- const response = await client.request("turn/steer", {
1379
- threadId,
1380
- expectedTurnId: activeTurnId,
1381
- input,
1382
- });
1383
- activeTurnId = response?.turnId || activeTurnId;
1384
- } catch (err) {
1385
- const providerError = err?.responseError;
1386
- if (isNoActiveTurnToSteer(providerError || err)) {
1531
+ if (next.done || turnCompleted || liveInputStopped) break;
1532
+ const message = next.value;
1533
+ if (!threadId || !activeTurnId || !turnReadyResolved) {
1387
1534
  await Promise.race([
1388
1535
  turnReady,
1389
- turnDone,
1390
- client.closed.then((closedErr) => { throw closedErr; }),
1536
+ liveInputStop,
1391
1537
  ]);
1392
- if (turnCompleted) break;
1393
- try {
1394
- const response = await client.request("turn/steer", {
1395
- threadId,
1396
- expectedTurnId: activeTurnId,
1397
- input,
1398
- });
1399
- activeTurnId = response?.turnId || activeTurnId;
1400
- continue;
1401
- } catch (retryErr) {
1402
- const retryProviderError = retryErr?.responseError
1403
- ? safeResponseError(retryErr.responseError)
1404
- : null;
1405
- emitEvent({
1406
- type: "runtime_warning",
1407
- warning_kind: isActiveTurnNotSteerable(retryProviderError) ? "active_turn_not_steerable" : "live_input_rejected",
1408
- message: safeDiagnostic(codexErrorMessage(retryProviderError || retryErr)),
1409
- });
1410
- continue;
1538
+ if (turnCompleted || liveInputStopped || !turnReadyResolved) break;
1539
+ }
1540
+ const input = userTextInput(formatLiveInputGuidance(message.body, options.prompts));
1541
+ try {
1542
+ const response = await client.request("turn/steer", {
1543
+ threadId,
1544
+ expectedTurnId: activeTurnId,
1545
+ input,
1546
+ });
1547
+ activeTurnId = response?.turnId || activeTurnId;
1548
+ invokeLiveInputCallback(message, "acknowledge");
1549
+ } catch (err) {
1550
+ const providerError = err?.responseError;
1551
+ if (isNoActiveTurnToSteer(providerError || err)) {
1552
+ await Promise.race([
1553
+ turnReady,
1554
+ liveInputStop,
1555
+ ]);
1556
+ if (turnCompleted || liveInputStopped) break;
1557
+ try {
1558
+ const response = await client.request("turn/steer", {
1559
+ threadId,
1560
+ expectedTurnId: activeTurnId,
1561
+ input,
1562
+ });
1563
+ activeTurnId = response?.turnId || activeTurnId;
1564
+ invokeLiveInputCallback(message, "acknowledge");
1565
+ continue;
1566
+ } catch (retryErr) {
1567
+ invokeLiveInputCallback(message, "reject", retryErr);
1568
+ const retryProviderError = retryErr?.responseError
1569
+ ? safeResponseError(retryErr.responseError)
1570
+ : null;
1571
+ emitEvent({
1572
+ type: "runtime_warning",
1573
+ warning_kind: isActiveTurnNotSteerable(retryProviderError) ? "active_turn_not_steerable" : "live_input_rejected",
1574
+ message: safeDiagnostic(codexErrorMessage(retryProviderError || retryErr)),
1575
+ });
1576
+ // Preserve FIFO fallback: once one message is rejected, later
1577
+ // entries must not overtake it inside this provider attempt.
1578
+ break;
1579
+ }
1411
1580
  }
1581
+ invokeLiveInputCallback(message, "reject", err);
1582
+ emitEvent({
1583
+ type: "runtime_warning",
1584
+ warning_kind: isActiveTurnNotSteerable(providerError) ? "active_turn_not_steerable" : "live_input_rejected",
1585
+ message: safeDiagnostic(codexErrorMessage(
1586
+ providerError ? safeResponseError(providerError) : err,
1587
+ )),
1588
+ });
1589
+ break;
1412
1590
  }
1413
- emitEvent({
1414
- type: "runtime_warning",
1415
- warning_kind: isActiveTurnNotSteerable(providerError) ? "active_turn_not_steerable" : "live_input_rejected",
1416
- message: safeDiagnostic(codexErrorMessage(
1417
- providerError ? safeResponseError(providerError) : err,
1418
- )),
1419
- });
1591
+ }
1592
+ } finally {
1593
+ if (typeof iterator.return === "function") {
1594
+ try { void Promise.resolve(iterator.return()).catch(() => {}); } catch { /* best-effort */ }
1420
1595
  }
1421
1596
  }
1422
1597
  }
@@ -1640,6 +1815,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1640
1815
  closedSignal.then((err) => {
1641
1816
  if (!turnCompleted) {
1642
1817
  prematureClose = true;
1818
+ stopLiveInput();
1643
1819
  throw err || new Error("codex app-server closed");
1644
1820
  }
1645
1821
  return null;
@@ -1654,9 +1830,10 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1654
1830
  }
1655
1831
  } finally {
1656
1832
  abortRaceCleanup();
1833
+ stopLiveInput();
1657
1834
  }
1658
1835
  turnCompleted = true;
1659
- await Promise.race([steerTask, Promise.resolve()]);
1836
+ await steerTask;
1660
1837
 
1661
1838
  const text = texts[texts.length - 1] || "";
1662
1839
  let codexErrorCode = prematureClose ? "codex_app_server_closed" : null;
@@ -1683,7 +1860,7 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1683
1860
  });
1684
1861
  }
1685
1862
  const hadPartialProgress = events.length > 0 || texts.length > 0;
1686
- const reference = `codex:${resolved.model}`;
1863
+ const reference = requestedReference;
1687
1864
  const inputTokens = usage?.input_tokens ?? usage?.inputTokens ?? 0;
1688
1865
  const outputTokens = usage?.output_tokens ?? usage?.outputTokens ?? 0;
1689
1866
  const cachedTokens = usage?.cache_read_tokens ?? usage?.cachedInputTokens ?? 0;
@@ -1774,6 +1951,14 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1774
1951
  }),
1775
1952
  };
1776
1953
  } finally {
1954
+ stopLiveInput();
1955
+ if (activeCompactions.size > 0) {
1956
+ const cancelled = !!options.abortSignal?.aborted;
1957
+ finalizeOpenCompactions(
1958
+ cancelled ? "cancelled" : "incomplete",
1959
+ cancelled ? "Compaction was interrupted." : "Compaction ended without a completion event.",
1960
+ );
1961
+ }
1777
1962
  options.abortSignal?.removeEventListener?.("abort", abortHandler);
1778
1963
  if (resumeEntry) {
1779
1964
  resumeEntry.busy = false;
@@ -1784,12 +1969,6 @@ export async function generateCodexAppResponse(systemPrompt, options = {}) {
1784
1969
  }
1785
1970
  }
1786
1971
 
1787
- export const codexAppBackend = {
1788
- kind: "codex-app",
1789
- capabilities: CODEX_APP_CAPABILITIES,
1790
- execute: generateCodexAppResponse,
1791
- };
1792
-
1793
1972
  // CLI bridge for sdk='codex' agents that opt into execution_mode='cli'. The
1794
1973
  // codex `app-server` is more capable than `codex exec` (better event
1795
1974
  // streaming, MCP support), so this is the default CLI path for Codex.
@@ -319,6 +319,57 @@ function usageFromInfo(info) {
319
319
  };
320
320
  }
321
321
 
322
+ async function opencodeContextWindows(client, directoryParams) {
323
+ try {
324
+ if (typeof client?.provider?.list !== "function") return new Map();
325
+ const listed = unwrap(await client.provider.list(directoryParams));
326
+ const providers = Array.isArray(listed?.all) ? listed.all : [];
327
+ const windows = new Map();
328
+ for (const provider of providers) {
329
+ const models = provider?.models && typeof provider.models === "object"
330
+ ? Object.values(provider.models)
331
+ : [];
332
+ for (const model of models) {
333
+ const providerID = model?.providerID || provider?.id;
334
+ const modelID = model?.id;
335
+ const contextWindow = Number(model?.limit?.context) || 0;
336
+ if (providerID && modelID && contextWindow > 0) {
337
+ windows.set(`${providerID}:${modelID}`, contextWindow);
338
+ }
339
+ }
340
+ }
341
+ return windows;
342
+ } catch {
343
+ return new Map();
344
+ }
345
+ }
346
+
347
+ function contextUsageFromInfo(info, contextWindows, fallbackProviderID, fallbackModelID) {
348
+ if (info?.role !== "assistant" || info.error) return null;
349
+ const total = num(info?.tokens?.total);
350
+ if (total === null || total <= 0) return null;
351
+ const providerID = info.providerID || fallbackProviderID;
352
+ const modelID = info.modelID || fallbackModelID;
353
+ const input = num(info.tokens?.input) || 0;
354
+ const output = num(info.tokens?.output) || 0;
355
+ const reasoning = num(info.tokens?.reasoning) || 0;
356
+ const cachedInput = num(info.tokens?.cache?.read) || 0;
357
+ const cacheCreation = num(info.tokens?.cache?.write) || 0;
358
+ const contextWindow = contextWindows.get(`${providerID}:${modelID}`);
359
+ return {
360
+ model: `opencode:${providerID}:${modelID}`,
361
+ tokens: {
362
+ input: Math.max(0, input - cachedInput),
363
+ cachedInput,
364
+ cacheCreation,
365
+ output,
366
+ reasoning,
367
+ total,
368
+ },
369
+ ...(contextWindow === undefined ? {} : { contextWindow }),
370
+ };
371
+ }
372
+
322
373
  function aggregateAssistantInfos(infos) {
323
374
  const entries = [...infos];
324
375
  const totals = {
@@ -587,11 +638,71 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
587
638
  const textDeltaPartIds = new Set();
588
639
  const reasoningDeltaPartIds = new Set();
589
640
  const assistantInfos = new Map();
590
- const recordAssistantInfo = (info, fallbackKey) => {
641
+ const contextUsageSignatures = new Map();
642
+ const compactionStatuses = new Map();
643
+ const activeCompactions = new Map();
644
+ const seenLegacyCompactionIds = new Set();
645
+ let nativeCompactionSeen = false;
646
+ let legacyCompactionMode = false;
647
+ let contextWindows = new Map();
648
+
649
+ /**
650
+ * @param {{operationId: string, status: string, trigger?: string, timestamp?: number, reason?: string, message?: string}} event
651
+ */
652
+ const emitCompaction = ({ operationId, status, trigger = "automatic", timestamp, reason, message }) => {
653
+ const previous = compactionStatuses.get(operationId);
654
+ if (previous === status || previous === "succeeded" || previous === "failed" || previous === "skipped") return;
655
+ compactionStatuses.set(operationId, status);
656
+ if (status === "running") activeCompactions.set(operationId, { trigger });
657
+ else activeCompactions.delete(operationId);
658
+ emit({
659
+ type: "context_compaction",
660
+ operationId,
661
+ status,
662
+ sdk: "opencode",
663
+ trigger,
664
+ timestamp: Number.isFinite(timestamp) ? timestamp : Date.now(),
665
+ model: reference,
666
+ ...(reason ? { reason } : {}),
667
+ ...(message ? { message } : {}),
668
+ });
669
+ };
670
+
671
+ const finalizeOpenCompactions = (reason, message) => {
672
+ for (const [operationId, active] of [...activeCompactions]) {
673
+ emitCompaction({
674
+ operationId,
675
+ status: "failed",
676
+ trigger: active.trigger,
677
+ reason,
678
+ message,
679
+ });
680
+ }
681
+ };
682
+
683
+ const recordAssistantInfo = (info, fallbackKey, { terminal = false } = {}) => {
591
684
  if (info?.role !== "assistant") return;
592
685
  const key = typeof info.id === "string" && info.id.length > 0 ? info.id : fallbackKey;
593
686
  assistantInfos.set(key, info);
594
687
  usage = aggregateAssistantInfos(assistantInfos.values()).usage;
688
+ const completed = terminal
689
+ || Number.isFinite(info?.time?.completed)
690
+ || (typeof info?.finish === "string" && info.finish.length > 0);
691
+ if (!completed) return;
692
+ const contextUsage = contextUsageFromInfo(info, contextWindows, providerID, modelID);
693
+ if (!contextUsage) return;
694
+ const signature = JSON.stringify([contextUsage.model, contextUsage.contextWindow, contextUsage.tokens]);
695
+ if (contextUsageSignatures.get(key) === signature) return;
696
+ contextUsageSignatures.set(key, signature);
697
+ emit({
698
+ type: "context_usage",
699
+ sdk: "opencode",
700
+ model: contextUsage.model,
701
+ timestamp: Number.isFinite(info?.time?.completed) ? info.time.completed : Date.now(),
702
+ measurementId: key,
703
+ ...(contextUsage.contextWindow === undefined ? {} : { contextWindow: contextUsage.contextWindow }),
704
+ tokens: contextUsage.tokens,
705
+ });
595
706
  };
596
707
 
597
708
  const abortHandler = () => {
@@ -618,6 +729,7 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
618
729
  client = opencode.client;
619
730
  server = opencode.server;
620
731
  options.abortSignal?.addEventListener?.("abort", abortHandler, { once: true });
732
+ contextWindows = await opencodeContextWindows(client, directoryParams);
621
733
 
622
734
  if (!sessionId) {
623
735
  const created = unwrap(await client.session.create(directoryParams));
@@ -758,6 +870,47 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
758
870
  recordAssistantInfo(info, "event-anonymous");
759
871
  return;
760
872
  }
873
+ case "session.next.compaction.started": {
874
+ if (props.sessionID && props.sessionID !== sessionId) return;
875
+ nativeCompactionSeen = true;
876
+ if (legacyCompactionMode) return;
877
+ const operationId = `opencode:${sessionId}:${props.messageID || event.id || "compaction"}`;
878
+ emitCompaction({
879
+ operationId,
880
+ status: "running",
881
+ trigger: props.reason === "manual" ? "manual" : "automatic",
882
+ timestamp: props.timestamp,
883
+ });
884
+ return;
885
+ }
886
+ case "session.next.compaction.ended": {
887
+ if (props.sessionID && props.sessionID !== sessionId) return;
888
+ nativeCompactionSeen = true;
889
+ if (legacyCompactionMode) return;
890
+ const operationId = `opencode:${sessionId}:${props.messageID || event.id || "compaction"}`;
891
+ emitCompaction({
892
+ operationId,
893
+ status: "succeeded",
894
+ trigger: props.reason === "manual" ? "manual" : "automatic",
895
+ timestamp: props.timestamp,
896
+ });
897
+ return;
898
+ }
899
+ case "session.compacted": {
900
+ if (props.sessionID && props.sessionID !== sessionId) return;
901
+ if (nativeCompactionSeen) return;
902
+ legacyCompactionMode = true;
903
+ const legacyId = typeof event.id === "string" && event.id.length > 0
904
+ ? event.id
905
+ : randomUUID();
906
+ if (seenLegacyCompactionIds.has(legacyId)) return;
907
+ seenLegacyCompactionIds.add(legacyId);
908
+ emitCompaction({
909
+ operationId: `opencode:${sessionId}:legacy:${legacyId}`,
910
+ status: "succeeded",
911
+ });
912
+ return;
913
+ }
761
914
  case "permission.asked":
762
915
  await respondToPermission(props);
763
916
  return;
@@ -765,10 +918,14 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
765
918
  if (props.sessionID && props.sessionID !== sessionId) return;
766
919
  errorMessage = safeOpenCodeErrorMessage(props.error, "OpenCode session error.");
767
920
  failureKind = mapErrorFailureKind(props.error);
921
+ finalizeOpenCompactions("provider_error", "Compaction was interrupted by a provider error.");
768
922
  pumpDone = true;
769
923
  return;
770
924
  case "session.idle":
771
- if (props.sessionID === sessionId) pumpDone = true;
925
+ if (props.sessionID === sessionId) {
926
+ finalizeOpenCompactions("incomplete", "Compaction ended without a completion event.");
927
+ pumpDone = true;
928
+ }
772
929
  return;
773
930
  default:
774
931
  return;
@@ -815,7 +972,7 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
815
972
  }
816
973
 
817
974
  const info = promptResult?.info || {};
818
- recordAssistantInfo(info, "prompt-final");
975
+ recordAssistantInfo(info, "prompt-final", { terminal: true });
819
976
  if (info.error && !errorMessage) {
820
977
  errorMessage = safeOpenCodeErrorMessage(info.error, "OpenCode turn failed.");
821
978
  failureKind = mapErrorFailureKind(info.error);
@@ -882,6 +1039,10 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
882
1039
  }),
883
1040
  };
884
1041
  } catch (err) {
1042
+ finalizeOpenCompactions(
1043
+ options.abortSignal?.aborted ? "cancelled" : "provider_error",
1044
+ options.abortSignal?.aborted ? "Compaction was interrupted." : "Compaction was interrupted by a provider error.",
1045
+ );
885
1046
  const partialAggregate = aggregateAssistantInfos(assistantInfos.values());
886
1047
  const partialUsage = partialAggregate.usage ?? usage;
887
1048
  const partialCost = partialAggregate.reportedCost !== null
@@ -929,6 +1090,10 @@ async function generateOpencodeAppResponse(systemPrompt, options = {}) {
929
1090
  }),
930
1091
  };
931
1092
  } finally {
1093
+ finalizeOpenCompactions(
1094
+ options.abortSignal?.aborted ? "cancelled" : "incomplete",
1095
+ options.abortSignal?.aborted ? "Compaction was interrupted." : "Compaction ended without a completion event.",
1096
+ );
932
1097
  options.abortSignal?.removeEventListener?.("abort", abortHandler);
933
1098
  try { await server?.close?.(); } catch { /* best effort */ }
934
1099
  }
@@ -1,13 +1,5 @@
1
1
  import { EMPTY_USAGE } from "./pi-models.js";
2
2
 
3
- export function promptTextFromMessages(messages) {
4
- if (!Array.isArray(messages) || !messages.length) return "";
5
- return messages
6
- .filter((message) => message?.role === "user")
7
- .map((message) => typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? ""))
8
- .join("\n");
9
- }
10
-
11
3
  function messageContent(value) {
12
4
  if (typeof value === "string") return value;
13
5
  if (Array.isArray(value)) {