@buyong/pi-devin 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -2
  3. package/index.ts +16 -0
  4. package/package.json +62 -3
  5. package/proto/README.md +78 -0
  6. package/proto/buf/validate/validate.proto +453 -0
  7. package/proto/cel/expr/checked.proto +109 -0
  8. package/proto/cel/expr/eval.proto +44 -0
  9. package/proto/cel/expr/explain.proto +22 -0
  10. package/proto/cel/expr/syntax.proto +119 -0
  11. package/proto/cel/expr/value.proto +47 -0
  12. package/proto/connectext/grpc/status/v1/status.proto +21 -0
  13. package/proto/errorspb/errors.proto +58 -0
  14. package/proto/errorspb/hintdetail.proto +9 -0
  15. package/proto/errorspb/markers.proto +12 -0
  16. package/proto/errorspb/tags.proto +14 -0
  17. package/proto/errorspb/testing.proto +8 -0
  18. package/proto/exa/analytics_pb/analytics.proto +190 -0
  19. package/proto/exa/api_server_pb/api_server.proto +2590 -0
  20. package/proto/exa/auth_pb/auth.proto +21 -0
  21. package/proto/exa/auto_cascade_common_pb/auto_cascade_common.proto +81 -0
  22. package/proto/exa/browser_preview_pb/browser_preview.proto +26 -0
  23. package/proto/exa/bug_checker_pb/bug_checker.proto +24 -0
  24. package/proto/exa/cascade_plugins_pb/cascade_plugins.proto +264 -0
  25. package/proto/exa/chat_client_server_pb/chat_client_server.proto +57 -0
  26. package/proto/exa/chat_pb/chat.proto +452 -0
  27. package/proto/exa/code_edit/code_edit_pb/code_edit.proto +186 -0
  28. package/proto/exa/codeium_common_pb/browser_context.proto +31 -0
  29. package/proto/exa/codeium_common_pb/codeium_common.proto +4183 -0
  30. package/proto/exa/context_module_pb/context_module.proto +174 -0
  31. package/proto/exa/cortex_pb/cortex.proto +3288 -0
  32. package/proto/exa/dev_pb/dev.proto +28 -0
  33. package/proto/exa/diff_action_pb/diff_action.proto +76 -0
  34. package/proto/exa/eval/pr_eval/datasets_pb/datasets.proto +106 -0
  35. package/proto/exa/eval_pb/eval.proto +1318 -0
  36. package/proto/exa/extension_server_pb/extension_server.proto +555 -0
  37. package/proto/exa/file_system_provider_pb/file_system_provider.proto +75 -0
  38. package/proto/exa/index_pb/index.proto +469 -0
  39. package/proto/exa/knowledge_base_pb/knowledge_base.proto +146 -0
  40. package/proto/exa/language_server_pb/language_server.proto +2435 -0
  41. package/proto/exa/model_management_pb/model_management.proto +184 -0
  42. package/proto/exa/opensearch_clients_pb/opensearch_clients.proto +495 -0
  43. package/proto/exa/product_analytics_pb/product_analytics.proto +39 -0
  44. package/proto/exa/prompt_pb/prompt.proto +97 -0
  45. package/proto/exa/reactive_component_pb/reactive_component.proto +98 -0
  46. package/proto/exa/seat_management_pb/seat_management.proto +2910 -0
  47. package/proto/exa/tokenizer_pb/tokenizer.proto +39 -0
  48. package/proto/exa/trainer_pb/config.proto +643 -0
  49. package/proto/exa/tree_sitter/language_data_pb/language_data.proto +14 -0
  50. package/proto/exa/trust_pb/trust.proto +192 -0
  51. package/proto/exa/user_analytics_pb/user_analytics.proto +752 -0
  52. package/proto/google/api/expr/v1alpha1/checked.proto +108 -0
  53. package/proto/google/api/expr/v1alpha1/eval.proto +38 -0
  54. package/proto/google/api/expr/v1alpha1/explain.proto +22 -0
  55. package/proto/google/api/expr/v1alpha1/syntax.proto +126 -0
  56. package/proto/google/api/expr/v1alpha1/value.proto +47 -0
  57. package/proto/google/protobuf/any.proto +15 -0
  58. package/proto/google/protobuf/cpp_features.proto +22 -0
  59. package/proto/google/protobuf/descriptor.proto +473 -0
  60. package/proto/google/protobuf/duration.proto +16 -0
  61. package/proto/google/protobuf/empty.proto +14 -0
  62. package/proto/google/protobuf/go_features.proto +31 -0
  63. package/proto/google/protobuf/struct.proto +34 -0
  64. package/proto/google/protobuf/timestamp.proto +16 -0
  65. package/proto/google/protobuf/wrappers.proto +47 -0
  66. package/proto/google/rpc/status.proto +18 -0
  67. package/proto/grpc/binlog/v1/binarylog.proto +89 -0
  68. package/proto/io/prometheus/client/metrics.proto +103 -0
  69. package/src/core/auth/access.ts +6 -0
  70. package/src/core/auth/index.ts +2 -0
  71. package/src/core/auth/login.ts +177 -0
  72. package/src/core/auth/rest-exchange.ts +35 -0
  73. package/src/core/client.ts +153 -0
  74. package/src/core/index.ts +12 -0
  75. package/src/core/inference/chat.ts +229 -0
  76. package/src/core/inference/images.ts +24 -0
  77. package/src/core/inference/messages.ts +34 -0
  78. package/src/core/inference/retry.ts +68 -0
  79. package/src/core/inference/state.ts +8 -0
  80. package/src/core/models/access.ts +7 -0
  81. package/src/core/models/catalog.ts +35 -0
  82. package/src/core/models/index.ts +4 -0
  83. package/src/core/models/select.ts +43 -0
  84. package/src/core/models/variants.ts +163 -0
  85. package/src/core/protocol/device.ts +73 -0
  86. package/src/core/protocol/fingerprint.ts +59 -0
  87. package/src/core/protocol/http.ts +109 -0
  88. package/src/core/protocol/protobuf.ts +112 -0
  89. package/src/core/protocol/transport.ts +501 -0
  90. package/src/core/types.ts +115 -0
  91. package/src/pi/auth.ts +134 -0
  92. package/src/pi/devin-command.ts +76 -0
  93. package/src/pi/devin-panel.ts +77 -0
  94. package/src/pi/devin-settings.ts +50 -0
  95. package/src/pi/extension.ts +80 -0
  96. package/src/pi/fusion-command.ts +83 -0
  97. package/src/pi/fusion-picker.ts +171 -0
  98. package/src/pi/fusion-settings.ts +44 -0
  99. package/src/pi/lifecycle.ts +217 -0
  100. package/src/pi/messages.ts +107 -0
  101. package/src/pi/models.ts +378 -0
  102. package/src/pi/provider.ts +241 -0
  103. package/src/pi/sidekick-report.ts +42 -0
  104. package/src/pi/sidekick-runtime.ts +287 -0
  105. package/src/pi/sidekick.ts +190 -0
  106. package/src/pi/stream.ts +124 -0
  107. package/src/pi/usage.ts +153 -0
