@getpaseo/server 0.1.72 → 0.1.73

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 (55) hide show
  1. package/dist/server/server/agent/agent-archive.d.ts +11 -0
  2. package/dist/server/server/agent/agent-archive.d.ts.map +1 -0
  3. package/dist/server/server/agent/agent-archive.js +16 -0
  4. package/dist/server/server/agent/agent-archive.js.map +1 -0
  5. package/dist/server/server/agent/agent-manager.d.ts.map +1 -1
  6. package/dist/server/server/agent/agent-manager.js +3 -23
  7. package/dist/server/server/agent/agent-manager.js.map +1 -1
  8. package/dist/server/server/agent/provider-launch-config.d.ts.map +1 -1
  9. package/dist/server/server/agent/provider-launch-config.js +1 -0
  10. package/dist/server/server/agent/provider-launch-config.js.map +1 -1
  11. package/dist/server/server/agent/providers/codex/test-utils/fake-app-server.d.ts +31 -0
  12. package/dist/server/server/agent/providers/codex/test-utils/fake-app-server.d.ts.map +1 -0
  13. package/dist/server/server/agent/providers/codex/test-utils/fake-app-server.js +172 -0
  14. package/dist/server/server/agent/providers/codex/test-utils/fake-app-server.js.map +1 -0
  15. package/dist/server/server/agent/providers/codex-app-server-agent.d.ts.map +1 -1
  16. package/dist/server/server/agent/providers/codex-app-server-agent.js +1 -1
  17. package/dist/server/server/agent/providers/codex-app-server-agent.js.map +1 -1
  18. package/dist/server/server/agent/providers/opencode-agent.d.ts +33 -1
  19. package/dist/server/server/agent/providers/opencode-agent.d.ts.map +1 -1
  20. package/dist/server/server/agent/providers/opencode-agent.js +528 -9
  21. package/dist/server/server/agent/providers/opencode-agent.js.map +1 -1
  22. package/dist/server/server/bootstrap.d.ts +2 -0
  23. package/dist/server/server/bootstrap.d.ts.map +1 -1
  24. package/dist/server/server/bootstrap.js +45 -8
  25. package/dist/server/server/bootstrap.js.map +1 -1
  26. package/dist/server/server/file-explorer/service.d.ts.map +1 -1
  27. package/dist/server/server/file-explorer/service.js +84 -67
  28. package/dist/server/server/file-explorer/service.js.map +1 -1
  29. package/dist/server/server/push/notifications.d.ts +9 -0
  30. package/dist/server/server/push/notifications.d.ts.map +1 -0
  31. package/dist/server/server/push/notifications.js +15 -0
  32. package/dist/server/server/push/notifications.js.map +1 -0
  33. package/dist/server/server/push/push-service.d.ts +1 -2
  34. package/dist/server/server/push/push-service.d.ts.map +1 -1
  35. package/dist/server/server/relay-transport.d.ts +7 -1
  36. package/dist/server/server/relay-transport.d.ts.map +1 -1
  37. package/dist/server/server/relay-transport.js +10 -5
  38. package/dist/server/server/relay-transport.js.map +1 -1
  39. package/dist/server/server/websocket/runtime-metrics.d.ts +71 -0
  40. package/dist/server/server/websocket/runtime-metrics.d.ts.map +1 -0
  41. package/dist/server/server/websocket/runtime-metrics.js +148 -0
  42. package/dist/server/server/websocket/runtime-metrics.js.map +1 -0
  43. package/dist/server/server/websocket-server.d.ts +4 -21
  44. package/dist/server/server/websocket-server.d.ts.map +1 -1
  45. package/dist/server/server/websocket-server.js +28 -137
  46. package/dist/server/server/websocket-server.js.map +1 -1
  47. package/dist/server/shared/connection-offer.d.ts +6 -6
  48. package/dist/server/shared/connection-offer.js +1 -1
  49. package/dist/server/shared/connection-offer.js.map +1 -1
  50. package/dist/server/utils/run-git-command.d.ts.map +1 -1
  51. package/dist/server/utils/run-git-command.js +1 -0
  52. package/dist/server/utils/run-git-command.js.map +1 -1
  53. package/dist/src/server/agent/provider-launch-config.js +1 -0
  54. package/dist/src/server/agent/provider-launch-config.js.map +1 -1
  55. package/package.json +4 -4
