@getpaseo/server 0.1.71 → 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 (126) 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/agent-response-loop.d.ts.map +1 -1
  9. package/dist/server/server/agent/agent-response-loop.js +2 -1
  10. package/dist/server/server/agent/agent-response-loop.js.map +1 -1
  11. package/dist/server/server/agent/create-agent-title.d.ts +8 -0
  12. package/dist/server/server/agent/create-agent-title.d.ts.map +1 -0
  13. package/dist/server/server/agent/create-agent-title.js +29 -0
  14. package/dist/server/server/agent/create-agent-title.js.map +1 -0
  15. package/dist/server/server/agent/import-sessions.d.ts +20 -1
  16. package/dist/server/server/agent/import-sessions.d.ts.map +1 -1
  17. package/dist/server/server/agent/import-sessions.js +101 -0
  18. package/dist/server/server/agent/import-sessions.js.map +1 -1
  19. package/dist/server/server/agent/provider-launch-config.d.ts.map +1 -1
  20. package/dist/server/server/agent/provider-launch-config.js +1 -0
  21. package/dist/server/server/agent/provider-launch-config.js.map +1 -1
  22. package/dist/server/server/agent/provider-registry.d.ts.map +1 -1
  23. package/dist/server/server/agent/provider-registry.js +2 -1
  24. package/dist/server/server/agent/provider-registry.js.map +1 -1
  25. package/dist/server/server/agent/providers/codex/app-server-transport.d.ts +25 -0
  26. package/dist/server/server/agent/providers/codex/app-server-transport.d.ts.map +1 -0
  27. package/dist/server/server/agent/providers/codex/app-server-transport.js +183 -0
  28. package/dist/server/server/agent/providers/codex/app-server-transport.js.map +1 -0
  29. package/dist/server/server/agent/providers/codex/test-utils/fake-app-server.d.ts +31 -0
  30. package/dist/server/server/agent/providers/codex/test-utils/fake-app-server.d.ts.map +1 -0
  31. package/dist/server/server/agent/providers/codex/test-utils/fake-app-server.js +172 -0
  32. package/dist/server/server/agent/providers/codex/test-utils/fake-app-server.js.map +1 -0
  33. package/dist/server/server/agent/providers/codex-app-server-agent.d.ts +1 -21
  34. package/dist/server/server/agent/providers/codex-app-server-agent.d.ts.map +1 -1
  35. package/dist/server/server/agent/providers/codex-app-server-agent.js +2 -171
  36. package/dist/server/server/agent/providers/codex-app-server-agent.js.map +1 -1
  37. package/dist/server/server/agent/providers/opencode/runtime.d.ts +27 -0
  38. package/dist/server/server/agent/providers/opencode/runtime.d.ts.map +1 -0
  39. package/dist/server/server/agent/providers/opencode/runtime.js +5 -0
  40. package/dist/server/server/agent/providers/opencode/runtime.js.map +1 -0
  41. package/dist/server/server/agent/providers/opencode/server-manager.d.ts +55 -0
  42. package/dist/server/server/agent/providers/opencode/server-manager.d.ts.map +1 -0
  43. package/dist/server/server/agent/providers/opencode/server-manager.js +255 -0
  44. package/dist/server/server/agent/providers/opencode/server-manager.js.map +1 -0
  45. package/dist/server/server/agent/providers/opencode/test-server-manager.d.ts +22 -0
  46. package/dist/server/server/agent/providers/opencode/test-server-manager.d.ts.map +1 -0
  47. package/dist/server/server/agent/providers/opencode/test-server-manager.js +28 -0
  48. package/dist/server/server/agent/providers/opencode/test-server-manager.js.map +1 -0
  49. package/dist/server/server/agent/providers/opencode/test-utils/test-opencode-runtime.d.ts +75 -0
  50. package/dist/server/server/agent/providers/opencode/test-utils/test-opencode-runtime.d.ts.map +1 -0
  51. package/dist/server/server/agent/providers/opencode/test-utils/test-opencode-runtime.js +169 -0
  52. package/dist/server/server/agent/providers/opencode/test-utils/test-opencode-runtime.js.map +1 -0
  53. package/dist/server/server/agent/providers/opencode-agent.d.ts +38 -36
  54. package/dist/server/server/agent/providers/opencode-agent.d.ts.map +1 -1
  55. package/dist/server/server/agent/providers/opencode-agent.js +556 -267
  56. package/dist/server/server/agent/providers/opencode-agent.js.map +1 -1
  57. package/dist/server/server/agent/providers/pi-direct-agent.d.ts +8 -3
  58. package/dist/server/server/agent/providers/pi-direct-agent.d.ts.map +1 -1
  59. package/dist/server/server/agent/providers/pi-direct-agent.js +44 -34
  60. package/dist/server/server/agent/providers/pi-direct-agent.js.map +1 -1
  61. package/dist/server/server/bootstrap.d.ts +2 -0
  62. package/dist/server/server/bootstrap.d.ts.map +1 -1
  63. package/dist/server/server/bootstrap.js +45 -8
  64. package/dist/server/server/bootstrap.js.map +1 -1
  65. package/dist/server/server/checkout/status-projection.d.ts +19 -0
  66. package/dist/server/server/checkout/status-projection.d.ts.map +1 -0
  67. package/dist/server/server/checkout/status-projection.js +98 -0
  68. package/dist/server/server/checkout/status-projection.js.map +1 -0
  69. package/dist/server/server/file-explorer/service.d.ts.map +1 -1
  70. package/dist/server/server/file-explorer/service.js +84 -67
  71. package/dist/server/server/file-explorer/service.js.map +1 -1
  72. package/dist/server/server/paseo-worktree-service.d.ts +2 -1
  73. package/dist/server/server/paseo-worktree-service.d.ts.map +1 -1
  74. package/dist/server/server/paseo-worktree-service.js +30 -3
  75. package/dist/server/server/paseo-worktree-service.js.map +1 -1
  76. package/dist/server/server/push/notifications.d.ts +9 -0
  77. package/dist/server/server/push/notifications.d.ts.map +1 -0
  78. package/dist/server/server/push/notifications.js +15 -0
  79. package/dist/server/server/push/notifications.js.map +1 -0
  80. package/dist/server/server/push/push-service.d.ts +1 -2
  81. package/dist/server/server/push/push-service.d.ts.map +1 -1
  82. package/dist/server/server/relay-transport.d.ts +7 -1
  83. package/dist/server/server/relay-transport.d.ts.map +1 -1
  84. package/dist/server/server/relay-transport.js +10 -5
  85. package/dist/server/server/relay-transport.js.map +1 -1
  86. package/dist/server/server/session.d.ts +1 -17
  87. package/dist/server/server/session.d.ts.map +1 -1
  88. package/dist/server/server/session.js +24 -233
  89. package/dist/server/server/session.js.map +1 -1
  90. package/dist/server/server/websocket/runtime-metrics.d.ts +71 -0
  91. package/dist/server/server/websocket/runtime-metrics.d.ts.map +1 -0
  92. package/dist/server/server/websocket/runtime-metrics.js +148 -0
  93. package/dist/server/server/websocket/runtime-metrics.js.map +1 -0
  94. package/dist/server/server/websocket-server.d.ts +4 -21
  95. package/dist/server/server/websocket-server.d.ts.map +1 -1
  96. package/dist/server/server/websocket-server.js +28 -137
  97. package/dist/server/server/websocket-server.js.map +1 -1
  98. package/dist/server/server/workspace-directory.d.ts +0 -2
  99. package/dist/server/server/workspace-directory.d.ts.map +1 -1
  100. package/dist/server/server/workspace-directory.js +9 -26
  101. package/dist/server/server/workspace-directory.js.map +1 -1
  102. package/dist/server/shared/agent-state-bucket.d.ts +13 -0
  103. package/dist/server/shared/agent-state-bucket.d.ts.map +1 -0
  104. package/dist/server/shared/agent-state-bucket.js +41 -0
  105. package/dist/server/shared/agent-state-bucket.js.map +1 -0
  106. package/dist/server/shared/connection-offer.d.ts +6 -6
  107. package/dist/server/shared/connection-offer.js +1 -1
  108. package/dist/server/shared/connection-offer.js.map +1 -1
  109. package/dist/server/shared/git-remote.d.ts +16 -0
  110. package/dist/server/shared/git-remote.d.ts.map +1 -0
  111. package/dist/server/shared/git-remote.js +72 -0
  112. package/dist/server/shared/git-remote.js.map +1 -0
  113. package/dist/server/utils/checkout-git.d.ts +1 -0
  114. package/dist/server/utils/checkout-git.d.ts.map +1 -1
  115. package/dist/server/utils/checkout-git.js +3 -0
  116. package/dist/server/utils/checkout-git.js.map +1 -1
  117. package/dist/server/utils/github-remote.d.ts +3 -7
  118. package/dist/server/utils/github-remote.d.ts.map +1 -1
  119. package/dist/server/utils/github-remote.js +4 -70
  120. package/dist/server/utils/github-remote.js.map +1 -1
  121. package/dist/server/utils/run-git-command.d.ts.map +1 -1
  122. package/dist/server/utils/run-git-command.js +1 -0
  123. package/dist/server/utils/run-git-command.js.map +1 -1
  124. package/dist/src/server/agent/provider-launch-config.js +1 -0
  125. package/dist/src/server/agent/provider-launch-config.js.map +1 -1
  126. package/package.json +5 -5
