@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.
@@ -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
- function restoredProjection(stored: Readonly<ModelsStoreEntry> | undefined, credential: Credential): VariantProjection | undefined {
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
- for (const model of stored.models) {
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 !== scope) return undefined;
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
- return restoreModelVariants([...configs.values()]);
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 = () => { models = []; projection = undefined; scope = undefined; checkedAtMs = 0; modelCredential = undefined; };
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: VariantProjection | undefined;
346
- try { restored = restoredProjection(context.stored, credential); }
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 restoredModels = build(restored, credential);
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
- projection = restored; models = restoredModels; scope = expectedScope; modelCredential = credential;
354
- checkedAtMs = restoredAtMs; endpointOverride = context.baseUrl;
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
- const nextModels = build(nextProjection, credential);
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
  },
@@ -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
- /** Quota from the last account status received. Makes no request; undefined if none was received. */
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
- let latestUsage: AccountUsage | undefined;
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
- accounts.set(catalogScope(credential), { ownerHash, status });
82
- latestUsage = accountUsage(status, now());
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
- if (!account) {
146
- const probe = clientFor(credential, { profile: "login", fetch: requestOptions.fetch, timeoutMs: requestOptions.timeoutMs });
147
- try {
148
- const status = await probe.status(signal);
149
- signal.throwIfAborted();
150
- if (generation !== auth.generation) throw new DOMException("The Devin authentication generation changed.", "AbortError");
151
- auth.assertUsable(credential);
152
- rememberAccount(credential, requiredString(probe.accountIdentityHash, "Devin account scope"), status);
153
- account = accounts.get(catalogScope(credential));
154
- } finally { probe.transport.close(); }
155
- }
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: () => latestUsage ? { ...latestUsage } : undefined,
228
- refreshModels: (context) => source.refreshModels({ ...context, signal: AbortSignal.any([context.signal, lifetime.signal]) }),
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
- const notice = `[The sidekick report exceeded about ${SIDEKICK_REPORT_TOKEN_LIMIT} tokens and was cut.`;
33
+ let notice = `\n\n[The sidekick report exceeded about ${SIDEKICK_REPORT_TOKEN_LIMIT} tokens and was cut.`;
34
+ let fullReportPath: string | undefined;
34
35
  try {
35
36
  const directory = await mkdtemp(join(tmpdir(), "pi-devin-sidekick-report-"));
36
- const fullReportPath = join(directory, "report.md");
37
- await writeFile(fullReportPath, text, { encoding: "utf8", mode: 0o600 });
38
- return { text: `${result.text}\n\n${notice} Full report: ${fullReportPath}]`, fullReportPath };
37
+ const path = join(directory, "report.md");
38
+ await writeFile(path, text, { encoding: "utf8", mode: 0o600 });
39
+ fullReportPath = path;
40
+ notice += ` Full report: ${path}]`;
39
41
  } catch (error) {
40
- return { text: `${result.text}\n\n${notice} The full report could not be saved: ${error instanceof Error ? error.message : String(error)}]` };
42
+ notice += ` The full report could not be saved: ${error instanceof Error ? error.message : String(error)}]`;
41
43
  }
44
+ // The notice is part of the same budget, including a filesystem error if preservation failed.
45
+ const suffix = truncateReport(notice, maxChars).text;
46
+ return { text: truncateReport(text, maxChars - suffix.length).text + suffix, ...(fullReportPath ? { fullReportPath } : {}) };
42
47
  }
@@ -151,35 +151,38 @@ export class SidekickRuntime {
151
151
  }
152
152
 
153
153
  handoff(message: string): Handoff {
154
- if (this.pending) {
154
+ const current = this.pending;
155
+ if (current) {
155
156
  this.send({ type: "steer", message });
156
- return { id: this.pending.id, done: this.pending.done, isSteer: true };
157
+ return { id: current.id, done: current.done, isSteer: true };
157
158
  }
158
159
  if (!this.isAlive()) this.start();
159
160
  let resolve!: (report: HandoffReport) => void;
160
161
  const done = new Promise<HandoffReport>((settle) => { resolve = settle; });
161
162
  const id = randomUUID();
162
163
  this.pending = { id, startedAt: Date.now(), toolCalls: 0, recentTools: [], isAborted: false, resolve, done };
163
- try { this.send({ id, type: "prompt", message }); }
164
- catch (error) { this.finish("error", "", error instanceof Error ? error.message : String(error)); }
164
+ this.send({ id, type: "prompt", message });
165
165
  return { id, done, isSteer: false };
166
166
  }
167
167
 
168
168
  abort(): void {
169
169
  if (!this.pending) return;
170
170
  this.pending.isAborted = true;
171
- try { this.send({ type: "abort" }); } catch { this.finish("aborted", ""); }
171
+ this.send({ type: "abort" });
172
172
  }
173
173
 
174
174
  close(): void {
175
175
  const child = this.child;
176
176
  this.child = undefined;
177
177
  if (this.pending) this.finish("aborted", "", "Stopped the sidekick process.");
178
+ for (const resolve of this.waiting.values()) resolve(undefined);
179
+ this.waiting.clear();
178
180
  if (!child || child.exitCode !== null || child.signalCode !== null) return;
179
181
  child.stdin?.end();
180
- child.kill("SIGTERM");
181
182
  const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, KILL_GRACE_MS);
182
183
  timer.unref();
184
+ child.once("close", () => clearTimeout(timer));
185
+ child.kill("SIGTERM");
183
186
  }
