@buyong/pi-devin 0.1.3 → 0.1.4

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/README.md CHANGED
@@ -80,7 +80,7 @@ There are two toggles, both off by default. Changes apply to all sessions at onc
80
80
  - **Recommended models only**: `/model` shows only the models that have a variant Devin marks as Recommended, plus Devin Fusion. Devin marks single variants such as `swe-2-high`, but the list keeps the model itself (`swe-2`), so you still pick the thinking level with `/thinking`. The recommended level is not applied automatically. If your account has no recommended models, the full list is shown.
81
81
  - **Usage in status line**: the TUI footer shows the remaining daily and weekly quota as bars, plus this session's input and output tokens, like `devin day ▕█████████░▏98% week ▕█████████░▏98% · ↑263 ↓49`. A bar turns to the warning color at 40% or less and to the error color at 20% or less.
82
82
 
83
- Quota is not live. It is refreshed when you log in and when Pi checks the login right before sending a model request, so the usage of the message you just sent shows up after the next request. `/devin` and the status line send no network requests. Credits, per-request costs and usage history are not shown.
83
+ Quota is not live. It is refreshed when you log in and by the account check right before each model request, so the usage of the message you just sent shows up after the next request. If that check cannot reach the Devin server (for example `ECONNREFUSED` or a 503), the request is still sent with the last checked account. A model request whose connection is refused, reset or cut off mid-response is reported as `Connection error (…)`, which Pi's automatic retry recognizes; a lasting outage reports that connection error instead of `OAuth refresh failed`. `/devin` and the status line send no network requests. Credits, per-request costs and usage history are not shown.
84
84
 
85
85
  If a filter change removes the model you are using from the list, you can keep using it in the current session. When you reopen the session, that model is not restored, so pick a model from the list again.
86
86
 
@@ -95,8 +95,9 @@ Picking **Devin Fusion** (`fusion`) in `/model` turns on the `devin_sidekick` to
95
95
  - You can also use arguments: `/devin-fusion <lead model ID> <sidekick model ID>` (for example, `/devin-fusion swe-2 swe-2-high`). The lead is a family ID from the `/model` list; the sidekick is a variant ID with a fixed thinking level. With the TUI picker you do not need to know the IDs. The saved pair applies to all sessions.
96
96
  - The lead's thinking level follows `/thinking`.
97
97
  - The sidekick runs as a separate Pi process for each lead session. It shares the working directory, active tools and Devin login with the lead.
98
- - By default, the lead waits for the sidekick's report. If the lead model calls the `devin_sidekick` tool with `block: false`, it continues without waiting, and runs again when the report arrives. Cancelling the lead also stops the sidekick task it was waiting for.
99
- - The lead receives at most about 131,072 tokens of each sidekick report, counted like Pi's own context estimate (4 characters per token). A longer report is cut at that point, and the cut report ends with the path of a temporary file that holds the complete text (see [Storage locations](#storage-locations)).
98
+ - By default, the lead waits for the sidekick's report. If the lead model calls the `devin_sidekick` tool with `block: false`, it continues without waiting, and runs again when the report arrives. A later blocking call to the running task receives the report as its tool result instead of another background message. Actual user input while waiting moves the task back to the background.
99
+ - Cancelling the lead also stops the sidekick task it was waiting for. A call already cancelled before handoff starts no work, including with `block: false`. A broken connection to the sidekick fails the task and closes that child process rather than terminating the lead Pi.
100
+ - The lead receives at most about 131,072 tokens of each sidekick report, counted like Pi's own context estimate (4 characters per token). This includes status, errors, background headings and truncation notices. A longer report is cut, and the cut report ends with the path of a temporary file that holds the complete report (see [Storage locations](#storage-locations)). If saving fails, the report stays bounded and includes a notice instead.
100
101
  - If you restricted tools with `pi --tools`, include `devin_sidekick` in the list for Fusion to work.
101
102
  - With Fusion, the lead and the sidekick each use quota as regular requests to the models they call.
102
103
 
@@ -108,7 +109,7 @@ Picking **Devin Fusion** (`fusion`) in `/model` turns on the `devin_sidekick` to
108
109
  | Request timeout | Follows the value set by Pi (300 seconds by default). |
