@desplega.ai/agent-swarm 1.71.2 → 1.72.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 (62) hide show
  1. package/README.md +3 -2
  2. package/openapi.json +994 -62
  3. package/package.json +2 -1
  4. package/src/be/budget-admission.ts +121 -0
  5. package/src/be/budget-refusal-notify.ts +145 -0
  6. package/src/be/db.ts +488 -5
  7. package/src/be/migrations/044_provider_meta.sql +2 -0
  8. package/src/be/migrations/046_budgets_and_pricing.sql +87 -0
  9. package/src/be/migrations/047_session_costs_cost_source.sql +16 -0
  10. package/src/cli.tsx +22 -1
  11. package/src/commands/claude-managed-setup.ts +687 -0
  12. package/src/commands/codex-login.ts +1 -1
  13. package/src/commands/runner.ts +175 -28
  14. package/src/commands/templates.ts +10 -6
  15. package/src/http/budgets.ts +219 -0
  16. package/src/http/index.ts +6 -0
  17. package/src/http/integrations.ts +134 -0
  18. package/src/http/poll.ts +161 -3
  19. package/src/http/pricing.ts +245 -0
  20. package/src/http/session-data.ts +54 -6
  21. package/src/http/tasks.ts +23 -2
  22. package/src/prompts/base-prompt.ts +103 -73
  23. package/src/prompts/session-templates.ts +43 -0
  24. package/src/providers/claude-adapter.ts +3 -1
  25. package/src/providers/claude-managed-adapter.ts +871 -0
  26. package/src/providers/claude-managed-models.ts +117 -0
  27. package/src/providers/claude-managed-swarm-events.ts +77 -0
  28. package/src/providers/codex-adapter.ts +3 -1
  29. package/src/providers/codex-skill-resolver.ts +10 -0
  30. package/src/providers/codex-swarm-events.ts +20 -161
  31. package/src/providers/devin-adapter.ts +894 -0
  32. package/src/providers/devin-api.ts +207 -0
  33. package/src/providers/devin-playbooks.ts +91 -0
  34. package/src/providers/devin-skill-resolver.ts +113 -0
  35. package/src/providers/index.ts +10 -1
  36. package/src/providers/pi-mono-adapter.ts +3 -1
  37. package/src/providers/swarm-events-shared.ts +262 -0
  38. package/src/providers/types.ts +26 -1
  39. package/src/tests/base-prompt.test.ts +199 -0
  40. package/src/tests/budget-admission.test.ts +339 -0
  41. package/src/tests/budget-claim-gate.test.ts +288 -0
  42. package/src/tests/budget-refusal-notification.test.ts +324 -0
  43. package/src/tests/budgets-routes.test.ts +331 -0
  44. package/src/tests/claude-managed-adapter.test.ts +1301 -0
  45. package/src/tests/claude-managed-setup.test.ts +325 -0
  46. package/src/tests/devin-adapter.test.ts +677 -0
  47. package/src/tests/devin-api.test.ts +339 -0
  48. package/src/tests/integrations-http.test.ts +211 -0
  49. package/src/tests/migration-046-budgets.test.ts +327 -0
  50. package/src/tests/pricing-routes.test.ts +315 -0
  51. package/src/tests/prompt-template-remaining.test.ts +4 -0
  52. package/src/tests/prompt-template-session.test.ts +2 -2
  53. package/src/tests/provider-adapter.test.ts +1 -1
  54. package/src/tests/runner-budget-refused.test.ts +271 -0
  55. package/src/tests/session-costs-codex-recompute.test.ts +386 -0
  56. package/src/tools/poll-task.ts +13 -2
  57. package/src/tools/task-action.ts +92 -2
  58. package/src/tools/templates.ts +29 -0
  59. package/src/types.ts +116 -0
  60. package/src/utils/budget-backoff.ts +34 -0
  61. package/src/utils/credentials.ts +4 -0
  62. package/src/utils/provider-metadata.ts +9 -0
