@buyong/pi-devin 0.1.4 → 0.1.6

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 (116) hide show
  1. package/README.ko.md +222 -0
  2. package/README.md +37 -41
  3. package/index.ts +6 -12
  4. package/package.json +4 -1
  5. package/src/core/auth/access.ts +7 -3
  6. package/src/core/auth/callback-server.ts +57 -0
  7. package/src/core/auth/index.ts +2 -1
  8. package/src/core/auth/login.ts +104 -151
  9. package/src/core/auth/pkce.ts +32 -0
  10. package/src/core/auth/rest-exchange.ts +32 -30
  11. package/src/core/cancellation.ts +99 -0
  12. package/src/core/client.ts +127 -122
  13. package/src/core/errors.ts +115 -0
  14. package/src/core/index.ts +18 -4
  15. package/src/core/inference/accumulator.ts +174 -0
  16. package/src/core/inference/images.ts +22 -15
  17. package/src/core/inference/request.ts +59 -0
  18. package/src/core/inference/retry.ts +31 -31
  19. package/src/core/inference/state.ts +7 -3
  20. package/src/core/inference/stream-chat.ts +48 -0
  21. package/src/core/models/access.ts +4 -3
  22. package/src/core/models/catalog.ts +29 -20
  23. package/src/core/models/select.ts +14 -12
  24. package/src/core/models/variants.ts +85 -72
  25. package/src/core/protocol/connect-client.ts +288 -0
  26. package/src/core/protocol/device.ts +33 -34
  27. package/src/core/protocol/endpoint.ts +22 -0
  28. package/src/core/protocol/fingerprint.ts +38 -26
  29. package/src/core/protocol/frames.ts +101 -0
  30. package/src/core/protocol/http/fetch-client.ts +42 -0
  31. package/src/core/protocol/http/node-client.ts +130 -0
  32. package/src/core/protocol/http/response.ts +99 -0
  33. package/src/core/protocol/http.ts +41 -101
  34. package/src/core/protocol/protobuf.ts +93 -74
  35. package/src/core/redaction.ts +33 -0
  36. package/src/core/services/device-identity.ts +15 -0
  37. package/src/core/services/http-transport.ts +28 -0
  38. package/src/core/services/platform.ts +54 -0
  39. package/src/core/types.ts +2 -23
  40. package/src/core/validation.ts +25 -0
  41. package/src/pi/adapters/pi/devin-command.ts +82 -0
  42. package/src/pi/adapters/pi/fusion-command.ts +92 -0
  43. package/src/pi/adapters/pi/oauth.ts +14 -0
  44. package/src/pi/adapters/pi/provider.ts +22 -0
  45. package/src/pi/adapters/pi/registration.ts +89 -0
  46. package/src/pi/adapters/pi/response-stream.ts +164 -0
  47. package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
  48. package/src/pi/adapters/pi/transcript.ts +177 -0
  49. package/src/pi/adapters/pi/types.ts +14 -0
  50. package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
  51. package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
  52. package/src/pi/adapters/pi/usage-status.ts +65 -0
  53. package/src/pi/adapters/storage/files.ts +66 -0
  54. package/src/pi/adapters/storage/json-file.ts +31 -0
  55. package/src/pi/app/config.ts +25 -0
  56. package/src/pi/app/environment.ts +30 -0
  57. package/src/pi/app/layer.ts +43 -0
  58. package/src/pi/app/runtime.ts +20 -0
  59. package/src/pi/app/session.ts +26 -0
  60. package/src/pi/features/accounts/service.ts +69 -0
  61. package/src/pi/features/accounts/types.ts +25 -0
  62. package/src/pi/features/auth/credential.ts +40 -0
  63. package/src/pi/features/auth/guard.ts +25 -0
  64. package/src/pi/features/auth/login.ts +116 -0
  65. package/src/pi/features/auth/state.ts +48 -0
  66. package/src/pi/features/auth/types.ts +36 -0
  67. package/src/pi/features/bounded.ts +4 -0
  68. package/src/pi/features/catalog/constants.ts +10 -0
  69. package/src/pi/features/catalog/fusion.ts +45 -0
  70. package/src/pi/features/catalog/ids.ts +79 -0
  71. package/src/pi/features/catalog/projection.ts +93 -0
  72. package/src/pi/features/catalog/remote.ts +15 -0
  73. package/src/pi/features/catalog/restore.ts +53 -0
  74. package/src/pi/features/catalog/service.ts +158 -0
  75. package/src/pi/features/catalog/types.ts +56 -0
  76. package/src/pi/features/clients/service.ts +47 -0
  77. package/src/pi/features/clients/types.ts +22 -0
  78. package/src/pi/features/default-scheduler.ts +12 -0
  79. package/src/pi/features/inference/request.ts +190 -0
  80. package/src/pi/features/inference/service.ts +33 -0
  81. package/src/pi/features/inference/types.ts +11 -0
  82. package/src/pi/features/lifetime/service.ts +19 -0
  83. package/src/pi/features/lifetime/types.ts +13 -0
  84. package/src/pi/features/runs/admission.ts +36 -0
  85. package/src/pi/features/runs/lifecycle.ts +207 -0
  86. package/src/pi/features/runs/service.ts +10 -0
  87. package/src/pi/features/runs/state.ts +37 -0
  88. package/src/pi/features/runs/types.ts +43 -0
  89. package/src/pi/features/settings/service.ts +46 -0
  90. package/src/pi/features/settings/types.ts +36 -0
  91. package/src/pi/features/sidekick/child.ts +228 -0
  92. package/src/pi/features/sidekick/command.ts +80 -0
  93. package/src/pi/features/sidekick/launcher.ts +12 -0
  94. package/src/pi/features/sidekick/prompts.ts +17 -0
  95. package/src/pi/features/sidekick/report.ts +51 -0
  96. package/src/pi/features/sidekick/service.ts +176 -0
  97. package/src/pi/features/sidekick/types.ts +99 -0
  98. package/src/pi/features/usage/changes.ts +20 -0
  99. package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
  100. package/src/pi/features/usage/types.ts +33 -0
  101. package/src/core/inference/chat.ts +0 -234
  102. package/src/core/protocol/transport.ts +0 -513
  103. package/src/pi/auth.ts +0 -145
  104. package/src/pi/devin-command.ts +0 -76
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -80
  107. package/src/pi/fusion-command.ts +0 -83
  108. package/src/pi/fusion-settings.ts +0 -44
  109. package/src/pi/lifecycle.ts +0 -217
  110. package/src/pi/messages.ts +0 -131
  111. package/src/pi/models.ts +0 -379
  112. package/src/pi/provider.ts +0 -249
  113. package/src/pi/sidekick-report.ts +0 -47
  114. package/src/pi/sidekick-runtime.ts +0 -300
  115. package/src/pi/sidekick.ts +0 -211
  116. package/src/pi/stream.ts +0 -124