109
110
  | Prompt cache | Short cache by default. `PI_CACHE_RETENTION=long` is allowed only for models Devin marks with a 24-hour cache; for other models the request is refused before sending. |
110
111
  | Images | On supported models, up to 4 MiB per image and 5 MiB in total per message. |
111
- | Sidekick report | The lead receives at most about 131,072 tokens (524,288 characters) of each report. The complete text of a longer report is saved in a temporary file. |
112
+ | Sidekick report | The lead receives at most about 131,072 tokens (524,288 characters), including errors and notices. The complete report is saved in a temporary file when possible. |
112
113
  | print and JSON modes | These modes do not fetch the model list; they use the list stored by an earlier TUI or RPC session. After logging in, run the TUI or RPC mode once, or `--model devin/<model ID>` is not found. |
113
114
 
114
115
  - On a 401 error, log in again. 403, 429 and timeouts are not treated as an expired login. If logging in again does not help, check your account permissions, quota and network.
@@ -212,4 +213,12 @@ SOFTWARE.
212
213
 
213
214
  ## Development
214
215
 
215
- The source and verification procedures are in the [GitHub repository](https://github.com/buYoung/pi-packages/tree/main/apps/pi-devin). See `verification/README.md` for verification and `proto/README.md` for the schema sources.
216
+ The source and verification procedures are in the [GitHub repository](https://github.com/buYoung/pi-packages/tree/main/apps/pi-devin). With development dependencies installed, run these from `apps/pi-devin/` in a source checkout; they use local fixtures and need no Devin account:
217
+
218
+ ```bash
219
+ pnpm run typecheck
220
+ pnpm run test:unit
221
+ node verification/scripts/import-closure.mjs . --expect verification/inventory.json
222
+ ```
223
+
224
+ Expected: exit code 0, all tests passing, and `ok: true` with no import-closure problems. The sidekick tests cover cancellation, broken child connections, one-time delivery and report limits. See `verification/README.md` for isolated Pi and archive checks, and `proto/README.md` for the schema sources.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@buyong/pi-devin",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "description": "Unofficial Pi provider for Devin accounts (OAuth)",
6
6
  "license": "MIT",
@@ -54,12 +54,13 @@
54
54
  "@earendil-works/pi-tui": "1.3.0",
55
55
  "@types/node": "26.6.5",
56
56
  "@typescript/native-preview": "7.0.0-dev.20260707.2",
57
+ "vitest": "4.1.12",
57
58
  "@repo/typescript-config": "0.0.0"
58
59
  },
59
60
  "scripts": {
60
61
  "typecheck": "tsgo --noEmit",
61
- "verify:offline": "node --test tests/*.test.ts",
62
- "test:unit": "node --test tests/*.test.ts",
62
+ "verify:offline": "vitest run",
63
+ "test:unit": "vitest run",
63
64
  "cli": "node cli.ts"
64
65
  }
65
66
  }
@@ -80,6 +80,18 @@ export function failureDetails(error: unknown): JsonObject {
80
80
  return { category: "error" };
81
81
  }
82
82
 
83
+ const TRANSIENT_CONNECTION_CODES = /^(ECONNREFUSED|ECONNRESET|ECONNABORTED|EPIPE|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|ENETDOWN|ENOTFOUND|EAI_AGAIN|ERR_STREAM_PREMATURE_CLOSE)$/;
84
+
85
+ /** The request got no usable answer from a working server, so sending it again may succeed. TLS and certificate failures are not included. */
86
+ export function isTransientConnectionFailure(error: unknown): boolean {
87
+ if (error instanceof RPCError) return error.code === "unavailable" || [502, 503, 504].includes(error.httpStatus ?? 0);
88
+ return error instanceof Error && "code" in error && typeof error.code === "string" && TRANSIENT_CONNECTION_CODES.test(error.code);
89
+ }
90
+
91
+ function prematureClose(message: string): Error {
92
+ return Object.assign(new Error(message), { code: "ERR_STREAM_PREMATURE_CLOSE" });
93
+ }
94
+
83
95
  function retryAfterMs(headers: Headers): number | undefined {
84
96
  const value = headers.get("retry-after")?.trim();
85
97
  if (!value) return undefined;
@@ -185,8 +197,8 @@ export async function* decodeFrames(
185
197
  yield { isEnd: hasEnded, data };
186
198
  }
187
199
  }
