@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/sidekick.ts
CHANGED
|
@@ -51,12 +51,18 @@ export function fusionOf(provider: Pick<DevinProvider, "getModels">, model: Mode
|
|
|
51
51
|
const launchKey = (launch: SidekickLaunch) => JSON.stringify([launch.cwd, launch.modelID, launch.thinking, launch.sessionFile, launch.tools]);
|
|
52
52
|
const shortID = (id: string) => id.slice(0, 8);
|
|
53
53
|
|
|
54
|
-
/**
|
|
55
|
-
async function reportText(report: HandoffReport): Promise<BoundedReport> {
|
|
56
|
-
const body =
|
|
57
|
-
if (report.status === "completed") return { ...body, text: body.text || "(The sidekick finished without a report.)" };
|
|
54
|
+
/** Bound and preserve the complete report, including status, errors and any background delivery heading. */
|
|
55
|
+
async function reportText(report: HandoffReport, heading = ""): Promise<BoundedReport> {
|
|
56
|
+
const body = report.text.trim();
|
|
58
57
|
const reason = report.error ?? (report.status === "aborted" ? "cancelled." : "failed.");
|
|
59
|
-
|
|
58
|
+
const text = report.status === "completed" ? body || "(The sidekick finished without a report.)"
|
|
59
|
+
: [`The sidekick task ${report.status === "aborted" ? "stopped" : "failed"}: ${reason}`, body].filter(Boolean).join("\n\n");
|
|
60
|
+
return boundReport(heading + text);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
interface ReportDelivery {
|
|
64
|
+
owner: "background" | "blocking" | "delivered";
|
|
65
|
+
scheduled: boolean;
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fullReportPath } : {};
|
|
@@ -67,25 +73,28 @@ const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fu
|
|
|
67
73
|
*/
|
|
68
74
|
export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, options: SidekickToolOptions) {
|
|
69
75
|
const runtimes = new Map<string, SidekickRuntime>();
|
|
70
|
-
|
|
76
|
+
// Steers share the same done promise; completed handoffs must not be retained by delivery bookkeeping.
|
|
77
|
+
const deliveries = new WeakMap<Promise<HandoffReport>, ReportDelivery>();
|
|
71
78
|
const waits = new Set<() => void>();
|
|
72
79
|
let progressListener: (() => void) | undefined;
|
|
73
80
|
|
|
74
|
-
const deliverLater = (handoff: Handoff, runtime: SidekickRuntime) => {
|
|
75
|
-
if (scheduled.
|
|
76
|
-
scheduled
|
|
81
|
+
const deliverLater = (handoff: Handoff, runtime: SidekickRuntime, delivery: ReportDelivery) => {
|
|
82
|
+
if (delivery.scheduled || delivery.owner === "delivered") return;
|
|
83
|
+
delivery.scheduled = true;
|
|
77
84
|
void handoff.done.then(async (report) => {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
85
|
+
try {
|
|
86
|
+
// A blocking call owns the report unless user input moves it back to the background.
|
|
87
|
+
const canDeliver = () => delivery.owner === "background" && [...runtimes.values()].includes(runtime);
|
|
88
|
+
if (report.status === "aborted" || !canDeliver()) return;
|
|
89
|
+
const { text, fullReportPath } = await reportText(report, `Sidekick report for background handoff ${shortID(report.id)}:\n\n`);
|
|
90
|
+
if (!canDeliver()) return;
|
|
91
|
+
delivery.owner = "delivered";
|
|
92
|
+
pi.sendMessage({
|
|
93
|
+
customType: SIDEKICK_REPORT_MESSAGE, display: true, content: text,
|
|
94
|
+
details: { sidekickModel: runtime.launch.modelID, handoffId: report.id, status: report.status, toolCalls: report.toolCalls, durationMs: report.durationMs, isSteer: false,
|
|
95
|
+
...reportPath(fullReportPath) } satisfies SidekickDetails,
|
|
96
|
+
}, { deliverAs: "followUp", triggerTurn: true });
|
|
97
|
+
} finally { delivery.scheduled = false; }
|
|
89
98
|
});
|
|
90
99
|
};
|
|
91
100
|
|
|
@@ -109,6 +118,7 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
|
|
|
109
118
|
defaultActive: false,
|
|
110
119
|
executionMode: "sequential",
|
|
111
120
|
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
121
|
+
signal?.throwIfAborted();
|
|
112
122
|
const fusion = fusionOf(provider, ctx.model);
|
|
113
123
|
if (!fusion) throw new Error("The sidekick is only available with the Devin Fusion model.");
|
|
114
124
|
const input = params as { message?: unknown; block?: unknown } | undefined;
|
|
@@ -131,18 +141,25 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
|
|
|
131
141
|
runtime = new SidekickRuntime(launch, { ...options.runtime, childEnv: { [FUSION_CHILD_ENV]: "1" }, onProgress: () => progressListener?.() });
|
|
132
142
|
runtimes.set(sessionId, runtime);
|
|
133
143
|
}
|
|
144
|
+
signal?.throwIfAborted();
|
|
134
145
|
const handoff = runtime.handoff(message);
|
|
146
|
+
let delivery = deliveries.get(handoff.done);
|
|
147
|
+
if (!delivery) {
|
|
148
|
+
delivery = { owner: "background", scheduled: false };
|
|
149
|
+
deliveries.set(handoff.done, delivery);
|
|
150
|
+
}
|
|
135
151
|
const details = (status: SidekickDetails["status"], report?: HandoffReport): SidekickDetails => ({
|
|
136
152
|
sidekickModel: launch.modelID, handoffId: handoff.id, status, toolCalls: report?.toolCalls ?? runtime!.progress()?.toolCalls ?? 0,
|
|
137
153
|
...(report ? { durationMs: report.durationMs } : {}), isSteer: handoff.isSteer,
|
|
138
154
|
});
|
|
139
155
|
const backgroundResult = (reason: string) => {
|
|
140
|
-
deliverLater(handoff, runtime
|
|
156
|
+
deliverLater(handoff, runtime!, delivery);
|
|
141
157
|
return { content: [{ type: "text" as const, text: `${reason} The report will arrive as a message. (handoff ${shortID(handoff.id)})` }], details: details("background") };
|
|
142
158
|
};
|
|
143
159
|
if (input?.block === false) {
|
|
144
160
|
return backgroundResult(handoff.isSteer ? "Added instructions to the running sidekick task." : "The sidekick started working in the background.");
|
|
145
161
|
}
|
|
162
|
+
delivery.owner = "blocking";
|
|
146
163
|
let settle!: (outcome: "done" | "aborted" | "interrupted") => void;
|
|
147
164
|
const waiting = new Promise<"done" | "aborted" | "interrupted">((resolve) => { settle = resolve; });
|
|
148
165
|
const interrupt = () => settle("interrupted"), abort = () => settle("aborted");
|
|
@@ -159,11 +176,15 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
|
|
|
159
176
|
waits.delete(interrupt);
|
|
160
177
|
signal?.removeEventListener("abort", abort);
|
|
161
178
|
});
|
|
179
|
+
if (outcome === "interrupted") {
|
|
180
|
+
delivery.owner = "background";
|
|
181
|
+
return backgroundResult("A user message arrived, so the sidekick task moved to the background.");
|
|
182
|
+
}
|
|
183
|
+
delivery.owner = "delivered";
|
|
162
184
|
if (outcome === "aborted") {
|
|
163
185
|
runtime.abort();
|
|
164
186
|
throw new Error("Cancelled the sidekick task.");
|
|
165
187
|
}
|
|
166
|
-
if (outcome === "interrupted") return backgroundResult("A user message arrived, so the sidekick task moved to the background.");
|
|
167
188
|
const report = await handoff.done;
|
|
168
189
|
const { text, fullReportPath } = await reportText(report);
|
|
169
190
|
if (report.status !== "completed") throw new Error(text);
|
|
@@ -184,7 +205,7 @@ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, op
|
|
|
184
205
|
},
|
|
185
206
|
reset(): void {
|
|
186
207
|
for (const runtime of runtimes.values()) runtime.close();
|
|
187
|
-
runtimes.clear();
|
|
208
|
+
runtimes.clear();
|
|
188
209
|
},
|
|
189
210
|
};
|
|
190
211
|
}
|
|
@@ -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
|
+
}
|