@buyong/pi-devin 0.1.4 → 0.1.6
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 +222 -0
- package/README.md +37 -41
- package/index.ts +6 -12
- package/package.json +4 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -145
- package/src/pi/devin-command.ts +0 -76
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -80
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -379
- package/src/pi/provider.ts +0 -249
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { toDevinError, type DevinError } from "../../../core/errors.ts";
|
|
6
|
+
import type { SidekickCommand, SidekickLaunch } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
/** 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. */
|
|
11
|
+
export const PACKAGE_ROOT = dirname(fileURLToPath(new URL("../../../../package.json", import.meta.url)));
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
function piPackageRoot(start: string): string | undefined {
|
|
15
|
+
let directory = dirname(start);
|
|
16
|
+
while (directory !== dirname(directory)) {
|
|
17
|
+
const manifest = join(directory, "package.json");
|
|
18
|
+
if (existsSync(manifest)) {
|
|
19
|
+
try { if ((JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }).name === PI_PACKAGE) return directory; } catch { /* try the next parent */ }
|
|
20
|
+
}
|
|
21
|
+
directory = dirname(directory);
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXTENSION_FLAGS = new Set(["-e", "--extension"]);
|
|
27
|
+
const EXTENSION_SWITCHES = new Set(["--no-extensions", "-ne", "--no-mcp"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 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.
|
|
31
|
+
* Local paths become absolute paths from the lead's working directory. Source specifiers such as `builtin:` and `npm:` stay as they are.
|
|
32
|
+
*/
|
|
33
|
+
export function extensionArgs(argv: readonly string[], cwd: string): string[] {
|
|
34
|
+
const forwarded: string[] = [];
|
|
35
|
+
for (let index = 0; index < argv.length; index++) {
|
|
36
|
+
const value = argv[index];
|
|
37
|
+
if (EXTENSION_SWITCHES.has(value)) forwarded.push(value);
|
|
38
|
+
else if (EXTENSION_FLAGS.has(value) && index + 1 < argv.length) {
|
|
39
|
+
const target = argv[++index];
|
|
40
|
+
forwarded.push("-e", /^[a-z][a-z0-9+.-]*:/i.test(target) || isAbsolute(target) ? target : resolve(cwd, target));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return forwarded;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findPiCommand(): SidekickCommand {
|
|
47
|
+
const candidates: { path: string; isRunningCLI: boolean }[] = [];
|
|
48
|
+
try { if (process.argv[1]) candidates.push({ path: realpathSync(process.argv[1]), isRunningCLI: true }); } catch { /* missing path */ }
|
|
49
|
+
try { candidates.push({ path: fileURLToPath(import.meta.resolve(PI_PACKAGE)), isRunningCLI: false }); } catch { /* cannot resolve */ }
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
const root = piPackageRoot(candidate.path);
|
|
52
|
+
if (!root) continue;
|
|
53
|
+
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { bin?: string | Record<string, string> };
|
|
54
|
+
const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi;
|
|
55
|
+
if (bin && existsSync(join(root, bin))) {
|
|
56
|
+
const execArgv = process.execArgv.filter((value) => !/^--(inspect|debug)/.test(value));
|
|
57
|
+
const forwarded = candidate.isRunningCLI ? extensionArgs(process.argv.slice(2), process.cwd()) : [];
|
|
58
|
+
return { command: process.execPath, args: [...execArgv, join(root, bin), ...forwarded] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw new Error("Could not find a Pi CLI to run the sidekick.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 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;
|
|
66
|
+
* when Pi is embedded through the SDK, the installed Pi package's bin is used. Inspect options are not passed, to avoid port conflicts.
|
|
67
|
+
*/
|
|
68
|
+
export const resolvePiCommand: Effect.Effect<SidekickCommand, DevinError> = Effect.try({ try: findPiCommand, catch: toDevinError });
|
|
69
|
+
|
|
70
|
+
export function launchArgs(launch: SidekickLaunch, packageRoot: string): string[] {
|
|
71
|
+
return [
|
|
72
|
+
"--mode", "rpc",
|
|
73
|
+
...(launch.sessionFile ? ["--session", launch.sessionFile] : ["--no-session"]),
|
|
74
|
+
"--model", `devin/${launch.modelID}`,
|
|
75
|
+
...(launch.thinking ? ["--thinking", launch.thinking] : []),
|
|
76
|
+
"--append-system-prompt", launch.systemPrompt,
|
|
77
|
+
"-e", packageRoot,
|
|
78
|
+
...(launch.tools.length ? ["--tools", launch.tools.join(",")] : ["--no-tools"]),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Context, Layer } from "effect";
|
|
3
|
+
import { PACKAGE_ROOT, resolvePiCommand } from "./command.ts";
|
|
4
|
+
import type { SidekickLauncherShape } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** How sidekick child Pi processes start. */
|
|
7
|
+
export class SidekickLauncher extends Context.Service<SidekickLauncher, SidekickLauncherShape>()("pi-devin/SidekickLauncher") {}
|
|
8
|
+
|
|
9
|
+
/** The same Node and Pi CLI as the lead, this process's environment and this package's own copy. */
|
|
10
|
+
export const NodeSidekickLauncher = Layer.sync(SidekickLauncher, () => ({
|
|
11
|
+
resolveCommand: resolvePiCommand, spawn, env: process.env, packageRoot: PACKAGE_ROOT,
|
|
12
|
+
} satisfies SidekickLauncherShape));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const SIDEKICK_TOOL_NAME = "devin_sidekick";
|
|
2
|
+
/** Marker set on the child Pi started as the sidekick. The child's extension does not register Fusion again. */
|
|
3
|
+
export const FUSION_CHILD_ENV = "PI_DEVIN_FUSION_CHILD";
|
|
4
|
+
/** Custom message type that delivers a background handoff report to the lead. */
|
|
5
|
+
export const SIDEKICK_REPORT_MESSAGE = "devin-sidekick-report";
|
|
6
|
+
/** Appended by the provider to Fusion lead requests only, so a custom system prompt cannot drop it. */
|
|
7
|
+
export const FUSION_LEAD_GUIDANCE = [
|
|
8
|
+
`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.`,
|
|
9
|
+
`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.`,
|
|
10
|
+
"Review its report critically, decide the next step, and do not redo work it already reported. Present the combined result as your own work.",
|
|
11
|
+
].join("\n");
|
|
12
|
+
export const SIDEKICK_SYSTEM_PROMPT = [
|
|
13
|
+
"You are the sidekick: a hands-on software engineer paired with a lead agent in the same workspace.",
|
|
14
|
+
"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.",
|
|
15
|
+
"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.",
|
|
16
|
+
"Your conversation persists across briefs from the same lead session, so build on what you already know.",
|
|
17
|
+
].join("\n");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Effect, FileSystem } from "effect";
|
|
3
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
4
|
+
import { fromPlatformError } from "../../../core/services/platform.ts";
|
|
5
|
+
import type { BoundedReport } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/** Upper bound on the sidekick report the lead receives. The Devin CLI analysis records no sidekick report limit, so it is fixed here. */
|
|
8
|
+
export const SIDEKICK_REPORT_TOKEN_LIMIT = 131_072;
|
|
9
|
+
/** Pi's context estimate (`estimateTokens`) counts 4 characters per token, so the limit uses the same unit. */
|
|
10
|
+
const CHARS_PER_TOKEN = 4;
|
|
11
|
+
export const SIDEKICK_REPORT_CHAR_LIMIT = SIDEKICK_REPORT_TOKEN_LIMIT * CHARS_PER_TOKEN;
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
|
|
15
|
+
|
|
16
|
+
/** Keeps the beginning of the report within `maxChars` without splitting a surrogate pair. */
|
|
17
|
+
export function truncateReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): { text: string; isTruncated: boolean } {
|
|
18
|
+
if (text.length <= maxChars) return { text, isTruncated: false };
|
|
19
|
+
const end = isHighSurrogate(text.charCodeAt(maxChars - 1)) ? maxChars - 1 : maxChars;
|
|
20
|
+
return { text: text.slice(0, end), isTruncated: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Writes the complete report to a new private temporary directory and returns the file path. */
|
|
24
|
+
function preserveReport(text: string): Effect.Effect<string, DevinError, FileSystem.FileSystem> {
|
|
25
|
+
return Effect.gen(function* () {
|
|
26
|
+
const fs = yield* FileSystem.FileSystem;
|
|
27
|
+
const directory = yield* fs.makeTempDirectory({ prefix: "pi-devin-sidekick-report-" });
|
|
28
|
+
const path = join(directory, "report.md");
|
|
29
|
+
yield* fs.writeFileString(path, text, { mode: 0o600 });
|
|
30
|
+
return path;
|
|
31
|
+
}).pipe(Effect.mapError(fromPlatformError));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bounds the report before it reaches the lead and keeps the complete text in a private temporary file.
|
|
36
|
+
* Never fails: background reports are delivered from a detached fiber, so a failed write only changes the notice.
|
|
37
|
+
*/
|
|
38
|
+
export function boundReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): Effect.Effect<BoundedReport, never, FileSystem.FileSystem> {
|
|
39
|
+
return Effect.gen(function* () {
|
|
40
|
+
const result = truncateReport(text, maxChars);
|
|
41
|
+
if (!result.isTruncated) return { text };
|
|
42
|
+
const saved = yield* Effect.result(preserveReport(text));
|
|
43
|
+
const fullReportPath = saved._tag === "Success" ? saved.success : undefined;
|
|
44
|
+
const notice = `\n\n[The sidekick report exceeded about ${SIDEKICK_REPORT_TOKEN_LIMIT} tokens and was cut.` + (saved._tag === "Success"
|
|
45
|
+
? ` Full report: ${saved.success}]`
|
|
46
|
+
: ` The full report could not be saved: ${saved.failure.message}]`);
|
|
47
|
+
// The notice is part of the same budget, including a filesystem error if preservation failed.
|
|
48
|
+
const suffix = truncateReport(notice, maxChars).text;
|
|
49
|
+
return { text: truncateReport(text, maxChars - suffix.length).text + suffix, ...(fullReportPath ? { fullReportPath } : {}) };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Context, Deferred, Effect, Exit, FiberSet, FileSystem, Layer, Predicate } from "effect";
|
|
3
|
+
import { toDevinFailure, type DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
import { failWith } from "../../../core/validation.ts";
|
|
5
|
+
import { ExtensionConfig } from "../../app/config.ts";
|
|
6
|
+
import { fusionOf } from "../catalog/fusion.ts";
|
|
7
|
+
import { ModelCatalog } from "../catalog/service.ts";
|
|
8
|
+
import { onDefaultScheduler } from "../default-scheduler.ts";
|
|
9
|
+
import { SidekickChild } from "./child.ts";
|
|
10
|
+
import { SidekickLauncher } from "./launcher.ts";
|
|
11
|
+
import { FUSION_CHILD_ENV, SIDEKICK_SYSTEM_PROMPT, SIDEKICK_TOOL_NAME } from "./prompts.ts";
|
|
12
|
+
import { boundReport } from "./report.ts";
|
|
13
|
+
import type { BoundedReport, Handoff, HandoffReport, HandoffResult, SidekickDetails, SidekickHostShape, SidekickLaunch, SidekicksShape } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** What the sidekick needs from the lead Pi. Provided by the Pi adapter. */
|
|
16
|
+
export class SidekickHost extends Context.Service<SidekickHost, SidekickHostShape>()("pi-devin/SidekickHost") {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The Fusion sidekick: one child Pi per lead session, started on the first handoff and kept for the rest of the session.
|
|
20
|
+
* Owns the children, which call owns each report, and the fibers that deliver background reports.
|
|
21
|
+
*/
|
|
22
|
+
export class Sidekicks extends Context.Service<Sidekicks, SidekicksShape>()("pi-devin/Sidekicks") {}
|
|
23
|
+
|
|
24
|
+
const launchKey = (launch: SidekickLaunch) => JSON.stringify([launch.cwd, launch.modelID, launch.thinking, launch.sessionFile, launch.tools]);
|
|
25
|
+
const shortID = (id: string) => id.slice(0, 8);
|
|
26
|
+
const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fullReportPath } : {};
|
|
27
|
+
const nextMicrotask = Effect.promise(() => Promise.resolve());
|
|
28
|
+
/** Stops before work starts when the call was already cancelled, as `signal.throwIfAborted()` does. */
|
|
29
|
+
const checkAborted = (signal: AbortSignal | undefined): Effect.Effect<void, DevinFailure> => signal?.aborted ? Effect.fail(toDevinFailure(signal.reason)) : Effect.void;
|
|
30
|
+
|
|
31
|
+
interface ReportDelivery {
|
|
32
|
+
owner: "background" | "blocking" | "delivered";
|
|
33
|
+
scheduled: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type WaitOutcome = "done" | "aborted" | "interrupted";
|
|
37
|
+
|
|
38
|
+
export const SidekicksLive = Layer.effect(Sidekicks, Effect.gen(function* () {
|
|
39
|
+
const host = yield* SidekickHost;
|
|
40
|
+
const launcher = yield* SidekickLauncher;
|
|
41
|
+
const catalog = yield* ModelCatalog;
|
|
42
|
+
const config = yield* ExtensionConfig;
|
|
43
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
44
|
+
const fibers = yield* FiberSet.make<void, never>();
|
|
45
|
+
const runInFibers = yield* FiberSet.runtime(fibers)<never>();
|
|
46
|
+
const children = new Map<string, SidekickChild>();
|
|
47
|
+
// Steers share the same report; completed handoffs must not be retained by delivery bookkeeping.
|
|
48
|
+
const deliveries = new WeakMap<Deferred.Deferred<HandoffReport>, ReportDelivery>();
|
|
49
|
+
const waits = new Set<() => void>();
|
|
50
|
+
// The tool runs sequentially, so at most one blocking call shows progress at a time.
|
|
51
|
+
let progressListener: (() => void) | undefined;
|
|
52
|
+
|
|
53
|
+
/** Bound and preserve the complete report, including status, errors and any background delivery heading. */
|
|
54
|
+
const reportText = (report: HandoffReport, heading = ""): Effect.Effect<BoundedReport> => {
|
|
55
|
+
const body = report.text.trim();
|
|
56
|
+
const reason = report.error ?? (report.status === "aborted" ? "cancelled." : "failed.");
|
|
57
|
+
const text = report.status === "completed" ? body || "(The sidekick finished without a report.)"
|
|
58
|
+
: [`The sidekick task ${report.status === "aborted" ? "stopped" : "failed"}: ${reason}`, body].filter(Boolean).join("\n\n");
|
|
59
|
+
return boundReport(heading + text).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const deliverLater = (handoff: Handoff, child: SidekickChild, delivery: ReportDelivery) => Effect.suspend(() => {
|
|
63
|
+
if (delivery.scheduled || delivery.owner === "delivered") return Effect.void;
|
|
64
|
+
delivery.scheduled = true;
|
|
65
|
+
// A blocking call owns the report unless user input moves it back to the background.
|
|
66
|
+
const canDeliver = () => delivery.owner === "background" && [...children.values()].includes(child);
|
|
67
|
+
return Effect.asVoid(FiberSet.run(fibers, Effect.gen(function* () {
|
|
68
|
+
const report = yield* Deferred.await(handoff.done);
|
|
69
|
+
if (report.status === "aborted" || !canDeliver()) return;
|
|
70
|
+
const { text, fullReportPath } = yield* reportText(report, `Sidekick report for background handoff ${shortID(report.id)}:\n\n`);
|
|
71
|
+
if (!canDeliver()) return;
|
|
72
|
+
delivery.owner = "delivered";
|
|
73
|
+
yield* host.deliverReport({
|
|
74
|
+
content: text,
|
|
75
|
+
details: { sidekickModel: child.launch.modelID, handoffId: report.id, status: report.status, toolCalls: report.toolCalls, durationMs: report.durationMs, isSteer: false,
|
|
76
|
+
...reportPath(fullReportPath) },
|
|
77
|
+
});
|
|
78
|
+
}).pipe(Effect.ensuring(Effect.sync(() => { delivery.scheduled = false; }))), { startImmediately: true }));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const closeAll = (entries: readonly [string, SidekickChild][]) => Effect.forEach(entries, ([id, child]) => Effect.andThen(child.close, Effect.sync(() => {
|
|
82
|
+
children.delete(id);
|
|
83
|
+
})), { discard: true });
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
handoff: (request) => Effect.gen(function* () {
|
|
87
|
+
const { signal } = request;
|
|
88
|
+
yield* checkAborted(signal);
|
|
89
|
+
const fusion = fusionOf(yield* catalog.models, request.model);
|
|
90
|
+
if (!fusion) return yield* failWith("The sidekick is only available with the Devin Fusion model.");
|
|
91
|
+
const input = Predicate.isObject(request.params) ? request.params as { message?: unknown; block?: unknown } : undefined;
|
|
92
|
+
const message = Predicate.isString(input?.message) ? input.message.trim() : "";
|
|
93
|
+
if (!message) return yield* failWith("A message for the sidekick is required.");
|
|
94
|
+
// The lead list hides exact UIDs covered by groups, so the level comes from the Fusion candidates.
|
|
95
|
+
const sidekickChoice = (yield* catalog.fusionChoices)?.sidekicks.find((item) => item.id === fusion.sidekickID);
|
|
96
|
+
if (!sidekickChoice) return yield* failWith("The sidekick model is not in the current Devin model list. Refresh the model list.");
|
|
97
|
+
const { sessionId } = request;
|
|
98
|
+
const launch: SidekickLaunch = {
|
|
99
|
+
cwd: request.cwd, modelID: fusion.sidekickID, thinking: sidekickChoice.level,
|
|
100
|
+
sessionFile: request.isSessionSaved ? join(config.agentDir(), "pi-devin", "sidekick", `${sessionId}.jsonl`) : undefined,
|
|
101
|
+
tools: (yield* host.activeTools).filter((name) => name !== SIDEKICK_TOOL_NAME),
|
|
102
|
+
systemPrompt: SIDEKICK_SYSTEM_PROMPT,
|
|
103
|
+
};
|
|
104
|
+
let child = children.get(sessionId);
|
|
105
|
+
// When the pair or tools change, only an idle process is restarted. Context continues through the same session file.
|
|
106
|
+
if (child && !child.isBusy() && launchKey(child.launch) !== launchKey(launch)) { yield* child.close; child = undefined; }
|
|
107
|
+
if (!child) {
|
|
108
|
+
child = new SidekickChild(launch, {
|
|
109
|
+
launcher, childEnv: { [FUSION_CHILD_ENV]: "1" }, onProgress: () => progressListener?.(),
|
|
110
|
+
fork: (effect) => { runInFibers(onDefaultScheduler(effect)); },
|
|
111
|
+
});
|
|
112
|
+
children.set(sessionId, child);
|
|
113
|
+
}
|
|
114
|
+
const active = child;
|
|
115
|
+
yield* checkAborted(signal);
|
|
116
|
+
const handoff = yield* active.handoff(message);
|
|
117
|
+
let delivery = deliveries.get(handoff.done);
|
|
118
|
+
if (!delivery) {
|
|
119
|
+
delivery = { owner: "background", scheduled: false };
|
|
120
|
+
deliveries.set(handoff.done, delivery);
|
|
121
|
+
}
|
|
122
|
+
const owned = delivery;
|
|
123
|
+
const details = (status: SidekickDetails["status"], report?: HandoffReport): SidekickDetails => ({
|
|
124
|
+
sidekickModel: launch.modelID, handoffId: handoff.id, status, toolCalls: report?.toolCalls ?? active.progress()?.toolCalls ?? 0,
|
|
125
|
+
...(report ? { durationMs: report.durationMs } : {}), isSteer: handoff.isSteer,
|
|
126
|
+
});
|
|
127
|
+
const backgroundResult = (reason: string) => Effect.as(deliverLater(handoff, active, owned), {
|
|
128
|
+
content: [{ type: "text" as const, text: `${reason} The report will arrive as a message. (handoff ${shortID(handoff.id)})` }], details: details("background"),
|
|
129
|
+
} satisfies HandoffResult);
|
|
130
|
+
if (input?.block === false) {
|
|
131
|
+
return yield* backgroundResult(handoff.isSteer ? "Added instructions to the running sidekick task." : "The sidekick started working in the background.");
|
|
132
|
+
}
|
|
133
|
+
owned.owner = "blocking";
|
|
134
|
+
// The first event decides the outcome. The report is observed one microtask after it settles, as a promise continuation
|
|
135
|
+
// would, so user input in the same tick still moves the handoff to the background.
|
|
136
|
+
const decided = Deferred.makeUnsafe<WaitOutcome>();
|
|
137
|
+
const decide = (outcome: WaitOutcome) => () => { Deferred.doneUnsafe(decided, Exit.succeed(outcome)); };
|
|
138
|
+
const interrupt = decide("interrupted"), abort = decide("aborted");
|
|
139
|
+
const listen = Effect.gen(function* () {
|
|
140
|
+
waits.add(interrupt);
|
|
141
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
142
|
+
progressListener = () => {
|
|
143
|
+
const progress = active.progress();
|
|
144
|
+
if (progress) request.onProgress?.(`sidekick: ${progress.toolCalls} tool calls${progress.recentTools.length ? ` · ${progress.recentTools.join(", ")}` : ""}`, details("running"));
|
|
145
|
+
};
|
|
146
|
+
if (signal?.aborted) abort();
|
|
147
|
+
yield* FiberSet.run(fibers, Deferred.await(handoff.done).pipe(Effect.andThen(nextMicrotask), Effect.andThen(Effect.sync(decide("done")))), { startImmediately: true });
|
|
148
|
+
});
|
|
149
|
+
const stopListening = Effect.sync(() => {
|
|
150
|
+
progressListener = undefined;
|
|
151
|
+
waits.delete(interrupt);
|
|
152
|
+
signal?.removeEventListener("abort", abort);
|
|
153
|
+
});
|
|
154
|
+
const outcome = yield* Effect.acquireUseRelease(listen, () => Deferred.await(decided), () => stopListening);
|
|
155
|
+
if (outcome === "interrupted") {
|
|
156
|
+
owned.owner = "background";
|
|
157
|
+
return yield* backgroundResult("A user message arrived, so the sidekick task moved to the background.");
|
|
158
|
+
}
|
|
159
|
+
owned.owner = "delivered";
|
|
160
|
+
if (outcome === "aborted") {
|
|
161
|
+
yield* active.abort;
|
|
162
|
+
return yield* failWith("Cancelled the sidekick task.");
|
|
163
|
+
}
|
|
164
|
+
const report = yield* Deferred.await(handoff.done);
|
|
165
|
+
const { text, fullReportPath } = yield* reportText(report);
|
|
166
|
+
if (report.status !== "completed") return yield* failWith(text);
|
|
167
|
+
return { content: [{ type: "text" as const, text }], details: { ...details(report.status, report), ...reportPath(fullReportPath) } } satisfies HandoffResult;
|
|
168
|
+
}),
|
|
169
|
+
userInput: (source) => Effect.sync(() => {
|
|
170
|
+
if (source === "extension") return;
|
|
171
|
+
for (const interrupt of [...waits]) interrupt();
|
|
172
|
+
}),
|
|
173
|
+
sessionStarted: (sessionId) => Effect.suspend(() => closeAll([...children].filter(([id]) => id !== sessionId))),
|
|
174
|
+
reset: Effect.suspend(() => closeAll([...children])),
|
|
175
|
+
} satisfies SidekicksShape;
|
|
176
|
+
}));
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import type { spawn } from "node:child_process";
|
|
2
|
+
import type { Deferred, Effect } from "effect";
|
|
3
|
+
import type { DevinError, DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
|
|
5
|
+
export interface SidekickCommand { command: string; args: string[] }
|
|
6
|
+
|
|
7
|
+
/** Launch settings of the sidekick child Pi. When they change, the process restarts before the next handoff. */
|
|
8
|
+
export interface SidekickLaunch {
|
|
9
|
+
cwd: string;
|
|
10
|
+
/** Devin model ID of the exact variant (its catalog UID). */
|
|
11
|
+
modelID: string;
|
|
12
|
+
thinking?: string;
|
|
13
|
+
/** Without it, the child starts with `--no-session`. */
|
|
14
|
+
sessionFile?: string;
|
|
15
|
+
tools: readonly string[];
|
|
16
|
+
systemPrompt: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HandoffProgress { toolCalls: number; recentTools: string[]; elapsedMs: number }
|
|
20
|
+
export interface HandoffReport {
|
|
21
|
+
id: string;
|
|
22
|
+
status: "completed" | "aborted" | "error";
|
|
23
|
+
text: string;
|
|
24
|
+
toolCalls: number;
|
|
25
|
+
durationMs: number;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
/** A handoff and its single report. A steer shares the running handoff's report. */
|
|
29
|
+
export interface Handoff { id: string; done: Deferred.Deferred<HandoffReport>; isSteer: boolean }
|
|
30
|
+
|
|
31
|
+
export interface BoundedReport {
|
|
32
|
+
text: string;
|
|
33
|
+
/** Private temporary file with the complete report, set only when the report was cut and the file was written. */
|
|
34
|
+
fullReportPath?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface SidekickDetails {
|
|
38
|
+
sidekickModel: string;
|
|
39
|
+
handoffId: string;
|
|
40
|
+
status: HandoffReport["status"] | "running" | "background";
|
|
41
|
+
toolCalls: number;
|
|
42
|
+
durationMs?: number;
|
|
43
|
+
isSteer: boolean;
|
|
44
|
+
/** Complete report saved when the text the lead received was cut. */
|
|
45
|
+
fullReportPath?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** How a sidekick child Pi is started. */
|
|
49
|
+
export interface SidekickLauncherShape {
|
|
50
|
+
/** Command that runs Pi. The default is the same Node and Pi CLI as the lead. */
|
|
51
|
+
readonly resolveCommand: Effect.Effect<SidekickCommand, DevinError>;
|
|
52
|
+
/**
|
|
53
|
+
* Starts the child. Node's spawner is used directly rather than Effect's: the child stays in the lead's process group,
|
|
54
|
+
* so terminal signals reach it, and a broken stdin is noticed while writing.
|
|
55
|
+
*/
|
|
56
|
+
readonly spawn: typeof spawn;
|
|
57
|
+
readonly env: NodeJS.ProcessEnv;
|
|
58
|
+
/** Passed to the child with `-e`, so it loads the same extension copy as the lead. */
|
|
59
|
+
readonly packageRoot: string;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** What the sidekick needs from the lead Pi. */
|
|
63
|
+
export interface SidekickHostShape {
|
|
64
|
+
/** The lead's active tools. The child gets them without the sidekick tool itself. */
|
|
65
|
+
readonly activeTools: Effect.Effect<readonly string[]>;
|
|
66
|
+
/** Delivers a background report to the lead as a follow-up message that starts a turn. */
|
|
67
|
+
readonly deliverReport: (report: { content: string; details: SidekickDetails }) => Effect.Effect<void>;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** One `devin_sidekick` call. */
|
|
71
|
+
export interface HandoffRequest {
|
|
72
|
+
/** Tool parameters as Pi passed them. */
|
|
73
|
+
params: unknown;
|
|
74
|
+
/** The lead's model. Only the Devin Fusion model may hand off. */
|
|
75
|
+
model: { provider?: string; api?: string; id: string } | undefined;
|
|
76
|
+
cwd: string;
|
|
77
|
+
sessionId: string;
|
|
78
|
+
/** The lead session is saved to a file; the child then keeps its own session file. */
|
|
79
|
+
isSessionSaved: boolean;
|
|
80
|
+
signal: AbortSignal | undefined;
|
|
81
|
+
/** Progress while a blocking call waits for the report. */
|
|
82
|
+
onProgress?: (text: string, details: SidekickDetails) => void;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface HandoffResult { content: { type: "text"; text: string }[]; details: SidekickDetails }
|
|
86
|
+
|
|
87
|
+
export interface SidekicksShape {
|
|
88
|
+
/**
|
|
89
|
+
* Starts or steers the session's handoff. By default waits for the report; with `block: false`, or when user input arrives
|
|
90
|
+
* while waiting, the report moves to the background and is delivered once.
|
|
91
|
+
*/
|
|
92
|
+
readonly handoff: (request: HandoffRequest) => Effect.Effect<HandoffResult, DevinFailure>;
|
|
93
|
+
/** User input moves a waiting handoff to the background. Input sent by extensions does not count. */
|
|
94
|
+
readonly userInput: (source: string) => Effect.Effect<void>;
|
|
95
|
+
/** Stops the sidekicks of other lead sessions. */
|
|
96
|
+
readonly sessionStarted: (sessionId: string) => Effect.Effect<void>;
|
|
97
|
+
/** Stops every sidekick. */
|
|
98
|
+
readonly reset: Effect.Effect<void>;
|
|
99
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { UsageChangesShape } from "./types.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Change notices for displays such as the status line. Owners of account records and the current credential publish;
|
|
6
|
+
* listeners run synchronously inside the publishing effect, so a redraw sees the state that caused it.
|
|
7
|
+
*/
|
|
8
|
+
export class UsageChanges extends Context.Service<UsageChanges, UsageChangesShape>()("pi-devin/UsageChanges") {}
|
|
9
|
+
|
|
10
|
+
export const UsageChangesLive = Layer.sync(UsageChanges, () => {
|
|
11
|
+
const listeners = new Set<{ readonly listener: Effect.Effect<void> }>();
|
|
12
|
+
return {
|
|
13
|
+
// A display failure must not affect requests or refreshes.
|
|
14
|
+
publish: Effect.suspend(() => Effect.forEach([...listeners], (entry) => Effect.catchCause(entry.listener, () => Effect.void), { discard: true })),
|
|
15
|
+
subscribe: (listener) => Effect.asVoid(Effect.acquireRelease(
|
|
16
|
+
Effect.sync(() => { const entry = { listener }; listeners.add(entry); return entry; }),
|
|
17
|
+
(entry) => Effect.sync(() => { listeners.delete(entry); }),
|
|
18
|
+
)),
|
|
19
|
+
} satisfies UsageChangesShape;
|
|
20
|
+
});
|
|
@@ -1,31 +1,6 @@
|
|
|
1
|
-
import type { JsonObject } from "
|
|
1
|
+
import type { JsonObject } from "../../../core/types.ts";
|
|
2
|
+
import type { AccountUsage, SessionUsage, UsageLine, UsagePainter, UsageTone } from "./types.ts";
|
|
2
3
|
|
|
3
|
-
/** Quota from the account status (`GetUserStatus`). Values may have changed since it was fetched. */
|
|
4
|
-
export interface AccountUsage {
|
|
5
|
-
billingStrategy?: string;
|
|
6
|
-
dailyRemainingPercent?: number;
|
|
7
|
-
weeklyRemainingPercent?: number;
|
|
8
|
-
dailyResetAtMs?: number;
|
|
9
|
-
weeklyResetAtMs?: number;
|
|
10
|
-
fetchedAtMs: number;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/** Totals of Devin assistant responses recorded in the current session branch. Sidekick child conversations are separate sessions and are not included. */
|
|
14
|
-
export interface SessionUsage {
|
|
15
|
-
requests: number;
|
|
16
|
-
input: number;
|
|
17
|
-
output: number;
|
|
18
|
-
cacheRead: number;
|
|
19
|
-
cacheWrite: number;
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
export interface BudgetUsage { used: number; limit: number; isActive: boolean }
|
|
23
|
-
|
|
24
|
-
/** Color level by remaining amount. Matches the Pi theme color names `success`, `warning` and `error`. */
|
|
25
|
-
export type UsageTone = "success" | "warning" | "error";
|
|
26
|
-
export interface UsageLine { text: string; isHeading?: boolean; tone?: UsageTone }
|
|
27
|
-
/** Colors a segment. The default returns it uncolored. */
|
|
28
|
-
export type UsagePainter = (tone: UsageTone, text: string) => string;
|
|
29
4
|
const plain: UsagePainter = (_tone, text) => text;
|
|
30
5
|
|
|
31
6
|
const QUOTA_STRATEGY = "BILLING_STRATEGY_QUOTA";
|
|
@@ -87,10 +62,6 @@ export function remainingTone(percent: number): UsageTone {
|
|
|
87
62
|
return percent <= 20 ? "error" : percent <= 40 ? "warning" : "success";
|
|
88
63
|
}
|
|
89
64
|
|
|
90
|
-
function budgetTone(used: number, limit: number): UsageTone {
|
|
91
|
-
return used >= limit ? "error" : used / limit >= 0.75 ? "warning" : "success";
|
|
92
|
-
}
|
|
93
|
-
|
|
94
65
|
function hasQuota(account: AccountUsage | undefined): account is AccountUsage & { dailyRemainingPercent: number; weeklyRemainingPercent: number } {
|
|
95
66
|
return account?.billingStrategy === QUOTA_STRATEGY && account.dailyRemainingPercent !== undefined && account.weeklyRemainingPercent !== undefined;
|
|
96
67
|
}
|
|
@@ -127,7 +98,7 @@ export function relativeTime(targetMs: number, nowMs: number): string {
|
|
|
127
98
|
}
|
|
128
99
|
|
|
129
100
|
/** Details shown by `/devin`. Built only from the last account status and session records; sends no requests. */
|
|
130
|
-
export function usageReport(account: AccountUsage | undefined, session: SessionUsage,
|
|
101
|
+
export function usageReport(account: AccountUsage | undefined, session: SessionUsage, nowMs: number): UsageLine[] {
|
|
131
102
|
const lines: UsageLine[] = [];
|
|
132
103
|
if (!account) {
|
|
133
104
|
lines.push({ text: "Account quota · not received yet", isHeading: true });
|
|
@@ -147,7 +118,5 @@ export function usageReport(account: AccountUsage | undefined, session: SessionU
|
|
|
147
118
|
lines.push({ text: "This session · lead conversation (sidekick excluded)", isHeading: true });
|
|
148
119
|
lines.push({ text: ` ${session.requests} ${session.requests === 1 ? "request" : "requests"} · input ${formatTokens(session.input)} · output ${formatTokens(session.output)}`
|
|
149
120
|
+ ` · cache read ${formatTokens(session.cacheRead)} · cache write ${formatTokens(session.cacheWrite)}` });
|
|
150
|
-
lines.push({ text: `Run budget · ${budget.isActive ? "in progress" : "last run"}`, isHeading: true });
|
|
151
|
-
lines.push({ text: ` ${progressBar((budget.used / budget.limit) * 100)} ${budget.used}/${budget.limit} model calls used`, tone: budgetTone(budget.used, budget.limit) });
|
|
152
121
|
return lines;
|
|
153
122
|
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Effect, Scope } from "effect";
|
|
2
|
+
|
|
3
|
+
/** Quota from the account status (`GetUserStatus`). Values may have changed since it was fetched. */
|
|
4
|
+
export interface AccountUsage {
|
|
5
|
+
billingStrategy?: string;
|
|
6
|
+
dailyRemainingPercent?: number;
|
|
7
|
+
weeklyRemainingPercent?: number;
|
|
8
|
+
dailyResetAtMs?: number;
|
|
9
|
+
weeklyResetAtMs?: number;
|
|
10
|
+
fetchedAtMs: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Totals of Devin assistant responses recorded in the current session branch. Sidekick child conversations are separate sessions and are not included. */
|
|
14
|
+
export interface SessionUsage {
|
|
15
|
+
requests: number;
|
|
16
|
+
input: number;
|
|
17
|
+
output: number;
|
|
18
|
+
cacheRead: number;
|
|
19
|
+
cacheWrite: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Color level by remaining amount. Matches the Pi theme color names `success`, `warning` and `error`. */
|
|
23
|
+
export type UsageTone = "success" | "warning" | "error";
|
|
24
|
+
export interface UsageLine { text: string; isHeading?: boolean; tone?: UsageTone }
|
|
25
|
+
/** Colors a segment. The default returns it uncolored. */
|
|
26
|
+
export type UsagePainter = (tone: UsageTone, text: string) => string;
|
|
27
|
+
|
|
28
|
+
export interface UsageChangesShape {
|
|
29
|
+
/** Tells every listener, in order, that the quota or the current credential may have changed. Listener failures never reach the caller. */
|
|
30
|
+
readonly publish: Effect.Effect<void>;
|
|
31
|
+
/** Listens until the scope closes. */
|
|
32
|
+
readonly subscribe: (listener: Effect.Effect<void>) => Effect.Effect<void, never, Scope.Scope>;
|
|
33
|
+
}
|