@@ -0,0 +1,894 @@
1
+ /**
2
+ * Devin provider adapter.
3
+ *
4
+ * Wraps the Devin v3 REST API to implement the `ProviderAdapter` /
5
+ * `ProviderSession` contract. Unlike Claude and Codex, Devin sessions are
6
+ * fully remote — there is no local child process. We poll the session status
7
+ * endpoint to drive the event stream and detect terminal states.
8
+ *
9
+ * Phase 1 — factory wiring, polling loop, status-to-event mapping, cost
10
+ * tracking, playbook resolution, approval flow, structured output & PR
11
+ * tracking.
12
+ */
13
+
14
+ import {
15
+ createSession,
16
+ type DevinSessionResponse,
17
+ type DevinSessionStatus,
18
+ type DevinStatusDetail,
19
+ getSession,
20
+ getSessionMessages,
21
+ sendMessage,
22
+ } from "./devin-api";
23
+ import { getOrCreatePlaybook } from "./devin-playbooks";
24
+ import { resolveDevinPrompt } from "./devin-skill-resolver";
25
+ import type {
26
+ CostData,
27
+ ProviderAdapter,
28
+ ProviderEvent,
29
+ ProviderResult,
30
+ ProviderSession,
31
+ ProviderSessionConfig,
32
+ ProviderTraits,
33
+ } from "./types";
34
+
35
+ /** Default polling interval in milliseconds. */
36
+ const DEFAULT_POLL_INTERVAL_MS = 15_000;
37
+
38
+ /** USD cost per ACU — configurable via env var. */
39
+ const DEFAULT_ACU_COST_USD = 2.25;
40
+
41
+ /** Give up after this many consecutive poll failures. */
42
+ const MAX_CONSECUTIVE_POLL_ERRORS = 10;
43
+
44
+ /** Max time to wait for a human approval response before giving up. */
45
+ const APPROVAL_TIMEOUT_MS = 60 * 60 * 1_000; // 1 hour
46
+
47
+ /**
48
+ * Structured output schema sent with every Devin session.
49
+ *
50
+ * Devin treats this as a "notepad" it fills as it works. The `status` field
51
+ * lets us detect completion even when Devin stays in `waiting_for_user`
52
+ * instead of transitioning to `finished`. The adapter checks for
53
+ * `status === "done"` in the `waiting_for_user` handler.
54
+ */
55
+ const DEVIN_STRUCTURED_OUTPUT_SCHEMA = {
56
+ type: "object",
57
+ properties: {
58
+ status: {
59
+ type: "string",
60
+ enum: ["working", "done", "needs_input", "error"],
61
+ description:
62
+ "Set to 'done' when the task is fully complete, 'needs_input' when you need clarification, 'error' if the task cannot be completed.",
63
+ },
64
+ output: {
65
+ type: "string",
66
+ description: "The final output or result of the task.",
67
+ },
68
+ summary: {
69
+ type: "string",
70
+ description: "A brief summary of what was accomplished.",
71
+ },
72
+ },
73
+ required: ["status"],
74
+ } as const;
75
+
76
+ // ---------------------------------------------------------------------------
77
+ // DevinSession
78
+ // ---------------------------------------------------------------------------
79
+
80
+ class DevinSession implements ProviderSession {
81
+ private readonly config: ProviderSessionConfig;
82
+ private readonly orgId: string;
83
+ private readonly devinApiKey: string;
84
+ private readonly pollIntervalMs: number;
85
+ private readonly acuCostUsd: number;
86
+ private readonly maxAcuLimit: number | undefined;
87
+
88
+ private readonly listeners: Array<(event: ProviderEvent) => void> = [];
89
+ private readonly eventQueue: ProviderEvent[] = [];
90
+ private readonly logFileHandle: ReturnType<ReturnType<typeof Bun.file>["writer"]>;
91
+ private readonly startTime = Date.now();
92
+ private readonly completionPromise: Promise<ProviderResult>;
93
+ private resolveCompletion!: (result: ProviderResult) => void;
94
+
95
+ private _sessionId: string | undefined;
96
+ private sessionUrl: string | undefined;
97
+ private pollTimer: ReturnType<typeof setInterval> | null = null;
98
+ private pollCount = 0;
99
+ private aborted = false;
100
+ private settled = false;
101
+
102
+ // State tracking for change detection across polls.
103
+ private lastStatus: DevinSessionStatus | undefined;
104
+ private lastStatusDetail: DevinStatusDetail | undefined;
105
+ private lastStructuredOutput: string | undefined;
106
+ private seenPrUrls = new Set<string>();
107
+ private seenMessageIds = new Set<string>();
108
+ private approvalRequested = false;
109
+ private consecutivePollErrors = 0;
110
+ private humanResponseTimer: ReturnType<typeof setInterval> | null = null;
111
+ private messageCursor: string | undefined;
112
+
113
+ constructor(
114
+ config: ProviderSessionConfig,
115
+ orgId: string,
116
+ devinApiKey: string,
117
+ sessionResponse: DevinSessionResponse,
118
+ maxAcuLimit?: number,
119
+ ) {
120
+ this.config = config;
121
+ this.orgId = orgId;
122
+ this.devinApiKey = devinApiKey;
123
+ this.pollIntervalMs = Number(process.env.DEVIN_POLL_INTERVAL_MS) || DEFAULT_POLL_INTERVAL_MS;
124
+ this.acuCostUsd = Number(process.env.DEVIN_ACU_COST_USD) || DEFAULT_ACU_COST_USD;
125
+ this.maxAcuLimit = maxAcuLimit;
126
+
127
+ this._sessionId = sessionResponse.session_id;
128
+ this.sessionUrl = sessionResponse.url;
129
+ this.logFileHandle = Bun.file(config.logFile).writer();
130
+
131
+ this.completionPromise = new Promise<ProviderResult>((resolve) => {
132
+ this.resolveCompletion = resolve;
133
+ });
134
+
135
+ // Emit initial session_init event.
136
+ this.emit({
137
+ type: "session_init",
138
+ sessionId: sessionResponse.session_id,
139
+ provider: "devin",
140
+ providerMeta: {
141
+ sessionUrl: sessionResponse.url,
142
+ ...(this.maxAcuLimit != null ? { maxAcuLimit: this.maxAcuLimit } : {}),
143
+ acuCostUsd: this.acuCostUsd,
144
+ },
145
+ });
146
+ this.emit({
147
+ type: "message",
148
+ role: "assistant",
149
+ content: `Devin session created: ${sessionResponse.url}`,
150
+ });
151
+
152
+ // Record initial state.
153
+ this.lastStatus = sessionResponse.status;
154
+ this.lastStatusDetail = sessionResponse.status_detail;
155
+
156
+ // Start the polling loop.
157
+ this.startPolling();
158
+ }
159
+
160
+ get sessionId(): string | undefined {
161
+ return this._sessionId;
162
+ }
163
+
164
+ onEvent(listener: (event: ProviderEvent) => void): void {
165
+ this.listeners.push(listener);
166
+ // Flush queued events to the new listener.
167
+ for (const event of this.eventQueue) {
168
+ listener(event);
169
+ }
170
+ this.eventQueue.length = 0;
171
+ }
172
+
173
+ async waitForCompletion(): Promise<ProviderResult> {
174
+ return this.completionPromise;
175
+ }
176
+
177
+ async abort(): Promise<void> {
178
+ this.aborted = true;
179
+ this.stopPolling();
180
+ // Deliberately do NOT archive the Devin session here. The session remains
181
+ // alive in Cognition's cloud so `canResume()` can return true and the
182
+ // runner can retry later via `sendMessage()`. Archiving is a hard kill
183
+ // with no undo — only do that via an explicit API call if needed.
184
+ if (!this.settled) {
185
+ const cost = this.buildCostData(0, true);
186
+ this.emit({ type: "result", cost, isError: true, errorCategory: "cancelled" });
187
+ this.settle({
188
+ exitCode: 130,
189
+ sessionId: this._sessionId,
190
+ cost,
191
+ isError: true,
192
+ failureReason: "cancelled",
193
+ });
194
+ }
195
+ }
196
+
197
+ // -------------------------------------------------------------------------
198
+ // Event infrastructure (mirrors codex-adapter)
199
+ // -------------------------------------------------------------------------
200
+
201
+ private emit(event: ProviderEvent): void {
202
+ try {
203
+ this.logFileHandle.write(
204
+ `${JSON.stringify({ ...event, timestamp: new Date().toISOString() })}\n`,
205
+ );
206
+ } catch {
207
+ // Log writer failure must not break the event stream.
208
+ }
209
+ if (this.listeners.length > 0) {
210
+ for (const listener of this.listeners) {
211
+ try {
212
+ listener(event);
213
+ } catch {
214
+ // Swallow listener errors.
215
+ }
216
+ }
217
+ } else {
218
+ this.eventQueue.push(event);
219
+ }
220
+ }
221
+
222
+ private settle(result: ProviderResult): void {
223
+ if (this.settled) return;
224
+ this.settled = true;
225
+ this.stopPolling();
226
+ try {
227
+ const flushed = this.logFileHandle.flush();
228
+ (flushed instanceof Promise ? flushed : Promise.resolve(flushed))
229
+ .then(() => this.logFileHandle.end())
230
+ .catch(() => {});
231
+ } catch {
232
+ // Ignore log writer cleanup failures.
233
+ }
234
+ this.resolveCompletion(result);
235
+ }
236
+
237
+ // -------------------------------------------------------------------------
238
+ // Polling loop
239
+ // -------------------------------------------------------------------------
240
+
241
+ private startPolling(): void {
242
+ // Do an immediate first poll, then set up the interval.
243
+ void this.poll();
244
+ this.pollTimer = setInterval(() => {
245
+ void this.poll();
246
+ }, this.pollIntervalMs);
247
+ }
248
+
249
+ private stopPolling(): void {
250
+ if (this.pollTimer) {
251
+ clearInterval(this.pollTimer);
252
+ this.pollTimer = null;
253
+ }
254
+ if (this.humanResponseTimer) {
255
+ clearInterval(this.humanResponseTimer);
256
+ this.humanResponseTimer = null;
257
+ }
258
+ }
259
+
260
+ private async poll(): Promise<void> {
261
+ if (this.settled || this.aborted) return;
262
+ this.pollCount += 1;
263
+
264
+ let response: DevinSessionResponse;
265
+ try {
266
+ response = await getSession(this.orgId, this.devinApiKey, this._sessionId!);
267
+ } catch (err) {
268
+ this.consecutivePollErrors += 1;
269
+ const message = err instanceof Error ? err.message : String(err);
270
+ this.emit({
271
+ type: "raw_stderr",
272
+ content: `[devin] Poll error (${this.consecutivePollErrors}/${MAX_CONSECUTIVE_POLL_ERRORS}): ${message}\n`,
273
+ });
274
+ if (this.consecutivePollErrors >= MAX_CONSECUTIVE_POLL_ERRORS) {
275
+ const reason = `Devin polling abandoned after ${MAX_CONSECUTIVE_POLL_ERRORS} consecutive errors. Last: ${message}`;
276
+ this.emit({ type: "error", message: reason });
277
+ const cost = this.buildCostData(0, true);
278
+ this.emit({ type: "result", cost, isError: true, errorCategory: "poll_failure" });
279
+ this.settle({
280
+ exitCode: 1,
281
+ sessionId: this._sessionId,
282
+ cost,
283
+ isError: true,
284
+ failureReason: reason,
285
+ });
286
+ }
287
+ return;
288
+ }
289
+ // Reset on successful poll.
290
+ this.consecutivePollErrors = 0;
291
+
292
+ // Log raw poll data to local JSONL file for debugging, but don't emit
293
+ // as raw_log — the session log viewer can't parse the Devin API shape
294
+ // and silently drops it. Conversation messages are emitted separately
295
+ // in pollMessages() in a format the viewer understands.
296
+ try {
297
+ this.logFileHandle.write(
298
+ `${JSON.stringify({ type: "raw_log", content: JSON.stringify(response), timestamp: new Date().toISOString() })}\n`,
299
+ );
300
+ } catch {
301
+ // Log writer failure must not break the event stream.
302
+ }
303
+
304
+ // Track structured output changes.
305
+ const currentStructuredOutput = response.structured_output
306
+ ? JSON.stringify(response.structured_output)
307
+ : undefined;
308
+ if (currentStructuredOutput && currentStructuredOutput !== this.lastStructuredOutput) {
309
+ this.lastStructuredOutput = currentStructuredOutput;
310
+ this.emit({
311
+ type: "custom",
312
+ name: "devin.structured_output",
313
+ data: { sessionId: this._sessionId, structuredOutput: response.structured_output },
314
+ });
315
+ const so = response.structured_output as Record<string, unknown>;
316
+ this.emitSystemLog("structured_output", {
317
+ taskStatus: so.status,
318
+ output: so.output,
319
+ summary: so.summary,
320
+ });
321
+ }
322
+
323
+ // Track new pull requests.
324
+ if (response.pull_requests) {
325
+ for (const pr of response.pull_requests) {
326
+ if (!this.seenPrUrls.has(pr.pr_url)) {
327
+ this.seenPrUrls.add(pr.pr_url);
328
+ this.emit({
329
+ type: "custom",
330
+ name: "devin.pull_request",
331
+ data: { sessionId: this._sessionId, prUrl: pr.pr_url, prState: pr.pr_state },
332
+ });
333
+ }
334
+ }
335
+ }
336
+
337
+ // Fetch new conversation messages from Devin.
338
+ await this.pollMessages();
339
+
340
+ // Process status transitions.
341
+ const statusChanged =
342
+ response.status !== this.lastStatus || response.status_detail !== this.lastStatusDetail;
343
+ this.lastStatus = response.status;
344
+ this.lastStatusDetail = response.status_detail;
345
+
346
+ this.processStatus(response, statusChanged);
347
+ }
348
+
349
+ // -------------------------------------------------------------------------
350
+ // Conversation messages
351
+ // -------------------------------------------------------------------------
352
+
353
+ private async pollMessages(): Promise<void> {
354
+ try {
355
+ const resp = await getSessionMessages(
356
+ this.orgId,
357
+ this.devinApiKey,
358
+ this._sessionId!,
359
+ this.messageCursor,
360
+ );
361
+ if (resp.end_cursor) {
362
+ this.messageCursor = resp.end_cursor;
363
+ }
364
+ for (const msg of resp.items) {
365
+ if (this.seenMessageIds.has(msg.event_id)) continue;
366
+ this.seenMessageIds.add(msg.event_id);
367
+ const role = msg.source === "devin" ? "assistant" : "user";
368
+ this.emit({
369
+ type: "raw_log",
370
+ content: JSON.stringify({
371
+ type: role,
372
+ message: { role, content: msg.message },
373
+ }),
374
+ });
375
+ this.emit({ type: "message", role, content: msg.message });
376
+ }
377
+ } catch {
378
+ // Non-fatal — messages are supplementary to status polling.
379
+ }
380
+ }
381
+
382
+ // -------------------------------------------------------------------------
383
+ // Status-to-event mapping
384
+ // -------------------------------------------------------------------------
385
+
386
+ private processStatus(response: DevinSessionResponse, statusChanged: boolean): void {
387
+ const { status } = response;
388
+
389
+ switch (status) {
390
+ case "new":
391
+ case "creating":
392
+ case "claimed":
393
+ case "resuming": {
394
+ if (statusChanged) {
395
+ this.emit({ type: "progress", message: status });
396
+ this.emitSystemLog("status", { status, statusDetail: status });
397
+ }
398
+ break;
399
+ }
400
+
401
+ case "running": {
402
+ this.processRunningStatus(response, statusChanged);
403
+ break;
404
+ }
405
+
406
+ case "exit": {
407
+ this.handleTerminalSuccess(response);
408
+ break;
409
+ }
410
+
411
+ case "error": {
412
+ this.handleTerminalError(response);
413
+ break;
414
+ }
415
+
416
+ case "suspended": {
417
+ this.handleSuspended(response);
418
+ break;
419
+ }
420
+ }
421
+ }
422
+
423
+ private processRunningStatus(response: DevinSessionResponse, statusChanged: boolean): void {
424
+ const detail = response.status_detail;
425
+
426
+ // Check structured output completion before examining status_detail.
427
+ // Devin may set structured output `status: "done"` while still in any
428
+ // running sub-state (working, waiting_for_user, etc.) — the structured
429
+ // output is the authoritative completion signal.
430
+ if (this.isStructuredOutputDone(response)) {
431
+ this.handleTerminalSuccess(response);
432
+ return;
433
+ }
434
+
435
+ switch (detail) {
436
+ case "working": {
437
+ if (statusChanged) {
438
+ this.emit({ type: "progress", message: "working" });
439
+ this.emitSystemLog("status", { status: "running", statusDetail: "working" });
440
+ }
441
+ break;
442
+ }
443
+
444
+ case "waiting_for_user": {
445
+ if (statusChanged) {
446
+ this.emit({ type: "progress", message: "waiting for user" });
447
+ this.emitSystemLog("status", {
448
+ status: "running",
449
+ statusDetail: "waiting_for_user",
450
+ });
451
+ this.emit({
452
+ type: "message",
453
+ role: "assistant",
454
+ content: `Devin is waiting for user input. Session: ${this.sessionUrl}`,
455
+ });
456
+ }
457
+ break;
458
+ }
459
+
460
+ case "waiting_for_approval": {
461
+ if (statusChanged) {
462
+ this.emit({ type: "progress", message: "waiting for approval" });
463
+ this.emitSystemLog("status", {
464
+ status: "running",
465
+ statusDetail: "waiting_for_approval",
466
+ });
467
+ }
468
+ // Request human input via the swarm API (once per approval cycle).
469
+ if (!this.approvalRequested) {
470
+ this.approvalRequested = true;
471
+ void this.requestHumanApproval();
472
+ }
473
+ break;
474
+ }
475
+
476
+ case "finished": {
477
+ this.handleTerminalSuccess(response);
478
+ break;
479
+ }
480
+
481
+ default: {
482
+ if (statusChanged) {
483
+ const label = detail ?? "unknown";
484
+ this.emit({ type: "progress", message: label });
485
+ this.emitSystemLog("status", { status: "running", statusDetail: label });
486
+ }
487
+ break;
488
+ }
489
+ }
490
+ }
491
+
492
+ private handleTerminalSuccess(response: DevinSessionResponse): void {
493
+ const acusConsumed = response.acus_consumed ?? 0;
494
+ const output = this.formatStructuredOutput();
495
+ const cost = this.buildCostData(acusConsumed, false);
496
+
497
+ this.emit({ type: "progress", message: "completed" });
498
+ this.emitSystemLog("status", {
499
+ status: "completed",
500
+ acusConsumed,
501
+ sessionUrl: this.sessionUrl,
502
+ });
503
+ this.emit({
504
+ type: "message",
505
+ role: "assistant",
506
+ content: `Devin session completed successfully. ACUs consumed: ${acusConsumed}. Session: ${this.sessionUrl}`,
507
+ });
508
+ this.emit({ type: "result", cost, output, isError: false });
509
+ this.settle({
510
+ exitCode: 0,
511
+ sessionId: this._sessionId,
512
+ cost,
513
+ output,
514
+ isError: false,
515
+ });
516
+ }
517
+
518
+ private handleTerminalError(response: DevinSessionResponse): void {
519
+ const acusConsumed = response.acus_consumed ?? 0;
520
+ const cost = this.buildCostData(acusConsumed, true);
521
+ const message = `Devin session ended with error. ACUs consumed: ${acusConsumed}. Session: ${this.sessionUrl}`;
522
+
523
+ this.emitSystemLog("status", {
524
+ status: "error",
525
+ acusConsumed,
526
+ sessionUrl: this.sessionUrl,
527
+ });
528
+ this.emit({ type: "error", message });
529
+ this.emit({ type: "result", cost, isError: true, errorCategory: "devin_error" });
530
+ this.settle({
531
+ exitCode: 1,
532
+ sessionId: this._sessionId,
533
+ cost,
534
+ isError: true,
535
+ failureReason: message,
536
+ });
537
+ }
538
+
539
+ private handleSuspended(response: DevinSessionResponse): void {
540
+ const acusConsumed = response.acus_consumed ?? 0;
541
+ const detail = response.status_detail;
542
+ const cost = this.buildCostData(acusConsumed, true);
543
+
544
+ const categoryMap: Record<string, string> = {
545
+ inactivity: "suspended_inactivity",
546
+ user_request: "suspended_user",
547
+ usage_limit_exceeded: "suspended_cost",
548
+ out_of_credits: "suspended_cost",
549
+ out_of_quota: "suspended_cost",
550
+ no_quota_allocation: "suspended_cost",
551
+ payment_declined: "suspended_cost",
552
+ org_usage_limit_exceeded: "suspended_cost",
553
+ error: "suspended_cost",
554
+ };
555
+
556
+ const errorCategory = categoryMap[detail ?? ""] ?? "suspended";
557
+ const reason = `Devin session suspended${detail ? `: ${detail.replaceAll("_", " ")}` : ""}`;
558
+
559
+ if (detail === "inactivity") {
560
+ this.emit({
561
+ type: "message",
562
+ role: "assistant",
563
+ content: `Devin session suspended due to inactivity. Session: ${this.sessionUrl}`,
564
+ });
565
+ }
566
+
567
+ if (errorCategory === "suspended_cost" || errorCategory === "suspended") {
568
+ this.emit({ type: "error", message: reason });
569
+ }
570
+
571
+ this.emit({ type: "result", cost, isError: true, errorCategory });
572
+ this.settle({
573
+ exitCode: 1,
574
+ sessionId: this._sessionId,
575
+ cost,
576
+ isError: true,
577
+ errorCategory,
578
+ failureReason: reason,
579
+ });
580
+ }
581
+
582
+ // -------------------------------------------------------------------------
583
+ // Approval flow
584
+ // -------------------------------------------------------------------------
585
+
586
+ private async requestHumanApproval(): Promise<void> {
587
+ if (!this.config.apiUrl || !this.config.apiKey || !this.config.taskId) return;
588
+
589
+ // Why a direct API call instead of an emit? The runner's event listener
590
+ // handles ProviderEvents generically (progress, cost) but has no built-in
591
+ // handler that creates human-input requests from events. Claude/Codex
592
+ // trigger this via their MCP tool (`request-human-input`), which calls
593
+ // the same API endpoint under the hood. Since Devin has no MCP, we call
594
+ // the API directly — it's what stores the request in the DB and triggers
595
+ // Slack routing.
596
+ try {
597
+ const res = await fetch(`${this.config.apiUrl}/api/tasks/${this.config.taskId}/human-input`, {
598
+ method: "POST",
599
+ headers: {
600
+ Authorization: `Bearer ${this.config.apiKey}`,
601
+ "Content-Type": "application/json",
602
+ "X-Agent-ID": this.config.agentId,
603
+ },
604
+ body: JSON.stringify({
605
+ question: `Devin is waiting for approval. Please review and respond. Session: ${this.sessionUrl}`,
606
+ }),
607
+ });
608
+ if (!res.ok) {
609
+ this.emit({
610
+ type: "raw_stderr",
611
+ content: `[devin] Failed to request human approval: HTTP ${res.status}\n`,
612
+ });
613
+ }
614
+ } catch (err) {
615
+ const message = err instanceof Error ? err.message : String(err);
616
+ this.emit({
617
+ type: "raw_stderr",
618
+ content: `[devin] Failed to request human approval: ${message}\n`,
619
+ });
620
+ }
621
+
622
+ // Poll for the human response and relay it to Devin.
623
+ void this.pollForHumanResponse();
624
+ }
625
+
626
+ private async pollForHumanResponse(): Promise<void> {
627
+ if (!this.config.apiUrl || !this.config.apiKey || !this.config.taskId) return;
628
+
629
+ // Clear any previous human-response timer before starting a new one.
630
+ if (this.humanResponseTimer) clearInterval(this.humanResponseTimer);
631
+
632
+ const approvalStart = Date.now();
633
+
634
+ // Simple polling loop — check every poll interval for a human response.
635
+ this.humanResponseTimer = setInterval(async () => {
636
+ if (this.settled || this.aborted) {
637
+ if (this.humanResponseTimer) {
638
+ clearInterval(this.humanResponseTimer);
639
+ this.humanResponseTimer = null;
640
+ }
641
+ return;
642
+ }
643
+
644
+ // Give up after APPROVAL_TIMEOUT_MS to avoid leaking timers on
645
+ // abandoned approval flows. Devin's own inactivity timeout will
646
+ // eventually suspend the session, which the main poll loop handles.
647
+ if (Date.now() - approvalStart > APPROVAL_TIMEOUT_MS) {
648
+ this.emit({
649
+ type: "raw_stderr",
650
+ content: `[devin] Approval polling timed out after ${APPROVAL_TIMEOUT_MS / 60_000} minutes\n`,
651
+ });
652
+ if (this.humanResponseTimer) {
653
+ clearInterval(this.humanResponseTimer);
654
+ this.humanResponseTimer = null;
655
+ }
656
+ return;
657
+ }
658
+
659
+ try {
660
+ const res = await fetch(
661
+ `${this.config.apiUrl}/api/tasks/${this.config.taskId}/human-input`,
662
+ {
663
+ method: "GET",
664
+ headers: {
665
+ Authorization: `Bearer ${this.config.apiKey}`,
666
+ "X-Agent-ID": this.config.agentId,
667
+ },
668
+ },
669
+ );
670
+ if (res.ok) {
671
+ const data = (await res.json()) as { response?: string; answered?: boolean };
672
+ if (data.answered && data.response) {
673
+ if (this.humanResponseTimer) {
674
+ clearInterval(this.humanResponseTimer);
675
+ this.humanResponseTimer = null;
676
+ }
677
+ this.approvalRequested = false;
678
+ // Relay the human response to Devin.
679
+ try {
680
+ await sendMessage(this.orgId, this.devinApiKey, this._sessionId!, data.response);
681
+ this.emit({
682
+ type: "message",
683
+ role: "user",
684
+ content: `Human response relayed to Devin: ${data.response}`,
685
+ });
686
+ } catch (err) {
687
+ const message = err instanceof Error ? err.message : String(err);
688
+ this.emit({
689
+ type: "raw_stderr",
690
+ content: `[devin] Failed to relay human response: ${message}\n`,
691
+ });
692
+ }
693
+ }
694
+ }
695
+ } catch {
696
+ // Transient failure — keep trying.
697
+ }
698
+ }, this.pollIntervalMs);
699
+ }
700
+
701
+ // -------------------------------------------------------------------------
702
+ // Structured output completion detection
703
+ // -------------------------------------------------------------------------
704
+
705
+ /**
706
+ * Check if the structured output signals task completion.
707
+ * Returns true when the structured output has `status: "done"`.
708
+ */
709
+ private isStructuredOutputDone(response: DevinSessionResponse): boolean {
710
+ const output = response.structured_output;
711
+ if (!output || typeof output !== "object") return false;
712
+ return (output as Record<string, unknown>).status === "done";
713
+ }
714
+
715
+ /**
716
+ * Extract human-readable text from the last structured output.
717
+ * Returns summary + output joined as plain text, or the raw JSON
718
+ * string if extraction fails.
719
+ */
720
+ private formatStructuredOutput(): string | undefined {
721
+ if (!this.lastStructuredOutput) return undefined;
722
+ try {
723
+ const parsed = JSON.parse(this.lastStructuredOutput);
724
+ if (typeof parsed === "object" && parsed !== null) {
725
+ const parts: string[] = [];
726
+ if (parsed.summary) parts.push(parsed.summary);
727
+ if (parsed.output) parts.push(parsed.output);
728
+ if (parts.length > 0) return parts.join("\n\n");
729
+ }
730
+ } catch {
731
+ // Fall through to raw.
732
+ }
733
+ return this.lastStructuredOutput;
734
+ }
735
+
736
+ // -------------------------------------------------------------------------
737
+ // Session log helpers
738
+ // -------------------------------------------------------------------------
739
+
740
+ /**
741
+ * Emit a system-role raw_log entry that the session log viewer can parse.
742
+ * Used for status transitions and structured output — these render as
743
+ * system messages with a `provider_meta` payload so the viewer can add
744
+ * pills/colors.
745
+ */
746
+ private emitSystemLog(kind: "status" | "structured_output", data: Record<string, unknown>): void {
747
+ this.emit({
748
+ type: "raw_log",
749
+ content: JSON.stringify({
750
+ type: "system",
751
+ message: { role: "system", content: "" },
752
+ provider_meta: { provider: "devin", kind, ...data },
753
+ }),
754
+ });
755
+ }
756
+
757
+ // -------------------------------------------------------------------------
758
+ // Cost tracking
759
+ // -------------------------------------------------------------------------
760
+
761
+ private buildCostData(acusConsumed: number, isError: boolean): CostData {
762
+ return {
763
+ sessionId: this._sessionId ?? "",
764
+ taskId: this.config.taskId,
765
+ agentId: this.config.agentId,
766
+ totalCostUsd: acusConsumed * this.acuCostUsd,
767
+ inputTokens: 0,
768
+ outputTokens: 0,
769
+ durationMs: Date.now() - this.startTime,
770
+ numTurns: this.pollCount,
771
+ model: "devin",
772
+ isError,
773
+ };
774
+ }
775
+ }
776
+
777
+ // ---------------------------------------------------------------------------
778
+ // DevinAdapter
779
+ // ---------------------------------------------------------------------------
780
+
781
+ export class DevinAdapter implements ProviderAdapter {
782
+ readonly name = "devin";
783
+ /** Cached from the most recent createSession() for canResume() fallback. */
784
+ private lastApiKey?: string;
785
+ private lastOrgId?: string;
786
+ get traits(): ProviderTraits {
787
+ const hasMcp = (process.env.HAS_MCP ?? "").toLowerCase() === "true";
788
+ return { hasMcp, hasLocalEnvironment: false };
789
+ }
790
+
791
+ async createSession(config: ProviderSessionConfig): Promise<ProviderSession> {
792
+ // Resolve credentials from config.env (injected by runner) or process.env.
793
+ const env = config.env ?? {};
794
+ const devinApiKey = env.DEVIN_API_KEY ?? process.env.DEVIN_API_KEY;
795
+ const orgId = env.DEVIN_ORG_ID ?? process.env.DEVIN_ORG_ID;
796
+
797
+ if (!devinApiKey) {
798
+ throw new Error("[devin] DEVIN_API_KEY is required. Set it in environment or agent config.");
799
+ }
800
+ if (!orgId) {
801
+ throw new Error("[devin] DEVIN_ORG_ID is required. Set it in environment or agent config.");
802
+ }
803
+
804
+ // Cache for canResume() which only receives a sessionId.
805
+ this.lastApiKey = devinApiKey;
806
+ this.lastOrgId = orgId;
807
+
808
+ const hasMcp = (env.HAS_MCP ?? process.env.HAS_MCP ?? "").toLowerCase() === "true";
809
+ if (hasMcp) {
810
+ throw new Error(
811
+ "[devin] HAS_MCP=true is not supported yet — Devin MCP integration has not been tested.",
812
+ );
813
+ }
814
+
815
+ // NOTE: is there a better place to handle this logic?
816
+ if (config.role === "lead" && !hasMcp) {
817
+ // Probably cannot happen as the envs from devin and the lead live in different files, but jsut in case
818
+ throw new Error(
819
+ "[devin] Devin is configured as lead but HAS_MCP=false. A lead needs access to the MCP to function. ",
820
+ );
821
+ }
822
+
823
+ // If there's a system prompt, resolve it to a playbook.
824
+ let playbookId: string | undefined;
825
+ if (config.systemPrompt) {
826
+ try {
827
+ playbookId = await getOrCreatePlaybook(
828
+ orgId,
829
+ devinApiKey,
830
+ `swarm-${config.taskId ?? "session"}`,
831
+ // systemPrompt is per-agent (not per-task). The runner composes it
832
+ // from the agent's template + role config. It's stable across tasks
833
+ // for the same agent, so the playbook cache effectively deduplicates
834
+ // — one playbook per agent configuration, reused across tasks.
835
+ config.systemPrompt,
836
+ config.apiUrl,
837
+ config.apiKey,
838
+ );
839
+ } catch (err) {
840
+ const message = err instanceof Error ? err.message : String(err);
841
+ // Non-fatal — log and continue without playbook.
842
+ console.error(`[devin] Failed to create playbook: ${message}`);
843
+ }
844
+ }
845
+
846
+ // Build repos array from the task's vcsRepo (e.g. "owner/repo").
847
+ const repos: string[] = [];
848
+ if (config.vcsRepo) {
849
+ repos.push(config.vcsRepo);
850
+ }
851
+ // Inline skill content if prompt starts with @skills:<name>.
852
+ const resolvedPrompt = await resolveDevinPrompt(config.prompt);
853
+
854
+ // Resolve max ACU limit from env.
855
+ const rawAcuLimit = env.DEVIN_MAX_ACU_LIMIT ?? process.env.DEVIN_MAX_ACU_LIMIT;
856
+ const maxAcuLimit = rawAcuLimit ? Number(rawAcuLimit) : undefined;
857
+
858
+ // Create the Devin session.
859
+ const sessionResponse = await createSession(orgId, devinApiKey, {
860
+ prompt: resolvedPrompt,
861
+ ...(playbookId ? { playbook_id: playbookId } : {}),
862
+ ...(repos.length > 0 ? { repos } : {}),
863
+ ...(maxAcuLimit != null ? { max_acu_limit: maxAcuLimit } : {}),
864
+ structured_output_schema: DEVIN_STRUCTURED_OUTPUT_SCHEMA,
865
+ title: `swarm-task-${config.taskId ?? "unknown"}`,
866
+ tags: ["agent-swarm", config.agentId],
867
+ });
868
+
869
+ return new DevinSession(config, orgId, devinApiKey, sessionResponse, maxAcuLimit);
870
+ }
871
+
872
+ async canResume(sessionId: string): Promise<boolean> {
873
+ if (!sessionId || typeof sessionId !== "string") return false;
874
+
875
+ const devinApiKey = this.lastApiKey ?? process.env.DEVIN_API_KEY;
876
+ const orgId = this.lastOrgId ?? process.env.DEVIN_ORG_ID;
877
+ if (!devinApiKey || !orgId) return false;
878
+
879
+ try {
880
+ const response = await getSession(orgId, devinApiKey, sessionId);
881
+ // Devin's API may allow sending messages to some errored sessions, but
882
+ // not all error subtypes are recoverable. Conservative default: treat
883
+ // `error` as non-resumable to avoid the runner looping on a broken session.
884
+ // Only `suspended` sessions (inactivity, user_request, cost limits) are resumable.
885
+ return response.status !== "exit" && response.status !== "error";
886
+ } catch {
887
+ return false;
888
+ }
889
+ }
890
+
891
+ formatCommand(commandName: string): string {
892
+ return `@skills:${commandName}`;
893
+ }
894
+ }