@@ -0,0 +1,42 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import { join } from "node:path";
4
+
5
+ /** Upper bound on the sidekick report the lead receives. The Devin CLI analysis records no sidekick report limit, so it is fixed here. */
6
+ export const SIDEKICK_REPORT_TOKEN_LIMIT = 131_072;
7
+ /** Pi's context estimate (`estimateTokens`) counts 4 characters per token, so the limit uses the same unit. */
8
+ const CHARS_PER_TOKEN = 4;
9
+ export const SIDEKICK_REPORT_CHAR_LIMIT = SIDEKICK_REPORT_TOKEN_LIMIT * CHARS_PER_TOKEN;
10
+
11
+ export interface BoundedReport {
12
+ text: string;
13
+ /** Private temporary file with the complete report, set only when the report was cut and the file was written. */
14
+ fullReportPath?: string;
15
+ }
16
+
17
+ const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
18
+
19
+ /** Keeps the beginning of the report within `maxChars` without splitting a surrogate pair. */
20
+ export function truncateReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): { text: string; isTruncated: boolean } {
21
+ if (text.length <= maxChars) return { text, isTruncated: false };
22
+ const end = isHighSurrogate(text.charCodeAt(maxChars - 1)) ? maxChars - 1 : maxChars;
23
+ return { text: text.slice(0, end), isTruncated: true };
24
+ }
25
+
26
+ /**
27
+ * Bounds the report before it reaches the lead and keeps the complete text in a private temporary file.
28
+ * Never rejects: background reports are delivered from a detached promise, so a failed write only changes the notice.
29
+ */
30
+ export async function boundReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): Promise<BoundedReport> {
31
+ const result = truncateReport(text, maxChars);
32
+ if (!result.isTruncated) return { text };
33
+ const notice = `[The sidekick report exceeded about ${SIDEKICK_REPORT_TOKEN_LIMIT} tokens and was cut.`;
34
+ try {
35
+ 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 };
39
+ } catch (error) {
40
+ return { text: `${result.text}\n\n${notice} The full report could not be saved: ${error instanceof Error ? error.message : String(error)}]` };
41
+ }
42
+ }
@@ -0,0 +1,287 @@
1
+ import { spawn as nodeSpawn, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
4
+ import { dirname, isAbsolute, join, resolve } from "node:path";
5
+ import { StringDecoder } from "node:string_decoder";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const PI_PACKAGE = "@earendil-works/pi-coding-agent";
9
+ const STDERR_TAIL = 2048;
10
+ const RECENT_TOOLS = 5;
11
+ const KILL_GRACE_MS = 2000;
12
+
13
+ /** This package root. Passed to the child Pi with `-e` so it uses the same extension copy as the lead. Pi loads a path only once. */
14
+ export const PACKAGE_ROOT = dirname(fileURLToPath(new URL("../../package.json", import.meta.url)));
15
+
16
+ export interface SidekickCommand { command: string; args: string[] }
17
+
18
+ /** Launch settings of the sidekick child Pi. When they change, the process restarts before the next handoff. */
19
+ export interface SidekickLaunch {
20
+ cwd: string;
21
+ /** Devin model ID of the exact variant (its catalog UID). */
22
+ modelID: string;
23
+ thinking?: string;
24
+ /** Without it, the child starts with `--no-session`. */
25
+ sessionFile?: string;
26
+ tools: readonly string[];
27
+ systemPrompt: string;
28
+ }
29
+
30
+ export interface HandoffProgress { toolCalls: number; recentTools: string[]; elapsedMs: number }
31
+ export interface HandoffReport {
32
+ id: string;
33
+ status: "completed" | "aborted" | "error";
34
+ text: string;
35
+ toolCalls: number;
36
+ durationMs: number;
37
+ error?: string;
38
+ }
39
+ export interface Handoff { id: string; done: Promise<HandoffReport>; isSteer: boolean }
40
+
41
+ export interface SidekickRuntimeOptions {
42
+ /** Command that runs Pi. Defaults to the same Node and Pi CLI as the lead. */
43
+ resolveCommand?: () => SidekickCommand;
44
+ spawn?: typeof nodeSpawn;
45
+ env?: NodeJS.ProcessEnv;
46
+ childEnv: Record<string, string>;
47
+ packageRoot?: string;
48
+ onProgress?: () => void;
49
+ }
50
+
51
+ function piPackageRoot(start: string): string | undefined {
52
+ let directory = dirname(start);
53
+ while (directory !== dirname(directory)) {
54
+ const manifest = join(directory, "package.json");
55
+ if (existsSync(manifest)) {
56
+ try { if ((JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }).name === PI_PACKAGE) return directory; } catch { /* try the next parent */ }
57
+ }
58
+ directory = dirname(directory);
59
+ }
60
+ return undefined;
61
+ }
62
+
63
+ const EXTENSION_FLAGS = new Set(["-e", "--extension"]);
64
+ const EXTENSION_SWITCHES = new Set(["--no-extensions", "-ne", "--no-mcp"]);
65
+
66
+ /**
67
+ * Passes only the extension selection from the lead CLI arguments to the child, so tools from extensions loaded only with `-e` work for the sidekick too.
68
+ * Local paths become absolute paths from the lead's working directory. Source specifiers such as `builtin:` and `npm:` stay as they are.
69
+ */
70
+ export function extensionArgs(argv: readonly string[], cwd: string): string[] {
71
+ const forwarded: string[] = [];
72
+ for (let index = 0; index < argv.length; index++) {
73
+ const value = argv[index];
74
+ if (EXTENSION_SWITCHES.has(value)) forwarded.push(value);
75
+ else if (EXTENSION_FLAGS.has(value) && index + 1 < argv.length) {
76
+ const target = argv[++index];
77
+ forwarded.push("-e", /^[a-z][a-z0-9+.-]*:/i.test(target) || isAbsolute(target) ? target : resolve(cwd, target));
78
+ }
79
+ }
80
+ return forwarded;
81
+ }
82
+
83
+ /**
84
+ * Starts the child with the same Node, exec options and Pi CLI as the lead Pi. The running CLI is preferred so extension selection arguments carry over;
85
+ * when Pi is embedded through the SDK, the installed Pi package's bin is used. Inspect options are not passed, to avoid port conflicts.
86
+ */
87
+ export function resolvePiCommand(): SidekickCommand {
88
+ const candidates: { path: string; isRunningCLI: boolean }[] = [];
89
+ try { if (process.argv[1]) candidates.push({ path: realpathSync(process.argv[1]), isRunningCLI: true }); } catch { /* missing path */ }
90
+ try { candidates.push({ path: fileURLToPath(import.meta.resolve(PI_PACKAGE)), isRunningCLI: false }); } catch { /* cannot resolve */ }
91
+ for (const candidate of candidates) {
92
+ const root = piPackageRoot(candidate.path);
93
+ if (!root) continue;
94
+ const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { bin?: string | Record<string, string> };
95
+ const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi;
96
+ if (bin && existsSync(join(root, bin))) {
97
+ const execArgv = process.execArgv.filter((value) => !/^--(inspect|debug)/.test(value));
98
+ const forwarded = candidate.isRunningCLI ? extensionArgs(process.argv.slice(2), process.cwd()) : [];
99
+ return { command: process.execPath, args: [...execArgv, join(root, bin), ...forwarded] };
100
+ }
101
+ }
102
+ throw new Error("Could not find a Pi CLI to run the sidekick.");
103
+ }
104
+
105
+ export function launchArgs(launch: SidekickLaunch, packageRoot: string): string[] {
106
+ return [
107
+ "--mode", "rpc",
108
+ ...(launch.sessionFile ? ["--session", launch.sessionFile] : ["--no-session"]),
109
+ "--model", `devin/${launch.modelID}`,
110
+ ...(launch.thinking ? ["--thinking", launch.thinking] : []),
111
+ "--append-system-prompt", launch.systemPrompt,
112
+ "-e", packageRoot,
113
+ ...(launch.tools.length ? ["--tools", launch.tools.join(",")] : ["--no-tools"]),
114
+ ];
115
+ }
116
+
117
+ interface PendingHandoff {
118
+ id: string;
119
+ startedAt: number;
120
+ toolCalls: number;
121
+ recentTools: string[];
122
+ lastError?: string;
123
+ isAborted: boolean;
124
+ resolve(report: HandoffReport): void;
125
+ done: Promise<HandoffReport>;
126
+ }
127
+
128
+ /**
129
+ * Sidekick child Pi RPC process bound to one lead session. Started on the first handoff; keeps its context for the rest of the session.
130
+ * A handoff during a run adds instructions with `steer`; when the child run ends (`agent_settled`), the last assistant text is returned as the report.
131
+ */
132
+ export class SidekickRuntime {
133
+ readonly launch: SidekickLaunch;
134
+ private readonly options: SidekickRuntimeOptions;
135
+ private child: ChildProcess | undefined;
136
+ private pending: PendingHandoff | undefined;
137
+ private stderrTail = "";
138
+ private readonly waiting = new Map<string, (text: string | undefined) => void>();
139
+
140
+ constructor(launch: SidekickLaunch, options: SidekickRuntimeOptions) {
141
+ this.launch = launch;
142
+ this.options = options;
143
+ }
144
+
145
+ isBusy(): boolean { return this.pending !== undefined; }
146
+ isAlive(): boolean { return this.child !== undefined && this.child.exitCode === null && this.child.signalCode === null; }
147
+
148
+ progress(): HandoffProgress | undefined {
149
+ const current = this.pending;
150
+ return current ? { toolCalls: current.toolCalls, recentTools: [...current.recentTools], elapsedMs: Date.now() - current.startedAt } : undefined;
151
+ }
152
+
153
+ handoff(message: string): Handoff {
154
+ if (this.pending) {
155
+ this.send({ type: "steer", message });
156
+ return { id: this.pending.id, done: this.pending.done, isSteer: true };
157
+ }
158
+ if (!this.isAlive()) this.start();
159
+ let resolve!: (report: HandoffReport) => void;
160
+ const done = new Promise<HandoffReport>((settle) => { resolve = settle; });
161
+ const id = randomUUID();
162
+ 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)); }
165
+ return { id, done, isSteer: false };
166
+ }
167
+
168
+ abort(): void {
169
+ if (!this.pending) return;
170
+ this.pending.isAborted = true;
171
+ try { this.send({ type: "abort" }); } catch { this.finish("aborted", ""); }
172
+ }
173
+
174
+ close(): void {
175
+ const child = this.child;
176
+ this.child = undefined;
177
+ if (this.pending) this.finish("aborted", "", "Stopped the sidekick process.");
178
+ if (!child || child.exitCode !== null || child.signalCode !== null) return;
179
+ child.stdin?.end();
180
+ child.kill("SIGTERM");
181
+ const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, KILL_GRACE_MS);
182
+ timer.unref();
183
+ }
184
+
185
+ private start(): void {
186
+ const base = (this.options.resolveCommand ?? resolvePiCommand)();
187
+ const args = [...base.args, ...launchArgs(this.launch, this.options.packageRoot ?? PACKAGE_ROOT)];
188
+ const child = (this.options.spawn ?? nodeSpawn)(base.command, args, {
189
+ cwd: this.launch.cwd,
190
+ env: { ...(this.options.env ?? process.env), ...this.options.childEnv },
191
+ stdio: ["pipe", "pipe", "pipe"],
192
+ });
193
+ this.child = child;
194
+ this.stderrTail = "";
195
+ const decoder = new StringDecoder("utf8");
196
+ let buffer = "";
197
+ child.stdout?.on("data", (chunk: Buffer) => {
198
+ buffer += decoder.write(chunk);
199
+ let index: number;
200
+ while ((index = buffer.indexOf("\n")) >= 0) {
201
+ let line = buffer.slice(0, index);
202
+ buffer = buffer.slice(index + 1);
203
+ if (line.endsWith("\r")) line = line.slice(0, -1);
204
+ if (!line) continue;
205
+ let record: Record<string, unknown>;
206
+ try { record = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
207
+ this.handle(record);
208
+ }
209
+ });
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
+ });
218
+ }
219
+
220
+ 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");
223
+ }
224
+
225
+ private request(type: string): Promise<string | undefined> {
226
+ const id = randomUUID();
227
+ return new Promise((resolve) => {
228
+ this.waiting.set(id, resolve);
229
+ try { this.send({ id, type }); } catch { this.waiting.delete(id); resolve(undefined); }
230
+ });
231
+ }
232
+
233
+ private handle(record: Record<string, unknown>): void {
234
+ const current = this.pending;
235
+ switch (record.type) {
236
+ case "response": {
237
+ const waiter = typeof record.id === "string" ? this.waiting.get(record.id) : undefined;
238
+ if (waiter) {
239
+ this.waiting.delete(record.id as string);
240
+ const data = record.data as { text?: unknown } | undefined;
241
+ waiter(typeof data?.text === "string" ? data.text : undefined);
242
+ } else if (current && record.command === "prompt" && record.success === false) {
243
+ this.finish("error", "", String(record.error ?? "The sidekick did not receive the instructions."));
244
+ }
245
+ return;
246
+ }
247
+ case "extension_ui_request":
248
+ // The sidekick never talks to the user. Dialogs that need an answer are returned as cancelled.
249
+ 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 */ }
251
+ }
252
+ return;
253
+ case "tool_execution_start":
254
+ if (!current) return;
255
+ current.toolCalls++;
256
+ current.recentTools = [...current.recentTools, String(record.toolName ?? "tool")].slice(-RECENT_TOOLS);
257
+ this.options.onProgress?.();
258
+ return;
259
+ case "message_end": {
260
+ const message = record.message as { role?: unknown; stopReason?: unknown; errorMessage?: unknown } | undefined;
261
+ if (current && message?.role === "assistant") {
262
+ current.lastError = message.stopReason === "error" && typeof message.errorMessage === "string" ? message.errorMessage : undefined;
263
+ }
264
+ return;
265
+ }
266
+ case "agent_settled":
267
+ if (current) void this.complete(current, record.aborted === true);
268
+ return;
269
+ }
270
+ }
271
+
272
+ private async complete(current: PendingHandoff, isAborted: boolean): Promise<void> {
273
+ const text = await this.request("get_last_assistant_text");
274
+ if (this.pending !== current) return;
275
+ if (isAborted || current.isAborted) this.finish("aborted", text ?? "");
276
+ else if (current.lastError) this.finish("error", text ?? "", current.lastError);
277
+ else this.finish("completed", text ?? "");
278
+ }
279
+
280
+ private finish(status: HandoffReport["status"], text: string, error?: string): void {
281
+ const current = this.pending;
282
+ if (!current) return;
283
+ this.pending = undefined;
284
+ current.resolve({ id: current.id, status, text, toolCalls: current.toolCalls, durationMs: Date.now() - current.startedAt, ...(error ? { error } : {}) });
285
+ this.options.onProgress?.();
286
+ }
287
+ }
@@ -0,0 +1,190 @@
1
+ import { join } from "node:path";
2
+ import type { Model } from "@earendil-works/pi-ai";
3
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
4
+ import { DEVIN_API, type DevinModel } from "./models.ts";
5
+ import type { DevinProvider } from "./provider.ts";
6
+ import { boundReport, type BoundedReport } from "./sidekick-report.ts";
7
+ import { SidekickRuntime, type Handoff, type HandoffReport, type SidekickLaunch, type SidekickRuntimeOptions } from "./sidekick-runtime.ts";
8
+
9
+ export const SIDEKICK_TOOL_NAME = "devin_sidekick";
10
+ /** Marker set on the child Pi started as the sidekick. The child's extension does not register Fusion again. */
11
+ export const FUSION_CHILD_ENV = "PI_DEVIN_FUSION_CHILD";
12
+ /** Custom message type that delivers a background handoff report to the lead. */
13
+ export const SIDEKICK_REPORT_MESSAGE = "devin-sidekick-report";
14
+ /** Appended by the provider to Fusion lead requests only, so a custom system prompt cannot drop it. */
15
+ export const FUSION_LEAD_GUIDANCE = [
16
+ `Fusion mode: you are the lead. Plan, talk to the user, and decide; delegate hands-on exploration, implementation, and verification to the \`${SIDEKICK_TOOL_NAME}\` tool with clear context and done criteria.`,
17
+ `The sidekick is a separate agent in this workspace with the same tools, and it keeps its context across handoffs. \`${SIDEKICK_TOOL_NAME}\` waits for the report by default; pass \`block: false\` to keep working while it runs, and its report arrives later as a message. Calling \`${SIDEKICK_TOOL_NAME}\` while it is busy adds instructions to the running handoff.`,
18
+ "Review its report critically, decide the next step, and do not redo work it already reported. Present the combined result as your own work.",
19
+ ].join("\n");
20
+ const SYSTEM_PROMPT = [
21
+ "You are the sidekick: a hands-on software engineer paired with a lead agent in the same workspace.",
22
+ "The lead sends you briefs with context and done criteria and may add instructions while you work. Do the work with the available tools: explore, implement, and verify.",
23
+ "You cannot talk to the user. When you finish, reply without tool calls with a concise report: what you did, how you verified it, and anything unresolved or risky.",
24
+ "Your conversation persists across briefs from the same lead session, so build on what you already know.",
25
+ ].join("\n");
26
+
27
+ export interface SidekickDetails {
28
+ sidekickModel: string;
29
+ handoffId: string;
30
+ status: HandoffReport["status"] | "running" | "background";
31
+ toolCalls: number;
32
+ durationMs?: number;
33
+ isSteer: boolean;
34
+ /** Complete report saved when the text the lead received was cut. */
35
+ fullReportPath?: string;
36
+ }
37
+
38
+ export interface SidekickToolOptions {
39
+ /** Pi agent directory that holds sidekick session files. */
40
+ agentDir: () => string;
41
+ /** Lets tests replace how the child is started. */
42
+ runtime?: Omit<SidekickRuntimeOptions, "childEnv" | "onProgress">;
43
+ }
44
+
45
+ /** The host may copy model objects in its settings layer, so Fusion data is looked up by id in the provider's current list. */
46
+ export function fusionOf(provider: Pick<DevinProvider, "getModels">, model: Model<any> | undefined): NonNullable<DevinModel["devin"]["fusion"]> | undefined {
47
+ if (model?.provider !== "devin" || model.api !== DEVIN_API) return undefined;
48
+ return (provider.getModels() as readonly DevinModel[]).find((candidate) => candidate.id === model.id)?.devin.fusion;
49
+ }
50
+
51
+ const launchKey = (launch: SidekickLaunch) => JSON.stringify([launch.cwd, launch.modelID, launch.thinking, launch.sessionFile, launch.tools]);
52
+ const shortID = (id: string) => id.slice(0, 8);
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.)" };
58
+ 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") };
60
+ }
61
+
62
+ const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fullReportPath } : {};
63
+
64
+ /**
65
+ * The lead's `devin_sidekick` tool. The sidekick runs as one child Pi process per lead session and uses the lead's active tools.
66
+ * By default it waits for the report; with `block: false`, or when a user message arrives while waiting, it moves to the background and delivers the report once.
67
+ */
68
+ export function createSidekickTool(pi: ExtensionAPI, provider: DevinProvider, options: SidekickToolOptions) {
69
+ const runtimes = new Map<string, SidekickRuntime>();
70
+ const scheduled = new Set<string>();
71
+ const waits = new Set<() => void>();
72
+ let progressListener: (() => void) | undefined;
73
+
74
+ const deliverLater = (handoff: Handoff, runtime: SidekickRuntime) => {
75
+ if (scheduled.has(handoff.id)) return;
76
+ scheduled.add(handoff.id);
77
+ 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 });
89
+ });
90
+ };
91
+
92
+ const definition: ToolDefinition = {
93
+ name: SIDEKICK_TOOL_NAME,
94
+ label: "Sidekick",
95
+ description: "Delegate hands-on work to your persistent sidekick, a separate coding agent that shares this workspace and your tools. "
96
+ + "Put the task context and done criteria in `message`. By default the call waits and returns the sidekick's report; "
97
+ + "set `block` to false to continue while it works, and the report arrives later as a message. "
98
+ + "Calling it while the sidekick is busy adds instructions to the running handoff.",
99
+ promptSnippet: "Delegate hands-on work to a persistent sidekick agent and get its report",
100
+ parameters: {
101
+ type: "object",
102
+ properties: {
103
+ message: { type: "string", description: "Brief for the sidekick: context, the task, and done criteria." },
104
+ block: { type: "boolean", description: "Wait for the report (default true). Use false to keep working while the sidekick runs." },
105
+ },
106
+ required: ["message"],
107
+ additionalProperties: false,
108
+ } as unknown as ToolDefinition["parameters"],
109
+ defaultActive: false,
110
+ executionMode: "sequential",
111
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
112
+ const fusion = fusionOf(provider, ctx.model);
113
+ if (!fusion) throw new Error("The sidekick is only available with the Devin Fusion model.");
114
+ const input = params as { message?: unknown; block?: unknown } | undefined;
115
+ const message = typeof input?.message === "string" ? input.message.trim() : "";
116
+ if (!message) throw new Error("A message for the sidekick is required.");
117
+ // The lead list hides exact UIDs covered by groups, so the level comes from the Fusion candidates.
118
+ const sidekickChoice = provider.getFusionChoices()?.sidekicks.find((item) => item.id === fusion.sidekickID);
119
+ if (!sidekickChoice) throw new Error("The sidekick model is not in the current Devin model list. Refresh the model list.");
120
+ const sessionId = ctx.sessionManager.getSessionId();
121
+ const launch: SidekickLaunch = {
122
+ cwd: ctx.cwd, modelID: fusion.sidekickID, thinking: sidekickChoice.level,
123
+ sessionFile: ctx.sessionManager.getSessionFile() ? join(options.agentDir(), "pi-devin", "sidekick", `${sessionId}.jsonl`) : undefined,
124
+ tools: pi.getActiveTools().filter((name) => name !== SIDEKICK_TOOL_NAME),
125
+ systemPrompt: SYSTEM_PROMPT,
126
+ };
127
+ let runtime = runtimes.get(sessionId);
128
+ // When the pair or tools change, only an idle process is restarted. Context continues through the same session file.
129
+ if (runtime && !runtime.isBusy() && launchKey(runtime.launch) !== launchKey(launch)) { runtime.close(); runtime = undefined; }
130
+ if (!runtime) {
131
+ runtime = new SidekickRuntime(launch, { ...options.runtime, childEnv: { [FUSION_CHILD_ENV]: "1" }, onProgress: () => progressListener?.() });
132
+ runtimes.set(sessionId, runtime);
133
+ }
134
+ const handoff = runtime.handoff(message);
135
+ const details = (status: SidekickDetails["status"], report?: HandoffReport): SidekickDetails => ({
136
+ sidekickModel: launch.modelID, handoffId: handoff.id, status, toolCalls: report?.toolCalls ?? runtime!.progress()?.toolCalls ?? 0,
137
+ ...(report ? { durationMs: report.durationMs } : {}), isSteer: handoff.isSteer,
138
+ });
139
+ const backgroundResult = (reason: string) => {
140
+ deliverLater(handoff, runtime!);
141
+ return { content: [{ type: "text" as const, text: `${reason} The report will arrive as a message. (handoff ${shortID(handoff.id)})` }], details: details("background") };
142
+ };
143
+ if (input?.block === false) {
144
+ return backgroundResult(handoff.isSteer ? "Added instructions to the running sidekick task." : "The sidekick started working in the background.");
145
+ }
146
+ let settle!: (outcome: "done" | "aborted" | "interrupted") => void;
147
+ const waiting = new Promise<"done" | "aborted" | "interrupted">((resolve) => { settle = resolve; });
148
+ const interrupt = () => settle("interrupted"), abort = () => settle("aborted");
149
+ waits.add(interrupt);
150
+ signal?.addEventListener("abort", abort, { once: true });
151
+ progressListener = () => {
152
+ const progress = runtime!.progress();
153
+ if (progress) onUpdate?.({ content: [{ type: "text", text: `sidekick: ${progress.toolCalls} tool calls${progress.recentTools.length ? ` · ${progress.recentTools.join(", ")}` : ""}` }], details: details("running") });
154
+ };
155
+ if (signal?.aborted) abort();
156
+ void handoff.done.then(() => settle("done"));
157
+ const outcome = await waiting.finally(() => {
158
+ progressListener = undefined;
159
+ waits.delete(interrupt);
160
+ signal?.removeEventListener("abort", abort);
161
+ });
162
+ if (outcome === "aborted") {
163
+ runtime.abort();
164
+ throw new Error("Cancelled the sidekick task.");
165
+ }
166
+ if (outcome === "interrupted") return backgroundResult("A user message arrived, so the sidekick task moved to the background.");
167
+ const report = await handoff.done;
168
+ const { text, fullReportPath } = await reportText(report);
169
+ if (report.status !== "completed") throw new Error(text);
170
+ return { content: [{ type: "text" as const, text }], details: { ...details(report.status, report), ...reportPath(fullReportPath) } };
171
+ },
172
+ };
173
+
174
+ return {
175
+ definition,
176
+ /** On user input, a waiting handoff moves to the background. Input sent by extensions does not count. */
177
+ onInput(source: string): void {
178
+ if (source === "extension") return;
179
+ for (const interrupt of [...waits]) interrupt();
180
+ },
181
+ /** Cleans up the sidekick of a different lead session. */
182
+ onSessionStart(sessionId: string): void {
183
+ for (const [id, runtime] of runtimes) if (id !== sessionId) { runtime.close(); runtimes.delete(id); }
184
+ },
185
+ reset(): void {
186
+ for (const runtime of runtimes.values()) runtime.close();
187
+ runtimes.clear(); scheduled.clear();
188
+ },
189
+ };
190
+ }
@@ -0,0 +1,124 @@
1
+ import { createAssistantMessageEventStream, type AssistantMessage, type Model, type ToolCall } from "@earendil-works/pi-ai";
2
+ import type { ChatAccumulator } from "../core/inference/chat.ts";
3
+ import type { ChatMessage, ChatResult, JsonObject } from "../core/types.ts";
4
+ import { attachReplayMetadata } from "./messages.ts";
5
+ import { DEVIN_API } from "./models.ts";
6
+
7
+ export class DevinResponseStream {
8
+ readonly stream = createAssistantMessageEventStream();
9
+ readonly message: AssistantMessage;
10
+ private active?: { type: "text" | "thinking"; index: number };
11
+ private tools = new Map<string, { index: number; characters: number; hasEnded: boolean }>();
12
+ private hasTerminated = false;
13
+ private usageModelUid?: string;
14
+ private hasUsageReported = false;
15
+ constructor(model: Model<typeof DEVIN_API>, requestedUID: string) {
16
+ this.message = {
17
+ role: "assistant", api: DEVIN_API, provider: "devin", model: model.id, providerThinkingLevel: requestedUID,
18
+ content: [], stopReason: "pending", timestamp: Date.now(),
19
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
20
+ };
21
+ this.stream.push({ type: "start", partial: this.message });
22
+ }
23
+ private closeText(): void {
24
+ if (!this.active) return;
25
+ const block = this.message.content[this.active.index];
26
+ if (block.type === "text") this.stream.push({ type: "text_end", contentIndex: this.active.index, content: block.text, partial: this.message });
27
+ else if (block.type === "thinking") this.stream.push({ type: "thinking_end", contentIndex: this.active.index, content: block.thinking, partial: this.message });
28
+ this.active = undefined;
29
+ }
30
+ private text(type: "text" | "thinking", delta: string, redacted = false): void {
31
+ if (!this.active || this.active.type !== type) {
32
+ this.closeText();
33
+ const index = this.message.content.length;
34
+ this.message.content.push(type === "text" ? { type, text: "" } : { type, thinking: "", ...(redacted ? { redacted: true } : {}) });
35
+ this.active = { type, index };
36
+ this.stream.push({ type: type === "text" ? "text_start" : "thinking_start", contentIndex: index, partial: this.message });
37
+ }
38
+ const block = this.message.content[this.active.index];
39
+ if (!delta) return;
40
+ if (block.type === "text") {
41
+ block.text += delta;
42
+ this.stream.push({ type: "text_delta", contentIndex: this.active.index, delta, partial: this.message });
43
+ } else if (block.type === "thinking") {
44
+ block.thinking += delta;
45
+ this.stream.push({ type: "thinking_delta", contentIndex: this.active.index, delta, partial: this.message });
46
+ }
47
+ }
48
+ accept(data: JsonObject, accumulator: ChatAccumulator): void {
49
+ if (this.hasTerminated) throw new Error("Cannot add a delta to a finished Pi response.");
50
+ if (accumulator.message.thinkingRedacted) {
51
+ for (const block of this.message.content) if (block.type === "thinking") { block.thinking = ""; block.redacted = true; }
52
+ if (!this.message.content.some((block) => block.type === "thinking")) this.text("thinking", "", true);
53
+ } else if (typeof data.deltaThinking === "string" && data.deltaThinking) this.text("thinking", data.deltaThinking);
54
+ if (typeof data.deltaText === "string" && data.deltaText) this.text("text", data.deltaText);
55
+ for (const call of accumulator.calls.values()) {
56
+ let state = this.tools.get(call.id);
57
+ if (!state) {
58
+ this.closeText();
59
+ const index = this.message.content.length;
60
+ this.message.content.push({ type: "toolCall", id: call.id, name: "", arguments: {} });
61
+ state = { index, characters: 0, hasEnded: false }; this.tools.set(call.id, state);
62
+ this.stream.push({ type: "toolcall_start", contentIndex: index, partial: this.message });
63
+ }
64
+ const block = this.message.content[state.index] as ToolCall;
65
+ block.name = call.name;
66
+ try { const value = JSON.parse(call.argumentsJson); if (value && typeof value === "object" && !Array.isArray(value)) block.arguments = value; } catch {}
67
+ const delta = call.argumentsJson.slice(state.characters);
68
+ state.characters = call.argumentsJson.length;
69
+ if (delta) this.stream.push({ type: "toolcall_delta", contentIndex: state.index, delta, partial: this.message });
70
+ }
71
+ if (accumulator.message.messageId) this.message.responseId = accumulator.message.messageId;
72
+ if (typeof data.actualModelUid === "string") this.message.responseModel = data.actualModelUid;
73
+ if (data.usage && typeof data.usage === "object") {
74
+ const usage = data.usage as JsonObject;
75
+ if (typeof usage.modelUid === "string") this.usageModelUid = usage.modelUid;
76
+ if (["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"].some((key) => usage[key] !== undefined)) this.hasUsageReported = true;
77
+ }
78
+ this.message.usage = { ...accumulator.usage, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
79
+ }
80
+ private closeContent(): void {
81
+ this.closeText();
82
+ for (const state of this.tools.values()) if (!state.hasEnded) {
83
+ state.hasEnded = true;
84
+ this.stream.push({ type: "toolcall_end", contentIndex: state.index, toolCall: this.message.content[state.index] as ToolCall, partial: this.message });
85
+ }
86
+ }
87
+ private provenance(native: ChatMessage, usageReported: boolean): void {
88
+ this.message.providerThinkingLevel = native.modelUid ?? this.message.providerThinkingLevel;
89
+ attachReplayMetadata(this.message, native);
90
+ this.message.diagnostics = [{ type: "devin_provenance", timestamp: Date.now(), details: {
91
+ requestedUID: this.message.providerThinkingLevel ?? "", ...(this.message.responseModel ? { responseUID: this.message.responseModel } : {}),
92
+ ...(this.usageModelUid ? { usageUID: this.usageModelUid } : {}), costsUnknown: true, usageReported,
93
+ } }];
94
+ }
95
+ redact(): void {
96
+ for (const block of this.message.content) {
97
+ if (block.type === "text") block.text = "";
98
+ else if (block.type === "thinking") { block.thinking = ""; block.redacted = true; }
99
+ else block.arguments = {};
100
+ }
101
+ this.closeContent(); this.message.content = []; this.tools.clear();
102
+ }
103
+ finish(result: ChatResult): void {
104
+ if (this.hasTerminated) return;
105
+ if (!this.message.content.length && (result.message.signature || result.message.geminiThoughtSignature || result.message.outputId)) this.text("text", "");
106
+ this.provenance(result.message, result.usageReported);
107
+ this.closeContent();
108
+ this.hasTerminated = true;
109
+ this.message.rawStopReason = result.stopReason;
110
+ this.message.stopReason = result.stopReason === "STOP_REASON_FUNCTION_CALL" ? "toolUse" : result.stopReason === "STOP_REASON_MAX_TOKENS" ? "length" : "stop";
111
+ this.stream.push({ type: "done", reason: this.message.stopReason, message: this.message });
112
+ this.stream.end(this.message);
113
+ }
114
+ fail(errorMessage: string, isAborted: boolean, native?: ChatMessage): void {
115
+ if (this.hasTerminated) return;
116
+ if (native) this.provenance(native, this.hasUsageReported);
117
+ this.closeContent();
118
+ this.hasTerminated = true;
119
+ this.message.stopReason = isAborted ? "aborted" : "error";
120
+ this.message.errorMessage = errorMessage;
121
+ this.stream.push({ type: "error", reason: this.message.stopReason, error: this.message });
122
+ this.stream.end(this.message);
123
+ }
124
+ }