@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.
@@ -51,12 +51,18 @@ export function fusionOf(provider: Pick<DevinProvider, "getModels">, model: Mode
51
51
  const launchKey = (launch: SidekickLaunch) => JSON.stringify([launch.cwd, launch.modelID, launch.thinking, launch.sessionFile, launch.tools]);
52
52
  const shortID = (id: string) => id.slice(0, 8);
53
53
 
54
- /** Report text for the lead. The sidekick's own text is bounded on every path: tool result, failure and background message. */
55
- async function reportText(report: HandoffReport): Promise<BoundedReport> {
56
- const body = await boundReport(report.text.trim());
57
- if (report.status === "completed") return { ...body, text: body.text || "(The sidekick finished without a report.)" };
54
+ /** Bound and preserve the complete report, including status, errors and any background delivery heading. */
55
+ async function reportText(report: HandoffReport, heading = ""): Promise<BoundedReport> {
56
+ const body = report.text.trim();
58
57
  const reason = report.error ?? (report.status === "aborted" ? "cancelled." : "failed.");
59
- return { ...body, text: [`The sidekick task ${report.status === "aborted" ? "stopped" : "failed"}: ${reason}`, body.text].filter(Boolean).join("\n\n") };
58
+ const text = report.status === "completed" ? body || "(The sidekick finished without a report.)"
59
+ : [`The sidekick task ${report.status === "aborted" ? "stopped" : "failed"}: ${reason}`, body].filter(Boolean).join("\n\n");
60
+ return boundReport(heading + text);
61
+ }
62
+
63
+ interface ReportDelivery {
64
+ owner: "background" | "blocking" | "delivered";
65
+ scheduled: boolean;
60
66
  }
61
67
 
62
68
  const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fullReportPath } : {};
@@ -67,25 +73,28 @@ const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fu
67
73
  */
68
74
  export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, options: SidekickToolOptions) {
69
75
  const runtimes = new Map<string, SidekickRuntime>();
70
- const scheduled = new Set<string>();
76
+ // Steers share the same done promise; completed handoffs must not be retained by delivery bookkeeping.
77
+ const deliveries = new WeakMap<Promise<HandoffReport>, ReportDelivery>();
71
78
  const waits = new Set<() => void>();
72
79
  let progressListener: (() => void) | undefined;
73
80
 
74
- const deliverLater = (handoff: Handoff, runtime: SidekickRuntime) => {
75
- if (scheduled.has(handoff.id)) return;
76
- scheduled.add(handoff.id);
81
+ const deliverLater = (handoff: Handoff, runtime: SidekickRuntime, delivery: ReportDelivery) => {
82
+ if (delivery.scheduled || delivery.owner === "delivered") return;
83
+ delivery.scheduled = true;
77
84
  void handoff.done.then(async (report) => {
78
- // Handoffs cleaned up by a session switch or shutdown, or cancelled by the lead, are not reported.
79
- const isCurrent = () => [...runtimes.values()].includes(runtime);
80
- if (report.status === "aborted" || !isCurrent()) return;
81
- const { text, fullReportPath } = await reportText(report);
82
- if (!isCurrent()) return;
83
- pi.sendMessage({
84
- customType: SIDEKICK_REPORT_MESSAGE, display: true,
85
- content: `Sidekick report for background handoff ${shortID(report.id)}:\n\n${text}`,
86
- details: { sidekickModel: runtime.launch.modelID, handoffId: report.id, status: report.status, toolCalls: report.toolCalls, durationMs: report.durationMs, isSteer: false,
87
- ...reportPath(fullReportPath) } satisfies SidekickDetails,
88
- }, { deliverAs: "followUp", triggerTurn: true });
85
+ try {
86
+ // A blocking call owns the report unless user input moves it back to the background.
87
+ const canDeliver = () => delivery.owner === "background" && [...runtimes.values()].includes(runtime);
88
+ if (report.status === "aborted" || !canDeliver()) return;
89
+ const { text, fullReportPath } = await reportText(report, `Sidekick report for background handoff ${shortID(report.id)}:\n\n`);
90
+ if (!canDeliver()) return;
91
+ delivery.owner = "delivered";
92
+ pi.sendMessage({
93
+ customType: SIDEKICK_REPORT_MESSAGE, display: true, content: text,
94
+ details: { sidekickModel: runtime.launch.modelID, handoffId: report.id, status: report.status, toolCalls: report.toolCalls, durationMs: report.durationMs, isSteer: false,
95
+ ...reportPath(fullReportPath) } satisfies SidekickDetails,
96
+ }, { deliverAs: "followUp", triggerTurn: true });
97
+ } finally { delivery.scheduled = false; }
89
98
  });
90
99
  };
91
100
 
@@ -109,6 +118,7 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
109
118
  defaultActive: false,
110
119
  executionMode: "sequential",
111
120
  async execute(_toolCallId, params, signal, onUpdate, ctx) {
121
+ signal?.throwIfAborted();
112
122
  const fusion = fusionOf(provider, ctx.model);
113
123
  if (!fusion) throw new Error("The sidekick is only available with the Devin Fusion model.");
114
124
  const input = params as { message?: unknown; block?: unknown } | undefined;
@@ -131,18 +141,25 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
131
141
  runtime = new SidekickRuntime(launch, { ...options.runtime, childEnv: { [FUSION_CHILD_ENV]: "1" }, onProgress: () => progressListener?.() });
132
142
  runtimes.set(sessionId, runtime);
133
143
  }
144
+ signal?.throwIfAborted();
134
145
  const handoff = runtime.handoff(message);
146
+ let delivery = deliveries.get(handoff.done);
147
+ if (!delivery) {
148
+ delivery = { owner: "background", scheduled: false };
149
+ deliveries.set(handoff.done, delivery);
150
+ }
135
151
  const details = (status: SidekickDetails["status"], report?: HandoffReport): SidekickDetails => ({
136
152
  sidekickModel: launch.modelID, handoffId: handoff.id, status, toolCalls: report?.toolCalls ?? runtime!.progress()?.toolCalls ?? 0,
137
153
  ...(report ? { durationMs: report.durationMs } : {}), isSteer: handoff.isSteer,
138
154
  });
139
155
  const backgroundResult = (reason: string) => {
140
- deliverLater(handoff, runtime!);
156
+ deliverLater(handoff, runtime!, delivery);
141
157
  return { content: [{ type: "text" as const, text: `${reason} The report will arrive as a message. (handoff ${shortID(handoff.id)})` }], details: details("background") };
142
158
  };
143
159
  if (input?.block === false) {
144
160
  return backgroundResult(handoff.isSteer ? "Added instructions to the running sidekick task." : "The sidekick started working in the background.");
145
161
  }
162
+ delivery.owner = "blocking";
146
163
  let settle!: (outcome: "done" | "aborted" | "interrupted") => void;
147
164
  const waiting = new Promise<"done" | "aborted" | "interrupted">((resolve) => { settle = resolve; });
148
165
  const interrupt = () => settle("interrupted"), abort = () => settle("aborted");
@@ -159,11 +176,15 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
159
176
  waits.delete(interrupt);
160
177
  signal?.removeEventListener("abort", abort);
161
178
  });