@@ -1,249 +0,0 @@
1
- import {
2
- clampThinkingLevel,
3
- type AssistantMessageEventStream, type Model, type Provider, type SimpleStreamOptions, type TranscriptContext,
4
- } from "@earendil-works/pi-ai";
5
- import { DevinClient, type DevinClientOptions } from "../core/client.ts";
6
- import { assertCanInfer } from "../core/auth/access.ts";
7
- import { ChatAccumulator, chatRequest } from "../core/inference/chat.ts";
8
- import { applyTeamModelPolicy } from "../core/models/access.ts";
9
- import type { BackendModelSelection, EffectiveThinkingLevel } from "../core/models/variants.ts";
10
- import { createFingerprintProvider, type FingerprintProvider } from "../core/protocol/fingerprint.ts";
11
- import { CHAT_METHOD } from "../core/protocol/protobuf.ts";
12
- import { awaitWithSignal, endpoint, isAuthenticationError, isTransientConnectionFailure, RPCError, safeError } from "../core/protocol/transport.ts";
13
- import { object, requiredString, tokenCount, type Credential, type JsonObject } from "../core/types.ts";
14
- import { createDevinAccountAuth } from "./auth.ts";
15
- import { DevinLifecycle, type RequestLease, type SessionBinding } from "./lifecycle.ts";
16
- import { toDevinTranscript } from "./messages.ts";
17
- import { catalogScope, createDevinModelSource, DEVIN_API, hasExtendedPromptCacheRetention, isFusionModelID, type FusionChoices, type FusionSelection } from "./models.ts";
18
- import { FUSION_LEAD_GUIDANCE } from "./sidekick.ts";
19
- import { DevinResponseStream } from "./stream.ts";
20
- import { accountUsage, type AccountUsage } from "./usage.ts";
21
-
22
- type LoginConfiguration = NonNullable<Parameters<typeof createDevinAccountAuth>[0]>["loginOptions"];
23
-
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
-
30
- function resolveCacheRetention(options: SimpleStreamOptions): NonNullable<SimpleStreamOptions["cacheRetention"]> {
31
- if (options.cacheRetention) return options.cacheRetention;
32
- return (options.env?.PI_CACHE_RETENTION || process.env.PI_CACHE_RETENTION) === "long" ? "long" : "short";
33
- }
34
- export interface DevinProvider extends Provider<typeof DEVIN_API> {
35
- refreshModels: NonNullable<Provider<typeof DEVIN_API>["refreshModels"]>;
36
- readonly lifecycle: DevinLifecycle;
37
- attachSession(binding: SessionBinding): void;
38
- getFusionChoices(): FusionChoices | undefined;
39
- /** Rebuilds the model list from the current catalog without network after the saved Fusion choice or display settings change. */
40
- rebuildModels(): void;
41
- /** Quota from the last account status received. Makes no request; undefined if none was received. */
42
- getAccountUsage(): AccountUsage | undefined;
43
- shutdown(): void;
44
- }
45
-
46
- export function createDevinProvider(options: {
47
- fetch?: typeof fetch;
48
- fingerprint?: (credential: Credential) => FingerprintProvider;
49
- http?: DevinClientOptions["http"];
50
- loginOptions?: LoginConfiguration;
51
- maxModelCalls?: number;
52
- /** Reads the saved Fusion choice. false (sidekick process) creates no Fusion entry. */
53
- fusion?: false | (() => FusionSelection | undefined);
54
- /** Reads the saved recommended filter setting. */
55
- recommendedOnly?: () => boolean;
56
- /** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
57
- listsAllExactModels?: boolean;
58
- now?: () => number;
59
- } = {}): DevinProvider {
60
- const lifecycle = new DevinLifecycle(options.maxModelCalls);
61
- let lifetime = new AbortController();
62
- const clients = new Set<DevinClient>();
63
- const fingerprints = new Map<string, FingerprintProvider>();
64
- const accounts = new Map<string, { ownerHash: string; status: JsonObject }>();
65
- const now = options.now ?? Date.now;
66
- let latestUsage: AccountUsage | undefined;
67
- const clientFor = (credential: Credential, extra: DevinClientOptions = {}): DevinClient => {
68
- const key = catalogScope(credential);
69
- let fingerprint = fingerprints.get(key);
70
- if (!fingerprint) {
71
- if (fingerprints.size >= 16) fingerprints.delete(fingerprints.keys().next().value!);
72
- fingerprint = options.fingerprint?.(credential) ?? createFingerprintProvider(credential.sessionToken);
73
- fingerprints.set(key, fingerprint);
74
- }
75
- const fetch = extra.fetch ?? options.fetch;
76
- const client = new DevinClient(credential, { ...extra, fingerprint, fetch, ...(!fetch && options.http ? { http: options.http } : {}) });
77
- const close = client.transport.close.bind(client.transport);
78
- let hasClosed = false;
79
- client.transport.close = () => { if (!hasClosed) { hasClosed = true; clients.delete(client); close(); } };
80
- clients.add(client);
81
- return client;
82
- };
83
- const rememberAccount = (credential: Credential, ownerHash: string, status: JsonObject) => {
84
- if (lifetime.signal.aborted) return;
85
- if (accounts.size >= 16) accounts.delete(accounts.keys().next().value!);
86
- accounts.set(catalogScope(credential), { ownerHash, status });
87
- latestUsage = accountUsage(status, now());
88
- };
89
- const auth = createDevinAccountAuth({
90
- loginOptions: { ...options.loginOptions, fetch: options.loginOptions?.fetch ?? options.fetch },
91
- createClient: (credential) => clientFor(credential, { profile: "login" }),
92
- getSignal: () => lifetime.signal,
93
- isAccountChecked: (credential) => accounts.has(catalogScope(credential)),
94
- onValidated: rememberAccount,
95
- onInvalidated: (credential, error) => { accounts.delete(catalogScope(credential)); latestUsage = undefined; lifecycle.abort(error); },
96
- onLogin: () => {
97
- accounts.clear(); fingerprints.clear(); latestUsage = undefined; source.invalidate();
98
- lifecycle.abort(new DOMException("The Devin account was re-authenticated.", "AbortError"));
99
- },
100
- });
101
- const authenticationFailed = (credential: Credential, error: unknown, generation: number) => {
102
- if (!isAuthenticationError(error)) return;
103
- auth.invalidate(credential, error, generation);
104
- };
105
- const source = createDevinModelSource({
106
- fusion: options.fusion,
107
- recommendedOnly: options.recommendedOnly,
108
- listsAllExactModels: options.listsAllExactModels,
109
- credentialFromHost: (credential) => credential?.type === "oauth" ? auth.credentialFromHost(credential) : undefined,
110
- fetchCatalog: async (credential, signal) => {
111
- const generation = auth.generation;
112
- auth.assertUsable(credential);
113
- const client = clientFor(credential);
114
- try {
115
- const catalog = await client.catalog(signal);
116
- applyTeamModelPolicy(catalog.models, await client.teamSettings(signal));
117
- return catalog;
118
- } catch (error) { authenticationFailed(credential, error, generation); throw error; }
119
- finally { client.transport.close(); }
120
- },
121
- });
122
- const infer = (model: Model<typeof DEVIN_API>, context: TranscriptContext, requestOptions: SimpleStreamOptions = {}) => {
123
- const effectiveLevel = (requestOptions.reasoning ?? clampThinkingLevel(model, "off")) as EffectiveThinkingLevel;
124
- const response = new DevinResponseStream(model, "");
125
- void (async () => {
126
- let client: DevinClient | undefined, lease: RequestLease | undefined;
127
- let credential: Credential | undefined;
128
- const accumulator = new ChatAccumulator();
129
- let actualUID = "", isRedacted = false;
130
- const generation = auth.generation;
131
- const signal = AbortSignal.any([lifetime.signal, ...(requestOptions.signal ? [requestOptions.signal] : [])]);
132
- try {
133
- signal.throwIfAborted();
134
- credential = { version: 1, sessionToken: requiredString(requestOptions.apiKey, "Devin account authentication (/login required)"), apiServerURL: endpoint(model.baseUrl), createdAt: new Date().toISOString() };
135
- // Pi can override the OAuth value with an apiKey in models.json or a caller option. Only OAuth sessions are accepted.
136
- auth.assertOAuthSession(credential);
137
- auth.assertUsable(credential);
138
- let selected = source.resolve(model.id, effectiveLevel, credential);
139
- actualUID = selected.uid;
140
- response.message.providerThinkingLevel = actualUID;
141
- if (requestOptions.maxRetries !== undefined && requestOptions.maxRetries !== 0) throw new Error("Devin uses Pi host retries. Only provider maxRetries 0 is supported.");
142
- if (requestOptions.transport && !["auto", "sse"].includes(requestOptions.transport)) throw new Error("Devin supports HTTP Connect streams only.");
143
- const cacheRetention = resolveCacheRetention(requestOptions);
144
- const assertCacheRetention = (selection: BackendModelSelection) => {
145
- if (cacheRetention === "long" && !hasExtendedPromptCacheRetention(selection.variant)) {
146
- throw new Error("Long cache lifetime is not confirmed for this Devin model. Long retention is supported only for models whose catalog Prompt Cache Retention is 24h.");
147
- }
148
- };
149
- assertCacheRetention(selected);
150
- // The per-request account check (permission and quota) runs here, outside Pi's shared credential lock.
151
- let account = accounts.get(catalogScope(credential));
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(); }
164
- if (!account) throw new Error("The Devin account check was interrupted.");
165
- assertCanInfer(account.status);
166
- lease = await lifecycle.acquire(account.ownerHash, signal);
167
- const currentLease = lease;
168
- const transcript = toDevinTranscript(context, selected.model);
169
- if (isFusionModelID(model.id)) transcript.systemPrompt = [transcript.systemPrompt, FUSION_LEAD_GUIDANCE].filter(Boolean).join("\n\n");
170
- const maximum = requestOptions.maxTokens === undefined ? selected.model.maxOutputTokens : tokenCount(requestOptions.maxTokens, "maxTokens");
171
- if (maximum < 1 || maximum > selected.model.maxOutputTokens) throw new Error("The output limit is outside the selected Devin model range.");
172
- client = clientFor(credential, { fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs,
173
- onRequest: (attempt) => { if (attempt.isMetered) currentLease.consume(); },
174
- });
175
- currentLease.addCleanup(() => client!.transport.close());
176
- const request = chatRequest({ metadata: client.metadata(), model: selected.uid, ...transcript, maxTokens: maximum,
177
- cascadeId: lease.cascadeId, trajectoryId: lease.trajectoryId, stepIndex: lease.stepIndex,
178
- executionId: lease.executionId, clientSessionId: requestOptions.sessionId ?? lease.sessionId,
179
- isAuxiliary: lease.isAuxiliary, isRecovery: lease.isRecovery, includeContext: false,
180
- });
181
- if (requestOptions.temperature !== undefined) {
182
- if (!Number.isFinite(requestOptions.temperature) || requestOptions.temperature < 0) throw new Error("temperature must be a finite value of 0 or more.");
183
- object(request.configuration, "configuration").temperature = requestOptions.temperature;
184
- }
185
- if (cacheRetention === "none") for (const prompt of request.chatMessagePrompts as JsonObject[]) delete prompt.promptCacheOptions;
186
- const identity = JSON.stringify([request.cascadeId, request.trajectoryReference, request.executionId, request.clientSessionId]);
187
- for await (const _frame of client.transport.stream(CHAT_METHOD, request, lease.signal, {
188
- headers: requestOptions.headers,
189
- onPayload: async (payload, scopeSignal) => {
190
- const replacement = requestOptions.onPayload ? await awaitWithSignal(Promise.resolve(requestOptions.onPayload(payload, model)), scopeSignal) : undefined;
191
- const finalPayload = replacement === undefined ? payload : object(replacement, "provider payload");
192
- if (JSON.stringify([finalPayload.cascadeId, finalPayload.trajectoryReference, finalPayload.executionId, finalPayload.clientSessionId]) !== identity) {
193
- throw new Error("A payload hook cannot change the owned run, session or step identifiers.");
194
- }
195
- actualUID = requiredString(finalPayload.chatModelUid, "chatModelUid");
196
- if (actualUID !== selected.uid) {
197
- // An exact UID hidden from the list is re-resolved at that UID's level when the current catalog has it.
198
- if (!source.exactLevel(actualUID)) throw new Error("A payload hook selected an unavailable model UID.");
199
- selected = source.resolveUID(actualUID, credential!);
200
- toDevinTranscript(context, selected.model); // A model change re-checks existing image and tool limits.
201
- assertCacheRetention(selected);
202
- }
203
- const outputLimit = tokenCount(object(finalPayload.configuration, "configuration").maxTokens, "maxTokens");
204
- if (outputLimit < 1 || outputLimit > selected.model.maxOutputTokens) throw new Error("The payload hook output limit is outside the model range.");
205
- response.message.providerThinkingLevel = actualUID;
206
- return finalPayload;
207
- },
208
- onResponse: async (info, scopeSignal) => {
209
- if (info.status === 401) authenticationFailed(credential!, new RPCError("unauthenticated", "HTTP 401", 401), generation);
210
- if (requestOptions.onResponse) await awaitWithSignal(Promise.resolve(requestOptions.onResponse(info, model)), scopeSignal);
211
- },
212
- onFrame: async (frame, scopeSignal) => {
213
- if (requestOptions.onProviderStreamEvent) await awaitWithSignal(Promise.resolve(requestOptions.onProviderStreamEvent(structuredClone(frame.data), model)), scopeSignal);
214
- if (frame.isEnd && frame.data.error) return; // Connect error classification belongs to the transport.
215
- if (frame.data.redact === true) { isRedacted = true; response.redact(); }
216
- accumulator.accept(frame.data, frame.isEnd);
217
- if (!frame.isEnd) response.accept(frame.data, accumulator);
218
- },
219
- })) { /* A frame handling failure also ends the request inside the transport first. */ }
220
- const result = accumulator.finish();
221
- result.message.modelUid = actualUID;
222
- response.finish(result);
223
- } catch (error) {
224
- if (credential) authenticationFailed(credential, error, generation);
225
- accumulator.message.modelUid = actualUID || undefined;
226
- response.fail(hostRetryableMessage(error, safeError(error, credential ? [credential.sessionToken] : [])), error instanceof Error && error.name === "AbortError", isRedacted ? undefined : accumulator.message);
227
- } finally { client?.transport.close(); lease?.release(); }
228
- })();
229
- return response.stream;
230
- };
231
- return {
232
- id: "devin", name: "Devin", auth: { oauth: auth }, lifecycle,
233
- getModels: source.getModels, filterModels: source.filterModels,
234
- getFusionChoices: source.getFusionChoices, rebuildModels: source.rebuildModels,
235
- getAccountUsage: () => latestUsage ? { ...latestUsage } : undefined,
236
- refreshModels: (context) => source.refreshModels({ ...context, signal: AbortSignal.any([context.signal, lifetime.signal]) }),
237
- stream: (model, context, requestOptions) => infer(model, context, requestOptions), streamSimple: (model, context, requestOptions) => infer(model, context, requestOptions),
238
- attachSession(binding) {
239
- if (lifetime.signal.aborted) lifetime = new AbortController();
240
- lifecycle.attach(binding);
241
- },
242
- shutdown() {
243
- lifetime.abort(new DOMException("The Devin provider shut down.", "AbortError"));
244
- lifecycle.shutdown();
245
- for (const client of [...clients]) client.transport.close();
246
- fingerprints.clear(); accounts.clear(); latestUsage = undefined;
247
- },
248
- };
249
- }
@@ -1,47 +0,0 @@
1
- import { mkdtemp, writeFile } from "node:fs/promises";
2
- import { tmpdir } from "node:os";
3
- import { join } from "node:path";
4
-
5
- /** Upper bound on the sidekick report the lead receives. The Devin CLI analysis records no sidekick report limit, so it is fixed here. */
6
- export const SIDEKICK_REPORT_TOKEN_LIMIT = 131_072;
7
- /** Pi's context estimate (`estimateTokens`) counts 4 characters per token, so the limit uses the same unit. */
8
- const CHARS_PER_TOKEN = 4;
9
- export const SIDEKICK_REPORT_CHAR_LIMIT = SIDEKICK_REPORT_TOKEN_LIMIT * CHARS_PER_TOKEN;
10
-
11
- export interface BoundedReport {
12
- text: string;
13
- /** Private temporary file with the complete report, set only when the report was cut and the file was written. */
14
- fullReportPath?: string;
15
- }
16
-
17
- const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
18
-
19
- /** Keeps the beginning of the report within `maxChars` without splitting a surrogate pair. */
20
- export function truncateReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): { text: string; isTruncated: boolean } {
21
- if (text.length <= maxChars) return { text, isTruncated: false };
22
- const end = isHighSurrogate(text.charCodeAt(maxChars - 1)) ? maxChars - 1 : maxChars;
23
- return { text: text.slice(0, end), isTruncated: true };
24
- }
25
-
26
- /**
27
- * Bounds the report before it reaches the lead and keeps the complete text in a private temporary file.
28
- * Never rejects: background reports are delivered from a detached promise, so a failed write only changes the notice.
29
- */
30
- export async function boundReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): Promise<BoundedReport> {
31
- const result = truncateReport(text, maxChars);
32
- if (!result.isTruncated) return { text };
33
- let notice = `\n\n[The sidekick report exceeded about ${SIDEKICK_REPORT_TOKEN_LIMIT} tokens and was cut.`;
34
- let fullReportPath: string | undefined;
35
- try {
36
- const directory = await mkdtemp(join(tmpdir(), "pi-devin-sidekick-report-"));
37
- const path = join(directory, "report.md");
38
- await writeFile(path, text, { encoding: "utf8", mode: 0o600 });
39
- fullReportPath = path;
40
- notice += ` Full report: ${path}]`;
41
- } catch (error) {
42
- notice += ` The full report could not be saved: ${error instanceof Error ? error.message : String(error)}]`;
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 } : {}) };
47
- }
@@ -1,300 +0,0 @@
1
- import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
2
- import { randomUUID } from "node:crypto";
3
- import { existsSync, readFileSync, realpathSync } from "node:fs";
4
- import { dirname, isAbsolute, join, resolve } from "node:path";
5
- import { StringDecoder } from "node:string_decoder";
6
- import { fileURLToPath } from "node:url";
7
-
8
- const PI_PACKAGE = "@earendil-works/pi-coding-agent";
9
- const STDERR_TAIL = 2048;
10
- const RECENT_TOOLS = 5;
11
- const KILL_GRACE_MS = 2000;
12
-
13
- /** This package root. Passed to the child Pi with `-e` so it uses the same extension copy as the lead. Pi loads a path only once. */
14
- export const PACKAGE_ROOT = dirname(fileURLToPath(new URL("../../package.json", import.meta.url)));
15
-
16
- export interface SidekickCommand { command: string; args: string[] }
17
-
18
- /** Launch settings of the sidekick child Pi. When they change, the process restarts before the next handoff. */
19
- export interface SidekickLaunch {
20
- cwd: string;
21
- /** Devin model ID of the exact variant (its catalog UID). */
22
- modelID: string;
23
- thinking?: string;
24
- /** Without it, the child starts with `--no-session`. */
25
- sessionFile?: string;
26
- tools: readonly string[];
27
- systemPrompt: string;
28
- }
29
-
30
- export interface HandoffProgress { toolCalls: number; recentTools: string[]; elapsedMs: number }
31
- export interface HandoffReport {
32
- id: string;
33
- status: "completed" | "aborted" | "error";
34
- text: string;
35
- toolCalls: number;
36
- durationMs: number;
37
- error?: string;
38
- }
39
- export interface Handoff { id: string; done: Promise<HandoffReport>; isSteer: boolean }
40
-
41
- export interface SidekickRuntimeOptions {
42
- /** Command that runs Pi. Defaults to the same Node and Pi CLI as the lead. */
43
- resolveCommand?: () => SidekickCommand;
44
- spawn?: typeof nodeSpawn;
45
- env?: NodeJS.ProcessEnv;
46
- childEnv: Record<string, string>;
47
- packageRoot?: string;
48
- onProgress?: () => void;
49
- }
50
-
51
- function piPackageRoot(start: string): string | undefined {
52
- let directory = dirname(start);
53
- while (directory !== dirname(directory)) {
54
- const manifest = join(directory, "package.json");
55
- if (existsSync(manifest)) {
56
- try { if ((JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }).name === PI_PACKAGE) return directory; } catch { /* try the next parent */ }
57
- }
58
- directory = dirname(directory);
59
- }
60
- return undefined;
61
- }
62
-
63
- const EXTENSION_FLAGS = new Set(["-e", "--extension"]);
64
- const EXTENSION_SWITCHES = new Set(["--no-extensions", "-ne", "--no-mcp"]);
65
-
66
- /**
67
- * Passes only the extension selection from the lead CLI arguments to the child, so tools from extensions loaded only with `-e` work for the sidekick too.
68
- * Local paths become absolute paths from the lead's working directory. Source specifiers such as `builtin:` and `npm:` stay as they are.
69
- */
70
- export function extensionArgs(argv: readonly string[], cwd: string): string[] {
71
- const forwarded: string[] = [];
72
- for (let index = 0; index < argv.length; index++) {
73
- const value = argv[index];
74
- if (EXTENSION_SWITCHES.has(value)) forwarded.push(value);
75
- else if (EXTENSION_FLAGS.has(value) && index + 1 < argv.length) {
76
- const target = argv[++index];
77
- forwarded.push("-e", /^[a-z][a-z0-9+.-]*:/i.test(target) || isAbsolute(target) ? target : resolve(cwd, target));
78
- }
79
- }
80
- return forwarded;
81
- }
82
-
83
- /**
84
- * Starts the child with the same Node, exec options and Pi CLI as the lead Pi. The running CLI is preferred so extension selection arguments carry over;
85
- * when Pi is embedded through the SDK, the installed Pi package's bin is used. Inspect options are not passed, to avoid port conflicts.
86
- */
87
- export function resolvePiCommand(): SidekickCommand {
88
- const candidates: { path: string; isRunningCLI: boolean }[] = [];
89
- try { if (process.argv[1]) candidates.push({ path: realpathSync(process.argv[1]), isRunningCLI: true }); } catch { /* missing path */ }
90
- try { candidates.push({ path: fileURLToPath(import.meta.resolve(PI_PACKAGE)), isRunningCLI: false }); } catch { /* cannot resolve */ }
91
- for (const candidate of candidates) {
92
- const root = piPackageRoot(candidate.path);
93
- if (!root) continue;
94
- const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { bin?: string | Record<string, string> };
95
- const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi;
96
- if (bin && existsSync(join(root, bin))) {
97
- const execArgv = process.execArgv.filter((value) => !/^--(inspect|debug)/.test(value));
98
- const forwarded = candidate.isRunningCLI ? extensionArgs(process.argv.slice(2), process.cwd()) : [];
99
- return { command: process.execPath, args: [...execArgv, join(root, bin), ...forwarded] };
100
- }
101
- }
102
- throw new Error("Could not find a Pi CLI to run the sidekick.");
103
- }
104
-
105
- export function launchArgs(launch: SidekickLaunch, packageRoot: string): string[] {
106
- return [
107
- "--mode", "rpc",
108
- ...(launch.sessionFile ? ["--session", launch.sessionFile] : ["--no-session"]),
109
- "--model", `devin/${launch.modelID}`,
110
- ...(launch.thinking ? ["--thinking", launch.thinking] : []),
111
- "--append-system-prompt", launch.systemPrompt,
112
- "-e", packageRoot,
113
- ...(launch.tools.length ? ["--tools", launch.tools.join(",")] : ["--no-tools"]),
114
- ];
115
- }
116
-
117
- interface PendingHandoff {
118
- id: string;
119
- startedAt: number;
120
- toolCalls: number;
121
- recentTools: string[];
122
- lastError?: string;
123
- isAborted: boolean;
124
- resolve(report: HandoffReport): void;
125
- done: Promise<HandoffReport>;
126
- }
127
-
128
- /**
129
- * Sidekick child Pi RPC process bound to one lead session. Started on the first handoff; keeps its context for the rest of the session.
130
- * A handoff during a run adds instructions with `steer`; when the child run ends (`agent_settled`), the last assistant text is returned as the report.
131
- */
132
- export class SidekickRuntime {
133
- readonly launch: SidekickLaunch;
134
- private readonly options: SidekickRuntimeOptions;
135
- private child: ChildProcess | undefined;
136
- private pending: PendingHandoff | undefined;
137
- private stderrTail = "";
138
- private readonly waiting = new Map<string, (text: string | undefined) => void>();
139
-
140
- constructor(launch: SidekickLaunch, options: SidekickRuntimeOptions) {
141
- this.launch = launch;
142
- this.options = options;
143
- }
144
-
145
- isBusy(): boolean { return this.pending !== undefined; }
146
- isAlive(): boolean { return this.child !== undefined && this.child.exitCode === null && this.child.signalCode === null; }
147
-
148
- progress(): HandoffProgress | undefined {
149
- const current = this.pending;
150
- return current ? { toolCalls: current.toolCalls, recentTools: [...current.recentTools], elapsedMs: Date.now() - current.startedAt } : undefined;
151
- }
152
-
153
- handoff(message: string): Handoff {
154
- const current = this.pending;
155
- if (current) {
156
- this.send({ type: "steer", message });
157
- return { id: current.id, done: current.done, isSteer: true };
158
- }
159
- if (!this.isAlive()) this.start();
160
- let resolve!: (report: HandoffReport) => void;
161
- const done = new Promise<HandoffReport>((settle) => { resolve = settle; });
162
- const id = randomUUID();
163
- this.pending = { id, startedAt: Date.now(), toolCalls: 0, recentTools: [], isAborted: false, resolve, done };
164
- this.send({ id, type: "prompt", message });
165
- return { id, done, isSteer: false };
166
- }
167
-
168
- abort(): void {
169
- if (!this.pending) return;
170
- this.pending.isAborted = true;
171
- this.send({ type: "abort" });
172
- }
173
-
174
- close(): void {
175
- const child = this.child;
176
- this.child = undefined;
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();
180
- if (!child || child.exitCode !== null || child.signalCode !== null) return;
181
- child.stdin?.end();
182
- const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, KILL_GRACE_MS);
183
- timer.unref();
184
- child.once("close", () => clearTimeout(timer));
185
- child.kill("SIGTERM");
186
- }
187
-
188
- private start(): void {
189
- const base = (this.options.resolveCommand ?? resolvePiCommand)();
190
- const args = [...base.args, ...launchArgs(this.launch, this.options.packageRoot ?? PACKAGE_ROOT)];
191
- const child = (this.options.spawn ?? nodeSpawn)(base.command, args, {
192
- cwd: this.launch.cwd,
193
- env: { ...(this.options.env ?? process.env), ...this.options.childEnv },
194
- stdio: ["pipe", "pipe", "pipe"],
195
- });
196
- this.child = child;
197
- this.stderrTail = "";
198
- const decoder = new StringDecoder("utf8");
199
- let buffer = "";
200
- child.stdout?.on("data", (chunk: Buffer) => {
201
- if (this.child !== child) return;
202
- buffer += decoder.write(chunk);
203
- let index: number;
204
- while ((index = buffer.indexOf("\n")) >= 0) {
205
- let line = buffer.slice(0, index);
206
- buffer = buffer.slice(index + 1);
207
- if (line.endsWith("\r")) line = line.slice(0, -1);
208
- if (!line) continue;
209
- let record: Record<string, unknown>;
210
- try { record = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
211
- this.handle(record);
212
- if (this.child !== child) return;
213
- }
214
- });
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();
226
- }
227
-
228
- private send(value: Record<string, unknown>): void {
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
- }
236
- }
237
-
238
- private request(type: string): Promise<string | undefined> {
239
- const id = randomUUID();
240
- return new Promise((resolve) => {
241
- this.waiting.set(id, resolve);
242
- this.send({ id, type });
243
- });
244
- }
245
-
246
- private handle(record: Record<string, unknown>): void {
247
- const current = this.pending;
248
- switch (record.type) {
249
- case "response": {
250
- const waiter = typeof record.id === "string" ? this.waiting.get(record.id) : undefined;
251
- if (waiter) {
252
- this.waiting.delete(record.id as string);
253
- const data = record.data as { text?: unknown } | undefined;
254
- waiter(typeof data?.text === "string" ? data.text : undefined);
255
- } else if (current && record.command === "prompt" && record.success === false) {
256
- this.finish("error", "", String(record.error ?? "The sidekick did not receive the instructions."));
257
- }
258
- return;
259
- }
260
- case "extension_ui_request":
261
- // The sidekick never talks to the user. Dialogs that need an answer are returned as cancelled.
262
- if (["select", "confirm", "input", "editor"].includes(String(record.method))) {
263
- this.send({ type: "extension_ui_response", id: record.id, cancelled: true });
264
- }
265
- return;
266
- case "tool_execution_start":
267
- if (!current) return;
268
- current.toolCalls++;
269
- current.recentTools = [...current.recentTools, String(record.toolName ?? "tool")].slice(-RECENT_TOOLS);
270
- this.options.onProgress?.();
271
- return;
272
- case "message_end": {
273
- const message = record.message as { role?: unknown; stopReason?: unknown; errorMessage?: unknown } | undefined;
274
- if (current && message?.role === "assistant") {
275
- current.lastError = message.stopReason === "error" && typeof message.errorMessage === "string" ? message.errorMessage : undefined;
276
- }
277
- return;
278
- }
279
- case "agent_settled":
280
- if (current) void this.complete(current, record.aborted === true);
281
- return;
282
- }
283
- }
284
-
285
- private async complete(current: PendingHandoff, isAborted: boolean): Promise<void> {
286
- const text = await this.request("get_last_assistant_text");
287
- if (this.pending !== current) return;
288
- if (isAborted || current.isAborted) this.finish("aborted", text ?? "");
289
- else if (current.lastError) this.finish("error", text ?? "", current.lastError);
290
- else this.finish("completed", text ?? "");
291
- }
292
-
293
- private finish(status: HandoffReport["status"], text: string, error?: string): void {
294
- const current = this.pending;
295
- if (!current) return;
296
- this.pending = undefined;
297
- current.resolve({ id: current.id, status, text, toolCalls: current.toolCalls, durationMs: Date.now() - current.startedAt, ...(error ? { error } : {}) });
298
- this.options.onProgress?.();
299
- }
300
- }