@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 +14 -5
- package/package.json +4 -3
- package/src/core/protocol/transport.ts +14 -2
- package/src/pi/auth.ts +13 -2
- package/src/pi/provider.ts +21 -13
- package/src/pi/sidekick-report.ts +10 -5
- package/src/pi/sidekick-runtime.ts +31 -18
- package/src/pi/sidekick.ts +44 -23
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
|
|
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.
|
|
99
|
-
-
|
|
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)
|
|
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).
|
|
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
|
+
"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": "
|
|
62
|
-
"test:unit": "
|
|
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
|
|
189
|
-
if (!hasEnded) throw
|
|
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) {
|
|
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) {
|
package/src/pi/provider.ts
CHANGED
|
@@ -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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
-
|
|
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
|
|
37
|
-
await writeFile(
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
154
|
+
const current = this.pending;
|
|
155
|
+
if (current) {
|
|
155
156
|
this.send({ type: "steer", message });
|
|
156
|
-
return { id:
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
212
|
-
child.on("
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
+
this.send({ type: "extension_ui_response", id: record.id, cancelled: true });
|
|
251
264
|
}
|
|
252
265
|
return;
|
|
253
266
|
case "tool_execution_start":
|
package/src/pi/sidekick.ts
CHANGED
|
@@ -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
|
-
/**
|
|
55
|
-
async function reportText(report: HandoffReport): Promise<BoundedReport> {
|
|
56
|
-
const body =
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
76
|
-
scheduled
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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();
|
|
208
|
+
runtimes.clear();
|
|
188
209
|
},
|
|
189
210
|
};
|
|
190
211
|
}
|