@@ -1,19 +1,18 @@
1
1
  import { readdir, readFile } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import path from "node:path";
4
- import { createOpencodeClient, } from "@opencode-ai/sdk/v2/client";
5
- import net from "node:net";
6
- import { z } from "zod";
7
- import { createProviderEnvSpec, resolveProviderCommandPrefix, } from "../provider-launch-config.js";
8
4
  import { findExecutable, isCommandAvailable } from "../../../utils/executable.js";
9
- import { terminateWithTreeKill } from "../../../utils/tree-kill.js";
5
+ import { z } from "zod";
6
+ import { createProviderEnvSpec } from "../provider-launch-config.js";
10
7
  import { withTimeout } from "../../../utils/promise-timeout.js";
11
- import { execCommand, spawnProcess } from "../../../utils/spawn.js";
8
+ import { execCommand } from "../../../utils/spawn.js";
12
9
  import { buildToolCallDisplayModel } from "../../../shared/tool-call-display.js";
13
10
  import { mapOpencodeToolCall } from "./opencode/tool-call-mapper.js";
11
+ import { OpenCodeServerManager } from "./opencode/server-manager.js";
14
12
  import { formatDiagnosticStatus, formatProviderDiagnostic, formatProviderDiagnosticError, resolveBinaryVersion, toDiagnosticErrorMessage, } from "./diagnostic-utils.js";