179
+ if (outcome === "interrupted") {
180
+ delivery.owner = "background";
181
+ return backgroundResult("A user message arrived, so the sidekick task moved to the background.");
182
+ }
183
+ delivery.owner = "delivered";
162
184
  if (outcome === "aborted") {
163
185
  runtime.abort();
164
186
  throw new Error("Cancelled the sidekick task.");
165
187
  }
166
- if (outcome === "interrupted") return backgroundResult("A user message arrived, so the sidekick task moved to the background.");
167
188
  const report = await handoff.done;
168
189
  const { text, fullReportPath } = await reportText(report);
169
190
  if (report.status !== "completed") throw new Error(text);
@@ -184,7 +205,7 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
184
205
  },
185
206
  reset(): void {
186
207
  for (const runtime of runtimes.values()) runtime.close();
187
- runtimes.clear(); scheduled.clear();
208
+ runtimes.clear();
188
209
  },
189
210
  };
190
211
  }
@@ -0,0 +1,75 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import type { AccountUsage } from "./usage.ts";
4
+
5
+ const SCHEMA_VERSION = 1;
6
+ const SCOPE = /^[a-f0-9]{64}$/;
7
+ const NUMBER_FIELDS = ["dailyRemainingPercent", "weeklyRemainingPercent", "dailyResetAtMs", "weeklyResetAtMs"] as const;
8
+
9
+ /**
10
+ * Last account quota received by any Pi process that shares the agent directory, such as subagents and the Fusion sidekick.
11
+ * Keyed by the credential scope hash. No token, account ID or raw response is stored.
12
+ */
13
+ export interface AccountUsageStore {
14
+ read(scope: string): AccountUsage | undefined;
15
+ /** Replaces the record unless the stored one for the same scope was fetched later. */
16
+ write(scope: string, usage: AccountUsage): void;
17
+ }
18
+
19
+ export function accountUsagePath(agentDir: string): string {
20
+ return join(agentDir, "pi-devin", "account-usage.json");
21
+ }
22
+
23
+ const isNotOlder = (current: AccountUsage | undefined, next: AccountUsage) => !current || next.fetchedAtMs >= current.fetchedAtMs;
24
+
25
+ /** Default store so SDK callers and tests never write to the real agent directory. */
26
+ export function memoryAccountUsageStore(): AccountUsageStore {
27
+ let record: { scope: string; usage: AccountUsage } | undefined;
28
+ return {
29
+ read: (scope) => record?.scope === scope ? { ...record.usage } : undefined,
30
+ write(scope, usage) {
31
+ if (record?.scope !== scope || isNotOlder(record.usage, usage)) record = { scope, usage: { ...usage } };
32
+ },
33
+ };
34
+ }
35
+
36
+ function parseUsage(value: unknown): AccountUsage | undefined {
37
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
38
+ const entry = value as Record<string, unknown>;
39
+ if (!Number.isSafeInteger(entry.fetchedAtMs) || (entry.fetchedAtMs as number) < 0) return undefined;
40
+ if (entry.billingStrategy !== undefined && typeof entry.billingStrategy !== "string") return undefined;
41
+ const usage: AccountUsage = { fetchedAtMs: entry.fetchedAtMs as number, ...(typeof entry.billingStrategy === "string" ? { billingStrategy: entry.billingStrategy } : {}) };
42
+ for (const key of NUMBER_FIELDS) {
43
+ const field = entry[key];
44
+ if (field === undefined) continue;
45
+ if (typeof field !== "number" || !Number.isFinite(field)) return undefined;
46
+ usage[key] = field;
47
+ }
48
+ return usage;
49
+ }
50
+
51
+ /** An unreadable or malformed file reads as no record and is not repaired by reading. Writes are replaced atomically within the same directory. */
52
+ export function fileAccountUsageStore(path: string): AccountUsageStore {
53
+ const readRecord = () => {
54
+ try {
55
+ const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
56
+ if (value.schemaVersion !== SCHEMA_VERSION || typeof value.scope !== "string" || !SCOPE.test(value.scope)) return undefined;
57
+ const usage = parseUsage(value.usage);
58
+ return usage && { scope: value.scope, usage };
59
+ } catch { return undefined; }
60
+ };
61
+ return {
62
+ read(scope) {
63
+ const record = readRecord();
64
+ return record?.scope === scope ? record.usage : undefined;
65
+ },
66
+ write(scope, usage) {
67
+ const current = readRecord();
68
+ if (current?.scope === scope && !isNotOlder(current.usage, usage)) return;
69
+ mkdirSync(dirname(path), { recursive: true });
70
+ const temporary = `${path}.${process.pid}.tmp`;
71
+ writeFileSync(temporary, JSON.stringify({ schemaVersion: SCHEMA_VERSION, scope, usage }, null, 2) + "\n");
72
+ renameSync(temporary, path);
73
+ },
74
+ };
75
+ }