@gajae-code/ai 0.16.7 → 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +44 -1
- package/dist/types/auth-storage.d.ts +23 -4
- package/dist/types/models.d.ts +14 -0
- package/dist/types/provider-models/special.d.ts +12 -0
- package/dist/types/providers/anthropic.d.ts +1 -1
- package/dist/types/providers/cursor.d.ts +33 -21
- package/dist/types/providers/devin-acp.d.ts +157 -0
- package/dist/types/providers/google-gemini-headers.d.ts +1 -1
- package/dist/types/providers/mock.d.ts +2 -0
- package/dist/types/providers/openai-responses-shared.d.ts +21 -1
- package/dist/types/providers/register-builtins.d.ts +1 -0
- package/dist/types/types.d.ts +52 -11
- package/dist/types/utils/block-symbols.d.ts +15 -5
- package/dist/types/utils/fallback-transport.d.ts +4 -1
- package/dist/types/utils.d.ts +13 -0
- package/package.json +4 -3
- package/src/api-registry.ts +1 -0
- package/src/auth-broker/redact.ts +10 -2
- package/src/auth-gateway/server.ts +56 -3
- package/src/auth-storage.ts +330 -116
- package/src/model-manager.ts +21 -2
- package/src/models.d.ts +14 -0
- package/src/models.json +117 -0
- package/src/models.ts +18 -0
- package/src/provider-models/descriptors.ts +7 -0
- package/src/provider-models/openai-compat.ts +14 -0
- package/src/provider-models/special.ts +39 -0
- package/src/providers/anthropic.d.ts +1 -1
- package/src/providers/anthropic.ts +1 -1
- package/src/providers/azure-openai-responses.ts +10 -1
- package/src/providers/cursor.d.ts +33 -21
- package/src/providers/cursor.ts +2024 -508
- package/src/providers/devin-acp.d.ts +157 -0
- package/src/providers/devin-acp.ts +1103 -0
- package/src/providers/google-gemini-headers.d.ts +1 -1
- package/src/providers/google-gemini-headers.ts +1 -1
- package/src/providers/mock.ts +16 -1
- package/src/providers/openai-chat-server.ts +3 -3
- package/src/providers/openai-codex-responses.ts +27 -17
- package/src/providers/openai-responses-server.ts +5 -5
- package/src/providers/openai-responses-shared.d.ts +21 -1
- package/src/providers/openai-responses-shared.ts +60 -6
- package/src/providers/openai-responses.ts +10 -1
- package/src/providers/register-builtins.d.ts +1 -0
- package/src/providers/register-builtins.ts +21 -1
- package/src/stream.ts +14 -0
- package/src/types.d.ts +52 -11
- package/src/types.ts +70 -8
- package/src/utils/block-symbols.d.ts +15 -5
- package/src/utils/block-symbols.ts +16 -6
- package/src/utils/discovery/cursor.ts +3 -2
- package/src/utils/fallback-transport.d.ts +4 -1
- package/src/utils/fallback-transport.ts +12 -5
- package/src/utils.d.ts +13 -0
- package/src/utils.ts +17 -0
- package/dist/types/utils/codex-entitlement.d.ts +0 -22
- package/src/utils/codex-entitlement.d.ts +0 -22
- package/src/utils/codex-entitlement.ts +0 -57
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Devin CLI provider — an ACP (Agent Client Protocol) client.
|
|
3
|
+
*
|
|
4
|
+
* Devin CLI exposes no raw model-inference endpoint. Its programmatic surface is
|
|
5
|
+
* `devin acp`: an Agent Client Protocol server over stdio that runs the whole
|
|
6
|
+
* agent (https://docs.devin.ai/cli/reference/commands#devin-acp). GJC therefore
|
|
7
|
+
* speaks ACP as the *client* and treats Devin as an agent-level provider:
|
|
8
|
+
*
|
|
9
|
+
* - GJC spawns `devin acp` and speaks ACP over the child's stdio.
|
|
10
|
+
* - One ACP session is reused for the lifetime of a GJC conversation, keyed by
|
|
11
|
+
* `providerSessionId` and stored in `providerSessionState`; `close()` kills
|
|
12
|
+
* the child process at session teardown.
|
|
13
|
+
* - Devin owns conversation history, so only the newest user turn is forwarded.
|
|
14
|
+
* - Devin executes its own tools. `session/update` tool calls are rendered as
|
|
15
|
+
* display-only `toolCall` blocks and every turn terminates with
|
|
16
|
+
* `stopReason: "stop"`, so GJC never re-executes a Devin tool call.
|
|
17
|
+
* - `session/request_permission` is answered from an explicit policy, never
|
|
18
|
+
* silently: see {@link DevinAcpPermissionMode}.
|
|
19
|
+
*
|
|
20
|
+
* Boundary (documented in docs/devin-provider.md): GJC tools, skills, workflows,
|
|
21
|
+
* hooks, and permission prompts for GJC's own tools do not apply inside a Devin
|
|
22
|
+
* turn; GJC maintenance work (compaction, handoff, branch summaries) and utility
|
|
23
|
+
* one-shots are refused rather than forwarded to Devin; and Devin bills its own
|
|
24
|
+
* account/ACU usage.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import {
|
|
28
|
+
type ToolCall as AcpToolCall,
|
|
29
|
+
type Agent,
|
|
30
|
+
type Client,
|
|
31
|
+
ClientSideConnection,
|
|
32
|
+
type ContentBlock,
|
|
33
|
+
type InitializeResponse,
|
|
34
|
+
ndJsonStream,
|
|
35
|
+
type PermissionOption,
|
|
36
|
+
type PromptCapabilities,
|
|
37
|
+
RequestError,
|
|
38
|
+
type RequestPermissionRequest,
|
|
39
|
+
type RequestPermissionResponse,
|
|
40
|
+
type SessionConfigOption,
|
|
41
|
+
type SessionNotification,
|
|
42
|
+
type StopReason,
|
|
43
|
+
type ToolCallUpdate,
|
|
44
|
+
} from "@agentclientprotocol/sdk";
|
|
45
|
+
import { VERSION } from "@gajae-code/utils";
|
|
46
|
+
import type {
|
|
47
|
+
Api,
|
|
48
|
+
AssistantMessage,
|
|
49
|
+
AssistantMessageEventStream as AssistantMessageEventStreamType,
|
|
50
|
+
Context,
|
|
51
|
+
Model,
|
|
52
|
+
ProviderSessionState,
|
|
53
|
+
StreamOptions,
|
|
54
|
+
ToolCall,
|
|
55
|
+
} from "../types";
|
|
56
|
+
import { kProviderResolvedToolCall, type ProviderResolvedCarrier } from "../utils/block-symbols";
|
|
57
|
+
import { AssistantMessageEventStream } from "../utils/event-stream";
|
|
58
|
+
import { getStreamFirstEventTimeoutMs, getStreamIdleTimeoutMs } from "../utils/idle-iterator";
|
|
59
|
+
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
// Constants
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
/** Environment variable overriding the Devin executable GJC spawns. */
|
|
65
|
+
export const DEVIN_ACP_CLI_ENV = "GJC_DEVIN_CLI_PATH";
|
|
66
|
+
/** Environment variable selecting how GJC answers Devin permission requests. */
|
|
67
|
+
export const DEVIN_ACP_PERMISSION_MODE_ENV = "GJC_DEVIN_PERMISSION_MODE";
|
|
68
|
+
/** Executable that serves `devin acp` when nothing overrides it. */
|
|
69
|
+
export const DEVIN_ACP_DEFAULT_CLI = "devin";
|
|
70
|
+
/**
|
|
71
|
+
* Placeholder base URL. The ACP transport never issues an HTTP request, but a
|
|
72
|
+
* model record must carry a non-empty base URL through the model registry.
|
|
73
|
+
*/
|
|
74
|
+
export const DEVIN_ACP_BASE_URL = "acp://devin-cli";
|
|
75
|
+
/**
|
|
76
|
+
* Conservative catalog defaults. ACP exposes no per-model token metadata, and
|
|
77
|
+
* GJC never sends conversation history to an ACP agent, so these values are
|
|
78
|
+
* display-only for this provider rather than a transport budget.
|
|
79
|
+
*/
|
|
80
|
+
export const DEVIN_ACP_CONTEXT_WINDOW = 200_000;
|
|
81
|
+
export const DEVIN_ACP_MAX_TOKENS = 64_000;
|
|
82
|
+
|
|
83
|
+
const DEVIN_ACP_CLIENT_NAME = "gajae-code";
|
|
84
|
+
/** ACP `auth_required` JSON-RPC error code (`RequestError.authRequired()`). */
|
|
85
|
+
const ACP_AUTH_REQUIRED_CODE = -32000;
|
|
86
|
+
/** Cap on one tool-call `arguments` payload copied into a transcript message. */
|
|
87
|
+
const ACP_TOOL_ARGUMENT_MAX_BYTES = 16 * 1024;
|
|
88
|
+
const DEVIN_ACP_STDERR_TAIL_BYTES = 4 * 1024;
|
|
89
|
+
/** Bound on waiting for a live ACP session before a cancellation settles anyway. */
|
|
90
|
+
const DEVIN_ACP_CANCEL_DELIVERY_GRACE_MS = 250;
|
|
91
|
+
/** Bound on waiting for a cancelled agent to acknowledge before a child is reaped. */
|
|
92
|
+
const DEVIN_ACP_CANCEL_ACK_GRACE_MS = 500;
|
|
93
|
+
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// Public provider surface
|
|
96
|
+
// ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* How GJC answers Devin's `session/request_permission` prompts.
|
|
100
|
+
*
|
|
101
|
+
* - `"allow"` (default) selects `allow_once`. A request that offers no
|
|
102
|
+
* `allow_once` is cancelled: GJC never grants a persistent approval on its own.
|
|
103
|
+
* - `"deny"` selects `reject_once` before `reject_always`.
|
|
104
|
+
*
|
|
105
|
+
* Neither mode escalates persistently on its own, and an unrecognized
|
|
106
|
+
* `GJC_DEVIN_PERMISSION_MODE` value fails closed to `"deny"`.
|
|
107
|
+
*/
|
|
108
|
+
export type DevinAcpPermissionMode = "allow" | "deny";
|
|
109
|
+
|
|
110
|
+
/** A permission request Devin raised for one of its own tool calls. */
|
|
111
|
+
export interface DevinAcpPermissionRequest {
|
|
112
|
+
sessionId: string;
|
|
113
|
+
toolCallId: string;
|
|
114
|
+
title: string;
|
|
115
|
+
kind?: string;
|
|
116
|
+
rawInput?: unknown;
|
|
117
|
+
options: ReadonlyArray<{ optionId: string; name: string; kind: string }>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Selected option id, or `cancelled` to answer with ACP `outcome: cancelled`. */
|
|
121
|
+
export type DevinAcpPermissionDecision = { optionId: string } | { cancelled: true };
|
|
122
|
+
|
|
123
|
+
/** Explicit decision callback; replaces the built-in permission mode policy. */
|
|
124
|
+
export type DevinAcpPermissionHandler = (
|
|
125
|
+
request: DevinAcpPermissionRequest,
|
|
126
|
+
) => Promise<DevinAcpPermissionDecision> | DevinAcpPermissionDecision;
|
|
127
|
+
|
|
128
|
+
/** Provider configuration threaded through `StreamOptions.devinAcp`. */
|
|
129
|
+
export interface DevinAcpConfig {
|
|
130
|
+
/** Executable serving `devin acp`. Defaults to {@link DEVIN_ACP_CLI_ENV} or `devin`. */
|
|
131
|
+
cliPath?: string;
|
|
132
|
+
/** Extra argv inserted before the `acp` verb. */
|
|
133
|
+
cliArgs?: readonly string[];
|
|
134
|
+
/** Working directory for the agent process. Defaults to `process.cwd()`. */
|
|
135
|
+
cwd?: string;
|
|
136
|
+
/**
|
|
137
|
+
* Overrides the permission policy for this request. Any value other than
|
|
138
|
+
* `"allow"` fails closed to `"deny"`, including out-of-type values from
|
|
139
|
+
* untyped callers.
|
|
140
|
+
*/
|
|
141
|
+
permissionMode?: DevinAcpPermissionMode;
|
|
142
|
+
/** Explicit permission decisions. When absent, the configured mode decides. */
|
|
143
|
+
permissionHandler?: DevinAcpPermissionHandler;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Devin ACP stream options. Every provider-specific field lives on
|
|
148
|
+
* `StreamOptions.devinAcp`; the alias exists so `ApiOptionsMap` can name this
|
|
149
|
+
* API's option type like every other API does.
|
|
150
|
+
*/
|
|
151
|
+
export type DevinAcpOptions = StreamOptions;
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Pure helpers (exported for the provider test suite)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
/** Map an ACP tool kind to the display tool name GJC renders. */
|
|
158
|
+
export function devinAcpDisplayToolName(kind: string | null | undefined, name?: string | null): string {
|
|
159
|
+
switch (kind) {
|
|
160
|
+
case "read":
|
|
161
|
+
return "read";
|
|
162
|
+
case "edit":
|
|
163
|
+
return "edit";
|
|
164
|
+
case "delete":
|
|
165
|
+
return "delete";
|
|
166
|
+
case "move":
|
|
167
|
+
return "move";
|
|
168
|
+
case "search":
|
|
169
|
+
return "grep";
|
|
170
|
+
case "execute":
|
|
171
|
+
return "bash";
|
|
172
|
+
case "think":
|
|
173
|
+
return "think";
|
|
174
|
+
case "fetch":
|
|
175
|
+
return "fetch";
|
|
176
|
+
case "switch_mode":
|
|
177
|
+
return "switch_mode";
|
|
178
|
+
default: {
|
|
179
|
+
const explicit = typeof name === "string" ? name.trim() : "";
|
|
180
|
+
return explicit.length > 0 ? explicit : "tool";
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Copy a tool-call `rawInput` payload into a transcript-safe `arguments` record.
|
|
187
|
+
*
|
|
188
|
+
* ACP payloads arrive through JSON-RPC, so they are already JSON-shaped; this
|
|
189
|
+
* only wraps non-objects and refuses to stage an unbounded payload.
|
|
190
|
+
*/
|
|
191
|
+
export function devinAcpToolArguments(rawInput: unknown): Record<string, unknown> {
|
|
192
|
+
if (rawInput === undefined || rawInput === null) return {};
|
|
193
|
+
if (typeof rawInput !== "object" || Array.isArray(rawInput)) return { value: rawInput };
|
|
194
|
+
let serialized: string;
|
|
195
|
+
try {
|
|
196
|
+
serialized = JSON.stringify(rawInput);
|
|
197
|
+
} catch {
|
|
198
|
+
return {};
|
|
199
|
+
}
|
|
200
|
+
if (serialized === undefined || serialized.length > ACP_TOOL_ARGUMENT_MAX_BYTES) return { truncated: true };
|
|
201
|
+
return rawInput as Record<string, unknown>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Map an ACP prompt stop reason onto GJC's assistant stop reason. */
|
|
205
|
+
export function devinAcpStopReason(stopReason: StopReason): "stop" | "length" | "aborted" {
|
|
206
|
+
switch (stopReason) {
|
|
207
|
+
case "max_tokens":
|
|
208
|
+
case "max_turn_requests":
|
|
209
|
+
return "length";
|
|
210
|
+
case "cancelled":
|
|
211
|
+
return "aborted";
|
|
212
|
+
default:
|
|
213
|
+
return "stop";
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** Flatten ACP select option groups into `{ id, name }` model entries. */
|
|
218
|
+
export function devinAcpSelectOptions(
|
|
219
|
+
options: Extract<SessionConfigOption, { type: "select" }>["options"],
|
|
220
|
+
): Array<{ id: string; name: string }> {
|
|
221
|
+
const entries: Array<{ id: string; name: string }> = [];
|
|
222
|
+
for (const candidate of options) {
|
|
223
|
+
if ("group" in candidate) {
|
|
224
|
+
for (const grouped of candidate.options) entries.push({ id: grouped.value, name: grouped.name });
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
entries.push({ id: candidate.value, name: candidate.name });
|
|
228
|
+
}
|
|
229
|
+
return entries;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Select the option matching the configured policy.
|
|
234
|
+
*
|
|
235
|
+
* `allow` grants a single action and never a persistent one: when the agent
|
|
236
|
+
* offers no `allow_once`, the request is cancelled rather than escalated to
|
|
237
|
+
* `allow_always`. `deny` may fall back to `reject_always`, because a persistent
|
|
238
|
+
* refusal only reduces what the agent may do.
|
|
239
|
+
*/
|
|
240
|
+
export function devinAcpSelectPermissionOption(
|
|
241
|
+
options: ReadonlyArray<PermissionOption>,
|
|
242
|
+
mode: DevinAcpPermissionMode,
|
|
243
|
+
): { optionId: string } | null {
|
|
244
|
+
const preferred = mode === "allow" ? (["allow_once"] as const) : (["reject_once", "reject_always"] as const);
|
|
245
|
+
for (const kind of preferred) {
|
|
246
|
+
const found = options.find(option => option.kind === kind);
|
|
247
|
+
if (found) return { optionId: found.optionId };
|
|
248
|
+
}
|
|
249
|
+
return null;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Resolve the permission policy from an explicit config value or the environment. */
|
|
253
|
+
export function devinAcpResolvePermissionMode(
|
|
254
|
+
configured: DevinAcpPermissionMode | undefined,
|
|
255
|
+
env: Record<string, string | undefined> = process.env,
|
|
256
|
+
): DevinAcpPermissionMode {
|
|
257
|
+
// An explicit value always wins and fails closed: an out-of-type value from an
|
|
258
|
+
// untyped caller must never fall through to the permissive default.
|
|
259
|
+
if (configured !== undefined) return configured === "allow" ? "allow" : "deny";
|
|
260
|
+
const raw = env[DEVIN_ACP_PERMISSION_MODE_ENV]?.trim().toLowerCase();
|
|
261
|
+
if (raw === undefined || raw.length === 0) return "allow";
|
|
262
|
+
return raw === "allow" ? "allow" : "deny";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Build the ACP prompt content blocks for the newest user turn. */
|
|
266
|
+
export function devinAcpPromptBlocks(context: Context, supportsImages: boolean): ContentBlock[] {
|
|
267
|
+
const messages = Array.isArray(context.messages) ? context.messages : [];
|
|
268
|
+
let lastUser: (typeof messages)[number] | undefined;
|
|
269
|
+
for (let index = messages.length - 1; index >= 0; index--) {
|
|
270
|
+
if (messages[index]?.role === "user") {
|
|
271
|
+
lastUser = messages[index];
|
|
272
|
+
break;
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (lastUser?.role !== "user") return [];
|
|
276
|
+
const content =
|
|
277
|
+
typeof lastUser.content === "string" ? [{ type: "text" as const, text: lastUser.content }] : lastUser.content;
|
|
278
|
+
const blocks: ContentBlock[] = [];
|
|
279
|
+
for (const item of content) {
|
|
280
|
+
if (item.type === "text") {
|
|
281
|
+
if (item.text.length > 0) blocks.push({ type: "text", text: item.text });
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (item.type === "image") {
|
|
285
|
+
if (!supportsImages) {
|
|
286
|
+
blocks.push({
|
|
287
|
+
type: "text",
|
|
288
|
+
text: `[image attachment omitted: this Devin ACP agent does not advertise image prompt support (${item.mimeType})]`,
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
blocks.push({ type: "image", data: item.data, mimeType: item.mimeType });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
return blocks;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// Turn state
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
|
|
302
|
+
interface ActiveTurn {
|
|
303
|
+
stream: AssistantMessageEventStream;
|
|
304
|
+
output: AssistantMessage;
|
|
305
|
+
textIndex: number | null;
|
|
306
|
+
thinkingIndex: number | null;
|
|
307
|
+
toolCallIndexes: Map<string, number>;
|
|
308
|
+
settled: boolean;
|
|
309
|
+
/**
|
|
310
|
+
* Resolved by `cancelTurn` so a cancellation that races the ACP handshake stops
|
|
311
|
+
* the turn instead of waiting for a session that may never be created.
|
|
312
|
+
*/
|
|
313
|
+
cancellation: Promise<void>;
|
|
314
|
+
requestCancellation: () => void;
|
|
315
|
+
/**
|
|
316
|
+
* Re-arms this turn's idle budget; set only while this turn's ACP prompt is in
|
|
317
|
+
* flight. It lives on the turn, not the bridge, so a stale turn can never
|
|
318
|
+
* re-arm — or cancel — the turn that replaced it.
|
|
319
|
+
*/
|
|
320
|
+
rearmIdle: (() => void) | undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function closeTextBlock(turn: ActiveTurn): void {
|
|
324
|
+
if (turn.textIndex === null) return;
|
|
325
|
+
const block = turn.output.content[turn.textIndex];
|
|
326
|
+
const text = block?.type === "text" ? block.text : "";
|
|
327
|
+
turn.stream.push({ type: "text_end", contentIndex: turn.textIndex, content: text, partial: turn.output });
|
|
328
|
+
turn.textIndex = null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function closeThinkingBlock(turn: ActiveTurn): void {
|
|
332
|
+
if (turn.thinkingIndex === null) return;
|
|
333
|
+
const block = turn.output.content[turn.thinkingIndex];
|
|
334
|
+
const thinking = block?.type === "thinking" ? block.thinking : "";
|
|
335
|
+
turn.stream.push({
|
|
336
|
+
type: "thinking_end",
|
|
337
|
+
contentIndex: turn.thinkingIndex,
|
|
338
|
+
content: thinking,
|
|
339
|
+
partial: turn.output,
|
|
340
|
+
});
|
|
341
|
+
turn.thinkingIndex = null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function appendTextDelta(turn: ActiveTurn, text: string): void {
|
|
345
|
+
if (text.length === 0) return;
|
|
346
|
+
closeThinkingBlock(turn);
|
|
347
|
+
if (turn.textIndex === null) {
|
|
348
|
+
const index = turn.output.content.length;
|
|
349
|
+
turn.output.content.push({ type: "text", text: "" });
|
|
350
|
+
turn.textIndex = index;
|
|
351
|
+
turn.stream.push({ type: "text_start", contentIndex: index, partial: turn.output });
|
|
352
|
+
}
|
|
353
|
+
const block = turn.output.content[turn.textIndex];
|
|
354
|
+
if (block?.type !== "text") return;
|
|
355
|
+
block.text += text;
|
|
356
|
+
turn.stream.push({ type: "text_delta", contentIndex: turn.textIndex, delta: text, partial: turn.output });
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function appendThinkingDelta(turn: ActiveTurn, text: string): void {
|
|
360
|
+
if (text.length === 0) return;
|
|
361
|
+
closeTextBlock(turn);
|
|
362
|
+
if (turn.thinkingIndex === null) {
|
|
363
|
+
const index = turn.output.content.length;
|
|
364
|
+
turn.output.content.push({ type: "thinking", thinking: "" });
|
|
365
|
+
turn.thinkingIndex = index;
|
|
366
|
+
turn.stream.push({ type: "thinking_start", contentIndex: index, partial: turn.output });
|
|
367
|
+
}
|
|
368
|
+
const block = turn.output.content[turn.thinkingIndex];
|
|
369
|
+
if (block?.type !== "thinking") return;
|
|
370
|
+
block.thinking += text;
|
|
371
|
+
turn.stream.push({ type: "thinking_delta", contentIndex: turn.thinkingIndex, delta: text, partial: turn.output });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Metadata GJC attaches to a tool call that ran inside the Devin agent. */
|
|
375
|
+
function acpAnnotation(toolCallId: string, kind?: string | null, status?: string | null): Record<string, unknown> {
|
|
376
|
+
return {
|
|
377
|
+
toolCallId,
|
|
378
|
+
...(kind ? { kind } : {}),
|
|
379
|
+
...(status ? { status } : {}),
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function upsertToolCall(turn: ActiveTurn, toolCall: AcpToolCall | ToolCallUpdate, existingOnly: boolean): void {
|
|
384
|
+
closeTextBlock(turn);
|
|
385
|
+
closeThinkingBlock(turn);
|
|
386
|
+
const kind = "kind" in toolCall ? toolCall.kind : undefined;
|
|
387
|
+
const status = "status" in toolCall ? toolCall.status : undefined;
|
|
388
|
+
const title = toolCall.title ?? "";
|
|
389
|
+
const index = turn.toolCallIndexes.get(toolCall.toolCallId);
|
|
390
|
+
if (index !== undefined) {
|
|
391
|
+
const block = turn.output.content[index];
|
|
392
|
+
if (block?.type !== "toolCall") return;
|
|
393
|
+
const previous = block.arguments._acp as Record<string, unknown> | undefined;
|
|
394
|
+
// ACP tool-call updates carry only changed fields, so merge with what the
|
|
395
|
+
// opening `tool_call` already recorded instead of dropping it.
|
|
396
|
+
block.arguments = {
|
|
397
|
+
...block.arguments,
|
|
398
|
+
_acp: {
|
|
399
|
+
...previous,
|
|
400
|
+
...(kind ? { kind } : {}),
|
|
401
|
+
...(status ? { status } : {}),
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
if (title.length > 0) block.intent = title;
|
|
405
|
+
turn.stream.push({ type: "toolcall_end", contentIndex: index, toolCall: block, partial: turn.output });
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
if (existingOnly) return;
|
|
409
|
+
const block: ToolCall & ProviderResolvedCarrier = {
|
|
410
|
+
type: "toolCall",
|
|
411
|
+
id: toolCall.toolCallId,
|
|
412
|
+
name: devinAcpDisplayToolName(kind, "name" in toolCall ? toolCall.name : undefined),
|
|
413
|
+
arguments: {
|
|
414
|
+
...devinAcpToolArguments("rawInput" in toolCall ? toolCall.rawInput : undefined),
|
|
415
|
+
_acp: acpAnnotation(toolCall.toolCallId, kind, status),
|
|
416
|
+
},
|
|
417
|
+
...(title.length > 0 ? { intent: title } : {}),
|
|
418
|
+
// The agent already ran this call. The marker is what stops the GJC agent
|
|
419
|
+
// loop from dispatching it to a local tool of the same display name.
|
|
420
|
+
[kProviderResolvedToolCall]: true,
|
|
421
|
+
};
|
|
422
|
+
const created = turn.output.content.length;
|
|
423
|
+
turn.output.content.push(block);
|
|
424
|
+
turn.toolCallIndexes.set(toolCall.toolCallId, created);
|
|
425
|
+
turn.stream.push({ type: "toolcall_start", contentIndex: created, partial: turn.output });
|
|
426
|
+
turn.stream.push({ type: "toolcall_end", contentIndex: created, toolCall: block, partial: turn.output });
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function applySessionUpdate(turn: ActiveTurn, update: SessionNotification["update"]): void {
|
|
430
|
+
switch (update.sessionUpdate) {
|
|
431
|
+
case "agent_message_chunk":
|
|
432
|
+
if (update.content.type === "text") appendTextDelta(turn, update.content.text);
|
|
433
|
+
return;
|
|
434
|
+
case "agent_thought_chunk":
|
|
435
|
+
if (update.content.type === "text") appendThinkingDelta(turn, update.content.text);
|
|
436
|
+
return;
|
|
437
|
+
case "tool_call":
|
|
438
|
+
upsertToolCall(turn, update, false);
|
|
439
|
+
return;
|
|
440
|
+
case "tool_call_update":
|
|
441
|
+
upsertToolCall(turn, update, true);
|
|
442
|
+
return;
|
|
443
|
+
default:
|
|
444
|
+
// user_message_chunk echoes our own prompt; plan/plan_update/plan_removed,
|
|
445
|
+
// available_commands_update, current_mode_update, config_option_update,
|
|
446
|
+
// session_info_update and usage_update carry no GJC-visible surface. Unknown
|
|
447
|
+
// update kinds are never assumed safe to render into the transcript.
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function settleTurn(turn: ActiveTurn, reason: "done" | "error" | "aborted"): void {
|
|
453
|
+
if (turn.settled) return;
|
|
454
|
+
turn.settled = true;
|
|
455
|
+
if (reason === "done") {
|
|
456
|
+
turn.stream.push({
|
|
457
|
+
type: "done",
|
|
458
|
+
reason: turn.output.stopReason === "length" ? "length" : "stop",
|
|
459
|
+
message: turn.output,
|
|
460
|
+
});
|
|
461
|
+
} else {
|
|
462
|
+
turn.stream.push({ type: "error", reason, error: turn.output });
|
|
463
|
+
}
|
|
464
|
+
turn.stream.end();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ---------------------------------------------------------------------------
|
|
468
|
+
// ACP bridge
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
|
|
471
|
+
interface DevinAcpSessionHandle {
|
|
472
|
+
id: string;
|
|
473
|
+
configOptions: SessionConfigOption[] | null;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function resolveSessionId(value: string | undefined): string | undefined {
|
|
477
|
+
if (typeof value !== "string") return undefined;
|
|
478
|
+
const trimmed = value.trim();
|
|
479
|
+
return trimmed.length > 0 ? trimmed : undefined;
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function errorMessage(error: unknown): string {
|
|
483
|
+
if (error instanceof Error && error.message.length > 0) return error.message;
|
|
484
|
+
return String(error);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function isAuthRequired(error: unknown): boolean {
|
|
488
|
+
return error instanceof RequestError && error.code === ACP_AUTH_REQUIRED_CODE;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* Devin turns are interactive session turns only.
|
|
493
|
+
*
|
|
494
|
+
* GJC routes several non-turn requests through a model provider — context
|
|
495
|
+
* compaction, handoff generation, session-title generation, branch summaries —
|
|
496
|
+
* and each of those would otherwise be sent to Devin as a real (billed) agent
|
|
497
|
+
* prompt that cannot answer it. They are refused up front with an actionable
|
|
498
|
+
* message instead.
|
|
499
|
+
*/
|
|
500
|
+
function refuseNonInteractiveTurn(options: DevinAcpOptions | undefined): void {
|
|
501
|
+
if (options?.maintenanceCall || options?.initiatorOverride === "agent") {
|
|
502
|
+
throw new Error(
|
|
503
|
+
"Devin ACP cannot serve GJC maintenance calls (context compaction, handoff, branch summaries): it is an agent, not a text model. Switch to a non-Devin model for that operation — Devin manages its own conversation context, so GJC-side compaction is not needed for a Devin turn.",
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
if (!resolveSessionId(options?.providerSessionId) && !resolveSessionId(options?.sessionId)) {
|
|
507
|
+
throw new Error(
|
|
508
|
+
"Devin ACP turns require a session identity (providerSessionId). GJC utility one-shots such as session-title generation are not supported on a Devin model.",
|
|
509
|
+
);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const DEVIN_ACP_INSTALL_HINT =
|
|
514
|
+
"Install Devin CLI (https://docs.devin.ai/cli) and run `devin auth login`, or point GJC_DEVIN_CLI_PATH at the executable.";
|
|
515
|
+
|
|
516
|
+
/** Turn a launch failure (missing binary, permissions) into an actionable message. */
|
|
517
|
+
function spawnFailureMessage(error: unknown): string {
|
|
518
|
+
const base = errorMessage(error);
|
|
519
|
+
return /ENOENT|not found|EACCES/i.test(base) ? `${base} ${DEVIN_ACP_INSTALL_HINT}` : base;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
class DevinAcpBridge implements ProviderSessionState {
|
|
523
|
+
readonly #proc: Bun.Subprocess<"pipe", "pipe", "pipe">;
|
|
524
|
+
readonly #connection: ClientSideConnection;
|
|
525
|
+
readonly #cwd: string;
|
|
526
|
+
/** Re-applied per turn: the bridge is cached, the policy is not. */
|
|
527
|
+
#permissionMode: DevinAcpPermissionMode;
|
|
528
|
+
#permissionHandler: DevinAcpPermissionHandler | undefined;
|
|
529
|
+
#init: Promise<InitializeResponse> | undefined;
|
|
530
|
+
#session: Promise<DevinAcpSessionHandle> | undefined;
|
|
531
|
+
#promptCapabilities: PromptCapabilities | undefined;
|
|
532
|
+
#turn: ActiveTurn | null = null;
|
|
533
|
+
#stderrTail = "";
|
|
534
|
+
#exitError: Error | null = null;
|
|
535
|
+
#disposed = false;
|
|
536
|
+
|
|
537
|
+
constructor(config: {
|
|
538
|
+
argv: readonly string[];
|
|
539
|
+
cwd: string;
|
|
540
|
+
permissionMode: DevinAcpPermissionMode;
|
|
541
|
+
permissionHandler?: DevinAcpPermissionHandler;
|
|
542
|
+
}) {
|
|
543
|
+
this.#cwd = config.cwd;
|
|
544
|
+
this.#permissionMode = config.permissionMode;
|
|
545
|
+
this.#permissionHandler = config.permissionHandler;
|
|
546
|
+
this.#proc = Bun.spawn([...config.argv], {
|
|
547
|
+
cwd: config.cwd,
|
|
548
|
+
stdin: "pipe",
|
|
549
|
+
stdout: "pipe",
|
|
550
|
+
stderr: "pipe",
|
|
551
|
+
env: process.env,
|
|
552
|
+
});
|
|
553
|
+
const sink = this.#proc.stdin;
|
|
554
|
+
const output = new WritableStream<Uint8Array>({
|
|
555
|
+
async write(chunk) {
|
|
556
|
+
sink.write(chunk);
|
|
557
|
+
await sink.flush();
|
|
558
|
+
},
|
|
559
|
+
async close() {
|
|
560
|
+
await sink.end();
|
|
561
|
+
},
|
|
562
|
+
async abort() {
|
|
563
|
+
try {
|
|
564
|
+
await sink.end();
|
|
565
|
+
} catch {
|
|
566
|
+
// The child already closed its end of the pipe.
|
|
567
|
+
}
|
|
568
|
+
},
|
|
569
|
+
});
|
|
570
|
+
this.#connection = new ClientSideConnection(
|
|
571
|
+
(_agent: Agent) => this.#client(),
|
|
572
|
+
ndJsonStream(output, this.#proc.stdout),
|
|
573
|
+
);
|
|
574
|
+
void this.#drainStderr();
|
|
575
|
+
void this.#watchExit();
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
get exitError(): Error | null {
|
|
579
|
+
return this.#exitFailure();
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Terminal child-process failure, if any. Read from the live exit code as well
|
|
584
|
+
* as the exit watcher so a turn that races the watcher still reports the real
|
|
585
|
+
* cause instead of a generic transport error.
|
|
586
|
+
*/
|
|
587
|
+
#exitFailure(): Error | null {
|
|
588
|
+
if (this.#exitError) return this.#exitError;
|
|
589
|
+
const code = this.#proc.exitCode;
|
|
590
|
+
if (code === null) return null;
|
|
591
|
+
const tail = this.#stderrTail.trim();
|
|
592
|
+
return new Error(`Devin ACP process exited with code ${code}${tail.length > 0 ? `: ${tail}` : "."}`);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
get supportsImages(): boolean {
|
|
596
|
+
return this.#promptCapabilities?.image === true;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
#client(): Client {
|
|
600
|
+
return {
|
|
601
|
+
requestPermission: params => this.#handlePermission(params),
|
|
602
|
+
sessionUpdate: notification => {
|
|
603
|
+
const turn = this.#turn;
|
|
604
|
+
if (!turn || turn.settled) return;
|
|
605
|
+
// The idle budget is the gap between agent updates, not a whole-turn wall
|
|
606
|
+
// clock: a long but active Devin turn must never be killed by it.
|
|
607
|
+
turn.rearmIdle?.();
|
|
608
|
+
applySessionUpdate(turn, notification.update);
|
|
609
|
+
},
|
|
610
|
+
};
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
async #drainStderr(): Promise<void> {
|
|
614
|
+
try {
|
|
615
|
+
const decoder = new TextDecoder();
|
|
616
|
+
for await (const chunk of this.#proc.stderr as ReadableStream<Uint8Array>) {
|
|
617
|
+
this.#stderrTail = (this.#stderrTail + decoder.decode(chunk, { stream: true })).slice(
|
|
618
|
+
-DEVIN_ACP_STDERR_TAIL_BYTES,
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
} catch {
|
|
622
|
+
// Diagnostics only; the protocol path reports real failures.
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async #watchExit(): Promise<void> {
|
|
627
|
+
await this.#proc.exited;
|
|
628
|
+
if (this.#disposed) return;
|
|
629
|
+
this.#disposed = true;
|
|
630
|
+
this.#exitError = this.#exitFailure();
|
|
631
|
+
const turn = this.#turn;
|
|
632
|
+
if (turn && !turn.settled) {
|
|
633
|
+
turn.output.stopReason = "error";
|
|
634
|
+
turn.output.errorMessage = this.#exitError?.message ?? "Devin ACP process exited.";
|
|
635
|
+
settleTurn(turn, "error");
|
|
636
|
+
}
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async #ensureInitialized(): Promise<InitializeResponse> {
|
|
640
|
+
this.#init ??= this.#connection.initialize({
|
|
641
|
+
protocolVersion: 1,
|
|
642
|
+
clientCapabilities: { fs: {}, terminal: false },
|
|
643
|
+
clientInfo: { name: DEVIN_ACP_CLIENT_NAME, version: VERSION },
|
|
644
|
+
});
|
|
645
|
+
try {
|
|
646
|
+
const response = await this.#init;
|
|
647
|
+
this.#promptCapabilities = response.agentCapabilities?.promptCapabilities ?? undefined;
|
|
648
|
+
return response;
|
|
649
|
+
} catch (error) {
|
|
650
|
+
this.#init = undefined;
|
|
651
|
+
throw error;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
async #ensureSession(): Promise<DevinAcpSessionHandle> {
|
|
656
|
+
this.#session ??= (async () => {
|
|
657
|
+
await this.#ensureInitialized();
|
|
658
|
+
try {
|
|
659
|
+
const response = await this.#connection.newSession({ cwd: this.#cwd, mcpServers: [] });
|
|
660
|
+
return { id: response.sessionId, configOptions: response.configOptions ?? null };
|
|
661
|
+
} catch (error) {
|
|
662
|
+
if (isAuthRequired(error)) {
|
|
663
|
+
throw new Error(
|
|
664
|
+
"Devin CLI is not authenticated. Run `devin auth login` (or set WINDSURF_API_KEY for enterprise builds) and retry.",
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
throw error;
|
|
668
|
+
}
|
|
669
|
+
})();
|
|
670
|
+
try {
|
|
671
|
+
return await this.#session;
|
|
672
|
+
} catch (error) {
|
|
673
|
+
this.#session = undefined;
|
|
674
|
+
throw error;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
#modelConfigOption(session: DevinAcpSessionHandle): Extract<SessionConfigOption, { type: "select" }> | undefined {
|
|
679
|
+
return session.configOptions?.find(
|
|
680
|
+
(candidate): candidate is Extract<SessionConfigOption, { type: "select" }> =>
|
|
681
|
+
candidate.category === "model" && candidate.type === "select",
|
|
682
|
+
);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
async listModels(): Promise<Array<{ id: string; name: string }>> {
|
|
686
|
+
const session = await this.#ensureSession();
|
|
687
|
+
const option = this.#modelConfigOption(session);
|
|
688
|
+
return option ? devinAcpSelectOptions(option.options) : [];
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Apply the GJC-selected model to the ACP session.
|
|
693
|
+
*
|
|
694
|
+
* When the session advertises a model selector, a model the account cannot
|
|
695
|
+
* use fails loudly instead of silently running a different (possibly more
|
|
696
|
+
* expensive) model. Agents without a model selector keep their own default.
|
|
697
|
+
*/
|
|
698
|
+
async #selectModel(modelId: string): Promise<void> {
|
|
699
|
+
if (modelId.length === 0) return;
|
|
700
|
+
const session = await this.#ensureSession();
|
|
701
|
+
const option = this.#modelConfigOption(session);
|
|
702
|
+
if (!option) return;
|
|
703
|
+
if (option.currentValue === modelId) return;
|
|
704
|
+
const available = devinAcpSelectOptions(option.options).some(entry => entry.id === modelId);
|
|
705
|
+
if (!available) {
|
|
706
|
+
throw new Error(
|
|
707
|
+
`Devin account does not offer model "${modelId}". Run \`devin models list\` or pick a discovered Devin model.`,
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
const response = await this.#connection.setSessionConfigOption({
|
|
711
|
+
sessionId: session.id,
|
|
712
|
+
configId: option.id,
|
|
713
|
+
value: modelId,
|
|
714
|
+
});
|
|
715
|
+
session.configOptions = response.configOptions;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
async #handlePermission(params: RequestPermissionRequest): Promise<RequestPermissionResponse> {
|
|
719
|
+
const request: DevinAcpPermissionRequest = {
|
|
720
|
+
sessionId: params.sessionId,
|
|
721
|
+
toolCallId: params.toolCall.toolCallId,
|
|
722
|
+
title: params.toolCall.title ?? "",
|
|
723
|
+
...(params.toolCall.kind ? { kind: params.toolCall.kind } : {}),
|
|
724
|
+
...(params.toolCall.rawInput !== undefined ? { rawInput: params.toolCall.rawInput } : {}),
|
|
725
|
+
options: params.options.map(option => ({ optionId: option.optionId, name: option.name, kind: option.kind })),
|
|
726
|
+
};
|
|
727
|
+
let decision: DevinAcpPermissionDecision | null = null;
|
|
728
|
+
if (this.#permissionHandler) {
|
|
729
|
+
try {
|
|
730
|
+
decision = await this.#permissionHandler(request);
|
|
731
|
+
} catch {
|
|
732
|
+
// A broken handler must never become an implicit approval.
|
|
733
|
+
decision = null;
|
|
734
|
+
}
|
|
735
|
+
} else {
|
|
736
|
+
decision = devinAcpSelectPermissionOption(params.options, this.#permissionMode);
|
|
737
|
+
}
|
|
738
|
+
if (!decision || "cancelled" in decision) return { outcome: { outcome: "cancelled" } };
|
|
739
|
+
if (!params.options.some(option => option.optionId === decision.optionId))
|
|
740
|
+
return { outcome: { outcome: "cancelled" } };
|
|
741
|
+
return { outcome: { outcome: "selected", optionId: decision.optionId } };
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Start one ACP prompt turn. Returns synchronously with the caller's stream;
|
|
746
|
+
* the returned promise settles when the turn finishes.
|
|
747
|
+
*
|
|
748
|
+
* One ACP session carries one turn at a time: a concurrent call would
|
|
749
|
+
* interleave two transcripts into the same session.
|
|
750
|
+
*/
|
|
751
|
+
beginTurn(
|
|
752
|
+
turn: ActiveTurn,
|
|
753
|
+
model: Model<"devin-acp">,
|
|
754
|
+
context: Context,
|
|
755
|
+
options: DevinAcpOptions | undefined,
|
|
756
|
+
): Promise<void> {
|
|
757
|
+
const active = this.#turn;
|
|
758
|
+
if (active && !active.settled) {
|
|
759
|
+
turn.output.stopReason = "error";
|
|
760
|
+
turn.output.errorMessage = "A Devin ACP turn is already running on this session.";
|
|
761
|
+
settleTurn(turn, "error");
|
|
762
|
+
return Promise.resolve();
|
|
763
|
+
}
|
|
764
|
+
this.#turn = turn;
|
|
765
|
+
return this.#runTurn(turn, model, context, options);
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
async #runTurn(
|
|
769
|
+
turn: ActiveTurn,
|
|
770
|
+
model: Model<"devin-acp">,
|
|
771
|
+
context: Context,
|
|
772
|
+
options: DevinAcpOptions | undefined,
|
|
773
|
+
): Promise<void> {
|
|
774
|
+
const { output } = turn;
|
|
775
|
+
// Every provider opens with a start event carrying its partial assistant
|
|
776
|
+
// message: the loop uses it to publish streaming updates and to hold the
|
|
777
|
+
// partial while the turn runs.
|
|
778
|
+
turn.stream.push({ type: "start", partial: output });
|
|
779
|
+
try {
|
|
780
|
+
refuseNonInteractiveTurn(options);
|
|
781
|
+
} catch (error) {
|
|
782
|
+
output.stopReason = "error";
|
|
783
|
+
output.errorMessage = errorMessage(error);
|
|
784
|
+
settleTurn(turn, "error");
|
|
785
|
+
if (this.#turn === turn) this.#turn = null;
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
// The bridge is cached per conversation, so the permission policy is applied
|
|
789
|
+
// per turn: a later turn that tightens the mode, or supplies its own handler,
|
|
790
|
+
// must not inherit whichever turn happened to create the child.
|
|
791
|
+
this.#permissionMode = devinAcpResolvePermissionMode(options?.devinAcp?.permissionMode);
|
|
792
|
+
this.#permissionHandler = options?.devinAcp?.permissionHandler;
|
|
793
|
+
const startedAt = Date.now();
|
|
794
|
+
const idleTimeoutMs = options?.streamIdleTimeoutMs ?? getStreamIdleTimeoutMs();
|
|
795
|
+
const firstEventTimeoutMs = options?.streamFirstEventTimeoutMs ?? getStreamFirstEventTimeoutMs(idleTimeoutMs);
|
|
796
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
797
|
+
const arm = (budgetMs: number | undefined, label: string) => {
|
|
798
|
+
if (watchdog !== undefined) clearTimeout(watchdog);
|
|
799
|
+
if (budgetMs === undefined || !Number.isFinite(budgetMs) || budgetMs <= 0) return;
|
|
800
|
+
watchdog = setTimeout(() => {
|
|
801
|
+
void this.cancelTurn(new Error(`Devin ACP ${label} timed out after ${budgetMs}ms`));
|
|
802
|
+
}, budgetMs);
|
|
803
|
+
};
|
|
804
|
+
const onAbort = () => {
|
|
805
|
+
void this.cancelTurn();
|
|
806
|
+
};
|
|
807
|
+
options?.signal?.addEventListener("abort", onAbort, { once: true });
|
|
808
|
+
try {
|
|
809
|
+
const exited = this.#exitFailure();
|
|
810
|
+
if (exited) throw exited;
|
|
811
|
+
if (options?.signal?.aborted) {
|
|
812
|
+
output.stopReason = "aborted";
|
|
813
|
+
settleTurn(turn, "aborted");
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
arm(firstEventTimeoutMs, "first-agent-update");
|
|
817
|
+
// Race the handshake against cancellation: a `devin acp` child that never
|
|
818
|
+
// answers `initialize`/`session/new` must not hang the turn (or leak the
|
|
819
|
+
// child) after the caller already cancelled it.
|
|
820
|
+
const handshake = this.#ensureSession();
|
|
821
|
+
void handshake.catch(() => undefined);
|
|
822
|
+
const session = await Promise.race([handshake, turn.cancellation.then(() => null)]);
|
|
823
|
+
if (session === null || turn.settled) return;
|
|
824
|
+
await this.#selectModel(model.id);
|
|
825
|
+
if (turn.settled) return;
|
|
826
|
+
const blocks = devinAcpPromptBlocks(context, this.supportsImages);
|
|
827
|
+
if (blocks.length === 0) {
|
|
828
|
+
throw new Error(
|
|
829
|
+
"Devin ACP turns require a user message to forward; an empty prompt is not sent to the agent.",
|
|
830
|
+
);
|
|
831
|
+
}
|
|
832
|
+
arm(idleTimeoutMs, "agent-update");
|
|
833
|
+
turn.rearmIdle = () => arm(idleTimeoutMs, "agent-update");
|
|
834
|
+
const promptCall = this.#connection.prompt({ sessionId: session.id, prompt: blocks });
|
|
835
|
+
// A child that never answers `session/prompt` must not park `#runTurn` forever,
|
|
836
|
+
// or a disposable bridge could never be reaped.
|
|
837
|
+
void promptCall.catch(() => undefined);
|
|
838
|
+
const response = await Promise.race([
|
|
839
|
+
promptCall,
|
|
840
|
+
turn.cancellation.then(async () => {
|
|
841
|
+
// Grace for the cancelled agent to answer `session/cancel` before a
|
|
842
|
+
// disposable child is reaped; a hung agent still unwinds after this bound.
|
|
843
|
+
await Bun.sleep(DEVIN_ACP_CANCEL_ACK_GRACE_MS);
|
|
844
|
+
return null;
|
|
845
|
+
}),
|
|
846
|
+
]);
|
|
847
|
+
// A cancel that raced the prompt settles the stream first; never rewrite the
|
|
848
|
+
// terminal state of a turn that has already been published.
|
|
849
|
+
if (response === null || turn.settled) return;
|
|
850
|
+
closeTextBlock(turn);
|
|
851
|
+
closeThinkingBlock(turn);
|
|
852
|
+
if (options?.signal?.aborted) {
|
|
853
|
+
output.stopReason = "aborted";
|
|
854
|
+
settleTurn(turn, "aborted");
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
output.stopReason = devinAcpStopReason(response.stopReason);
|
|
858
|
+
if (output.stopReason === "length") {
|
|
859
|
+
output.errorMessage = "Devin ended the turn at its own turn or token limit.";
|
|
860
|
+
}
|
|
861
|
+
output.duration = Date.now() - startedAt;
|
|
862
|
+
settleTurn(turn, "done");
|
|
863
|
+
} catch (error) {
|
|
864
|
+
closeTextBlock(turn);
|
|
865
|
+
closeThinkingBlock(turn);
|
|
866
|
+
const mapped = (await this.#settledExitFailure()) ?? error;
|
|
867
|
+
// A turn already published by a racing cancel keeps its terminal state.
|
|
868
|
+
if (!turn.settled) {
|
|
869
|
+
output.stopReason = options?.signal?.aborted ? "aborted" : "error";
|
|
870
|
+
output.errorMessage = errorMessage(mapped);
|
|
871
|
+
settleTurn(turn, output.stopReason === "aborted" ? "aborted" : "error");
|
|
872
|
+
}
|
|
873
|
+
} finally {
|
|
874
|
+
if (watchdog !== undefined) clearTimeout(watchdog);
|
|
875
|
+
turn.rearmIdle = undefined;
|
|
876
|
+
options?.signal?.removeEventListener("abort", onAbort);
|
|
877
|
+
if (this.#turn === turn) this.#turn = null;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async #settledExitFailure(): Promise<Error | null> {
|
|
882
|
+
const immediate = this.#exitFailure();
|
|
883
|
+
if (immediate) return immediate;
|
|
884
|
+
// The ACP transport can report a closed connection before the exit watcher
|
|
885
|
+
// observes the child's status; give the process a bounded moment to settle so
|
|
886
|
+
// the turn reports the real cause (exit code and stderr tail).
|
|
887
|
+
await Promise.race([this.#proc.exited, Bun.sleep(250)]);
|
|
888
|
+
return this.#exitFailure();
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Cancel the in-flight turn: notify the agent best-effort, then settle the stream. */
|
|
892
|
+
async cancelTurn(reason?: Error): Promise<void> {
|
|
893
|
+
const turn = this.#turn;
|
|
894
|
+
const session = await Promise.race([
|
|
895
|
+
this.#session?.catch(() => undefined) ?? Promise.resolve(undefined),
|
|
896
|
+
Bun.sleep(DEVIN_ACP_CANCEL_DELIVERY_GRACE_MS).then(() => undefined),
|
|
897
|
+
]);
|
|
898
|
+
if (session) {
|
|
899
|
+
try {
|
|
900
|
+
await this.#connection.cancel({ sessionId: session.id });
|
|
901
|
+
} catch {
|
|
902
|
+
// The agent may already have finished; the turn settles below regardless.
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
// Only now wake a parked handshake or prompt: the cancellation must be delivered
|
|
906
|
+
// before `#runTurn` unwinds and a disposable child is reaped.
|
|
907
|
+
turn?.requestCancellation();
|
|
908
|
+
if (turn && !turn.settled) {
|
|
909
|
+
turn.output.stopReason = reason ? "error" : "aborted";
|
|
910
|
+
if (reason) turn.output.errorMessage = errorMessage(reason);
|
|
911
|
+
settleTurn(turn, reason ? "error" : "aborted");
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
close(): void {
|
|
916
|
+
if (this.#disposed) return;
|
|
917
|
+
this.#disposed = true;
|
|
918
|
+
const turn = this.#turn;
|
|
919
|
+
if (turn && !turn.settled) {
|
|
920
|
+
turn.output.stopReason = "aborted";
|
|
921
|
+
settleTurn(turn, "aborted");
|
|
922
|
+
}
|
|
923
|
+
try {
|
|
924
|
+
this.#proc.kill();
|
|
925
|
+
} catch {
|
|
926
|
+
// Already exited.
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
// ---------------------------------------------------------------------------
|
|
932
|
+
// Provider entry points
|
|
933
|
+
// ---------------------------------------------------------------------------
|
|
934
|
+
|
|
935
|
+
function createTurn(model: Model<"devin-acp">): ActiveTurn {
|
|
936
|
+
const stream = new AssistantMessageEventStream();
|
|
937
|
+
const output: AssistantMessage = {
|
|
938
|
+
role: "assistant",
|
|
939
|
+
content: [],
|
|
940
|
+
api: "devin-acp" as Api,
|
|
941
|
+
provider: model.provider,
|
|
942
|
+
model: model.id,
|
|
943
|
+
usage: {
|
|
944
|
+
input: 0,
|
|
945
|
+
output: 0,
|
|
946
|
+
cacheRead: 0,
|
|
947
|
+
cacheWrite: 0,
|
|
948
|
+
totalTokens: 0,
|
|
949
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
950
|
+
},
|
|
951
|
+
stopReason: "stop",
|
|
952
|
+
timestamp: Date.now(),
|
|
953
|
+
};
|
|
954
|
+
const cancellation = Promise.withResolvers<void>();
|
|
955
|
+
return {
|
|
956
|
+
stream,
|
|
957
|
+
output,
|
|
958
|
+
textIndex: null,
|
|
959
|
+
thinkingIndex: null,
|
|
960
|
+
toolCallIndexes: new Map(),
|
|
961
|
+
settled: false,
|
|
962
|
+
cancellation: cancellation.promise,
|
|
963
|
+
requestCancellation: cancellation.resolve,
|
|
964
|
+
rearmIdle: undefined,
|
|
965
|
+
};
|
|
966
|
+
}
|
|
967
|
+
|
|
968
|
+
/**
|
|
969
|
+
* Stable identity for the cached ACP child.
|
|
970
|
+
*
|
|
971
|
+
* `cwd` is part of the identity on purpose: `/move` changes `process.cwd()`, and a
|
|
972
|
+
* cached child keeps the directory it was spawned in, so a cwd change must miss the
|
|
973
|
+
* cache and respawn instead of running Devin's tools in the abandoned tree.
|
|
974
|
+
*/
|
|
975
|
+
export function devinAcpBridgeIdentity(
|
|
976
|
+
conversationId: string | undefined,
|
|
977
|
+
cwd: string,
|
|
978
|
+
argv: readonly string[],
|
|
979
|
+
): string {
|
|
980
|
+
return `devin-acp:${conversationId ?? "ephemeral"}:${cwd}\u0000${argv.join("\u0000")}`;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* Resolve (or create) the ACP bridge for this conversation.
|
|
985
|
+
*
|
|
986
|
+
* A bridge is stored in the caller-owned `providerSessionState` map so its child
|
|
987
|
+
* process is killed by GJC's session teardown. Without that map the caller gets a
|
|
988
|
+
* disposable bridge, and `streamDevinAcp` closes it when the turn settles.
|
|
989
|
+
*/
|
|
990
|
+
function resolveBridge(config: { streamOptions: DevinAcpOptions | undefined; conversationId: string | undefined }): {
|
|
991
|
+
bridge: DevinAcpBridge;
|
|
992
|
+
owned: boolean;
|
|
993
|
+
} {
|
|
994
|
+
const acp = config.streamOptions?.devinAcp;
|
|
995
|
+
const cliPath = acp?.cliPath ?? process.env[DEVIN_ACP_CLI_ENV]?.trim() ?? DEVIN_ACP_DEFAULT_CLI;
|
|
996
|
+
const argv = [cliPath, ...(acp?.cliArgs ?? []), "acp"];
|
|
997
|
+
const cwd = acp?.cwd ?? process.cwd();
|
|
998
|
+
const spawn = () =>
|
|
999
|
+
new DevinAcpBridge({
|
|
1000
|
+
argv,
|
|
1001
|
+
cwd,
|
|
1002
|
+
permissionMode: devinAcpResolvePermissionMode(acp?.permissionMode),
|
|
1003
|
+
permissionHandler: acp?.permissionHandler,
|
|
1004
|
+
});
|
|
1005
|
+
const stateMap = config.streamOptions?.providerSessionState;
|
|
1006
|
+
if (!stateMap) return { bridge: spawn(), owned: true };
|
|
1007
|
+
const key = devinAcpBridgeIdentity(config.conversationId, cwd, argv);
|
|
1008
|
+
const existing = stateMap.get(key);
|
|
1009
|
+
if (existing instanceof DevinAcpBridge && !existing.exitError) return { bridge: existing, owned: false };
|
|
1010
|
+
if (existing) existing.close();
|
|
1011
|
+
// One conversation maps to one live child. A cwd (or argv) change supersedes the
|
|
1012
|
+
// previous bridge, and leaving it in the map would keep a child bound to the
|
|
1013
|
+
// abandoned directory running until session teardown.
|
|
1014
|
+
const prefix = `devin-acp:${config.conversationId ?? "ephemeral"}:`;
|
|
1015
|
+
for (const [candidateKey, candidate] of stateMap) {
|
|
1016
|
+
if (candidateKey === key || !candidateKey.startsWith(prefix)) continue;
|
|
1017
|
+
stateMap.delete(candidateKey);
|
|
1018
|
+
if (candidate instanceof DevinAcpBridge) candidate.close();
|
|
1019
|
+
}
|
|
1020
|
+
const created = spawn();
|
|
1021
|
+
stateMap.set(key, created);
|
|
1022
|
+
return { bridge: created, owned: false };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export const streamDevinAcp: (
|
|
1026
|
+
model: Model<"devin-acp">,
|
|
1027
|
+
context: Context,
|
|
1028
|
+
options?: DevinAcpOptions,
|
|
1029
|
+
) => AssistantMessageEventStreamType = (model, context, options) => {
|
|
1030
|
+
try {
|
|
1031
|
+
const conversationId = resolveSessionId(options?.providerSessionId) ?? resolveSessionId(options?.sessionId);
|
|
1032
|
+
const { bridge, owned } = resolveBridge({ streamOptions: options, conversationId });
|
|
1033
|
+
const turn = createTurn(model);
|
|
1034
|
+
const settled = bridge.beginTurn(turn, model, context, options);
|
|
1035
|
+
if (owned) {
|
|
1036
|
+
// A disposable child is reaped as soon as the turn unwinds, which the
|
|
1037
|
+
// cancellation races above guarantee even for an agent that never answers.
|
|
1038
|
+
void settled.then(
|
|
1039
|
+
() => bridge.close(),
|
|
1040
|
+
() => bridge.close(),
|
|
1041
|
+
);
|
|
1042
|
+
}
|
|
1043
|
+
return turn.stream;
|
|
1044
|
+
} catch (error) {
|
|
1045
|
+
// Launch failures must surface as a stream error, never as a thrown
|
|
1046
|
+
// provider call: callers (and the lazy dispatch wrapper) expect a stream.
|
|
1047
|
+
const turn = createTurn(model);
|
|
1048
|
+
turn.output.stopReason = "error";
|
|
1049
|
+
turn.output.errorMessage = spawnFailureMessage(error);
|
|
1050
|
+
settleTurn(turn, "error");
|
|
1051
|
+
return turn.stream;
|
|
1052
|
+
}
|
|
1053
|
+
};
|
|
1054
|
+
|
|
1055
|
+
/**
|
|
1056
|
+
* Discover the account's Devin models over ACP.
|
|
1057
|
+
*
|
|
1058
|
+
* `devin models list --format json` is deliberately not parsed: its schema is
|
|
1059
|
+
* undocumented, while the session's own `model` config option is the
|
|
1060
|
+
* authoritative ACP surface for the authenticated account and enterprise
|
|
1061
|
+
* allowlists. Returns `null` when the CLI is missing, unauthenticated, or the
|
|
1062
|
+
* agent advertises no model selector — which the model registry treats as
|
|
1063
|
+
* "no dynamic models".
|
|
1064
|
+
*/
|
|
1065
|
+
export async function fetchDevinAcpModels(
|
|
1066
|
+
config: { cliPath?: string; cliArgs?: readonly string[]; cwd?: string } = {},
|
|
1067
|
+
): Promise<Model<"devin-acp">[] | null> {
|
|
1068
|
+
const cliPath = config.cliPath ?? process.env[DEVIN_ACP_CLI_ENV]?.trim() ?? DEVIN_ACP_DEFAULT_CLI;
|
|
1069
|
+
const bridge = (() => {
|
|
1070
|
+
try {
|
|
1071
|
+
return new DevinAcpBridge({
|
|
1072
|
+
argv: [cliPath, ...(config.cliArgs ?? []), "acp"],
|
|
1073
|
+
cwd: config.cwd ?? process.cwd(),
|
|
1074
|
+
permissionMode: "deny",
|
|
1075
|
+
});
|
|
1076
|
+
} catch {
|
|
1077
|
+
// A missing or unlaunchable CLI is "no discovered models", not a failure.
|
|
1078
|
+
return null;
|
|
1079
|
+
}
|
|
1080
|
+
})();
|
|
1081
|
+
if (!bridge) return null;
|
|
1082
|
+
try {
|
|
1083
|
+
const models = await bridge.listModels();
|
|
1084
|
+
if (models.length === 0) return null;
|
|
1085
|
+
const input = bridge.supportsImages ? (["text", "image"] as const) : (["text"] as const);
|
|
1086
|
+
return models.map(entry => ({
|
|
1087
|
+
id: entry.id,
|
|
1088
|
+
name: entry.name,
|
|
1089
|
+
api: "devin-acp",
|
|
1090
|
+
provider: "devin",
|
|
1091
|
+
baseUrl: DEVIN_ACP_BASE_URL,
|
|
1092
|
+
reasoning: false,
|
|
1093
|
+
input: [...input],
|
|
1094
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
1095
|
+
contextWindow: DEVIN_ACP_CONTEXT_WINDOW,
|
|
1096
|
+
maxTokens: DEVIN_ACP_MAX_TOKENS,
|
|
1097
|
+
}));
|
|
1098
|
+
} catch {
|
|
1099
|
+
return null;
|
|
1100
|
+
} finally {
|
|
1101
|
+
bridge.close();
|
|
1102
|
+
}
|
|
1103
|
+
}
|