15
13
  import { runProviderTurn } from "./provider-runner.js";
16
14
  import { renderPromptAttachmentAsText } from "../prompt-attachments.js";
15
+ import { createSdkOpenCodeClient, } from "./opencode/runtime.js";
17
16
  const OPENCODE_CAPABILITIES = {
18
17
  supportsStreaming: true,
19
18
  supportsSessionPersistence: true,
@@ -25,6 +24,22 @@ const OPENCODE_CAPABILITIES = {
25
24
  const OPENCODE_BUILD_MODE_ID = "build";
26
25
  const OPENCODE_FULL_ACCESS_MODE_ID = "full-access";
27
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;
28
43
  const DEFAULT_MODES = [
29
44
  {
30
45
  id: OPENCODE_BUILD_MODE_ID,
@@ -82,8 +97,6 @@ const OpenCodeStoredPartSchema = z
82
97
  .passthrough();
83
98
  const MCP_ALREADY_PRESENT_ERROR_TOKENS = ["already", "exists", "connected"];
84
99
  const OPENCODE_PROVIDER_LIST_TIMEOUT_MS = 30000;
85
- const OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS = 5000;
86
- const OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS = 1000;
87
100
  const OPENCODE_HANDLED_BUILTIN_SLASH_COMMANDS = [
88
101
  { name: "compact", description: "Compact the current session", argumentHint: "" },
89
102
  { name: "summarize", description: "Compact the current session", argumentHint: "" },
@@ -162,13 +175,6 @@ const OpencodeToolPartToTimelineItemSchema = OpencodeToolPartTimelineEnvelopeSch
162
175
  output: part.output,
163
176
  error: part.error,
164
177
  }));
165
- async function resolveOpenCodeBinary() {
166
- const found = await findExecutable("opencode");
167
- if (found) {
168
- return found;
169
- }
170
- throw new Error("OpenCode binary not found. Install OpenCode (https://github.com/opencode-ai/opencode) and ensure it is available in your shell PATH.");
171
- }
172
178
  function toOpenCodeMcpConfig(config) {
173
179
  if (config.type === "stdio") {
174
180
  return {
@@ -278,22 +284,6 @@ function isAlreadyPresentMcpError(error) {
278
284
  const normalized = toDiagnosticErrorMessage(error).toLowerCase();
279
285
  return MCP_ALREADY_PRESENT_ERROR_TOKENS.some((token) => normalized.includes(token));
280
286
  }
281
- async function findAvailablePort() {
282
- return new Promise((resolve, reject) => {
283
- const server = net.createServer();
284
- server.listen(0, () => {
285
- const address = server.address();
286
- if (address && typeof address === "object") {
287
- const port = address.port;
288
- server.close(() => resolve(port));
289
- }
290
- else {
291
- server.close(() => reject(new Error("Failed to get port")));
292
- }
293
- });
294
- server.on("error", reject);
295
- });
296
- }
297
287
  function resolvePartDedupeKey(part, partType) {
298
288
  if (part.id.trim().length > 0) {
299
289
  return `${partType}:${part.id}`;
@@ -461,6 +451,16 @@ function mergeOpenCodeStepFinishUsage(usage, part) {
461
451
  usage.totalCostUsd = (usage.totalCostUsd ?? 0) + cost;
462
452
  }
463
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
+ }
464
464
  function hasNormalizedOpenCodeUsage(usage) {
465
465
  return [
466
466
  usage.inputTokens,
@@ -600,17 +600,24 @@ async function readOpenCodeSessionTimeline(storageRoot, sessionId) {
600
600
  return timeline;
601
601
  }
602
602
  async function readOpenCodeMessageText(storageRoot, messageId) {
603
+ const parts = await readOpenCodeStoredParts(storageRoot, messageId);
604
+ return readOpenCodeTextFromParts(parts);
605
+ }
606
+ async function readOpenCodeStoredParts(storageRoot, messageId) {
603
607
  const partRoot = path.join(storageRoot, "part", messageId);
604
608
  const partFiles = await findJsonFiles(partRoot);
605
609
  const parts = [];
606
610
  for (const file of partFiles) {
607
611
  const parsed = await readJsonFile(file, OpenCodeStoredPartSchema);
608
- if (parsed?.type === "text" && typeof parsed.text === "string") {
612
+ if (parsed) {
609
613
  parts.push(parsed);
610
614
  }
611
615
  }
616
+ return parts.sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right));
617
+ }
618
+ function readOpenCodeTextFromParts(parts) {
612
619
  return parts
613
- .sort((left, right) => getOpenCodePartTimestamp(left) - getOpenCodePartTimestamp(right))
620
+ .filter((part) => part.type === "text" && typeof part.text === "string")
614
621
  .map((part) => part.text?.trim() ?? "")
615
622
  .filter(Boolean)
616
623
  .join("\n\n");
@@ -670,244 +677,45 @@ export const __openCodeInternals = {
670
677
  return OpenCodeAgentSession;
671
678
  },
672
679
  };
673
- export class OpenCodeServerManager {
674
- constructor(logger, runtimeSettings) {
675
- this.currentServer = null;
676
- this.retiredServers = new Set();
677
- this.startPromise = null;
678
- this.forcedRefreshPromise = null;
679
- this.logger = logger;
680
- this.runtimeSettings = runtimeSettings;
681
- this.runtimeSettingsKey = JSON.stringify(runtimeSettings ?? {});
680
+ class ProductionOpenCodeRuntime {
681
+ constructor(serverManager) {
682
+ this.serverManager = serverManager;
682
683
  }
683
- static getInstance(logger, runtimeSettings) {
684
- const nextSettingsKey = JSON.stringify(runtimeSettings ?? {});
685
- if (!OpenCodeServerManager.instance) {
686
- OpenCodeServerManager.instance = new OpenCodeServerManager(logger, runtimeSettings);
687
- OpenCodeServerManager.registerExitHandler();
688
- }
689
- else if (OpenCodeServerManager.instance.runtimeSettingsKey !== nextSettingsKey) {
690
- logger.warn({
691
- existingRuntimeSettings: OpenCodeServerManager.instance.runtimeSettingsKey,
692
- requestedRuntimeSettings: nextSettingsKey,
693
- }, "OpenCode server manager already initialized with different runtime settings");
694
- }
695
- return OpenCodeServerManager.instance;
684
+ async acquireServer(options) {
685
+ return this.serverManager.acquire(options);
696
686
  }
697
- static registerExitHandler() {
698
- if (OpenCodeServerManager.exitHandlerRegistered) {
699
- return;
700
- }
701
- OpenCodeServerManager.exitHandlerRegistered = true;
702
- const cleanup = () => {
703
- const instance = OpenCodeServerManager.instance;
704
- void instance?.shutdown();
705
- };
706
- process.on("exit", cleanup);
707
- process.on("SIGTERM", cleanup);
708
- process.on("SIGINT", cleanup);
709
- }
710
- async ensureRunning() {
711
- const acquisition = await this.acquire({ force: false });
712
- acquisition.release();
713
- return acquisition.server;
714
- }
715
- async acquire(options) {
716
- const server = options.force
717
- ? await this.getForcedRefreshServer()
718
- : await this.getCurrentServer();
719
- server.refCount += 1;
720
- let released = false;
721
- return {
722
- server: { port: server.port, url: server.url },
723
- release: () => {
724
- if (released) {
725
- return;
726
- }
727
- released = true;
728
- server.refCount -= 1;
729
- this.cleanupRetiredServers();
730
- },
731
- };
687
+ async ensureServerRunning() {
688
+ return this.serverManager.ensureRunning();
732
689
  }
733
- async getForcedRefreshServer() {
734
- if (this.forcedRefreshPromise) {
735
- return this.forcedRefreshPromise;
736
- }
737
- this.forcedRefreshPromise = Promise.resolve()
738
- .then(async () => {
739
- await this.rotateCurrentServer();
740
- return this.getCurrentServer();
741
- })
742
- .finally(() => {
743
- this.forcedRefreshPromise = null;
744
- });
745
- return this.forcedRefreshPromise;
746
- }
747
- async getCurrentServer() {
748
- if (this.startPromise) {
749
- return this.startPromise;
750
- }
751
- if (this.currentServer && !this.currentServer.process.killed) {
752
- return this.currentServer;
753
- }
754
- this.startPromise = this.startServer();
755
- try {
756
- const result = await this.startPromise;
757
- if (!result.retired) {
758
- this.currentServer = result;
759
- }
760
- return result;
761
- }
762
- finally {
763
- this.startPromise = null;
764
- }
765
- }
766
- async rotateCurrentServer() {
767
- const existing = this.currentServer;
768
- if (existing) {
769
- existing.retired = true;
770
- this.retiredServers.add(existing);
771
- this.currentServer = null;
772
- this.cleanupRetiredServers();
773
- }
774
- if (this.startPromise) {
775
- const pending = await this.startPromise;
776
- pending.retired = true;
777
- this.retiredServers.add(pending);
778
- this.currentServer = null;
779
- this.cleanupRetiredServers();
780
- }
781
- }
782
- async startServer() {
783
- const port = await findAvailablePort();
784
- const url = `http://127.0.0.1:${port}`;
785
- const launchPrefix = await resolveProviderCommandPrefix(this.runtimeSettings?.command, resolveOpenCodeBinary);
786
- return new Promise((resolve, reject) => {
787
- const serverProcess = spawnProcess(launchPrefix.command, [...launchPrefix.args, "serve", "--port", String(port)], {
788
- detached: process.platform !== "win32",
789
- stdio: ["ignore", "pipe", "pipe"],
790
- ...createProviderEnvSpec({ runtimeSettings: this.runtimeSettings }),
791
- });
792
- let started = false;
793
- let stderrBuffer = "";
794
- let stdoutBuffer = "";
795
- const STARTUP_BUFFER_CAP = 8192;
796
- const appendCapped = (current, chunk) => {
797
- if (current.length >= STARTUP_BUFFER_CAP) {
798
- return current;
799
- }
800
- const remaining = STARTUP_BUFFER_CAP - current.length;
801
- return current + chunk.slice(0, remaining);
802
- };
803
- const buildStartupErrorMessage = (headline) => {
804
- const sections = [headline];
805
- const stderrTrimmed = stderrBuffer.trim();
806
- if (stderrTrimmed.length > 0) {
807
- sections.push(`stderr: ${stderrTrimmed}`);
808
- }
809
- const stdoutTrimmed = stdoutBuffer.trim();
810
- if (stdoutTrimmed.length > 0) {
811
- sections.push(`stdout: ${stdoutTrimmed}`);
812
- }
813
- return sections.join("\n");
814
- };
815
- const timeout = setTimeout(() => {
816
- if (!started) {
817
- reject(new Error(buildStartupErrorMessage("OpenCode server startup timeout")));
818
- }
819
- }, 30000);
820
- serverProcess.stdout?.on("data", (data) => {
821
- const output = data.toString();
822
- stdoutBuffer = appendCapped(stdoutBuffer, output);
823
- if (output.includes("listening on") && !started) {
824
- started = true;
825
- clearTimeout(timeout);
826
- resolve({
827
- process: serverProcess,
828
- port,
829
- url,
830
- refCount: 0,
831
- retired: false,
832
- });
833
- }
834
- });
835
- serverProcess.stderr?.on("data", (data) => {
836
- const output = data.toString();
837
- stderrBuffer = appendCapped(stderrBuffer, output);
838
- this.logger.error({ stderr: output.trim() }, "OpenCode server stderr");
839
- });
840
- serverProcess.on("error", (error) => {
841
- clearTimeout(timeout);
842
- const headline = error instanceof Error ? error.message : String(error);
843
- reject(new Error(buildStartupErrorMessage(headline)));
844
- });
845
- serverProcess.on("exit", (code) => {
846
- if (!started) {
847
- clearTimeout(timeout);
848
- reject(new Error(buildStartupErrorMessage(`OpenCode server exited with code ${code}`)));
849
- }
850
- if (this.currentServer?.process === serverProcess) {
851
- this.currentServer = null;
852
- }
853
- for (const retired of Array.from(this.retiredServers)) {
854
- if (retired.process === serverProcess) {
855
- this.retiredServers.delete(retired);
856
- }
857
- }
858
- });
859
- });
690
+ createClient(options) {
691
+ return createSdkOpenCodeClient(options);
860
692
  }
861
693
  async shutdown() {
862
- const servers = [
863
- ...(this.currentServer ? [this.currentServer] : []),
864
- ...Array.from(this.retiredServers),
865
- ];
866
- await Promise.all(servers.map((server) => this.killServer(server)));
867
- this.currentServer = null;
868
- this.retiredServers.clear();
869
- }
870
- cleanupRetiredServers() {
871
- for (const server of Array.from(this.retiredServers)) {
872
- if (server.refCount === 0) {
873
- this.retiredServers.delete(server);
874
- void this.killServer(server);
875
- }
876
- }
877
- }
878
- async killServer(server) {
879
- if (server.process.killed) {
880
- return;
881
- }
882
- const result = await terminateWithTreeKill(server.process, {
883
- gracefulTimeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS,
884
- forceTimeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS,
885
- onForceSignal: () => {
886
- this.logger.warn({ timeoutMs: OPENCODE_SERVER_GRACEFUL_SHUTDOWN_TIMEOUT_MS }, "OpenCode server did not exit after SIGTERM; sending SIGKILL");
887
- },
888
- });
889
- if (result === "kill-timeout") {
890
- this.logger.warn({ timeoutMs: OPENCODE_SERVER_FORCE_SHUTDOWN_TIMEOUT_MS }, "OpenCode server did not report exit after SIGKILL");
891
- }
694
+ await this.serverManager.shutdown();
892
695
  }
893
696
  }
894
- OpenCodeServerManager.instance = null;
895
- OpenCodeServerManager.exitHandlerRegistered = false;
896
697
  export class OpenCodeAgentClient {
897
- constructor(logger, runtimeSettings, storageRoot) {
698
+ constructor(logger, runtimeSettings, storageRoot, deps = {}) {
898
699
  this.provider = "opencode";
899
700
  this.capabilities = OPENCODE_CAPABILITIES;
900
701
  this.modelContextWindows = new Map();
901
702
  this.logger = logger.child({ module: "agent", provider: "opencode" });
902
703
  this.runtimeSettings = runtimeSettings;
903
704
  this.storageRoot = storageRoot ?? resolveOpenCodeStorageRoot();
904
- this.serverManager = OpenCodeServerManager.getInstance(this.logger, runtimeSettings);
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
+ };
710
+ this.runtime =
711
+ deps.runtime ??
712
+ new ProductionOpenCodeRuntime(OpenCodeServerManager.getInstance(this.logger, runtimeSettings));
905
713
  }
906
714
  async createSession(config, _launchContext, options) {
907
715
  const openCodeConfig = this.assertConfig(config);
908
- const acquisition = await this.serverManager.acquire({ force: false });
716
+ const acquisition = await this.runtime.acquireServer({ force: false });
909
717
  const { url } = acquisition.server;
910
- const client = createOpencodeClient({
718
+ const client = this.runtime.createClient({
911
719
  baseUrl: url,
912
720
  directory: openCodeConfig.cwd,
913
721
  });
@@ -921,7 +729,7 @@ export class OpenCodeAgentClient {
921
729
  throw new Error("OpenCode session creation returned no data");
922
730
  }
923
731
  await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
924
- 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);
925
733
  }
926
734
  catch (error) {
927
735
  acquisition.release();
@@ -939,15 +747,15 @@ export class OpenCodeAgentClient {
939
747
  ...overrides,
940
748
  };
941
749
  const openCodeConfig = this.assertConfig(config);
942
- const acquisition = await this.serverManager.acquire({ force: false });
750
+ const acquisition = await this.runtime.acquireServer({ force: false });
943
751
  const { url } = acquisition.server;
944
- const client = createOpencodeClient({
752
+ const client = this.runtime.createClient({
945
753
  baseUrl: url,
946
754
  directory: openCodeConfig.cwd,
947
755
  });
948
756
  try {
949
757
  await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
950
- 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);
951
759
  }
952
760
  catch (error) {
953
761
  acquisition.release();
@@ -955,9 +763,9 @@ export class OpenCodeAgentClient {
955
763
  }
956
764
  }
957
765
  async listModels(options) {
958
- const acquisition = await this.serverManager.acquire({ force: options.force });
766
+ const acquisition = await this.runtime.acquireServer({ force: options.force });
959
767
  const { url } = acquisition.server;
960
- const client = createOpencodeClient({
768
+ const client = this.runtime.createClient({
961
769
  baseUrl: url,
962
770
  directory: options.cwd,
963
771
  });
@@ -1001,10 +809,10 @@ export class OpenCodeAgentClient {
1001
809
  }
1002
810
  }
1003
811
  async listModes(options) {
1004
- const acquisition = await this.serverManager.acquire({ force: options.force });
812
+ const acquisition = await this.runtime.acquireServer({ force: options.force });
1005
813
  const { url } = acquisition.server;
1006
814
  const directory = options.cwd;
1007
- const client = createOpencodeClient({ baseUrl: url, directory });
815
+ const client = this.runtime.createClient({ baseUrl: url, directory });
1008
816
  try {
1009
817
  const response = await withTimeout(client.app.agents({ directory }), 10000, "OpenCode app.agents timed out after 10s");
1010
818
  if (response.error || !response.data) {
@@ -1041,7 +849,7 @@ export class OpenCodeAgentClient {
1041
849
  let modelsValue = "Not checked";
1042
850
  let status = formatDiagnosticStatus(available);
1043
851
  try {
1044
- const { url } = await this.serverManager.ensureRunning();
852
+ const { url } = await this.runtime.ensureServerRunning();
1045
853
  serverStatus = `Running (${url})`;
1046
854
  }
1047
855
  catch (error) {
@@ -1784,13 +1592,31 @@ function createDeferred() {
1784
1592
  });
1785
1593
  return { promise, resolve, reject };
1786
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
+ }
1787
1608
  class OpenCodeAgentSession {
1788
- 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
+ }) {
1789
1614
  this.provider = "opencode";
1790
1615
  this.capabilities = OPENCODE_CAPABILITIES;
1791
1616
  this.currentMode = "default";
1792
1617
  this.pendingPermissions = new Map();
1793
1618
  this.abortController = null;
1619
+ this.pendingAbortPromise = null;
1794
1620
  this.accumulatedUsage = {};
1795
1621
  this.mcpConfigured = false;
1796
1622
  this.mcpSetupPromise = null;
@@ -1811,14 +1637,25 @@ class OpenCodeAgentSession {
1811
1637
  this.subAgentCallIdByChildSessionId = new Map();
1812
1638
  this.pendingChildToolPartsBySessionId = new Map();
1813
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;
1814
1649
  this.config = config;
1815
1650
  this.client = client;
1816
1651
  this.sessionId = sessionId;
1817
1652
  this.logger = logger;
1653
+ this.storageRoot = storageRoot;
1818
1654
  this.modelContextWindowsByModelKey = modelContextWindowsByModelKey;
1819
1655
  this.currentMode = normalizeOpenCodeModeId(config.modeId);
1820
1656
  this.releaseServer = releaseServer ?? null;
1821
1657
  this.persistSession = persistSession;
1658
+ this.recovery = recovery;
1822
1659
  this.selectedModelContextWindowMaxTokens = this.resolveConfiguredModelContextWindowMaxTokens(config.model);
1823
1660
  }
1824
1661
  get id() {
@@ -1856,22 +1693,65 @@ class OpenCodeAgentSession {
1856
1693
  const turnId = this.activeForegroundTurnId;
1857
1694
  const turnAbortController = this.abortController;
1858
1695
  turnAbortController?.abort();
1859
- await this.client.session.abort({
1860
- sessionID: this.sessionId,
1861
- 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");
1862
1705
  });
1863
1706
  if (turnId) {
1864
1707
  this.finishForegroundTurn({ type: "turn_canceled", provider: "opencode", reason: "interrupted" }, turnId);
1865
1708
  }
1866
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
+ }
1867
1737
  async startTurn(prompt, options) {
1868
1738
  if (this.activeForegroundTurnId) {
1869
1739
  throw new Error("A foreground turn is already active");
1870
1740
  }
1741
+ await this.awaitPendingAbortBeforeStartingTurn();
1742
+ this.foregroundTurnStartedAt = Date.now();
1871
1743
  this.runningToolCalls.clear();
1872
1744
  this.subAgentsByCallId.clear();
1873
1745
  this.subAgentCallIdByChildSessionId.clear();
1874
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();
1875
1755
  const turnAbortController = new AbortController();
1876
1756
  this.abortController = turnAbortController;
1877
1757
  await this.ensureMcpServersConfigured();
@@ -1980,6 +1860,14 @@ class OpenCodeAgentSession {
1980
1860
  // SDK input validation) is caught alongside async rejections. A plain
1981
1861
  // `.then().catch()` chain would let a sync throw escape unhandled.
1982
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
+ });
1983
1871
  try {
1984
1872
  const promptResponse = await this.client.session.promptAsync({
1985
1873
  sessionID: this.sessionId,
@@ -1998,6 +1886,12 @@ class OpenCodeAgentSession {
1998
1886
  ...(effectiveMode ? { agent: effectiveMode } : {}),
1999
1887
  ...(effectiveVariant ? { variant: effectiveVariant } : {}),
2000
1888
  });
1889
+ traceOpenCode("promptAsync.response", {
1890
+ turnId,
1891
+ hasError: promptResponse.error !== undefined,
1892
+ error: promptResponse.error,
1893
+ data: promptResponse.data,
1894
+ });
2001
1895
  if (promptResponse.error) {
2002
1896
  this.finishForegroundTurn({
2003
1897
  type: "turn_failed",
@@ -2007,6 +1901,12 @@ class OpenCodeAgentSession {
2007
1901
  }
2008
1902
  }
2009
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
+ });
2010
1910
  this.finishForegroundTurn({
2011
1911
  type: "turn_failed",
2012
1912
  provider: "opencode",
@@ -2024,16 +1924,39 @@ class OpenCodeAgentSession {
2024
1924
  };
2025
1925
  }
2026
1926
  async consumeEventStream(turnId, turnAbortController, subscriptionReady) {
1927
+ traceOpenCode("subscribe.start", { turnId, sessionId: this.sessionId, cwd: this.config.cwd });
2027
1928
  try {
2028
- 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 });
2029
1931
  subscriptionReady.resolve();
1932
+ let eventCount = 0;
2030
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
+ });
2031
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
+ });
2032
1948
  break;
2033
1949
  }
2034
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
+ });
2035
1957
  for (const e of translated) {
2036
1958
  if (this.activeForegroundTurnId !== turnId) {
1959
+ traceOpenCode("event.translated.skip-active", { turnId, type: e.type });
2037
1960
  return;
2038
1961
  }
2039
1962
  if (e.type === "timeline" && e.item.type === "tool_call") {
@@ -2041,13 +1964,26 @@ class OpenCodeAgentSession {
2041
1964
  }
2042
1965
  const terminalEvent = toTerminalTurnEvent(e);
2043
1966
  if (terminalEvent) {
1967
+ traceOpenCode("event.terminal", { turnId, type: terminalEvent.type });
2044
1968
  this.finishForegroundTurn(terminalEvent, turnId);
2045
1969
  return;
2046
1970
  }
2047
1971
  this.notifySubscribers(e, turnId);
2048
1972
  }
2049
1973
  }
1974
+ traceOpenCode("stream.eof", {
1975
+ turnId,
1976
+ eventCount,
1977
+ aborted: turnAbortController.signal.aborted,
1978
+ stillActive: this.activeForegroundTurnId === turnId,
1979
+ });
2050
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 });
2051
1987
  this.finishForegroundTurn({
2052
1988
  type: "turn_failed",
2053
1989
  provider: "opencode",
@@ -2056,6 +1992,10 @@ class OpenCodeAgentSession {
2056
1992
  }
2057
1993
  }
2058
1994
  catch (error) {
1995
+ traceOpenCode("subscribe.error", {
1996
+ turnId,
1997
+ error: error instanceof Error ? { name: error.name, message: error.message } : String(error),
1998
+ });
2059
1999
  subscriptionReady.reject(error);
2060
2000
  if (!turnAbortController.signal.aborted && this.activeForegroundTurnId === turnId) {
2061
2001
  this.finishForegroundTurn({
@@ -2078,7 +2018,348 @@ class OpenCodeAgentSession {
2078
2018
  }
2079
2019
  }
2080
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
+ }
2081
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
+ });
2082
2363
  if (this.activeForegroundTurnId !== turnId) {
2083
2364
  return;
2084
2365
  }
@@ -2088,6 +2369,7 @@ class OpenCodeAgentSession {
2088
2369
  else {
2089
2370
  this.runningToolCalls.clear();
2090
2371
  }
2372
+ this.foregroundTurnStartedAt = null;
2091
2373
  this.activeForegroundTurnId = null;
2092
2374
  // Abort the SSE connection so the SDK tears down the underlying fetch.
2093
2375
  this.abortController?.abort();
@@ -2126,6 +2408,13 @@ class OpenCodeAgentSession {
2126
2408
  }
2127
2409
  notifySubscribers(event, turnIdOverride) {
2128
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
+ }
2129
2418
  const tagged = turnId ? { ...event, turnId } : event;
2130
2419
  for (const callback of this.subscribers) {
2131
2420
  try {