@bitkyc08/opencodex 2.52.0-preview.20260911 → 2.52.0

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 (166) hide show
  1. package/gui/dist/assets/index-CWXut3rG.js +115 -0
  2. package/gui/dist/assets/index-EdoPnm9_.css +1 -0
  3. package/gui/dist/index.html +2 -2
  4. package/gui/dist/provider-icons/devin.svg +49 -0
  5. package/gui/dist/provider-icons/omo.svg +42 -0
  6. package/package.json +3 -1
  7. package/src/AGENTS.md +1 -1
  8. package/src/adapters/cline-pass-deepseek-v4-tool-replay.ts +0 -1
  9. package/src/adapters/command-code.ts +0 -1
  10. package/src/adapters/cursor/checkpoint-store.ts +37 -0
  11. package/src/adapters/cursor/live-transport.ts +74 -2
  12. package/src/adapters/cursor.ts +6 -0
  13. package/src/adapters/devin/cloud-direct/auth.ts +264 -0
  14. package/src/adapters/devin/cloud-direct/catalog.ts +306 -0
  15. package/src/adapters/devin/cloud-direct/chat.ts +1274 -0
  16. package/src/adapters/devin/cloud-direct/index.ts +65 -0
  17. package/src/adapters/devin/cloud-direct/metadata.ts +134 -0
  18. package/src/adapters/devin/cloud-direct/wire.ts +206 -0
  19. package/src/adapters/devin/live-models.ts +133 -0
  20. package/src/adapters/devin-cli/acp.ts +204 -0
  21. package/src/adapters/devin-cli/adapter.ts +345 -0
  22. package/src/adapters/devin-cli/binary.ts +69 -0
  23. package/src/adapters/devin-cli/models.ts +57 -0
  24. package/src/adapters/devin.ts +326 -0
  25. package/src/adapters/google.ts +1 -1
  26. package/src/adapters/openai-chat.ts +31 -10
  27. package/src/adapters/openai-responses.ts +16 -1
  28. package/src/adapters/registry.ts +25 -1
  29. package/src/bridge.ts +8 -2
  30. package/src/claude/inbound-cache-stabilize.ts +130 -0
  31. package/src/claude/inbound.ts +45 -5
  32. package/src/cli/account-auth.ts +60 -1
  33. package/src/cli/account-extended.ts +23 -30
  34. package/src/cli/account.ts +2 -1
  35. package/src/cli/capabilities.ts +50 -8
  36. package/src/cli/config-command.ts +2 -2
  37. package/src/cli/dispatch.ts +14 -2
  38. package/src/cli/export-command.ts +11 -25
  39. package/src/cli/help.ts +2 -2
  40. package/src/cli/opencode.ts +5 -0
  41. package/src/cli/registry.ts +15 -4
  42. package/src/clients/config-export/cline.ts +71 -0
  43. package/src/clients/config-export/contracts.ts +10 -1
  44. package/src/clients/config-export/model-metadata.ts +33 -0
  45. package/src/clients/config-export/zcode.ts +23 -12
  46. package/src/clients/config-export.ts +156 -4
  47. package/src/codex/account-store.ts +7 -2
  48. package/src/codex/auth-context.ts +1 -1
  49. package/src/codex/catalog/effort.ts +4 -3
  50. package/src/codex/catalog/metadata.ts +8 -4
  51. package/src/codex/catalog/native-models.ts +24 -4
  52. package/src/codex/catalog/parsing.ts +19 -1
  53. package/src/codex/catalog/provider-fetch.ts +57 -0
  54. package/src/codex/catalog/sync.ts +1 -1
  55. package/src/codex/context-compat.ts +97 -0
  56. package/src/codex/context-owner.ts +201 -0
  57. package/src/codex/history-provider.ts +161 -14
  58. package/src/codex/inject-coordination.ts +3 -2
  59. package/src/codex/inject.ts +128 -9
  60. package/src/codex/pool-rotation.ts +8 -292
  61. package/src/codex/quota.ts +41 -12
  62. package/src/codex/retired-model-migration.ts +41 -0
  63. package/src/codex/routing.ts +327 -22
  64. package/src/codex/warmup.ts +2 -2
  65. package/src/combos/failover.ts +5 -0
  66. package/src/combos/resolve.ts +11 -15
  67. package/src/config.ts +46 -14
  68. package/src/generated/compatibility-version.json +293 -113
  69. package/src/grok/grpc-web.ts +120 -0
  70. package/src/grok/reset-coupon-ledger.ts +139 -0
  71. package/src/grok/reset-coupons.ts +278 -0
  72. package/src/integrations/catalog-refresh.ts +1 -1
  73. package/src/integrations/cline-document.ts +73 -0
  74. package/src/integrations/cline-io.ts +149 -0
  75. package/src/integrations/cline-transaction.ts +42 -0
  76. package/src/integrations/config-io.ts +21 -2
  77. package/src/integrations/journal.ts +43 -1
  78. package/src/integrations/omp-yaml-source.ts +123 -4
  79. package/src/integrations/ownership-policy.ts +7 -0
  80. package/src/integrations/ownership.ts +36 -0
  81. package/src/integrations/registry.ts +27 -0
  82. package/src/integrations/state.ts +5 -2
  83. package/src/integrations/store.ts +6 -0
  84. package/src/integrations/writer.ts +53 -9
  85. package/src/lab/subject/behavior-fingerprint.ts +1 -1
  86. package/src/lib/abort.ts +36 -0
  87. package/src/lib/local-destinations.ts +1 -1
  88. package/src/lib/upstream-retry.ts +36 -1
  89. package/src/oauth/account-quota-rank.ts +11 -0
  90. package/src/oauth/callback-server.ts +10 -5
  91. package/src/oauth/devin/api-base.ts +63 -0
  92. package/src/oauth/devin/login.ts +1 -0
  93. package/src/oauth/devin/register-user.ts +186 -0
  94. package/src/oauth/devin/types.ts +71 -0
  95. package/src/oauth/devin-cli.ts +149 -0
  96. package/src/oauth/devin.ts +170 -0
  97. package/src/oauth/generic-account-failover.ts +169 -1
  98. package/src/oauth/index.ts +20 -1
  99. package/src/oauth/login-cli.ts +19 -5
  100. package/src/oauth/pool-kernel.ts +321 -0
  101. package/src/oauth/pool-settings-capability.ts +127 -9
  102. package/src/oauth/store.ts +7 -3
  103. package/src/oauth/token-guardian.ts +1 -1
  104. package/src/providers/codebuddy-models.ts +0 -3
  105. package/src/providers/command-code-efforts.ts +23 -4
  106. package/src/providers/default-aliases.ts +4 -0
  107. package/src/providers/derive.ts +8 -0
  108. package/src/providers/devin-cli-authmode-migration.ts +57 -0
  109. package/src/providers/key-failover.ts +175 -0
  110. package/src/providers/model-rename-startup.ts +21 -1
  111. package/src/providers/qoder-models.ts +0 -1
  112. package/src/providers/quota-key-accounts.ts +50 -0
  113. package/src/providers/quota-routing-cache.ts +65 -6
  114. package/src/providers/quota-types.ts +7 -0
  115. package/src/providers/quota.ts +113 -30
  116. package/src/providers/registry.ts +237 -77
  117. package/src/providers/stale-context-window-migration.ts +92 -0
  118. package/src/providers/zai-responses-migration.ts +45 -0
  119. package/src/quota/reset-observer.ts +2 -1
  120. package/src/quota/reset-seen-store.ts +9 -1
  121. package/src/remote-control/crypto.ts +442 -0
  122. package/src/remote-control/host.ts +175 -0
  123. package/src/remote-control/index.ts +100 -0
  124. package/src/remote-control/protocol.ts +200 -0
  125. package/src/remote-control/relay.ts +162 -0
  126. package/src/remote-control/workspace-agent-protocol.ts +246 -0
  127. package/src/remote-control/workspace-rpc-framing.ts +128 -0
  128. package/src/remote-control/workspace-tools.ts +237 -0
  129. package/src/remote-control/workspace-utf8.ts +24 -0
  130. package/src/responses/custom-tool-compat.ts +23 -0
  131. package/src/router.ts +6 -1
  132. package/src/routing/compatibility/behavior.ts +3 -0
  133. package/src/server/adapter-resolve.ts +6 -2
  134. package/src/server/auth-cors.ts +71 -6
  135. package/src/server/chat-completions.ts +5 -3
  136. package/src/server/chat-native.ts +9 -0
  137. package/src/server/claude-messages.ts +25 -30
  138. package/src/server/context-history.ts +207 -0
  139. package/src/server/images.ts +28 -1
  140. package/src/server/index.ts +42 -22
  141. package/src/server/live.ts +2 -1
  142. package/src/server/management/agent-settings-routes.ts +7 -1
  143. package/src/server/management/config-routes.ts +3 -3
  144. package/src/server/management/grok-coupon-routes.ts +287 -0
  145. package/src/server/management/integration-routes.ts +6 -1
  146. package/src/server/management/oauth-account-routes.ts +124 -7
  147. package/src/server/management/provider-routes.ts +77 -2
  148. package/src/server/management/route-registry.ts +6 -1
  149. package/src/server/management-api.ts +7 -0
  150. package/src/server/request-log.ts +4 -0
  151. package/src/server/responses/codex-ws-exchange.ts +90 -14
  152. package/src/server/responses/codex-ws-wire.ts +51 -1
  153. package/src/server/responses/compact.ts +8 -1
  154. package/src/server/responses/core.ts +188 -22
  155. package/src/server/responses/ws-upstream.ts +1 -1
  156. package/src/server/responses-undeclared-tool-guard.ts +261 -12
  157. package/src/server/zai-responses-startup.ts +21 -0
  158. package/src/types/config.ts +33 -3
  159. package/src/types/provider.ts +54 -4
  160. package/src/types/request.ts +1 -1
  161. package/src/types/tools.ts +36 -6
  162. package/src/update/job.ts +33 -11
  163. package/src/vision/plan.ts +40 -37
  164. package/src/web-search/index.ts +1 -0
  165. package/gui/dist/assets/index-BoBRSehJ.css +0 -1
  166. package/gui/dist/assets/index-Dx0xv2EA.js +0 -115
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Agent Client Protocol framing for the Devin CLI.
3
+ *
4
+ * `devin acp` speaks newline-delimited JSON-RPC on stdin/stdout. One ACP session
5
+ * answers one prompt, so a turn is: initialize -> session/new -> session/prompt,
6
+ * with session/update notifications streaming in between and a unary reply to
7
+ * the prompt carrying the stop reason and usage.
8
+ *
9
+ * This module is pure. It never spawns a process and never touches the network,
10
+ * so the framing and the event mapping are testable against captured lines, the
11
+ * same discipline src/adapters/coding-agent/protocol.ts follows for the
12
+ * stream-json CLIs.
13
+ */
14
+ import type { AdapterEvent, OcxParsedRequest, OcxToolCall, OcxUsage } from "../../types";
15
+
16
+ /** Hard ceiling on a single buffered stdout line. */
17
+ export const MAX_ACP_LINE_BYTES = 8 * 1024 * 1024;
18
+ /** Hard ceiling on total stdout bytes consumed for one turn. */
19
+ export const MAX_ACP_TOTAL_BYTES = 64 * 1024 * 1024;
20
+
21
+ export class AcpProtocolError extends Error {
22
+ readonly code = "protocol_error";
23
+ readonly status = 502;
24
+ constructor(message: string) {
25
+ super(message);
26
+ this.name = "AcpProtocolError";
27
+ }
28
+ }
29
+
30
+ export const ACP_INITIALIZE_ID = 1;
31
+ export const ACP_SESSION_NEW_ID = 2;
32
+ export const ACP_SESSION_PROMPT_ID = 3;
33
+
34
+ export function initializeFrame(clientVersion: string): Record<string, unknown> {
35
+ return {
36
+ jsonrpc: "2.0",
37
+ id: ACP_INITIALIZE_ID,
38
+ method: "initialize",
39
+ params: { protocolVersion: 1, clientInfo: { name: "opencodex", version: clientVersion }, capabilities: {} },
40
+ };
41
+ }
42
+
43
+ export function sessionNewFrame(cwd: string, modelId?: string): Record<string, unknown> {
44
+ const params: Record<string, unknown> = { cwd, mcpServers: [] };
45
+ // The CLI picks its own default when no model is named, which is what an
46
+ // unset or vendor-default selection should do.
47
+ if (modelId) params.model = modelId;
48
+ return { jsonrpc: "2.0", id: ACP_SESSION_NEW_ID, method: "session/new", params };
49
+ }
50
+
51
+ export function sessionPromptFrame(sessionId: string, prompt: string): Record<string, unknown> {
52
+ return {
53
+ jsonrpc: "2.0",
54
+ id: ACP_SESSION_PROMPT_ID,
55
+ method: "session/prompt",
56
+ params: { sessionId, prompt: [{ type: "text", text: prompt }] },
57
+ };
58
+ }
59
+
60
+ /**
61
+ * Answer a permission request without a human.
62
+ *
63
+ * A headless turn has nobody to approve a tool call, and an unanswered
64
+ * `session/request_permission` stalls the agent until the turn times out. The
65
+ * answer is a refusal by default: this provider runs an agent in the operator's
66
+ * own tree, and auto-approving whatever it asks for would let any prompt that
67
+ * reaches the proxy read, write and execute there. Approval is an explicit
68
+ * operator decision, and only then is an allow-shaped option preferred over
69
+ * positional guessing — the first option in a real prompt is sometimes the
70
+ * rejection.
71
+ */
72
+ export function permissionResponseFrame(
73
+ id: number | string,
74
+ options: Array<{ optionId?: string; name?: string; kind?: string }> | undefined,
75
+ allowed = false,
76
+ ): Record<string, unknown> {
77
+ if (!allowed) {
78
+ return { jsonrpc: "2.0", id, result: { outcome: { outcome: "cancelled" } } };
79
+ }
80
+ const list = options ?? [];
81
+ const allow =
82
+ list.find((o) => typeof o.kind === "string" && /^allow/i.test(o.kind)) ??
83
+ list.find((o) => /allow|accept|yes/i.test(`${o.optionId ?? ""} ${o.name ?? ""}`));
84
+ if (!allow?.optionId) {
85
+ // Nothing offered says "allow". Guessing at `list[0]` here is how an
86
+ // auto-answer selects a rejection and calls it approval.
87
+ return { jsonrpc: "2.0", id, result: { outcome: { outcome: "cancelled" } } };
88
+ }
89
+ return { jsonrpc: "2.0", id, result: { outcome: { outcome: "selected", optionId: allow.optionId } } };
90
+ }
91
+
92
+ /**
93
+ * Flatten an OcxContext into the single prompt string one ACP session takes.
94
+ *
95
+ * ACP has no multi-message history on session/prompt, so the conversation is
96
+ * projected into labelled blocks. Tool calls and results are rendered rather
97
+ * than dropped, because a turn that omits them loses the thread of a tool loop.
98
+ */
99
+ export function buildAcpPrompt(parsed: OcxParsedRequest): string {
100
+ const blocks: string[] = [];
101
+ const system = parsed.context.systemPrompt?.filter((line) => line.trim().length > 0).join("\n");
102
+ if (system) blocks.push(fence("System", system));
103
+ for (const message of parsed.context.messages) {
104
+ if (message.role === "toolResult") {
105
+ const body = typeof message.content === "string" ? message.content : JSON.stringify(message.content ?? "");
106
+ blocks.push(fence("Tool", `[result id=${message.toolCallId}]\n${body}`));
107
+ continue;
108
+ }
109
+ const parts = typeof message.content === "string" ? [] : message.content;
110
+ let text = typeof message.content === "string"
111
+ ? message.content
112
+ : parts.map((p) => (p.type === "text" ? p.text : "")).filter(Boolean).join("\n");
113
+ if (message.role === "assistant" && Array.isArray(parts)) {
114
+ const calls = parts
115
+ .filter((p): p is OcxToolCall => p.type === "toolCall")
116
+ .map((c) => `[call ${c.name} id=${c.id}]\n${JSON.stringify(c.arguments ?? {})}`)
117
+ .join("\n\n");
118
+ if (calls) text = text ? `${text}\n\n${calls}` : calls;
119
+ }
120
+ if (!text.trim()) continue;
121
+ const label = message.role === "assistant" ? "Assistant" : message.role === "developer" ? "System" : "User";
122
+ blocks.push(fence(label, text));
123
+ }
124
+ if (blocks.length === 0) return "(empty)";
125
+ const joined = blocks.join("\n\n");
126
+ // Keep the oldest turns rather than the newest when trimming: the tail is
127
+ // what the agent is answering.
128
+ return joined.length > MAX_ACP_PROMPT_CHARS
129
+ ? `[truncated]\n${joined.slice(joined.length - MAX_ACP_PROMPT_CHARS)}`
130
+ : joined;
131
+ }
132
+
133
+ export type AcpTurnOutcome = { stopReason?: string; usage?: OcxUsage };
134
+
135
+ /** ACP stop reasons that mean the turn ended normally. */
136
+ const NATURAL_STOP = new Set(["end_turn", "stop", "completed"]);
137
+
138
+ export function mapAcpStopReason(reason: unknown): string | undefined {
139
+ if (typeof reason !== "string" || NATURAL_STOP.has(reason)) return undefined;
140
+ if (reason === "max_tokens") return "max_tokens";
141
+ return reason;
142
+ }
143
+
144
+ export function mapAcpUsage(raw: unknown): OcxUsage | undefined {
145
+ if (!raw || typeof raw !== "object") return undefined;
146
+ const u = raw as Record<string, unknown>;
147
+ const input = typeof u.inputTokens === "number" ? u.inputTokens : 0;
148
+ const output = typeof u.outputTokens === "number" ? u.outputTokens : 0;
149
+ if (input === 0 && output === 0) return undefined;
150
+ const total = typeof u.totalTokens === "number" ? u.totalTokens : input + output;
151
+ return { inputTokens: input, outputTokens: output, ...(total > 0 ? { totalTokens: total } : {}) };
152
+ }
153
+
154
+ function chunkText(content: unknown): string {
155
+ if (typeof content === "string") return content;
156
+ if (content && typeof content === "object") {
157
+ const text = (content as { text?: unknown }).text;
158
+ if (typeof text === "string") return text;
159
+ }
160
+ return "";
161
+ }
162
+
163
+ /**
164
+ * Translate one session/update notification into adapter events.
165
+ *
166
+ * Tool lifecycle is explicit in ACP: `tool_call` opens one and
167
+ * `tool_call_update` with a terminal status closes it, so the caller does not
168
+ * have to infer boundaries from interleaving the way a delta-only wire forces.
169
+ */
170
+ export function acpUpdateToEvents(update: Record<string, unknown>): AdapterEvent[] {
171
+ const kind = update.sessionUpdate;
172
+ if (kind === "agent_message_chunk") {
173
+ const text = chunkText(update.content);
174
+ return text ? [{ type: "text_delta", text }] : [];
175
+ }
176
+ if (kind === "agent_thought_chunk") {
177
+ const text = chunkText(update.content);
178
+ return text ? [{ type: "thinking_delta", thinking: text }] : [];
179
+ }
180
+ // The CLI's own tool calls are NOT client tools. Devin executes them itself
181
+ // inside its session, so emitting tool_call_start here would either fail the
182
+ // turn — the Responses bridge rejects a tool Codex never declared — or ask
183
+ // Codex to run something the agent has already run. Vendor tools stay
184
+ // internal and Codex keeps ownership of mutation, which is the same rule the
185
+ // CodeBuddy and Qoder adapters follow.
186
+ //
187
+ // They are not dropped silently, though. A Devin tool operation that runs
188
+ // longer than the bridge's stall timeout would otherwise look like upstream
189
+ // silence and get the still-working turn aborted, so an internal update
190
+ // becomes a heartbeat: proof of life without a client-visible tool.
191
+ if (kind === "tool_call" || kind === "tool_call_update" || kind === "plan" || kind === "current_mode_update") {
192
+ return [{ type: "heartbeat" }];
193
+ }
194
+ return [];
195
+ }
196
+ /** Ceiling on the flattened conversation handed to one ACP prompt. */
197
+ export const MAX_ACP_PROMPT_CHARS = 200_000;
198
+
199
+ /** Fence a block label so a message body cannot forge one. */
200
+ function fence(label: string, body: string): string {
201
+ // A user or tool result that contains a line reading `[System]` would
202
+ // otherwise appear to open a system block in the flattened prompt.
203
+ return `[${label}]\n${body.replace(/^\[(System|User|Assistant|Tool)\]/gm, " $&")}`;
204
+ }
@@ -0,0 +1,345 @@
1
+ /**
2
+ * Devin CLI adapter: one ACP session per turn over stdio.
3
+ *
4
+ * This is the local half of Devin support. The cloud-direct `devin` adapter
5
+ * talks to Cognition's api-server; this one drives the installed `devin` CLI,
6
+ * which carries its own credentials from `devin auth login`, so the proxy never
7
+ * sees a token for this provider.
8
+ *
9
+ * runTurn-only, like the Cursor and cloud Devin adapters: a JSON-RPC handshake
10
+ * over a child process has no fetch-shaped request to hand to the generic wire
11
+ * path.
12
+ *
13
+ * The child is treated as untrusted and unprivileged. It gets a scoped
14
+ * environment rather than the proxy's, its permission requests are refused
15
+ * unless an operator opted in, and it is reaped rather than merely signalled,
16
+ * because a Devin grandchild that ignores SIGTERM would otherwise keep writing
17
+ * in the operator's tree after the turn returned.
18
+ */
19
+ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
20
+ import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig, OcxUsage } from "../../types";
21
+ import type { IncomingMeta, ProviderAdapter } from "../base";
22
+ import { baseScopedEnv } from "../coding-agent/turn";
23
+ import {
24
+ ACP_INITIALIZE_ID,
25
+ ACP_SESSION_NEW_ID,
26
+ ACP_SESSION_PROMPT_ID,
27
+ MAX_ACP_LINE_BYTES,
28
+ MAX_ACP_TOTAL_BYTES,
29
+ acpUpdateToEvents,
30
+ buildAcpPrompt,
31
+ initializeFrame,
32
+ mapAcpStopReason,
33
+ mapAcpUsage,
34
+ permissionResponseFrame,
35
+ sessionNewFrame,
36
+ sessionPromptFrame,
37
+ } from "./acp";
38
+ import { DEVIN_CLI_INSTALL_HINT, resolveDevinCliBinary } from "./binary";
39
+
40
+ /** A turn that has not produced a prompt reply by this point is abandoned. */
41
+ const DEVIN_CLI_TURN_TIMEOUT_MS = 10 * 60 * 1000;
42
+ /** Grace between SIGTERM and SIGKILL when reaping the child. */
43
+ const DEVIN_CLI_KILL_GRACE_MS = 2_000;
44
+ /** How long to wait for the child to actually exit before giving up on it. */
45
+ const DEVIN_CLI_REAP_MS = 5_000;
46
+
47
+ /**
48
+ * Identity URL for the provider. The CLI does the real transport over stdio;
49
+ * this is only what the configuration records as the destination, and it has to
50
+ * be an http(s) URL because provider config validation rejects other schemes.
51
+ */
52
+ export const DEVIN_CLI_IDENTITY_URL = "https://cli.devin.ai";
53
+
54
+ /**
55
+ * Opt-in for letting the CLI act on the machine.
56
+ *
57
+ * Off by default: this provider runs an agent in the operator's own tree, and a
58
+ * proxy that auto-approves whatever a prompt asks for is a remote shell.
59
+ */
60
+ const DEVIN_CLI_ALLOW_TOOLS_ENV = "OPENCODEX_DEVIN_CLI_ALLOW_TOOLS";
61
+
62
+ export type DevinCliSpawn = (binary: string, args: string[], options: { cwd: string; env: Record<string, string> }) => ChildProcessWithoutNullStreams;
63
+
64
+ export function devinCliToolsAllowed(env: NodeJS.ProcessEnv = process.env): boolean {
65
+ const raw = env[DEVIN_CLI_ALLOW_TOOLS_ENV]?.trim().toLowerCase();
66
+ return raw === "1" || raw === "true" || raw === "yes";
67
+ }
68
+
69
+ export function createDevinCliAdapter(provider: OcxProviderConfig, deps?: { spawn?: DevinCliSpawn }): ProviderAdapter {
70
+ const spawnChild: DevinCliSpawn = deps?.spawn
71
+ ?? ((binary, args, options) => spawn(binary, args, {
72
+ ...options,
73
+ stdio: ["pipe", "pipe", "pipe"],
74
+ windowsHide: true,
75
+ // Give the child its own process group on POSIX so the reap below can
76
+ // signal the whole tree. Devin spawns shells and tools of its own when
77
+ // the operator allows them, and signalling only the direct pid leaves
78
+ // those descendants writing in the operator's tree after the turn ended.
79
+ detached: process.platform !== "win32",
80
+ }) as ChildProcessWithoutNullStreams);
81
+
82
+ return {
83
+ name: "devin-cli",
84
+
85
+ buildRequest() {
86
+ // Placeholder: this adapter never travels the fetch path. The URL is the
87
+ // provider's identity, not a destination anything connects to.
88
+ return { url: provider.baseUrl || DEVIN_CLI_IDENTITY_URL, method: "POST", headers: {}, body: "" };
89
+ },
90
+
91
+ async *parseStream(): AsyncGenerator<AdapterEvent> {
92
+ yield { type: "error", message: "Devin CLI adapter uses runTurn; the fetch/parseStream path is disabled." };
93
+ },
94
+
95
+ async runTurn(parsed: OcxParsedRequest, incoming: IncomingMeta, emit: (event: AdapterEvent) => void) {
96
+ if (incoming.abortSignal?.aborted) {
97
+ emit({ type: "error", message: "Devin CLI turn was aborted before start." });
98
+ return;
99
+ }
100
+ const binary = resolveDevinCliBinary();
101
+ if (!binary) {
102
+ emit({ type: "error", message: `Devin CLI not found. ${DEVIN_CLI_INSTALL_HINT}` });
103
+ return;
104
+ }
105
+
106
+ const modelId = parsed.modelId.includes("/")
107
+ ? parsed.modelId.slice(parsed.modelId.lastIndexOf("/") + 1)
108
+ : parsed.modelId;
109
+ const cwd = process.env.OPENCODEX_DEVIN_CLI_CWD?.trim() || process.cwd();
110
+ const toolsAllowed = devinCliToolsAllowed();
111
+
112
+ await new Promise<void>((resolve) => {
113
+ let child: ChildProcessWithoutNullStreams;
114
+ try {
115
+ child = spawnChild(binary, ["acp"], {
116
+ cwd,
117
+ env: {
118
+ // A scoped environment, not the proxy's. The child would
119
+ // otherwise inherit every credential this process holds.
120
+ ...baseScopedEnv(),
121
+ NO_COLOR: "1",
122
+ // "normal" is the CLI's own refuse-by-default mode. "ask" reads like
123
+ // the right name for it but is not a value the binary accepts: Devin
124
+ // CLI 3000.10.21 exits 2 with
125
+ // invalid value 'ask' for '--permission-mode <PERMISSION_MODE>'
126
+ // Valid options: normal (auto), accept-edits, dangerous (yolo,
127
+ // bypass), autonomous (requires --sandbox)
128
+ // before answering a single prompt, so every turn on this provider
129
+ // failed with the default (tools not allowed) configuration — the
130
+ // one path most operators are on. Found by running a real turn
131
+ // against an installed, signed-in CLI; no unit test could see it,
132
+ // because the spawn is injected and the fake child accepts anything.
133
+ DEVIN_PERMISSION_MODE: toolsAllowed ? (process.env.DEVIN_PERMISSION_MODE ?? "bypass") : "normal",
134
+ },
135
+ });
136
+ } catch (error) {
137
+ emit({ type: "error", message: `Devin CLI failed to start (${binary}): ${(error as Error).message}. ${DEVIN_CLI_INSTALL_HINT}` });
138
+ return resolve();
139
+ }
140
+
141
+ let settled = false;
142
+ let closed = false;
143
+ let sawProtocolFrame = false;
144
+ let sawPromptReply = false;
145
+ let buffer = "";
146
+ let totalBytes = 0;
147
+ let usage: OcxUsage | undefined;
148
+ let stopReason: string | undefined;
149
+ let stderrTail = "";
150
+
151
+ const turnTimer = setTimeout(
152
+ () => finish(`Devin CLI turn exceeded ${DEVIN_CLI_TURN_TIMEOUT_MS}ms`),
153
+ DEVIN_CLI_TURN_TIMEOUT_MS,
154
+ );
155
+ const onAbort = () => finish("Devin CLI turn was aborted.");
156
+
157
+ /**
158
+ * Reap the child rather than just signalling it, then resolve.
159
+ *
160
+ * `child.killed` only records that a signal was sent. Resolving on that
161
+ * lets a grandchild keep running in the operator's tree after runTurn
162
+ * returned, which is why this waits for `close` and escalates.
163
+ */
164
+ function reapAndResolve(): void {
165
+ if (closed || child.exitCode !== null || child.signalCode !== null) return resolve();
166
+ let done = false;
167
+ const settle = () => {
168
+ if (done) return;
169
+ done = true;
170
+ clearTimeout(killTimer);
171
+ clearTimeout(reapTimer);
172
+ resolve();
173
+ };
174
+ child.once("close", settle);
175
+ signalTree("SIGTERM");
176
+ const killTimer = setTimeout(() => signalTree("SIGKILL"), DEVIN_CLI_KILL_GRACE_MS);
177
+ const reapTimer = setTimeout(settle, DEVIN_CLI_REAP_MS);
178
+ }
179
+
180
+ /**
181
+ * Signal the child's whole process group where the platform has one.
182
+ * Devin launches shells and tools of its own once the operator allows
183
+ * them, and those descendants do not receive a signal aimed at the
184
+ * direct pid. Falls back to the single process when the group send is
185
+ * unavailable or the group is already gone.
186
+ */
187
+ function signalTree(signal: NodeJS.Signals): void {
188
+ const pid = child.pid;
189
+ if (pid !== undefined && process.platform !== "win32") {
190
+ try {
191
+ process.kill(-pid, signal);
192
+ return;
193
+ } catch { /* no group, or already reaped - fall through */ }
194
+ }
195
+ try { child.kill(signal); } catch { /* already gone */ }
196
+ }
197
+
198
+ /** Terminate the turn exactly once, with an error when given a reason. */
199
+ function finish(errorMessage?: string): void {
200
+ if (settled) return;
201
+ settled = true;
202
+ clearTimeout(turnTimer);
203
+ incoming.abortSignal?.removeEventListener("abort", onAbort);
204
+ child.stdout.destroy();
205
+ if (errorMessage) emit({ type: "error", message: errorMessage, ...(usage ? { usage } : {}) });
206
+ else emit({ type: "done", ...(usage ? { usage } : {}), ...(stopReason ? { stopReason } : {}) });
207
+ reapAndResolve();
208
+ }
209
+
210
+ incoming.abortSignal?.addEventListener("abort", onAbort, { once: true });
211
+ // The signal can fire between the pre-spawn check and this listener.
212
+ if (incoming.abortSignal?.aborted) return finish("Devin CLI turn was aborted.");
213
+
214
+ const send = (frame: Record<string, unknown>): void => {
215
+ if (!child.stdin.destroyed) child.stdin.write(`${JSON.stringify(frame)}\n`);
216
+ };
217
+ // EPIPE after the child is killed is an ordinary race, not a crash.
218
+ child.stdin.on("error", () => {});
219
+
220
+ child.on("error", (err) => finish(`Devin CLI failed to start (${binary}): ${err.message}. ${DEVIN_CLI_INSTALL_HINT}`));
221
+
222
+ child.on("close", (code) => {
223
+ closed = true;
224
+ if (settled) return;
225
+ // Flush a final frame that arrived without a trailing newline before
226
+ // deciding the turn failed: the prompt reply carrying usage and the
227
+ // stop reason is often the last line written.
228
+ flush(buffer);
229
+ buffer = "";
230
+ if (settled) return;
231
+ // A close without a prompt reply is a failure, not an empty success.
232
+ const detail = stderrTail.trim().slice(-400);
233
+ finish(
234
+ `Devin CLI exited (code ${code ?? "null"}) before answering the prompt` +
235
+ (detail ? `: ${detail}` : "."),
236
+ );
237
+ });
238
+
239
+ child.stderr?.setEncoding("utf8");
240
+ child.stderr?.on("data", (chunk: string) => {
241
+ // Bounded: diagnostics are for the error message, not a buffer to grow.
242
+ stderrTail = (stderrTail + chunk).slice(-4096);
243
+ });
244
+
245
+ child.stdout.setEncoding("utf8");
246
+ child.stdout.on("data", (chunk: string) => {
247
+ if (settled) return;
248
+ totalBytes += Buffer.byteLength(chunk, "utf8");
249
+ if (totalBytes > MAX_ACP_TOTAL_BYTES) return finish("Devin CLI produced more output than one turn may consume.");
250
+ buffer += chunk;
251
+ let index: number;
252
+ while ((index = buffer.indexOf("\n")) >= 0) {
253
+ const line = buffer.slice(0, index);
254
+ buffer = buffer.slice(index + 1);
255
+ flush(line);
256
+ if (settled) return;
257
+ }
258
+ if (Buffer.byteLength(buffer, "utf8") > MAX_ACP_LINE_BYTES) {
259
+ finish("Devin CLI emitted a single line larger than the frame cap.");
260
+ }
261
+ });
262
+
263
+ // The prompt reply carrying usage and the stop reason is often the last
264
+ // thing written, and it is not guaranteed to end with a newline. Flush
265
+ // the remainder when the stream ends rather than waiting for the child
266
+ // to exit and then calling a complete turn a failure.
267
+ child.stdout.on("end", () => {
268
+ const tail = buffer;
269
+ buffer = "";
270
+ flush(tail);
271
+ });
272
+
273
+ function flush(raw: string): void {
274
+ const line = raw.trim();
275
+ if (!line || settled) return;
276
+ let frame: Record<string, unknown>;
277
+ try {
278
+ frame = JSON.parse(line) as Record<string, unknown>;
279
+ } catch {
280
+ // The CLI prints a banner before the protocol starts, so plain text
281
+ // is expected up to the first valid frame. After that the stream is
282
+ // protocol, and a line that is shaped like a frame but does not
283
+ // parse is corruption: dropping it silently loses a session/update
284
+ // or lets the turn wait out the timeout for a reply that already
285
+ // arrived damaged.
286
+ if (sawProtocolFrame || line.startsWith("{")) {
287
+ finish("Devin CLI emitted a malformed ACP frame.");
288
+ }
289
+ return;
290
+ }
291
+ sawProtocolFrame = true;
292
+ handle(frame);
293
+ }
294
+
295
+ /** JSON-RPC ids are allowed to come back as strings. */
296
+ const idOf = (value: unknown): number | undefined => {
297
+ if (typeof value === "number") return value;
298
+ if (typeof value === "string" && /^\d+$/.test(value)) return Number(value);
299
+ return undefined;
300
+ };
301
+
302
+ function handle(frame: Record<string, unknown>): void {
303
+ if (settled) return;
304
+ const id = idOf(frame.id);
305
+ const error = frame.error as { message?: string } | undefined;
306
+
307
+ if (id === ACP_INITIALIZE_ID) {
308
+ if (error) return finish(`Devin CLI initialize failed: ${error.message ?? "unknown error"}`);
309
+ send(sessionNewFrame(cwd, modelId));
310
+ return;
311
+ }
312
+ if (id === ACP_SESSION_NEW_ID) {
313
+ if (error) return finish(`Devin CLI session/new failed: ${error.message ?? "unknown error"}`);
314
+ const sessionId = (frame.result as { sessionId?: string } | undefined)?.sessionId;
315
+ if (!sessionId) return finish("Devin CLI session/new returned no sessionId.");
316
+ send(sessionPromptFrame(sessionId, buildAcpPrompt(parsed)));
317
+ return;
318
+ }
319
+ if (frame.method === "session/request_permission" && frame.id != null) {
320
+ const params = frame.params as { options?: Array<{ optionId?: string; name?: string; kind?: string }> } | undefined;
321
+ send(permissionResponseFrame(frame.id as number | string, params?.options, toolsAllowed));
322
+ return;
323
+ }
324
+ if (frame.method === "session/update") {
325
+ const update = (frame.params as { update?: Record<string, unknown> } | undefined)?.update;
326
+ if (!update) return;
327
+ for (const event of acpUpdateToEvents(update)) emit(event);
328
+ return;
329
+ }
330
+ if (id === ACP_SESSION_PROMPT_ID) {
331
+ if (error) return finish(`Devin CLI session/prompt failed: ${error.message ?? "unknown error"}`);
332
+ sawPromptReply = true;
333
+ const result = frame.result as { stopReason?: unknown; usage?: unknown } | undefined;
334
+ usage = mapAcpUsage(result?.usage) ?? usage;
335
+ stopReason = mapAcpStopReason(result?.stopReason);
336
+ finish();
337
+ }
338
+ }
339
+
340
+ void sawPromptReply;
341
+ send(initializeFrame(process.env.OPENCODEX_VERSION ?? "0.0.0"));
342
+ });
343
+ },
344
+ };
345
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Locate the Devin CLI.
3
+ *
4
+ * The official installer (`curl -fsSL https://cli.devin.ai/install.sh | bash`)
5
+ * and the Homebrew cask both drop the binary in one of a small set of places.
6
+ * The environment override comes first so an operator can point at a specific
7
+ * build without touching PATH, and PATH is the last resort rather than the
8
+ * first so a shadowed name cannot silently win.
9
+ */
10
+ import { existsSync } from "node:fs";
11
+ import { delimiter, join } from "node:path";
12
+ import { homedir } from "node:os";
13
+
14
+ export const DEVIN_CLI_BIN_ENV = "OPENCODEX_DEVIN_CLI_BIN";
15
+
16
+ export const DEVIN_CLI_INSTALL_HINT =
17
+ "Install the Devin CLI with `curl -fsSL https://cli.devin.ai/install.sh | bash` or `brew install --cask devin-cli`, then run `devin auth login`.";
18
+
19
+ let cached: string | undefined;
20
+
21
+ /** Reset the discovery cache (tests, or an explicit re-check after an install). */
22
+ export function clearDevinCliBinaryCache(): void {
23
+ cached = undefined;
24
+ }
25
+
26
+ function candidatePaths(home: string): string[] {
27
+ return [
28
+ join(home, "AppData", "Local", "Microsoft", "WinGet", "Links", "devin.exe"),
29
+ join(home, ".local", "share", "devin", "bin", "devin"),
30
+ join(home, ".devin", "bin", "devin"),
31
+ join(home, ".local", "bin", "devin"),
32
+ "/opt/homebrew/bin/devin",
33
+ "/usr/local/bin/devin",
34
+ "/usr/bin/devin",
35
+ ];
36
+ }
37
+
38
+ function fromPath(exists: (p: string) => boolean): string | undefined {
39
+ const pathVar = process.env.PATH ?? "";
40
+ for (const dir of pathVar.split(delimiter)) {
41
+ if (!dir) continue;
42
+ for (const name of ["devin", "devin.exe"]) {
43
+ const full = join(dir, name);
44
+ if (exists(full)) return full;
45
+ }
46
+ }
47
+ return undefined;
48
+ }
49
+
50
+ /**
51
+ * Resolve the executable, or undefined when it is not installed.
52
+ *
53
+ * `exists` and `home` are seams so the resolution order can be tested without
54
+ * depending on what happens to be installed on the machine running the tests.
55
+ */
56
+ export function resolveDevinCliBinary(opts?: { exists?: (p: string) => boolean; home?: string; useCache?: boolean }): string | undefined {
57
+ const exists = opts?.exists ?? existsSync;
58
+ const useCache = opts?.useCache ?? opts === undefined;
59
+ if (useCache && cached) return cached;
60
+ const override = process.env[DEVIN_CLI_BIN_ENV]?.trim();
61
+ if (override) {
62
+ if (useCache) cached = override;
63
+ return override;
64
+ }
65
+ const home = opts?.home ?? homedir();
66
+ const found = candidatePaths(home).find((p) => exists(p)) ?? fromPath(exists);
67
+ if (found && useCache) cached = found;
68
+ return found;
69
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Models the Devin CLI accepts on `session/new`.
3
+ *
4
+ * The CLI picks its own default when no model is named, so this roster exists
5
+ * for the picker rather than as a gate. It is a static list on purpose: ACP has
6
+ * no discovery call, and the vendor roster moves faster than a pinned copy
7
+ * would, so an unknown id is passed through to the CLI to accept or refuse.
8
+ */
9
+ export const DEVIN_CLI_DEFAULT_MODEL = "swe-2";
10
+
11
+ export const DEVIN_CLI_MODELS = [
12
+ "swe-2",
13
+ "swe-2-high",
14
+ "claude-opus-5-medium",
15
+ "claude-fable-5-1-medium",
16
+ "claude-sonnet-5-medium",
17
+ "gpt-6-astra-medium",
18
+ "gpt-5-6-sol-medium",
19
+ "gemini-3-8-flash-medium",
20
+ "glm-5-3-high",
21
+ "glm-5-3-low",
22
+ "kimi-k3-high",
23
+ ] as const;
24
+
25
+ /**
26
+ * Context windows for the CLI roster, in the same effort-suffixed ids the CLI
27
+ * accepts.
28
+ *
29
+ * Without this the picker fell back to the 128k default for every Devin CLI
30
+ * model, including `swe-2`, which is the roster's own default — so the one
31
+ * model most sessions ran reported less than half its real window.
32
+ *
33
+ * The numbers come from Cognition's `GetCascadeModelConfigs` catalog
34
+ * (`ClientModelConfig` field #18), which is the only first-party source: the
35
+ * Devin CLI and Desktop model pages, the SWE-2 announcement, and the Windsurf
36
+ * model reference all list these models without a window. The CLI is a separate
37
+ * product from the cloud, but Cognition documents the same models on both and
38
+ * describes no per-surface difference — the SWE-2 announcement ships it to
39
+ * Desktop, CLI, Web, and Fusion in one sentence — so the catalog's figure is
40
+ * used for both rather than inventing a second table.
41
+ *
42
+ * ACP has no discovery call, so unlike the cloud provider this cannot be
43
+ * refreshed live; it needs updating when the roster above does.
44
+ */
45
+ export const DEVIN_CLI_MODEL_CONTEXT_WINDOWS: Record<string, number> = {
46
+ "swe-2": 262_000,
47
+ "swe-2-high": 262_000,
48
+ "claude-opus-5-medium": 1_000_000,
49
+ "claude-fable-5-1-medium": 1_000_000,
50
+ "claude-sonnet-5-medium": 1_000_000,
51
+ "gpt-6-astra-medium": 1_000_000,
52
+ "gpt-5-6-sol-medium": 1_000_000,
53
+ "gemini-3-8-flash-medium": 1_048_576,
54
+ "glm-5-3-high": 1_048_576,
55
+ "glm-5-3-low": 1_048_576,
56
+ "kimi-k3-high": 1_048_576,
57
+ };