184
187
 
185
188
  private start(): void {
@@ -195,6 +198,7 @@ export class SidekickRuntime {
195
198
  const decoder = new StringDecoder("utf8");
196
199
  let buffer = "";
197
200
  child.stdout?.on("data", (chunk: Buffer) => {
201
+ if (this.child !== child) return;
198
202
  buffer += decoder.write(chunk);
199
203
  let index: number;
200
204
  while ((index = buffer.indexOf("\n")) >= 0) {
@@ -205,28 +209,37 @@ export class SidekickRuntime {
205
209
  let record: Record<string, unknown>;
206
210
  try { record = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
207
211
  this.handle(record);
212
+ if (this.child !== child) return;
208
213
  }
209
214
  });
210
- child.stderr?.on("data", (chunk: Buffer) => { this.stderrTail = (this.stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL); });
211
- child.on("error", (error) => { if (this.child === child) this.finish("error", "", error.message); });
212
- child.on("close", () => {
213
- if (this.child === child) this.child = undefined;
214
- for (const resolve of this.waiting.values()) resolve(undefined);
215
- this.waiting.clear();
216
- if (this.pending) this.finish("error", "", this.stderrTail.trim() || "The sidekick process exited.");
217
- });
215
+ child.stderr?.on("data", (chunk: Buffer) => { if (this.child === child) this.stderrTail = (this.stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL); });
216
+ child.stdin?.on("error", (error) => this.fail(child, error.message));
217
+ child.on("error", (error) => this.fail(child, error.message));
218
+ child.on("close", () => this.fail(child, this.stderrTail.trim() || "The sidekick process exited."));
219
+ }
220
+
221
+ /** Transport errors settle the handoff and all RPC waits once, even if the failed child closes after its replacement starts. */
222
+ private fail(child: ChildProcess | undefined, error: string): void {
223
+ if (this.child !== child) return;
224
+ this.finish(this.pending?.isAborted ? "aborted" : "error", "", error);
225
+ this.close();
218
226
  }
219
227
 
220
228
  private send(value: Record<string, unknown>): void {
221
- if (!this.child?.stdin?.writable) throw new Error("Cannot write to the sidekick process.");
222
- this.child.stdin.write(JSON.stringify(value) + "\n");
229
+ const child = this.child;
230
+ try {
231
+ if (!child?.stdin?.writable) throw new Error("Cannot write to the sidekick process.");
232
+ child.stdin.write(JSON.stringify(value) + "\n");
233
+ } catch (error) {
234
+ this.fail(child, error instanceof Error ? error.message : String(error));
235
+ }
223
236
  }
224
237
 
225
238
  private request(type: string): Promise<string | undefined> {
226
239
  const id = randomUUID();
227
240
  return new Promise((resolve) => {
228
241
  this.waiting.set(id, resolve);
229
- try { this.send({ id, type }); } catch { this.waiting.delete(id); resolve(undefined); }
242
+ this.send({ id, type });
230
243
  });
231
244
  }
232
245
 
@@ -247,7 +260,7 @@ export class SidekickRuntime {
247
260
  case "extension_ui_request":
248
261
  // The sidekick never talks to the user. Dialogs that need an answer are returned as cancelled.
249
262
  if (["select", "confirm", "input", "editor"].includes(String(record.method))) {
250
- try { this.send({ type: "extension_ui_response", id: record.id, cancelled: true }); } catch { /* shutting down */ }
263
+ this.send({ type: "extension_ui_response", id: record.id, cancelled: true });
251
264
  }
252
265
  return;
253
266
  case "tool_execution_start":