188
- if (buffered.length !== 0) throw new Error("The connection ended with an incomplete Connect frame.");
189
- if (!hasEnded) throw new Error("The connection ended without EndStream.");
200
+ if (buffered.length !== 0) throw prematureClose("The connection ended with an incomplete Connect frame.");
201
+ if (!hasEnded) throw prematureClose("The connection ended without EndStream.");
190
202
  }
191
203
 
192
204
  async function* responseChunks(response: Response, signal: AbortSignal): AsyncGenerator<Uint8Array> {
package/src/pi/auth.ts CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import type { OAuthAuth, OAuthCredential } from "@earendil-works/pi-ai";
3
3
  import { loginAccount, type LoginOptions } from "../core/auth/index.ts";
4
4
  import { DevinClient } from "../core/client.ts";
5
- import { awaitWithSignal, endpoint, isAuthenticationError, RPCError } from "../core/protocol/transport.ts";
5
+ import { awaitWithSignal, endpoint, isAuthenticationError, isTransientConnectionFailure, RPCError } from "../core/protocol/transport.ts";
6
6
  import { object, requiredString, type Credential, type JsonObject } from "../core/types.ts";
7
7
 
8
8
  export const OAUTH_ONLY_MESSAGE = "Devin uses only the OAuth account session from /login. API key authentication is not supported.";
@@ -48,6 +48,8 @@ export function createDevinAccountAuth(options: {
48
48
  onValidated?: (credential: Credential, ownerHash: string, status: JsonObject) => void;
49
49
  onInvalidated?: (credential: Credential, error: unknown) => void;
50
50
  getSignal?: () => AbortSignal;
51
+ /** The account was already checked in this process; each model request checks it again outside Pi's credential lock. */
52
+ isAccountChecked?: (credential: Credential) => boolean;
51
53
  } = {}): DevinAccountAuth {
52
54
  const invalid = new Set<string>();
53
55
  // Remembers only a hash, never the token. Pi calls toAuth on every request, so the session in use moves back to the front.
@@ -104,6 +106,9 @@ export function createDevinAccountAuth(options: {
104
106
  const credential = credentialFromOAuth(current);
105
107
  requireUsable(credential);
106
108
  rememberOAuth(credential);
109
+ // expires 0 makes Pi call this before every request while holding the shared auth.json lock. A network
110
+ // check here serializes every Pi process using Devin, so only the first resolution in a process checks.
111
+ if (options.isAccountChecked?.(credential)) return current;
107
112
  const expectedGeneration = generation;
108
113
  const client = (options.createClient ?? ((value) => new DevinClient(value, { profile: "login" })))(credential);
109
114
  try {
@@ -111,7 +116,13 @@ export function createDevinAccountAuth(options: {
111
116
  const status = await client.status(signal);
112
117
  if (expectedGeneration === generation) options.onValidated?.(credential, requiredString(client.accountIdentityHash, "Devin account scope"), status);
113
118
  return current;
114
- } catch (error) { auth.invalidate(credential, error, expectedGeneration); throw error; }
119
+ } catch (error) {
120
+ auth.invalidate(credential, error, expectedGeneration);
121
+ // An unreachable check must not block the request: keep the stored session so the model request
122
+ // succeeds or reports its own retryable transport error.
123
+ if (!signal.aborted && isTransientConnectionFailure(error)) return current;
124
+ throw error;
125
+ }
115
126
  finally { client.transport.close(); }
116
127
  },
117
128
  async toAuth(current) {
@@ -9,7 +9,7 @@ import { applyTeamModelPolicy } from "../core/models/access.ts";
9
9
  import type { BackendModelSelection, EffectiveThinkingLevel } from "../core/models/variants.ts";
10
10
  import { createFingerprintProvider, type FingerprintProvider } from "../core/protocol/fingerprint.ts";
11
11
  import { CHAT_METHOD } from "../core/protocol/protobuf.ts";
12
- import { awaitWithSignal, endpoint, isAuthenticationError, RPCError, safeError } from "../core/protocol/transport.ts";
12
+ import { awaitWithSignal, endpoint, isAuthenticationError, isTransientConnectionFailure, RPCError, safeError } from "../core/protocol/transport.ts";
13
13
  import { object, requiredString, tokenCount, type Credential, type JsonObject } from "../core/types.ts";
14
14
  import { createDevinAccountAuth } from "./auth.ts";
15
15
  import { DevinLifecycle, type RequestLease, type SessionBinding } from "./lifecycle.ts";
@@ -22,6 +22,11 @@ import { accountUsage, type AccountUsage } from "./usage.ts";
22
22
  type LoginConfiguration = NonNullable<Parameters<typeof createDevinAccountAuth>[0]>["loginOptions"];
23
23
 
24
24
  /** Like Pi's built-in providers: the caller option wins; otherwise only PI_CACHE_RETENTION=long (provider env first) means long retention. */
25
+ /** Node and Connect report these failures as codes ("read ECONNRESET", "unavailable: …") that Pi's host retry does not match. */
26
+ function hostRetryableMessage(error: unknown, message: string): string {
27
+ return isTransientConnectionFailure(error) ? `Connection error (${message})` : message;
28
+ }
29
+
25
30
  function resolveCacheRetention(options: SimpleStreamOptions): NonNullable<SimpleStreamOptions["cacheRetention"]> {
26
31
  if (options.cacheRetention) return options.cacheRetention;
27
32
  return (options.env?.PI_CACHE_RETENTION || process.env.PI_CACHE_RETENTION) === "long" ? "long" : "short";
@@ -85,6 +90,7 @@ export function createDevinProvider(options: {
85
90
  loginOptions: { ...options.loginOptions, fetch: options.loginOptions?.fetch ?? options.fetch },
86
91
  createClient: (credential) => clientFor(credential, { profile: "login" }),
87
92
  getSignal: () => lifetime.signal,
93
+ isAccountChecked: (credential) => accounts.has(catalogScope(credential)),
88
94
  onValidated: rememberAccount,
89
95
  onInvalidated: (credential, error) => { accounts.delete(catalogScope(credential)); latestUsage = undefined; lifecycle.abort(error); },
90
96
  onLogin: () => {
@@ -141,18 +147,20 @@ export function createDevinProvider(options: {
141
147
  }
142
148
  };
143
149
  assertCacheRetention(selected);
150
+ // The per-request account check (permission and quota) runs here, outside Pi's shared credential lock.
144
151
  let account = accounts.get(catalogScope(credential));
145
- if (!account) {
146
- const probe = clientFor(credential, { profile: "login", fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs });
147
- try {
148
- const status = await probe.status(signal);
149
- signal.throwIfAborted();
150
- if (generation !== auth.generation) throw new DOMException("The Devin authentication generation changed.", "AbortError");
151
- auth.assertUsable(credential);
152
- rememberAccount(credential, requiredString(probe.accountIdentityHash, "Devin account scope"), status);
153
- account = accounts.get(catalogScope(credential));
154
- } finally { probe.transport.close(); }
155
- }
152
+ const probe = clientFor(credential, { profile: "login", fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs });
153
+ try {
154
+ const status = await probe.status(signal);
155
+ signal.throwIfAborted();
156
+ if (generation !== auth.generation) throw new DOMException("The Devin authentication generation changed.", "AbortError");
157
+ auth.assertUsable(credential);
158
+ rememberAccount(credential, requiredString(probe.accountIdentityHash, "Devin account scope"), status);
159
+ account = accounts.get(catalogScope(credential));
160
+ } catch (error) {
161
+ // A check that cannot reach the server keeps the last known account; the model request reports its own failure.
162
+ if (!account || signal.aborted || !isTransientConnectionFailure(error)) throw error;
163
+ } finally { probe.transport.close(); }
156
164
  if (!account) throw new Error("The Devin account check was interrupted.");
157
165
  assertCanInfer(account.status);
158
166
  lease = await lifecycle.acquire(account.ownerHash, signal);
@@ -215,7 +223,7 @@ export function createDevinProvider(options: {
215
223
  } catch (error) {
216
224
  if (credential) authenticationFailed(credential, error, generation);
217
225
  accumulator.message.modelUid = actualUID || undefined;
218
- response.fail(safeError(error, credential ? [credential.sessionToken] : []), error instanceof Error && error.name === "AbortError", isRedacted ? undefined : accumulator.message);
226
+ response.fail(hostRetryableMessage(error, safeError(error, credential ? [credential.sessionToken] : [])), error instanceof Error && error.name === "AbortError", isRedacted ? undefined : accumulator.message);
219
227
  } finally { client?.transport.close(); lease?.release(); }
220
228
  })();
221
229
  return response.stream;
@@ -30,13 +30,18 @@ export function truncateReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIM
30
30
  export async function boundReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): Promise<BoundedReport> {
31
31
  const result = truncateReport(text, maxChars);
32
32
  if (!result.isTruncated) return { text };
33
- const notice = `[The sidekick report exceeded about ${SIDEKICK_REPORT_TOKEN_LIMIT} tokens and was cut.`;
33
+ let notice = `\n\n[The sidekick report exceeded about ${SIDEKICK_REPORT_TOKEN_LIMIT} tokens and was cut.`;
34
+ let fullReportPath: string | undefined;
34
35
  try {
35
36
  const directory = await mkdtemp(join(tmpdir(), "pi-devin-sidekick-report-"));
36
- const fullReportPath = join(directory, "report.md");
37
- await writeFile(fullReportPath, text, { encoding: "utf8", mode: 0o600 });
38
- return { text: `${result.text}\n\n${notice} Full report: ${fullReportPath}]`, fullReportPath };
37
+ const path = join(directory, "report.md");
38
+ await writeFile(path, text, { encoding: "utf8", mode: 0o600 });
39
+ fullReportPath = path;
40
+ notice += ` Full report: ${path}]`;
39
41
  } catch (error) {
40
- return { text: `${result.text}\n\n${notice} The full report could not be saved: ${error instanceof Error ? error.message : String(error)}]` };
42
+ notice += ` The full report could not be saved: ${error instanceof Error ? error.message : String(error)}]`;
41
43
  }
44
+ // The notice is part of the same budget, including a filesystem error if preservation failed.
45
+ const suffix = truncateReport(notice, maxChars).text;
46
+ return { text: truncateReport(text, maxChars - suffix.length).text + suffix, ...(fullReportPath ? { fullReportPath } : {}) };
42
47
  }
@@ -151,35 +151,38 @@ export class SidekickRuntime {
151
151
  }
152
152
 
153
153
  handoff(message: string): Handoff {
154
- if (this.pending) {
154
+ const current = this.pending;
155
+ if (current) {
155
156
  this.send({ type: "steer", message });
156
- return { id: this.pending.id, done: this.pending.done, isSteer: true };
157
+ return { id: current.id, done: current.done, isSteer: true };
157
158
  }
158
159
  if (!this.isAlive()) this.start();
159
160
  let resolve!: (report: HandoffReport) => void;
160
161
  const done = new Promise<HandoffReport>((settle) => { resolve = settle; });
161
162
  const id = randomUUID();
162
163
  this.pending = { id, startedAt: Date.now(), toolCalls: 0, recentTools: [], isAborted: false, resolve, done };
163
- try { this.send({ id, type: "prompt", message }); }
164
- catch (error) { this.finish("error", "", error instanceof Error ? error.message : String(error)); }
164
+ this.send({ id, type: "prompt", message });
165
165
  return { id, done, isSteer: false };
166
166
  }
167
167
 
168
168
  abort(): void {
169
169
  if (!this.pending) return;
170
170
  this.pending.isAborted = true;
171
- try { this.send({ type: "abort" }); } catch { this.finish("aborted", ""); }
171
+ this.send({ type: "abort" });
172
172
  }
173
173
 
174
174
  close(): void {
175
175
  const child = this.child;
176
176
  this.child = undefined;
177
177
  if (this.pending) this.finish("aborted", "", "Stopped the sidekick process.");
178
+ for (const resolve of this.waiting.values()) resolve(undefined);
179
+ this.waiting.clear();
178
180
  if (!child || child.exitCode !== null || child.signalCode !== null) return;
179
181
  child.stdin?.end();
180
- child.kill("SIGTERM");
181
182
  const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, KILL_GRACE_MS);
182
183
  timer.unref();
184
+ child.once("close", () => clearTimeout(timer));
185
+ child.kill("SIGTERM");
183
186
  }
184
187
 
185
188
  private start(): void {
@@ -195,6 +198,7 @@ export class SidekickRuntime {
195
198
  const decoder = new StringDecoder("utf8");
196
199
  let buffer = "";
197
200
  child.stdout?.on("data", (chunk: Buffer) => {
201
+ if (this.child !== child) return;
198
202
  buffer += decoder.write(chunk);
199
203
  let index: number;
200
204
  while ((index = buffer.indexOf("\n")) >= 0) {
@@ -205,28 +209,37 @@ export class SidekickRuntime {
205
209
  let record: Record<string, unknown>;
206
210
  try { record = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
207
211
  this.handle(record);
212
+ if (this.child !== child) return;
208
213
  }
209
214
  });
210
- child.stderr?.on("data", (chunk: Buffer) => { this.stderrTail = (this.stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL); });
211
- child.on("error", (error) => { if (this.child === child) this.finish("error", "", error.message); });
212
- child.on("close", () => {
213
- if (this.child === child) this.child = undefined;
214
- for (const resolve of this.waiting.values()) resolve(undefined);
215
- this.waiting.clear();
216
- if (this.pending) this.finish("error", "", this.stderrTail.trim() || "The sidekick process exited.");
217
- });
215
+ child.stderr?.on("data", (chunk: Buffer) => { if (this.child === child) this.stderrTail = (this.stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL); });
216
+ child.stdin?.on("error", (error) => this.fail(child, error.message));
217
+ child.on("error", (error) => this.fail(child, error.message));
218
+ child.on("close", () => this.fail(child, this.stderrTail.trim() || "The sidekick process exited."));
219
+ }
220
+
221
+ /** Transport errors settle the handoff and all RPC waits once, even if the failed child closes after its replacement starts. */
222
+ private fail(child: ChildProcess | undefined, error: string): void {
223
+ if (this.child !== child) return;
224
+ this.finish(this.pending?.isAborted ? "aborted" : "error", "", error);
225
+ this.close();
218
226
  }
219
227
 
220
228
  private send(value: Record<string, unknown>): void {
221
- if (!this.child?.stdin?.writable) throw new Error("Cannot write to the sidekick process.");
222
- this.child.stdin.write(JSON.stringify(value) + "\n");
229
+ const child = this.child;
230
+ try {
231
+ if (!child?.stdin?.writable) throw new Error("Cannot write to the sidekick process.");
232
+ child.stdin.write(JSON.stringify(value) + "\n");
233
+ } catch (error) {
234
+ this.fail(child, error instanceof Error ? error.message : String(error));
235
+ }
223
236
  }
224
237
 
225
238
  private request(type: string): Promise<string | undefined> {
226
239
  const id = randomUUID();
227
240
  return new Promise((resolve) => {
228
241
  this.waiting.set(id, resolve);
229
- try { this.send({ id, type }); } catch { this.waiting.delete(id); resolve(undefined); }
242
+ this.send({ id, type });
230
243
  });
231
244
  }
232
245
 
@@ -247,7 +260,7 @@ export class SidekickRuntime {
247
260
  case "extension_ui_request":
248
261
  // The sidekick never talks to the user. Dialogs that need an answer are returned as cancelled.
249
262
  if (["select", "confirm", "input", "editor"].includes(String(record.method))) {
250
- try { this.send({ type: "extension_ui_response", id: record.id, cancelled: true }); } catch { /* shutting down */ }
263
+ this.send({ type: "extension_ui_response", id: record.id, cancelled: true });
251
264
  }
252
265
  return;
253
266
  case "tool_execution_start":
@@ -51,12 +51,18 @@ export function fusionOf(provider: Pick<DevinProvider, "getModels">, model: Mode
51
51
  const launchKey = (launch: SidekickLaunch) => JSON.stringify([launch.cwd, launch.modelID, launch.thinking, launch.sessionFile, launch.tools]);
52
52
  const shortID = (id: string) => id.slice(0, 8);
53
53
 
54
- /** Report text for the lead. The sidekick's own text is bounded on every path: tool result, failure and background message. */
55
- async function reportText(report: HandoffReport): Promise<BoundedReport> {
56
- const body = await boundReport(report.text.trim());
57
- if (report.status === "completed") return { ...body, text: body.text || "(The sidekick finished without a report.)" };
54
+ /** Bound and preserve the complete report, including status, errors and any background delivery heading. */
55
+ async function reportText(report: HandoffReport, heading = ""): Promise<BoundedReport> {
56
+ const body = report.text.trim();
58
57
  const reason = report.error ?? (report.status === "aborted" ? "cancelled." : "failed.");
59
- return { ...body, text: [`The sidekick task ${report.status === "aborted" ? "stopped" : "failed"}: ${reason}`, body.text].filter(Boolean).join("\n\n") };
58
+ const text = report.status === "completed" ? body || "(The sidekick finished without a report.)"
59
+ : [`The sidekick task ${report.status === "aborted" ? "stopped" : "failed"}: ${reason}`, body].filter(Boolean).join("\n\n");
60
+ return boundReport(heading + text);
61
+ }
62
+
63
+ interface ReportDelivery {
64
+ owner: "background" | "blocking" | "delivered";
65
+ scheduled: boolean;
60
66
  }
61
67
 
62
68
  const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fullReportPath } : {};
@@ -67,25 +73,28 @@ const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fu
67
73
  */
68
74
  export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, options: SidekickToolOptions) {
69
75
  const runtimes = new Map<string, SidekickRuntime>();
70
- const scheduled = new Set<string>();
76
+ // Steers share the same done promise; completed handoffs must not be retained by delivery bookkeeping.
77
+ const deliveries = new WeakMap<Promise<HandoffReport>, ReportDelivery>();
71
78
  const waits = new Set<() => void>();
72
79
  let progressListener: (() => void) | undefined;
73
80
 
74
- const deliverLater = (handoff: Handoff, runtime: SidekickRuntime) => {
75
- if (scheduled.has(handoff.id)) return;
76
- scheduled.add(handoff.id);
81
+ const deliverLater = (handoff: Handoff, runtime: SidekickRuntime, delivery: ReportDelivery) => {
82
+ if (delivery.scheduled || delivery.owner === "delivered") return;
83
+ delivery.scheduled = true;
77
84
  void handoff.done.then(async (report) => {
78
- // Handoffs cleaned up by a session switch or shutdown, or cancelled by the lead, are not reported.
79
- const isCurrent = () => [...runtimes.values()].includes(runtime);
80
- if (report.status === "aborted" || !isCurrent()) return;
81
- const { text, fullReportPath } = await reportText(report);
82
- if (!isCurrent()) return;
83
- pi.sendMessage({
84
- customType: SIDEKICK_REPORT_MESSAGE, display: true,
85
- content: `Sidekick report for background handoff ${shortID(report.id)}:\n\n${text}`,
86
- details: { sidekickModel: runtime.launch.modelID, handoffId: report.id, status: report.status, toolCalls: report.toolCalls, durationMs: report.durationMs, isSteer: false,
87
- ...reportPath(fullReportPath) } satisfies SidekickDetails,
88
- }, { deliverAs: "followUp", triggerTurn: true });
85
+ try {
86
+ // A blocking call owns the report unless user input moves it back to the background.
87
+ const canDeliver = () => delivery.owner === "background" && [...runtimes.values()].includes(runtime);
88
+ if (report.status === "aborted" || !canDeliver()) return;
89
+ const { text, fullReportPath } = await reportText(report, `Sidekick report for background handoff ${shortID(report.id)}:\n\n`);
90
+ if (!canDeliver()) return;
91
+ delivery.owner = "delivered";
92
+ pi.sendMessage({
93
+ customType: SIDEKICK_REPORT_MESSAGE, display: true, content: text,
94
+ details: { sidekickModel: runtime.launch.modelID, handoffId: report.id, status: report.status, toolCalls: report.toolCalls, durationMs: report.durationMs, isSteer: false,
95
+ ...reportPath(fullReportPath) } satisfies SidekickDetails,
96
+ }, { deliverAs: "followUp", triggerTurn: true });
97
+ } finally { delivery.scheduled = false; }
89
98
  });
90
99
  };
91
100
 
@@ -109,6 +118,7 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
109
118
  defaultActive: false,
110
119
  executionMode: "sequential",
111
120
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
121
+ signal?.throwIfAborted();
112
122
  const fusion = fusionOf(provider, ctx.model);
113
123
  if (!fusion) throw new Error("The sidekick is only available with the Devin Fusion model.");
114
124
  const input = params as { message?: unknown; block?: unknown } | undefined;
@@ -131,18 +141,25 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
131
141
  runtime = new SidekickRuntime(launch, { ...options.runtime, childEnv: { [FUSION_CHILD_ENV]: "1" }, onProgress: () => progressListener?.() });
132
142
  runtimes.set(sessionId, runtime);
133
143
  }
144
+ signal?.throwIfAborted();
134
145
  const handoff = runtime.handoff(message);
146
+ let delivery = deliveries.get(handoff.done);
147
+ if (!delivery) {
148
+ delivery = { owner: "background", scheduled: false };
149
+ deliveries.set(handoff.done, delivery);
150
+ }
135
151
  const details = (status: SidekickDetails["status"], report?: HandoffReport): SidekickDetails => ({
136
152
  sidekickModel: launch.modelID, handoffId: handoff.id, status, toolCalls: report?.toolCalls ?? runtime!.progress()?.toolCalls ?? 0,
137
153
  ...(report ? { durationMs: report.durationMs } : {}), isSteer: handoff.isSteer,
138
154
  });
139
155
  const backgroundResult = (reason: string) => {
140
- deliverLater(handoff, runtime!);
156
+ deliverLater(handoff, runtime!, delivery);
141
157
  return { content: [{ type: "text" as const, text: `${reason} The report will arrive as a message. (handoff ${shortID(handoff.id)})` }], details: details("background") };
142
158
  };
143
159
  if (input?.block === false) {
144
160
  return backgroundResult(handoff.isSteer ? "Added instructions to the running sidekick task." : "The sidekick started working in the background.");
145
161
  }
162
+ delivery.owner = "blocking";
146
163
  let settle!: (outcome: "done" | "aborted" | "interrupted") => void;
147
164
  const waiting = new Promise<"done" | "aborted" | "interrupted">((resolve) => { settle = resolve; });
148
165
  const interrupt = () => settle("interrupted"), abort = () => settle("aborted");
@@ -159,11 +176,15 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
159
176
  waits.delete(interrupt);
160
177
  signal?.removeEventListener("abort", abort);
161
178
  });
179
+ if (outcome === "interrupted") {
180
+ delivery.owner = "background";
181
+ return backgroundResult("A user message arrived, so the sidekick task moved to the background.");
182
+ }
183
+ delivery.owner = "delivered";
162
184
  if (outcome === "aborted") {
163
185
  runtime.abort();
164
186
  throw new Error("Cancelled the sidekick task.");
165
187
  }
166
- if (outcome === "interrupted") return backgroundResult("A user message arrived, so the sidekick task moved to the background.");
167
188
  const report = await handoff.done;
168
189
  const { text, fullReportPath } = await reportText(report);
169
190
  if (report.status !== "completed") throw new Error(text);
@@ -184,7 +205,7 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
184
205
  },
185
206
  reset(): void {
186
207
  for (const runtime of runtimes.values()) runtime.close();
187
- runtimes.clear(); scheduled.clear();
208
+ runtimes.clear();
188
209
  },
189
210
  };
190
211
  }