@@ -24,6 +24,22 @@ const OPENCODE_CAPABILITIES = {
24
24
  const OPENCODE_BUILD_MODE_ID = "build";
25
25
  const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
26
26
  const OPENCODE_STORAGE_SESSION_LIMIT = 200;
27
+ // COMPAT(opencodeEofRecovery): added in v0.1.73 to compensate for OpenCode 1.14.42+
28
+ // closing the /event SSE stream cleanly after `server.connected`. Drop this whole
29
+ // recovery path once OpenCode upstream restores live event delivery and the floor
30
+ // version reflects that.
31
+ // Upstream: anomalyco/opencode#26697 (SSE /event closes immediately after
32
+ // server.connected) and anomalyco/opencode#26635 (prompt_async silently discards
33
+ // requests; SSE path broken).
34
+ const OPENCODE_EOF_RECOVERY_TIMEOUT_MS = 5 * 60 * 1000;
35
+ const OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS = 1000;
36
+ const OPENCODE_RECOVERY_ABORT_TIMEOUT_MS = 2000;
37
+ const OPENCODE_PENDING_ABORT_START_TIMEOUT_MS = 10000;
38
+ // If OpenCode silently rejects the prompt (invalid model/mode/auth), no assistant
39
+ // message is ever persisted. Bound the wait so the turn fails in seconds instead
40
+ // of hanging until the completion cap. Valid models normally persist their first
41
+ // message within a second of LLM start, so 10s leaves comfortable headroom.
42
+ const OPENCODE_EOF_RECOVERY_LIVENESS_MS = 10000;
27
43
  const DEFAULT_MODES = [
28
44
  {
29
45
  id: OPENCODE_BUILD_MODE_ID,
@@ -435,6 +451,16 @@ function mergeOpenCodeStepFinishUsage(usage, part) {
435
451
  usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
436
452
  }
437
453
  }
454
+ function formatOpenCodeAssistantErrorMessage(error) {
455
+ const data = error.data;
456
+ if (data && typeof data === "object" && "message" in data) {
457
+ const message = data.message;
458
+ if (typeof message === "string" && message.trim().length > 0) {
459
+ return message.trim();
460
+ }
461
+ }
462
+ return error.name;
463
+ }
438
464
  function hasNormalizedOpenCodeUsage(usage) {
439
465
  return [
440
466
  usage.inputTokens,
@@ -574,17 +600,24 @@ async function readOpenCodeSessionTimeline(storageRoot, sessionId) {
574
600
  return timeline;
575
601
  }
576
602
  async function readOpenCodeMessageText(storageRoot, messageId) {
603
+ const parts = await readOpenCodeStoredParts(storageRoot, messageId);
604
+ return readOpenCodeTextFromParts(parts);
605
+ }
606
+ async function readOpenCodeStoredParts(storageRoot, messageId) {
577
607
  const partRoot = path.join(storageRoot, "part", messageId);
578
608
  const partFiles = await findJsonFiles(partRoot);
579
609
  const parts = [];
580
610
  for (const file of partFiles) {
581
611
  const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
582
- if (parsed?.type === "text" && typeof parsed.text === "string") {
612
+ if (parsed) {
583
613
  parts.push(parsed);
584
614
  }
585
615
  }
616
+ return parts.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right));
617
+ }
618
+ function readOpenCodeTextFromParts(parts) {
586
619
  return parts
587
- .sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
620
+ .filter((part) => part.type === "text" && typeof part.text === "string")
588
621
  .map((part) => part.text?.trim() ?? "")
589
622
  .filter(Boolean)
590
623
  .join("\n\n");
@@ -669,6 +702,11 @@ export class OpenCodeAgentClient {
669
702
  this.logger = logger.child({ module: "agent", provider: "opencode" });
670
703
  this.runtimeSettings = runtimeSettings;
671
704
  this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
705
+ this.recovery = deps.recovery ?? {
706
+ timeoutMs: OPENCODE_EOF_RECOVERY_TIMEOUT_MS,
707
+ pollIntervalMs: OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS,
708
+ livenessMs: OPENCODE_EOF_RECOVERY_LIVENESS_MS,
709
+ };
672
710
  this.runtime =
673
711
  deps.runtime ??
674
712
  new ProductionOpenCodeRuntime(OpenCodeServerManager.getInstance(this.logger, runtimeSettings));
@@ -691,7 +729,7 @@ export class OpenCodeAgentClient {
691
729
  throw new Error("OpenCode session creation returned no data");
692
730
  }
693
731
  await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
694
- return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger, new Map(this.modelContextWindows), acquisition.release, options?.persistSession);
732
+ return new OpenCodeAgentSession(openCodeConfig, client, session.id, this.logger, this.storageRoot, new Map(this.modelContextWindows), acquisition.release, options?.persistSession, this.recovery);
695
733
  }
696
734
  catch (error) {
697
735
  acquisition.release();
@@ -717,7 +755,7 @@ export class OpenCodeAgentClient {
717
755
  });
718
756
  try {
719
757
  await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
720
- return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger, new Map(this.modelContextWindows), acquisition.release);
758
+ return new OpenCodeAgentSession(openCodeConfig, client, handle.sessionId, this.logger, this.storageRoot, new Map(this.modelContextWindows), acquisition.release, undefined, this.recovery);
721
759
  }
722
760
  catch (error) {
723
761
  acquisition.release();
@@ -1554,13 +1592,31 @@ function createDeferred() {
1554
1592
  });
1555
1593
  return { promise, resolve, reject };
1556
1594
  }
1595
+ const OPENCODE_TRACE_ENABLED = process.env.PASEO_OPENCODE_TRACE === "1";
1596
+ function traceOpenCode(tag, data = {}) {
1597
+ if (!OPENCODE_TRACE_ENABLED)
1598
+ return;
1599
+ const line = JSON.stringify({ ts: new Date().toISOString(), tag, ...data }, (_k, v) => {
1600
+ if (v instanceof Error)
1601
+ return { name: v.name, message: v.message, stack: v.stack };
1602
+ if (typeof v === "bigint")
1603
+ return v.toString();
1604
+ return v;
1605
+ });
1606
+ process.stderr.write(`[opencode-trace] ${line}\n`);
1607
+ }
1557
1608
  class OpenCodeAgentSession {
1558
- constructor(config, client, sessionId, logger, modelContextWindowsByModelKey = new Map(), releaseServer, persistSession = true) {
1609
+ constructor(config, client, sessionId, logger, storageRoot, modelContextWindowsByModelKey = new Map(), releaseServer, persistSession = true, recovery = {
1610
+ timeoutMs: OPENCODE_EOF_RECOVERY_TIMEOUT_MS,
1611
+ pollIntervalMs: OPENCODE_EOF_RECOVERY_POLL_INTERVAL_MS,
1612
+ livenessMs: OPENCODE_EOF_RECOVERY_LIVENESS_MS,
1613
+ }) {
1559
1614
  this.provider = "opencode";
1560
1615
  this.capabilities = OPENCODE_CAPABILITIES;
1561
1616
  this.currentMode = "default";
1562
1617
  this.pendingPermissions = new Map();
1563
1618
  this.abortController = null;
1619
+ this.pendingAbortPromise = null;
1564
1620
  this.accumulatedUsage = {};
1565
1621
  this.mcpConfigured = false;
1566
1622
  this.mcpSetupPromise = null;
@@ -1581,14 +1637,25 @@ class OpenCodeAgentSession {
1581
1637
  this.subAgentCallIdByChildSessionId = new Map();
1582
1638
  this.pendingChildToolPartsBySessionId = new Map();
1583
1639
  this.deletedFromProvider = false;
1640
+ this.foregroundAssistantMessageEmitted = false;
1641
+ this.foregroundAssistantText = "";
1642
+ this.foregroundUsageUpdated = false;
1643
+ this.foregroundKnownMessageIds = new Set();
1644
+ this.foregroundEmittedQuestionIds = new Set();
1645
+ this.foregroundEmittedPermissionIds = new Set();
1646
+ this.foregroundEmittedReasoningTextLengthByPartId = new Map();
1647
+ this.foregroundEmittedToolCallSignatureByCallId = new Map();
1648
+ this.foregroundTurnStartedAt = null;
1584
1649
  this.config = config;
1585
1650
  this.client = client;
1586
1651
  this.sessionId = sessionId;
1587
1652
  this.logger = logger;
1653
+ this.storageRoot = storageRoot;
1588
1654
  this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
1589
1655
  this.currentMode = normalizeOpenCodeModeId(config.modeId);
1590
1656
  this.releaseServer = releaseServer ?? null;
1591
1657
  this.persistSession = persistSession;
1658
+ this.recovery = recovery;
1592
1659
  this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(config.model);
1593
1660
  }
1594
1661
  get id() {
@@ -1626,22 +1693,65 @@ class OpenCodeAgentSession {
1626
1693
  const turnId = this.activeForegroundTurnId;
1627
1694
  const turnAbortController = this.abortController;
1628
1695
  turnAbortController?.abort();
1629
- await this.client.session.abort({
1630
- sessionID: this.sessionId,
1631
- directory: this.config.cwd,
1696
+ // COMPAT(opencodeSlowAbort): OpenCode 1.14.42+ blocks session.abort until
1697
+ // the running tool actually stops, which can be tens of seconds for
1698
+ // long-running tools. Cap the wait so the user-visible cancel lands
1699
+ // quickly while still giving OpenCode a chance to confirm the abort
1700
+ // cleanly. Drop the timeout once upstream returns abort acknowledgement
1701
+ // before tool teardown.
1702
+ const abortPromise = this.beginSessionAbort(turnId, "interrupt");
1703
+ await withTimeout(abortPromise, 2000, "OpenCode session.abort").catch((error) => {
1704
+ this.logger.warn({ err: error, sessionId: this.sessionId, turnId }, "OpenCode session.abort exceeded the cancel cap; proceeding with local cancel");
1632
1705
  });
1633
1706
  if (turnId) {
1634
1707
  this.finishForegroundTurn({ type: "turn_canceled", provider: "opencode", reason: "interrupted" }, turnId);
1635
1708
  }
1636
1709
  }
1710
+ beginSessionAbort(turnId, reason) {
1711
+ const abortPromise = this.client.session
1712
+ .abort({
1713
+ sessionID: this.sessionId,
1714
+ directory: this.config.cwd,
1715
+ })
1716
+ .then(() => undefined)
1717
+ .catch((error) => {
1718
+ this.logger.warn({ err: error, sessionId: this.sessionId, turnId, reason }, "OpenCode session.abort rejected");
1719
+ });
1720
+ const trackedAbortPromise = abortPromise.finally(() => {
1721
+ if (this.pendingAbortPromise === trackedAbortPromise) {
1722
+ this.pendingAbortPromise = null;
1723
+ }
1724
+ });
1725
+ this.pendingAbortPromise = trackedAbortPromise;
1726
+ return trackedAbortPromise;
1727
+ }
1728
+ async awaitPendingAbortBeforeStartingTurn() {
1729
+ const pendingAbortPromise = this.pendingAbortPromise;
1730
+ if (!pendingAbortPromise) {
1731
+ return;
1732
+ }
1733
+ await withTimeout(pendingAbortPromise, OPENCODE_PENDING_ABORT_START_TIMEOUT_MS, "OpenCode pending session.abort").catch((error) => {
1734
+ this.logger.warn({ err: error, sessionId: this.sessionId }, "OpenCode session.abort was still pending before starting the next turn");
1735
+ });
1736
+ }
1637
1737
  async startTurn(prompt, options) {
1638
1738
  if (this.activeForegroundTurnId) {
1639
1739
  throw new Error("A foreground turn is already active");
1640
1740
  }
1741
+ await this.awaitPendingAbortBeforeStartingTurn();
1742
+ this.foregroundTurnStartedAt = Date.now();
1641
1743
  this.runningToolCalls.clear();
1642
1744
  this.subAgentsByCallId.clear();
1643
1745
  this.subAgentCallIdByChildSessionId.clear();
1644
1746
  this.pendingChildToolPartsBySessionId.clear();
1747
+ this.foregroundAssistantMessageEmitted = false;
1748
+ this.foregroundAssistantText = "";
1749
+ this.foregroundUsageUpdated = false;
1750
+ this.foregroundEmittedQuestionIds.clear();
1751
+ this.foregroundEmittedPermissionIds.clear();
1752
+ this.foregroundEmittedReasoningTextLengthByPartId.clear();
1753
+ this.foregroundEmittedToolCallSignatureByCallId.clear();
1754
+ this.foregroundKnownMessageIds = await this.readPersistedSessionMessageIds();
1645
1755
  const turnAbortController = new AbortController();
1646
1756
  this.abortController = turnAbortController;
1647
1757
  await this.ensureMcpServersConfigured();
@@ -1750,6 +1860,14 @@ class OpenCodeAgentSession {
1750
1860
  // SDK input validation) is caught alongside async rejections. A plain
1751
1861
  // `.then().catch()` chain would let a sync throw escape unhandled.
1752
1862
  void (async () => {
1863
+ traceOpenCode("promptAsync.start", {
1864
+ turnId,
1865
+ sessionId: this.sessionId,
1866
+ model,
1867
+ effectiveMode,
1868
+ effectiveVariant,
1869
+ partTypes: parts.map((p) => p.type),
1870
+ });
1753
1871
  try {
1754
1872
  const promptResponse = await this.client.session.promptAsync({
1755
1873
  sessionID: this.sessionId,
@@ -1768,6 +1886,12 @@ class OpenCodeAgentSession {
1768
1886
  ...(effectiveMode ? { agent: effectiveMode } : {}),
1769
1887
  ...(effectiveVariant ? { variant: effectiveVariant } : {}),
1770
1888
  });
1889
+ traceOpenCode("promptAsync.response", {
1890
+ turnId,
1891
+ hasError: promptResponse.error !== undefined,
1892
+ error: promptResponse.error,
1893
+ data: promptResponse.data,
1894
+ });
1771
1895
  if (promptResponse.error) {
1772
1896
  this.finishForegroundTurn({
1773
1897
  type: "turn_failed",
@@ -1777,6 +1901,12 @@ class OpenCodeAgentSession {
1777
1901
  }
1778
1902
  }
1779
1903
  catch (error) {
1904
+ traceOpenCode("promptAsync.throw", {
1905
+ turnId,
1906
+ error: error instanceof Error
1907
+ ? { name: error.name, message: error.message, stack: error.stack }
1908
+ : String(error),
1909
+ });
1780
1910
  this.finishForegroundTurn({
1781
1911
  type: "turn_failed",
1782
1912
  provider: "opencode",
@@ -1794,16 +1924,39 @@ class OpenCodeAgentSession {
1794
1924
  };
1795
1925
  }
1796
1926
  async consumeEventStream(turnId, turnAbortController, subscriptionReady) {
1927
+ traceOpenCode("subscribe.start", { turnId, sessionId: this.sessionId, cwd: this.config.cwd });
1797
1928
  try {
1798
- const result = await this.client.event.subscribe({ directory: this.config.cwd }, { signal: turnAbortController.signal, sseMaxRetryAttempts: 0 });
1929
+ const result = await this.client.event.subscribe({ directory: this.config.cwd }, { signal: turnAbortController.signal });
1930
+ traceOpenCode("subscribe.ready", { turnId, sessionId: this.sessionId });
1799
1931
  subscriptionReady.resolve();
1932
+ let eventCount = 0;
1800
1933
  for await (const event of result.stream) {
1934
+ eventCount += 1;
1935
+ traceOpenCode("event.raw", {
1936
+ turnId,
1937
+ n: eventCount,
1938
+ type: event.type,
1939
+ properties: event.properties,
1940
+ });
1801
1941
  if (turnAbortController.signal.aborted || this.activeForegroundTurnId !== turnId) {
1942
+ traceOpenCode("event.skip", {
1943
+ turnId,
1944
+ n: eventCount,
1945
+ aborted: turnAbortController.signal.aborted,
1946
+ activeTurnId: this.activeForegroundTurnId,
1947
+ });
1802
1948
  break;
1803
1949
  }
1804
1950
  const translated = await this.translateEvent(event);
1951
+ traceOpenCode("event.translated", {
1952
+ turnId,
1953
+ n: eventCount,
1954
+ count: translated.length,
1955
+ types: translated.map((t) => t.type),
1956
+ });
1805
1957
  for (const e of translated) {
1806
1958
  if (this.activeForegroundTurnId !== turnId) {
1959
+ traceOpenCode("event.translated.skip-active", { turnId, type: e.type });
1807
1960
  return;
1808
1961
  }
1809
1962
  if (e.type === "timeline" && e.item.type === "tool_call") {
@@ -1811,13 +1964,26 @@ class OpenCodeAgentSession {
1811
1964
  }
1812
1965
  const terminalEvent = toTerminalTurnEvent(e);
1813
1966
  if (terminalEvent) {
1967
+ traceOpenCode("event.terminal", { turnId, type: terminalEvent.type });
1814
1968
  this.finishForegroundTurn(terminalEvent, turnId);
1815
1969
  return;
1816
1970
  }
1817
1971
  this.notifySubscribers(e, turnId);
1818
1972
  }
1819
1973
  }
1974
+ traceOpenCode("stream.eof", {
1975
+ turnId,
1976
+ eventCount,
1977
+ aborted: turnAbortController.signal.aborted,
1978
+ stillActive: this.activeForegroundTurnId === turnId,
1979
+ });
1820
1980
  if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
1981
+ const recovered = await this.recoverTurnFromPersistedCompletion(turnId);
1982
+ traceOpenCode("recovery.result", { turnId, recovered });
1983
+ if (recovered) {
1984
+ return;
1985
+ }
1986
+ traceOpenCode("turn.fail.eof", { turnId, eventCount });
1821
1987
  this.finishForegroundTurn({
1822
1988
  type: "turn_failed",
1823
1989
  provider: "opencode",
@@ -1826,6 +1992,10 @@ class OpenCodeAgentSession {
1826
1992
  }
1827
1993
  }
1828
1994
  catch (error) {
1995
+ traceOpenCode("subscribe.error", {
1996
+ turnId,
1997
+ error: error instanceof Error ? { name: error.name, message: error.message } : String(error),
1998
+ });
1829
1999
  subscriptionReady.reject(error);
1830
2000
  if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
1831
2001
  this.finishForegroundTurn({
@@ -1848,7 +2018,348 @@ class OpenCodeAgentSession {
1848
2018
  }
1849
2019
  }
1850
2020
  }
2021
+ async recoverTurnFromPersistedCompletion(turnId) {
2022
+ traceOpenCode("recovery.start", {
2023
+ turnId,
2024
+ foregroundTurnStartedAt: this.foregroundTurnStartedAt,
2025
+ knownMessageIds: Array.from(this.foregroundKnownMessageIds),
2026
+ sessionId: this.sessionId,
2027
+ timeoutMs: this.recovery.timeoutMs,
2028
+ pollIntervalMs: this.recovery.pollIntervalMs,
2029
+ });
2030
+ const startedAt = this.foregroundTurnStartedAt;
2031
+ if (startedAt === null) {
2032
+ traceOpenCode("recovery.no-start-time", { turnId });
2033
+ return false;
2034
+ }
2035
+ const completionDeadline = Date.now() + this.recovery.timeoutMs;
2036
+ const livenessDeadline = Date.now() + this.recovery.livenessMs;
2037
+ let attempt = 0;
2038
+ let observedActivity = false;
2039
+ while (true) {
2040
+ if (this.activeForegroundTurnId !== turnId) {
2041
+ traceOpenCode("recovery.cancelled", { turnId, attempt });
2042
+ return true;
2043
+ }
2044
+ attempt += 1;
2045
+ const emittedPromptIds = await this.pollPendingQuestionsAndPermissions(turnId);
2046
+ if (emittedPromptIds > 0) {
2047
+ observedActivity = true;
2048
+ }
2049
+ const outcome = await this.fetchAssistantOutcomeFromMessagesApi(startedAt);
2050
+ traceOpenCode("recovery.poll", {
2051
+ turnId,
2052
+ attempt,
2053
+ kind: outcome?.kind ?? "none",
2054
+ messageId: outcome?.messageId,
2055
+ emittedPromptIds,
2056
+ });
2057
+ if (outcome?.kind === "failure") {
2058
+ this.foregroundKnownMessageIds.add(outcome.messageId);
2059
+ this.finishForegroundTurn({
2060
+ type: "turn_failed",
2061
+ provider: "opencode",
2062
+ error: outcome.error,
2063
+ }, turnId);
2064
+ return true;
2065
+ }
2066
+ if (outcome?.kind === "completion") {
2067
+ this.emitIncrementalAssistantParts(outcome.parts, turnId);
2068
+ return this.applyRecoveredAssistantCompletion(outcome, turnId);
2069
+ }
2070
+ if (outcome?.kind === "in-progress") {
2071
+ observedActivity = true;
2072
+ this.emitIncrementalAssistantParts(outcome.parts, turnId);
2073
+ }
2074
+ const now = Date.now();
2075
+ if (!observedActivity && now >= livenessDeadline) {
2076
+ const deferred = await this.deferForPendingPermissionOrFailRecoveredTurnAfterCap(turnId, attempt, "liveness");
2077
+ if (deferred) {
2078
+ continue;
2079
+ }
2080
+ return true;
2081
+ }
2082
+ if (now >= completionDeadline) {
2083
+ const deferred = await this.deferForPendingPermissionOrFailRecoveredTurnAfterCap(turnId, attempt, "completion");
2084
+ if (deferred) {
2085
+ continue;
2086
+ }
2087
+ return true;
2088
+ }
2089
+ const waitMs = Math.min(this.recovery.pollIntervalMs, completionDeadline - now);
2090
+ await new Promise((resolve) => setTimeout(resolve, waitMs));
2091
+ }
2092
+ }
2093
+ async deferForPendingPermissionOrFailRecoveredTurnAfterCap(turnId, attempt, cap) {
2094
+ if (this.pendingPermissions.size > 0) {
2095
+ // A pending OpenCode question/permission means the turn is blocked on
2096
+ // user input, not dead. Keep polling until the user response lets the
2097
+ // assistant finish or the turn is canceled.
2098
+ traceOpenCode(`recovery.${cap}-deferred-for-permission`, {
2099
+ turnId,
2100
+ attempt,
2101
+ pendingPermissionIds: Array.from(this.pendingPermissions.keys()),
2102
+ });
2103
+ await new Promise((resolve) => setTimeout(resolve, this.recovery.pollIntervalMs));
2104
+ return true;
2105
+ }
2106
+ traceOpenCode(cap === "liveness" ? "recovery.liveness-exhausted" : "recovery.exhausted", {
2107
+ turnId,
2108
+ attempt,
2109
+ });
2110
+ await this.failRecoveredTurnAfterCap(turnId, cap);
2111
+ return false;
2112
+ }
2113
+ async failRecoveredTurnAfterCap(turnId, cap) {
2114
+ await this.abortOpenCodeSessionAfterRecoveryCap(turnId, cap);
2115
+ this.finishForegroundTurn({
2116
+ type: "turn_failed",
2117
+ provider: "opencode",
2118
+ error: "OpenCode event stream ended before the turn reached a terminal state",
2119
+ }, turnId);
2120
+ }
2121
+ async abortOpenCodeSessionAfterRecoveryCap(turnId, cap) {
2122
+ const abortPromise = this.beginSessionAbort(turnId, `recovery-${cap}`);
2123
+ await withTimeout(abortPromise, OPENCODE_RECOVERY_ABORT_TIMEOUT_MS, "OpenCode session.abort").catch((error) => {
2124
+ this.logger.warn({ err: error, sessionId: this.sessionId, turnId, cap }, "OpenCode session.abort exceeded the EOF recovery cap");
2125
+ });
2126
+ }
2127
+ async pollPendingQuestionsAndPermissions(turnId) {
2128
+ const [questionsResponse, permissionsResponse] = await Promise.all([
2129
+ Promise.resolve()
2130
+ .then(() => this.client.question.list({ directory: this.config.cwd }))
2131
+ .catch((error) => {
2132
+ traceOpenCode("recovery.question-list.throw", {
2133
+ turnId,
2134
+ error: error instanceof Error
2135
+ ? { name: error.name, message: error.message, stack: error.stack }
2136
+ : String(error),
2137
+ });
2138
+ return null;
2139
+ }),
2140
+ Promise.resolve()
2141
+ .then(() => this.client.permission.list({ directory: this.config.cwd }))
2142
+ .catch((error) => {
2143
+ traceOpenCode("recovery.permission-list.throw", {
2144
+ turnId,
2145
+ error: error instanceof Error
2146
+ ? { name: error.name, message: error.message, stack: error.stack }
2147
+ : String(error),
2148
+ });
2149
+ return null;
2150
+ }),
2151
+ ]);
2152
+ if (this.activeForegroundTurnId !== turnId)
2153
+ return 0;
2154
+ let emitted = 0;
2155
+ for (const question of questionsResponse?.data ?? []) {
2156
+ if (question.sessionID !== this.sessionId)
2157
+ continue;
2158
+ if (this.foregroundEmittedQuestionIds.has(question.id))
2159
+ continue;
2160
+ this.foregroundEmittedQuestionIds.add(question.id);
2161
+ emitted += 1;
2162
+ const synthetic = {
2163
+ id: question.id,
2164
+ type: "question.asked",
2165
+ properties: question,
2166
+ };
2167
+ const events = await this.translateEvent(synthetic);
2168
+ for (const event of events) {
2169
+ this.notifySubscribers(event, turnId);
2170
+ }
2171
+ }
2172
+ for (const permission of permissionsResponse?.data ?? []) {
2173
+ if (permission.sessionID !== this.sessionId)
2174
+ continue;
2175
+ if (this.foregroundEmittedPermissionIds.has(permission.id))
2176
+ continue;
2177
+ this.foregroundEmittedPermissionIds.add(permission.id);
2178
+ emitted += 1;
2179
+ const synthetic = {
2180
+ id: permission.id,
2181
+ type: "permission.asked",
2182
+ properties: permission,
2183
+ };
2184
+ const events = await this.translateEvent(synthetic);
2185
+ for (const event of events) {
2186
+ this.notifySubscribers(event, turnId);
2187
+ }
2188
+ }
2189
+ return emitted;
2190
+ }
2191
+ async fetchAssistantOutcomeFromMessagesApi(startedAt) {
2192
+ const response = await Promise.resolve()
2193
+ .then(() => this.client.session.messages({
2194
+ sessionID: this.sessionId,
2195
+ directory: this.config.cwd,
2196
+ }))
2197
+ .catch((error) => {
2198
+ traceOpenCode("recovery.messages.throw", {
2199
+ error: error instanceof Error
2200
+ ? { name: error.name, message: error.message, stack: error.stack }
2201
+ : String(error),
2202
+ });
2203
+ return null;
2204
+ });
2205
+ if (response === null) {
2206
+ return null;
2207
+ }
2208
+ if (response.error || !response.data) {
2209
+ return null;
2210
+ }
2211
+ for (let index = response.data.length - 1; index >= 0; index -= 1) {
2212
+ const item = response.data[index];
2213
+ if (!item)
2214
+ continue;
2215
+ const info = item.info;
2216
+ if (info.role !== "assistant")
2217
+ continue;
2218
+ if (this.foregroundKnownMessageIds.has(info.id))
2219
+ continue;
2220
+ if (typeof info.time?.created === "number" && info.time.created < startedAt)
2221
+ continue;
2222
+ if (info.error) {
2223
+ return {
2224
+ kind: "failure",
2225
+ messageId: info.id,
2226
+ error: formatOpenCodeAssistantErrorMessage(info.error),
2227
+ };
2228
+ }
2229
+ if (typeof info.time?.completed !== "number") {
2230
+ return { kind: "in-progress", messageId: info.id, parts: item.parts };
2231
+ }
2232
+ let text = item.parts
2233
+ .filter((part) => part.type === "text")
2234
+ .map((part) => (part.text ?? "").trim())
2235
+ .filter((part) => part.length > 0)
2236
+ .join("\n\n");
2237
+ if (!text) {
2238
+ text = stringifyStructuredAssistantMessage(info.structured) ?? "";
2239
+ }
2240
+ if (!text)
2241
+ continue;
2242
+ const usage = {};
2243
+ mergeOpenCodeStepFinishUsage(usage, { cost: info.cost, tokens: info.tokens });
2244
+ return { kind: "completion", messageId: info.id, text, parts: item.parts, usage };
2245
+ }
2246
+ return null;
2247
+ }
2248
+ emitIncrementalAssistantParts(parts, turnId) {
2249
+ for (const part of parts) {
2250
+ if (part.type === "reasoning" && part.text) {
2251
+ const emittedTextLength = this.foregroundEmittedReasoningTextLengthByPartId.get(part.id) ?? 0;
2252
+ if (part.text.length <= emittedTextLength)
2253
+ continue;
2254
+ const text = part.text.slice(emittedTextLength);
2255
+ this.foregroundEmittedReasoningTextLengthByPartId.set(part.id, part.text.length);
2256
+ this.notifySubscribers({
2257
+ type: "timeline",
2258
+ provider: "opencode",
2259
+ item: { type: "reasoning", text },
2260
+ }, turnId);
2261
+ continue;
2262
+ }
2263
+ if (part.type !== "tool")
2264
+ continue;
2265
+ const parsedToolPart = OpencodeToolPartToTimelineItemSchema.safeParse(part);
2266
+ if (!parsedToolPart.success || !parsedToolPart.data)
2267
+ continue;
2268
+ const callId = parsedToolPart.data.callId;
2269
+ const signature = this.createRecoveredToolCallSignature(part, parsedToolPart.data);
2270
+ const lastSignature = this.foregroundEmittedToolCallSignatureByCallId.get(callId);
2271
+ if (lastSignature === signature)
2272
+ continue;
2273
+ this.foregroundEmittedToolCallSignatureByCallId.set(callId, signature);
2274
+ this.trackToolCall(parsedToolPart.data);
2275
+ this.notifySubscribers({
2276
+ type: "timeline",
2277
+ provider: "opencode",
2278
+ item: parsedToolPart.data,
2279
+ }, turnId);
2280
+ }
2281
+ }
2282
+ createRecoveredToolCallSignature(part, item) {
2283
+ const state = part
2284
+ .state;
2285
+ return JSON.stringify([
2286
+ item.callId,
2287
+ item.status,
2288
+ state?.input ?? null,
2289
+ state?.output ?? null,
2290
+ state?.error ?? null,
2291
+ ]);
2292
+ }
2293
+ applyRecoveredAssistantCompletion(completion, turnId) {
2294
+ if (this.activeForegroundTurnId !== turnId) {
2295
+ return false;
2296
+ }
2297
+ this.foregroundKnownMessageIds.add(completion.messageId);
2298
+ this.logger.warn({ sessionId: this.sessionId, turnId }, "Recovered OpenCode turn completion via messages API after SSE EOF");
2299
+ const recoveryText = this.resolvePersistedAssistantRecoveryText(completion.text);
2300
+ if (recoveryText === null) {
2301
+ return false;
2302
+ }
2303
+ if (recoveryText.length > 0) {
2304
+ this.notifySubscribers({
2305
+ type: "timeline",
2306
+ provider: "opencode",
2307
+ item: { type: "assistant_message", text: recoveryText },
2308
+ }, turnId);
2309
+ this.foregroundAssistantMessageEmitted = true;
2310
+ }
2311
+ if (hasNormalizedOpenCodeUsage(completion.usage) && !this.foregroundUsageUpdated) {
2312
+ this.accumulatedUsage = {
2313
+ ...this.accumulatedUsage,
2314
+ ...completion.usage,
2315
+ };
2316
+ this.notifySubscribers({
2317
+ type: "usage_updated",
2318
+ provider: "opencode",
2319
+ usage: { ...this.accumulatedUsage },
2320
+ }, turnId);
2321
+ this.foregroundUsageUpdated = true;
2322
+ }
2323
+ this.finishForegroundTurn({
2324
+ type: "turn_completed",
2325
+ provider: "opencode",
2326
+ usage: hasNormalizedOpenCodeUsage(this.accumulatedUsage)
2327
+ ? { ...this.accumulatedUsage }
2328
+ : undefined,
2329
+ }, turnId);
2330
+ return true;
2331
+ }
2332
+ resolvePersistedAssistantRecoveryText(completedText) {
2333
+ if (!this.foregroundAssistantMessageEmitted) {
2334
+ return completedText;
2335
+ }
2336
+ if (completedText === this.foregroundAssistantText) {
2337
+ return "";
2338
+ }
2339
+ return completedText.startsWith(this.foregroundAssistantText)
2340
+ ? completedText.slice(this.foregroundAssistantText.length)
2341
+ : null;
2342
+ }
2343
+ async readPersistedSessionMessageIds() {
2344
+ const messageRoot = path.join(this.storageRoot, "message", this.sessionId);
2345
+ const messageFiles = await findJsonFiles(messageRoot);
2346
+ const messageIds = new Set();
2347
+ for (const file of messageFiles) {
2348
+ const parsed = await readJsonFile(file, OpenCodeStoredMessageSchema);
2349
+ if (parsed?.sessionID === this.sessionId) {
2350
+ messageIds.add(parsed.id);
2351
+ }
2352
+ }
2353
+ return messageIds;
2354
+ }
1851
2355
  finishForegroundTurn(event, turnId) {
2356
+ traceOpenCode("finishForegroundTurn", {
2357
+ turnId,
2358
+ activeTurnId: this.activeForegroundTurnId,
2359
+ type: event.type,
2360
+ error: event.type === "turn_failed" ? event.error : undefined,
2361
+ reason: event.type === "turn_canceled" ? event.reason : undefined,
2362
+ });
1852
2363
  if (this.activeForegroundTurnId !== turnId) {
1853
2364
  return;
1854
2365
  }
@@ -1858,6 +2369,7 @@ class OpenCodeAgentSession {
1858
2369
  else {
1859
2370
  this.runningToolCalls.clear();
1860
2371
  }
2372
+ this.foregroundTurnStartedAt = null;
1861
2373
  this.activeForegroundTurnId = null;
1862
2374
  // Abort the SSE connection so the SDK tears down the underlying fetch.
1863
2375
  this.abortController?.abort();
@@ -1896,6 +2408,13 @@ class OpenCodeAgentSession {
1896
2408
  }
1897
2409
  notifySubscribers(event, turnIdOverride) {
1898
2410
  const turnId = turnIdOverride ?? this.activeForegroundTurnId;
2411
+ if (event.type === "timeline" && event.item.type === "assistant_message") {
2412
+ this.foregroundAssistantMessageEmitted = true;
2413
+ this.foregroundAssistantText += event.item.text;
2414
+ }
2415
+ if (event.type === "usage_updated") {
2416
+ this.foregroundUsageUpdated = true;
2417
+ }
1899
2418
  const tagged = turnId ? { ...event, turnId } : event;
1900
2419
  for (const callback of this.subscribers) {
1901
2420
  try {