@buyong/pi-devin 0.1.3 → 0.1.5
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.ko.md +224 -0
- package/README.md +35 -28
- package/index.ts +3 -0
- package/package.json +5 -3
- package/src/core/protocol/transport.ts +14 -2
- package/src/pi/auth.ts +42 -10
- package/src/pi/devin-command.ts +13 -3
- package/src/pi/extension.ts +14 -5
- package/src/pi/models.ts +62 -26
- package/src/pi/provider.ts +57 -20
- package/src/pi/sidekick-report.ts +10 -5
- package/src/pi/sidekick-runtime.ts +31 -18
- package/src/pi/sidekick.ts +44 -23
- package/src/pi/usage-store.ts +75 -0
package/src/pi/extension.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Model } from "@earendil-works/pi-ai";
|
|
2
|
-
import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import { createDevinProvider, type DevinProvider } from "./provider.ts";
|
|
4
4
|
import { DEVIN_STATE_ENTRY } from "./lifecycle.ts";
|
|
5
5
|
import { registerDevinCommand, updateUsageStatus } from "./devin-command.ts";
|
|
@@ -9,7 +9,7 @@ import { memoryFusionSettings, type FusionSettings } from "./fusion-settings.ts"
|
|
|
9
9
|
import { createSidekickTool, fusionOf, SIDEKICK_TOOL_NAME, type SidekickToolOptions } from "./sidekick.ts";
|
|
10
10
|
|
|
11
11
|
type ProviderOptions = NonNullable<Parameters<typeof createDevinProvider>[0]>;
|
|
12
|
-
export interface DevinExtensionOptions extends Omit<ProviderOptions, "fusion" | "listsAllExactModels" | "recommendedOnly"> {
|
|
12
|
+
export interface DevinExtensionOptions extends Omit<ProviderOptions, "fusion" | "listsAllExactModels" | "recommendedOnly" | "onAccountUsage"> {
|
|
13
13
|
/** Store for the `/devin-fusion` choice. Kept in memory only when omitted. */
|
|
14
14
|
fusionSettings?: FusionSettings;
|
|
15
15
|
/** Store for the `/devin` display settings. Kept in memory only when omitted. */
|
|
@@ -24,14 +24,20 @@ export function registerDevinExtension(pi: ExtensionAPI, options: DevinExtension
|
|
|
24
24
|
const { fusionSettings, devinSettings, isFusionChild, sidekick: sidekickOptions, ...providerOptions } = options;
|
|
25
25
|
const settings = fusionSettings ?? memoryFusionSettings();
|
|
26
26
|
const preferences = isFusionChild ? undefined : devinSettings ?? memoryDevinSettings();
|
|
27
|
+
// Context of the current session, used to redraw the status when the quota changes outside event handlers.
|
|
28
|
+
let statusContext: ExtensionContext | undefined;
|
|
29
|
+
const refreshStatus = (context: ExtensionContext, pendingMessage?: unknown) => { if (preferences) updateUsageStatus(context, provider, preferences, pendingMessage); };
|
|
27
30
|
// The child Pi runs on the exact UID the lead chose, so it lists every exact UID and ignores the recommended filter.
|
|
28
31
|
const provider = createDevinProvider({
|
|
29
32
|
...providerOptions, fusion: isFusionChild ? false : () => settings.read(), listsAllExactModels: isFusionChild === true,
|
|
30
33
|
recommendedOnly: preferences ? () => preferences.read().recommendedOnly : undefined,
|
|
34
|
+
onAccountUsage: () => {
|
|
35
|
+
if (!statusContext) return;
|
|
36
|
+
try { refreshStatus(statusContext); } catch { /* The session was replaced or reloaded; its next session_start redraws. */ }
|
|
37
|
+
},
|
|
31
38
|
});
|
|
32
39
|
pi.registerProvider(provider);
|
|
33
40
|
if (preferences) registerDevinCommand(pi, provider, preferences);
|
|
34
|
-
const refreshStatus = (context: Parameters<typeof updateUsageStatus>[0]) => { if (preferences) updateUsageStatus(context, provider, preferences); };
|
|
35
41
|
const sidekick = isFusionChild ? undefined
|
|
36
42
|
: createSidekickTool(pi, provider, { agentDir: sidekickOptions?.agentDir ?? getAgentDir, runtime: sidekickOptions?.runtime });
|
|
37
43
|
if (sidekick) {
|
|
@@ -60,11 +66,14 @@ export function registerDevinExtension(pi: ExtensionAPI, options: DevinExtension
|
|
|
60
66
|
});
|
|
61
67
|
sidekick?.onSessionStart(context.sessionManager.getSessionId());
|
|
62
68
|
syncSidekick(context.model);
|
|
69
|
+
statusContext = context;
|
|
63
70
|
refreshStatus(context);
|
|
64
71
|
});
|
|
65
72
|
pi.on("message_end", (event, context) => {
|
|
66
|
-
if (event.message.role === "assistant" && event.message.provider === "devin") refreshStatus(context);
|
|
73
|
+
if (event.message.role === "assistant" && event.message.provider === "devin") refreshStatus(context, event.message);
|
|
67
74
|
});
|
|
75
|
+
// Subagents and other sessions record quota in the shared store; any model's turn picks it up.
|
|
76
|
+
pi.on("turn_end", (_event, context) => { refreshStatus(context); });
|
|
68
77
|
pi.on("before_agent_start", () => { provider.lifecycle.begin("agent"); });
|
|
69
78
|
pi.on("agent_start", () => { provider.lifecycle.agentStarted(); });
|
|
70
79
|
// Host retry or overflow recovery can continue after agent_end.
|
|
@@ -75,6 +84,6 @@ export function registerDevinExtension(pi: ExtensionAPI, options: DevinExtension
|
|
|
75
84
|
pi.on("session_before_tree", (event) => { if (event.preparation.userWantsSummary) provider.lifecycle.beforeTree(); });
|
|
76
85
|
pi.on("session_tree", (_event, context) => { provider.lifecycle.treeChanged(); refreshStatus(context); });
|
|
77
86
|
pi.on("cache_warming_decision", (_event, context) => context.model?.provider === "devin" ? { action: "stop" } : undefined);
|
|
78
|
-
pi.on("session_shutdown", () => { sidekick?.reset(); provider.shutdown(); });
|
|
87
|
+
pi.on("session_shutdown", () => { statusContext = undefined; sidekick?.reset(); provider.shutdown(); });
|
|
79
88
|
return provider;
|
|
80
89
|
}
|
package/src/pi/models.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface DevinModel extends Model<typeof DEVIN_API> {
|
|
|
28
28
|
fusion?: FusionSelection;
|
|
29
29
|
/** Marked as recommended by the catalog. For a group, true when any variant is recommended. */
|
|
30
30
|
recommended?: true;
|
|
31
|
+
/** Account identity hash (endpoint and user ID) the catalog belongs to. Lets a new session of the same account reuse it. */
|
|
32
|
+
account?: string;
|
|
31
33
|
};
|
|
32
34
|
}
|
|
33
35
|
|
|
@@ -39,6 +41,8 @@ export interface ProjectionOptions {
|
|
|
39
41
|
recommendedOnly?: boolean;
|
|
40
42
|
/** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
|
|
41
43
|
listsAllExactModels?: boolean;
|
|
44
|
+
/** Account identity hash recorded with every model. */
|
|
45
|
+
account?: string;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export function isFusionModelID(id: string): boolean { return id === FUSION_MODEL_ID; }
|
|
@@ -59,7 +63,7 @@ function thinkingMap(mappings: Partial<Record<EffectiveThinkingLevel, string>>)
|
|
|
59
63
|
return Object.fromEntries(EFFECTIVE_THINKING_LEVELS.map((level) => [level, mappings[level] ?? null]));
|
|
60
64
|
}
|
|
61
65
|
|
|
62
|
-
function modelDefinition(id: string, name: string, variants: ModelVariant[], mappings: Partial<Record<EffectiveThinkingLevel, string>>, credential: Credential): DevinModel {
|
|
66
|
+
function modelDefinition(id: string, name: string, variants: ModelVariant[], mappings: Partial<Record<EffectiveThinkingLevel, string>>, credential: Credential, account?: string): DevinModel {
|
|
63
67
|
const first = variants[0].model;
|
|
64
68
|
return {
|
|
65
69
|
id, name, api: DEVIN_API, provider: "devin", baseUrl: credential.apiServerURL,
|
|
@@ -68,7 +72,7 @@ function modelDefinition(id: string, name: string, variants: ModelVariant[], map
|
|
|
68
72
|
contextWindow: first.contextWindow, maxTokens: first.maxOutputTokens,
|
|
69
73
|
// Placeholder for Pi's required numeric fields. It does not mean free or a real USD price.
|
|
70
74
|
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
71
|
-
devin: { version: CATALOG_VERSION, scope: catalogScope(credential), costsUnknown: true, configs: variants.map((variant) => variant.config) },
|
|
75
|
+
devin: { version: CATALOG_VERSION, scope: catalogScope(credential), costsUnknown: true, configs: variants.map((variant) => variant.config), ...(account ? { account } : {}) },
|
|
72
76
|
};
|
|
73
77
|
}
|
|
74
78
|
|
|
@@ -199,7 +203,7 @@ export function modelsFromProjection(projection: VariantProjection, credential:
|
|
|
199
203
|
const isListed = (uids: readonly string[]) => !isRecommendedOnly || uids.some((uid) => recommendedUIDs.has(uid));
|
|
200
204
|
for (const group of published) {
|
|
201
205
|
if (!isListed(Object.values(group.mappings))) continue;
|
|
202
|
-
const model = modelDefinition(ids.groups.get(group.id)!, names.get(group.id)!, publishedVariants(group), group.mappings, credential);
|
|
206
|
+
const model = modelDefinition(ids.groups.get(group.id)!, names.get(group.id)!, publishedVariants(group), group.mappings, credential, options.account);
|
|
203
207
|
if (group.variants.some((variant) => variant.model.isRecommended)) model.devin.recommended = true;
|
|
204
208
|
models.push(model);
|
|
205
209
|
}
|
|
@@ -209,7 +213,7 @@ export function modelsFromProjection(projection: VariantProjection, credential:
|
|
|
209
213
|
const listedNames = new Set(models.map((model) => model.name));
|
|
210
214
|
for (const variant of exact) {
|
|
211
215
|
const name = listedNames.has(variant.model.name) ? `${variant.model.name} (${variant.model.id})` : variant.model.name;
|
|
212
|
-
const model = modelDefinition(ids.variants.get(variant.model.id)!, name, [variant], { [variant.level!]: variant.model.id }, credential);
|
|
216
|
+
const model = modelDefinition(ids.variants.get(variant.model.id)!, name, [variant], { [variant.level!]: variant.model.id }, credential, options.account);
|
|
213
217
|
if (variant.model.isRecommended) model.devin.recommended = true;
|
|
214
218
|
models.push(model);
|
|
215
219
|
}
|
|
@@ -218,7 +222,7 @@ export function modelsFromProjection(projection: VariantProjection, credential:
|
|
|
218
222
|
if (pair) {
|
|
219
223
|
const lead = projection.groups.find((group) => ids.groups.get(group.id) === pair.leadID)!;
|
|
220
224
|
const sidekick = projection.variants.find((variant) => ids.variants.get(variant.model.id) === pair.sidekickID)!;
|
|
221
|
-
const model = modelDefinition(FUSION_MODEL_ID, `Devin Fusion · ${names.get(lead.id)} + ${sidekick.model.name}`, publishedVariants(lead), lead.mappings, credential);
|
|
225
|
+
const model = modelDefinition(FUSION_MODEL_ID, `Devin Fusion · ${names.get(lead.id)} + ${sidekick.model.name}`, publishedVariants(lead), lead.mappings, credential, options.account);
|
|
222
226
|
model.devin.fusion = pair;
|
|
223
227
|
models.push(model);
|
|
224
228
|
}
|
|
@@ -228,13 +232,24 @@ export function modelsFromProjection(projection: VariantProjection, credential:
|
|
|
228
232
|
return models;
|
|
229
233
|
}
|
|
230
234
|
|
|
231
|
-
|
|
235
|
+
const ACCOUNT_HASH = /^[a-f0-9]{64}$/;
|
|
236
|
+
interface RestoredCatalog { projection: VariantProjection; account?: string; isSameSession: boolean }
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Restores the stored catalog of the same session, or of an earlier session of an account this process verified for `account`.
|
|
240
|
+
* Every entry must share one scope and one account, so a mixed or edited cache is never partly applied.
|
|
241
|
+
*/
|
|
242
|
+
function restoredCatalog(stored: Readonly<ModelsStoreEntry> | undefined, credential: Credential, account: string | undefined): RestoredCatalog | undefined {
|
|
232
243
|
if (!stored?.models.length) return undefined;
|
|
233
244
|
const scope = catalogScope(credential), configs = new Map<string, JsonObject>();
|
|
234
|
-
|
|
245
|
+
let storedScope: unknown, storedAccount: unknown;
|
|
246
|
+
for (const [index, model] of stored.models.entries()) {
|
|
235
247
|
const metadata = object((model as DevinModel).devin, "cached Devin metadata");
|
|
236
248
|
if ((model.type !== undefined && model.type !== "chat") || model.provider !== "devin" || model.api !== DEVIN_API || model.baseUrl !== credential.apiServerURL
|
|
237
|
-
|| metadata.version !== CATALOG_VERSION || metadata.scope !==
|
|
249
|
+
|| metadata.version !== CATALOG_VERSION || typeof metadata.scope !== "string") return undefined;
|
|
250
|
+
if (metadata.account !== undefined && (typeof metadata.account !== "string" || !ACCOUNT_HASH.test(metadata.account))) return undefined;
|
|
251
|
+
if (index === 0) { storedScope = metadata.scope; storedAccount = metadata.account; }
|
|
252
|
+
else if (metadata.scope !== storedScope || metadata.account !== storedAccount) return undefined;
|
|
238
253
|
if (!Array.isArray(metadata.configs)) return undefined;
|
|
239
254
|
const entries = [...metadata.configs, ...(Array.isArray(metadata.unavailable) ? metadata.unavailable : [])];
|
|
240
255
|
for (const value of entries) {
|
|
@@ -245,7 +260,10 @@ function restoredProjection(stored: Readonly<ModelsStoreEntry> | undefined, cred
|
|
|
245
260
|
configs.set(config.modelUid, config);
|
|
246
261
|
}
|
|
247
262
|
}
|
|
248
|
-
|
|
263
|
+
const isSameSession = storedScope === scope;
|
|
264
|
+
// Another session's catalog is used only for the same verified account; account changes and logout never expose it.
|
|
265
|
+
if (!isSameSession && (account === undefined || storedAccount !== account)) return undefined;
|
|
266
|
+
return { projection: restoreModelVariants([...configs.values()]), account: account ?? storedAccount as string | undefined, isSameSession };
|
|
249
267
|
}
|
|
250
268
|
|
|
251
269
|
export interface DevinModelSource {
|
|
@@ -262,6 +280,8 @@ export interface DevinModelSource {
|
|
|
262
280
|
getFusionChoices(): FusionChoices | undefined;
|
|
263
281
|
/** Rebuilds the model list from the current catalog without network after a saved choice or setting changes. */
|
|
264
282
|
rebuildModels(): void;
|
|
283
|
+
/** Scope of the credential seen by the last model refresh, even before its catalog loads. Undefined after logout or sign-in. */
|
|
284
|
+
currentScope(): string | undefined;
|
|
265
285
|
invalidate(): void;
|
|
266
286
|
}
|
|
267
287
|
|
|
@@ -276,20 +296,25 @@ export function createDevinModelSource(options: {
|
|
|
276
296
|
recommendedOnly?: () => boolean;
|
|
277
297
|
/** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
|
|
278
298
|
listsAllExactModels?: boolean;
|
|
299
|
+
/** Account identity hash this process verified for the credential, if any. */
|
|
300
|
+
accountOf?: (credential: Credential) => string | undefined;
|
|
279
301
|
}): DevinModelSource {
|
|
280
302
|
const now = options.now ?? Date.now, freshnessMs = options.freshnessMs ?? DEFAULT_FRESHNESS_MS;
|
|
281
303
|
if (!Number.isSafeInteger(freshnessMs) || freshnessMs < 0) throw new Error("The model cache check interval must be an integer of 0 or more.");
|
|
282
304
|
const readFusion = options.fusion;
|
|
283
305
|
// The stored list is shared by every Pi process that uses the lead's agent directory. Only the default list is stored,
|
|
284
306
|
// without display filters or the sidekick-only exact list. It still holds every catalog config, so each process rebuilds its own list after restore.
|
|
285
|
-
const build = (next: VariantProjection, credential: Credential, isForStorage = false) => modelsFromProjection(next, credential, {
|
|
307
|
+
const build = (next: VariantProjection, credential: Credential, account: string | undefined, isForStorage = false) => modelsFromProjection(next, credential, {
|
|
286
308
|
fusion: readFusion === false ? false : readFusion?.(),
|
|
287
309
|
recommendedOnly: !isForStorage && options.recommendedOnly?.() === true,
|
|
288
310
|
listsAllExactModels: !isForStorage && options.listsAllExactModels === true,
|
|
311
|
+
account,
|
|
289
312
|
});
|
|
290
|
-
let models: DevinModel[] = [], projection: VariantProjection | undefined, scope: string | undefined;
|
|
291
|
-
let checkedAtMs = 0, endpointOverride: string | undefined, modelCredential: Credential | undefined;
|
|
292
|
-
const clear = () => {
|
|
313
|
+
let models: DevinModel[] = [], projection: VariantProjection | undefined, scope: string | undefined, catalogAccount: string | undefined;
|
|
314
|
+
let checkedAtMs = 0, endpointOverride: string | undefined, modelCredential: Credential | undefined, credentialScope: string | undefined;
|
|
315
|
+
const clear = () => {
|
|
316
|
+
models = []; projection = undefined; scope = undefined; catalogAccount = undefined; checkedAtMs = 0; modelCredential = undefined; credentialScope = undefined;
|
|
317
|
+
};
|
|
293
318
|
const currentProjection = (credential: Credential) => {
|
|
294
319
|
if (!projection || scope !== catalogScope(credential)) throw new Error("No Devin model list matches the current account and endpoint. Refresh the model list.");
|
|
295
320
|
return projection;
|
|
@@ -304,8 +329,9 @@ export function createDevinModelSource(options: {
|
|
|
304
329
|
exactLevel: (uid) => projection?.variants.find((variant) => variant.model.id === uid && isUsable(variant))?.level,
|
|
305
330
|
getFusionChoices: () => projection && readFusion !== false ? fusionChoices(projection) : undefined,
|
|
306
331
|
rebuildModels() {
|
|
307
|
-
if (projection && modelCredential) models = build(projection, modelCredential);
|
|
332
|
+
if (projection && modelCredential) models = build(projection, modelCredential, catalogAccount);
|
|
308
333
|
},
|
|
334
|
+
currentScope: () => credentialScope,
|
|
309
335
|
invalidate: clear,
|
|
310
336
|
filterModels(candidates, credential) {
|
|
311
337
|
try {
|
|
@@ -342,21 +368,29 @@ export function createDevinModelSource(options: {
|
|
|
342
368
|
return;
|
|
343
369
|
}
|
|
344
370
|
const expectedScope = catalogScope(credential);
|
|
345
|
-
let restored:
|
|
346
|
-
try { restored =
|
|
371
|
+
let restored: RestoredCatalog | undefined;
|
|
372
|
+
try { restored = restoredCatalog(context.stored, credential, options.accountOf?.(credential)); }
|
|
347
373
|
catch { /* A bad cache is never used as run data. The current good state is kept. */ }
|
|
348
374
|
if (restored && (scope !== expectedScope || !projection || (context.stored?.checkedAt ?? 0) > checkedAtMs)) {
|
|
349
|
-
const
|
|
375
|
+
const { projection: restoredProjection, account: restoredAccount, isSameSession } = restored;
|
|
376
|
+
const restoredModels = build(restoredProjection, credential, restoredAccount);
|
|
350
377
|
const checkedAt = context.stored?.checkedAt;
|
|
351
378
|
const restoredAtMs = typeof checkedAt === "number" && Number.isFinite(checkedAt) && checkedAt >= 0 && checkedAt <= now() ? checkedAt : 0;
|
|
379
|
+
const accepted = await context.publish({
|
|
380
|
+
// A catalog reused for a new session of the same account is stored under the new session, so a restart restores it too.
|
|
381
|
+
...(isSameSession ? {} : { persist: { models: build(restoredProjection, credential, restoredAccount, true), checkedAt: restoredAtMs } }),
|
|
382
|
+
update: () => {
|
|
383
|
+
projection = restoredProjection; models = restoredModels; scope = expectedScope; catalogAccount = restoredAccount; modelCredential = credential;
|
|
384
|
+
checkedAtMs = restoredAtMs; endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
385
|
+
},
|
|
386
|
+
});
|
|
387
|
+
if (!accepted) return;
|
|
388
|
+
} else if (scope !== expectedScope || endpointOverride !== context.baseUrl || credentialScope !== expectedScope) {
|
|
352
389
|
const accepted = await context.publish({ update: () => {
|
|
353
|
-
|
|
354
|
-
|
|
390
|
+
if (scope !== expectedScope || endpointOverride !== context.baseUrl) clear();
|
|
391
|
+
endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
355
392
|
} });
|
|
356
393
|
if (!accepted) return;
|
|
357
|
-
} else if (scope !== expectedScope || endpointOverride !== context.baseUrl) {
|
|
358
|
-
const accepted = await context.publish({ update: () => { clear(); endpointOverride = context.baseUrl; } });
|
|
359
|
-
if (!accepted) return;
|
|
360
394
|
}
|
|
361
395
|
if (!context.allowNetwork) return;
|
|
362
396
|
const ageMs = now() - checkedAtMs;
|
|
@@ -364,14 +398,16 @@ export function createDevinModelSource(options: {
|
|
|
364
398
|
const catalog = await awaitWithSignal(options.fetchCatalog(credential, context.signal), context.signal);
|
|
365
399
|
context.signal.throwIfAborted();
|
|
366
400
|
const nextProjection = projectModelVariants(catalog);
|
|
367
|
-
|
|
401
|
+
// Known after an account check in this process, or carried over from this same session's catalog.
|
|
402
|
+
const nextAccount = options.accountOf?.(credential) ?? (scope === expectedScope ? catalogAccount : undefined);
|
|
403
|
+
const nextModels = build(nextProjection, credential, nextAccount);
|
|
368
404
|
const checkedAt = now();
|
|
369
405
|
if (!Number.isSafeInteger(checkedAt) || checkedAt < 0) throw new Error("Invalid model cache check time.");
|
|
370
406
|
await context.publish({
|
|
371
|
-
persist: { models: build(nextProjection, credential, true), checkedAt },
|
|
407
|
+
persist: { models: build(nextProjection, credential, nextAccount, true), checkedAt },
|
|
372
408
|
update: () => {
|
|
373
|
-
projection = nextProjection; models = nextModels; scope = expectedScope; modelCredential = credential;
|
|
374
|
-
checkedAtMs = checkedAt; endpointOverride = context.baseUrl;
|
|
409
|
+
projection = nextProjection; models = nextModels; scope = expectedScope; catalogAccount = nextAccount; modelCredential = credential;
|
|
410
|
+
checkedAtMs = checkedAt; endpointOverride = context.baseUrl; credentialScope = expectedScope;
|
|
375
411
|
},
|
|
376
412
|
});
|
|
377
413
|
},
|
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";
|
|
@@ -18,10 +18,16 @@ import { catalogScope, createDevinModelSource, DEVIN_API, hasExtendedPromptCache
|
|
|
18
18
|
import { FUSION_LEAD_GUIDANCE } from "./sidekick.ts";
|
|
19
19
|
import { DevinResponseStream } from "./stream.ts";
|
|
20
20
|
import { accountUsage, type AccountUsage } from "./usage.ts";
|
|
21
|
+
import { memoryAccountUsageStore, type AccountUsageStore } from "./usage-store.ts";
|
|
21
22
|
|
|
22
23
|
type LoginConfiguration = NonNullable<Parameters<typeof createDevinAccountAuth>[0]>["loginOptions"];
|
|
23
24
|
|
|
24
25
|
/** Like Pi's built-in providers: the caller option wins; otherwise only PI_CACHE_RETENTION=long (provider env first) means long retention. */
|
|
26
|
+
/** Node and Connect report these failures as codes ("read ECONNRESET", "unavailable: …") that Pi's host retry does not match. */
|
|
27
|
+
function hostRetryableMessage(error: unknown, message: string): string {
|
|
28
|
+
return isTransientConnectionFailure(error) ? `Connection error (${message})` : message;
|
|
29
|
+
}
|
|
30
|
+
|
|
25
31
|
function resolveCacheRetention(options: SimpleStreamOptions): NonNullable<SimpleStreamOptions["cacheRetention"]> {
|
|
26
32
|
if (options.cacheRetention) return options.cacheRetention;
|
|
27
33
|
return (options.env?.PI_CACHE_RETENTION || process.env.PI_CACHE_RETENTION) === "long" ? "long" : "short";
|
|
@@ -33,7 +39,10 @@ export interface DevinProvider extends Provider<typeof DEVIN_API> {
|
|
|
33
39
|
getFusionChoices(): FusionChoices | undefined;
|
|
34
40
|
/** Rebuilds the model list from the current catalog without network after the saved Fusion choice or display settings change. */
|
|
35
41
|
rebuildModels(): void;
|
|
36
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* Newest quota received for the current credential by this process or another one sharing the usage store.
|
|
44
|
+
* Makes no request; undefined if none was received or no credential is current.
|
|
45
|
+
*/
|
|
37
46
|
getAccountUsage(): AccountUsage | undefined;
|
|
38
47
|
shutdown(): void;
|
|
39
48
|
}
|
|
@@ -50,6 +59,10 @@ export function createDevinProvider(options: {
|
|
|
50
59
|
recommendedOnly?: () => boolean;
|
|
51
60
|
/** Also lists exact UIDs that a group already covers. Used only by the sidekick child Pi. */
|
|
52
61
|
listsAllExactModels?: boolean;
|
|
62
|
+
/** Shares the last account quota with other Pi processes using the same agent directory. Kept in memory only when omitted. */
|
|
63
|
+
accountUsage?: AccountUsageStore;
|
|
64
|
+
/** Called after the quota or the current credential may have changed, outside Pi event handlers. */
|
|
65
|
+
onAccountUsage?: () => void;
|
|
53
66
|
now?: () => number;
|
|
54
67
|
} = {}): DevinProvider {
|
|
55
68
|
const lifecycle = new DevinLifecycle(options.maxModelCalls);
|
|
@@ -58,7 +71,9 @@ export function createDevinProvider(options: {
|
|
|
58
71
|
const fingerprints = new Map<string, FingerprintProvider>();
|
|
59
72
|
const accounts = new Map<string, { ownerHash: string; status: JsonObject }>();
|
|
60
73
|
const now = options.now ?? Date.now;
|
|
61
|
-
|
|
74
|
+
const usageStore = options.accountUsage ?? memoryAccountUsageStore();
|
|
75
|
+
let latestUsage: { scope: string; usage: AccountUsage } | undefined;
|
|
76
|
+
const usageChanged = () => { try { options.onAccountUsage?.(); } catch { /* A display failure must not affect requests or refreshes. */ } };
|
|
62
77
|
const clientFor = (credential: Credential, extra: DevinClientOptions = {}): DevinClient => {
|
|
63
78
|
const key = catalogScope(credential);
|
|
64
79
|
let fingerprint = fingerprints.get(key);
|
|
@@ -78,19 +93,26 @@ export function createDevinProvider(options: {
|
|
|
78
93
|
const rememberAccount = (credential: Credential, ownerHash: string, status: JsonObject) => {
|
|
79
94
|
if (lifetime.signal.aborted) return;
|
|
80
95
|
if (accounts.size >= 16) accounts.delete(accounts.keys().next().value!);
|
|
81
|
-
|
|
82
|
-
|
|
96
|
+
const scope = catalogScope(credential);
|
|
97
|
+
accounts.set(scope, { ownerHash, status });
|
|
98
|
+
latestUsage = { scope, usage: accountUsage(status, now()) };
|
|
99
|
+
// Subagents and the Fusion sidekick run in other processes; a failed write only loses sharing.
|
|
100
|
+
try { usageStore.write(scope, latestUsage.usage); } catch { /* best effort */ }
|
|
101
|
+
usageChanged();
|
|
83
102
|
};
|
|
84
103
|
const auth = createDevinAccountAuth({
|
|
85
104
|
loginOptions: { ...options.loginOptions, fetch: options.loginOptions?.fetch ?? options.fetch },
|
|
86
105
|
createClient: (credential) => clientFor(credential, { profile: "login" }),
|
|
87
106
|
getSignal: () => lifetime.signal,
|
|
107
|
+
isAccountChecked: (credential) => accounts.has(catalogScope(credential)),
|
|
88
108
|
onValidated: rememberAccount,
|
|
89
|
-
onInvalidated: (credential, error) => { accounts.delete(catalogScope(credential)); latestUsage = undefined; lifecycle.abort(error); },
|
|
109
|
+
onInvalidated: (credential, error) => { accounts.delete(catalogScope(credential)); latestUsage = undefined; lifecycle.abort(error); usageChanged(); },
|
|
90
110
|
onLogin: () => {
|
|
91
111
|
accounts.clear(); fingerprints.clear(); latestUsage = undefined; source.invalidate();
|
|
92
112
|
lifecycle.abort(new DOMException("The Devin account was re-authenticated.", "AbortError"));
|
|
113
|
+
usageChanged();
|
|
93
114
|
},
|
|
115
|
+
now,
|
|
94
116
|
});
|
|
95
117
|
const authenticationFailed = (credential: Credential, error: unknown, generation: number) => {
|
|
96
118
|
if (!isAuthenticationError(error)) return;
|
|
@@ -101,6 +123,7 @@ export function createDevinProvider(options: {
|
|
|
101
123
|
recommendedOnly: options.recommendedOnly,
|
|
102
124
|
listsAllExactModels: options.listsAllExactModels,
|
|
103
125
|
credentialFromHost: (credential) => credential?.type === "oauth" ? auth.credentialFromHost(credential) : undefined,
|
|
126
|
+
accountOf: (credential) => accounts.get(catalogScope(credential))?.ownerHash,
|
|
104
127
|
fetchCatalog: async (credential, signal) => {
|
|
105
128
|
const generation = auth.generation;
|
|
106
129
|
auth.assertUsable(credential);
|
|
@@ -141,18 +164,20 @@ export function createDevinProvider(options: {
|
|
|
141
164
|
}
|
|
142
165
|
};
|
|
143
166
|
assertCacheRetention(selected);
|
|
167
|
+
// The per-request account check (permission and quota) runs here, outside Pi's shared credential lock.
|
|
144
168
|
let account = accounts.get(catalogScope(credential));
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
169
|
+
const probe = clientFor(credential, { profile: "login", fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs });
|
|
170
|
+
try {
|
|
171
|
+
const status = await probe.status(signal);
|
|
172
|
+
signal.throwIfAborted();
|
|
173
|
+
if (generation !== auth.generation) throw new DOMException("The Devin authentication generation changed.", "AbortError");
|
|
174
|
+
auth.assertUsable(credential);
|
|
175
|
+
rememberAccount(credential, requiredString(probe.accountIdentityHash, "Devin account scope"), status);
|
|
176
|
+
account = accounts.get(catalogScope(credential));
|
|
177
|
+
} catch (error) {
|
|
178
|
+
// A check that cannot reach the server keeps the last known account; the model request reports its own failure.
|
|
179
|
+
if (!account || signal.aborted || !isTransientConnectionFailure(error)) throw error;
|
|
180
|
+
} finally { probe.transport.close(); }
|
|
156
181
|
if (!account) throw new Error("The Devin account check was interrupted.");
|
|
157
182
|
assertCanInfer(account.status);
|
|
158
183
|
lease = await lifecycle.acquire(account.ownerHash, signal);
|
|
@@ -215,7 +240,7 @@ export function createDevinProvider(options: {
|
|
|
215
240
|
} catch (error) {
|
|
216
241
|
if (credential) authenticationFailed(credential, error, generation);
|
|
217
242
|
accumulator.message.modelUid = actualUID || undefined;
|
|
218
|
-
response.fail(safeError(error, credential ? [credential.sessionToken] : []), error instanceof Error && error.name === "AbortError", isRedacted ? undefined : accumulator.message);
|
|
243
|
+
response.fail(hostRetryableMessage(error, safeError(error, credential ? [credential.sessionToken] : [])), error instanceof Error && error.name === "AbortError", isRedacted ? undefined : accumulator.message);
|
|
219
244
|
} finally { client?.transport.close(); lease?.release(); }
|
|
220
245
|
})();
|
|
221
246
|
return response.stream;
|
|
@@ -224,8 +249,20 @@ export function createDevinProvider(options: {
|
|
|
224
249
|
id: "devin", name: "Devin", auth: { oauth: auth }, lifecycle,
|
|
225
250
|
getModels: source.getModels, filterModels: source.filterModels,
|
|
226
251
|
getFusionChoices: source.getFusionChoices, rebuildModels: source.rebuildModels,
|
|
227
|
-
getAccountUsage: () =>
|
|
228
|
-
|
|
252
|
+
getAccountUsage: () => {
|
|
253
|
+
const scope = source.currentScope();
|
|
254
|
+
if (!scope) return undefined;
|
|
255
|
+
const local = latestUsage?.scope === scope ? latestUsage.usage : undefined;
|
|
256
|
+
let shared: AccountUsage | undefined;
|
|
257
|
+
try { shared = usageStore.read(scope); } catch { shared = undefined; }
|
|
258
|
+
const newest = shared && (!local || shared.fetchedAtMs > local.fetchedAtMs) ? shared : local;
|
|
259
|
+
return newest ? { ...newest } : undefined;
|
|
260
|
+
},
|
|
261
|
+
async refreshModels(context) {
|
|
262
|
+
// Sign-in, logout and restores change the current credential, which decides whose quota is shown.
|
|
263
|
+
try { await source.refreshModels({ ...context, signal: AbortSignal.any([context.signal, lifetime.signal]) }); }
|
|
264
|
+
finally { usageChanged(); }
|
|
265
|
+
},
|
|
229
266
|
stream: (model, context, requestOptions) => infer(model, context, requestOptions), streamSimple: (model, context, requestOptions) => infer(model, context, requestOptions),
|
|
230
267
|
attachSession(binding) {
|
|
231
268
|
if (lifetime.signal.aborted) lifetime = new AbortController();
|
|
@@ -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":
|