@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.
Files changed (116) hide show
  1. package/README.ko.md +222 -0
  2. package/README.md +37 -41
  3. package/index.ts +6 -12
  4. package/package.json +4 -1
  5. package/src/core/auth/access.ts +7 -3
  6. package/src/core/auth/callback-server.ts +57 -0
  7. package/src/core/auth/index.ts +2 -1
  8. package/src/core/auth/login.ts +104 -151
  9. package/src/core/auth/pkce.ts +32 -0
  10. package/src/core/auth/rest-exchange.ts +32 -30
  11. package/src/core/cancellation.ts +99 -0
  12. package/src/core/client.ts +127 -122
  13. package/src/core/errors.ts +115 -0
  14. package/src/core/index.ts +18 -4
  15. package/src/core/inference/accumulator.ts +174 -0
  16. package/src/core/inference/images.ts +22 -15
  17. package/src/core/inference/request.ts +59 -0
  18. package/src/core/inference/retry.ts +31 -31
  19. package/src/core/inference/state.ts +7 -3
  20. package/src/core/inference/stream-chat.ts +48 -0
  21. package/src/core/models/access.ts +4 -3
  22. package/src/core/models/catalog.ts +29 -20
  23. package/src/core/models/select.ts +14 -12
  24. package/src/core/models/variants.ts +85 -72
  25. package/src/core/protocol/connect-client.ts +288 -0
  26. package/src/core/protocol/device.ts +33 -34
  27. package/src/core/protocol/endpoint.ts +22 -0
  28. package/src/core/protocol/fingerprint.ts +38 -26
  29. package/src/core/protocol/frames.ts +101 -0
  30. package/src/core/protocol/http/fetch-client.ts +42 -0
  31. package/src/core/protocol/http/node-client.ts +130 -0
  32. package/src/core/protocol/http/response.ts +99 -0
  33. package/src/core/protocol/http.ts +41 -101
  34. package/src/core/protocol/protobuf.ts +93 -74
  35. package/src/core/redaction.ts +33 -0
  36. package/src/core/services/device-identity.ts +15 -0
  37. package/src/core/services/http-transport.ts +28 -0
  38. package/src/core/services/platform.ts +54 -0
  39. package/src/core/types.ts +2 -23
  40. package/src/core/validation.ts +25 -0
  41. package/src/pi/adapters/pi/devin-command.ts +82 -0
  42. package/src/pi/adapters/pi/fusion-command.ts +92 -0
  43. package/src/pi/adapters/pi/oauth.ts +14 -0
  44. package/src/pi/adapters/pi/provider.ts +22 -0
  45. package/src/pi/adapters/pi/registration.ts +89 -0
  46. package/src/pi/adapters/pi/response-stream.ts +164 -0
  47. package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
  48. package/src/pi/adapters/pi/transcript.ts +177 -0
  49. package/src/pi/adapters/pi/types.ts +14 -0
  50. package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
  51. package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
  52. package/src/pi/adapters/pi/usage-status.ts +65 -0
  53. package/src/pi/adapters/storage/files.ts +66 -0
  54. package/src/pi/adapters/storage/json-file.ts +31 -0
  55. package/src/pi/app/config.ts +25 -0
  56. package/src/pi/app/environment.ts +30 -0
  57. package/src/pi/app/layer.ts +43 -0
  58. package/src/pi/app/runtime.ts +20 -0
  59. package/src/pi/app/session.ts +26 -0
  60. package/src/pi/features/accounts/service.ts +69 -0
  61. package/src/pi/features/accounts/types.ts +25 -0
  62. package/src/pi/features/auth/credential.ts +40 -0
  63. package/src/pi/features/auth/guard.ts +25 -0
  64. package/src/pi/features/auth/login.ts +116 -0
  65. package/src/pi/features/auth/state.ts +48 -0
  66. package/src/pi/features/auth/types.ts +36 -0
  67. package/src/pi/features/bounded.ts +4 -0
  68. package/src/pi/features/catalog/constants.ts +10 -0
  69. package/src/pi/features/catalog/fusion.ts +45 -0
  70. package/src/pi/features/catalog/ids.ts +79 -0
  71. package/src/pi/features/catalog/projection.ts +93 -0
  72. package/src/pi/features/catalog/remote.ts +15 -0
  73. package/src/pi/features/catalog/restore.ts +53 -0
  74. package/src/pi/features/catalog/service.ts +158 -0
  75. package/src/pi/features/catalog/types.ts +56 -0
  76. package/src/pi/features/clients/service.ts +47 -0
  77. package/src/pi/features/clients/types.ts +22 -0
  78. package/src/pi/features/default-scheduler.ts +12 -0
  79. package/src/pi/features/inference/request.ts +190 -0
  80. package/src/pi/features/inference/service.ts +33 -0
  81. package/src/pi/features/inference/types.ts +11 -0
  82. package/src/pi/features/lifetime/service.ts +19 -0
  83. package/src/pi/features/lifetime/types.ts +13 -0
  84. package/src/pi/features/runs/admission.ts +36 -0
  85. package/src/pi/features/runs/lifecycle.ts +207 -0
  86. package/src/pi/features/runs/service.ts +10 -0
  87. package/src/pi/features/runs/state.ts +37 -0
  88. package/src/pi/features/runs/types.ts +43 -0
  89. package/src/pi/features/settings/service.ts +46 -0
  90. package/src/pi/features/settings/types.ts +36 -0
  91. package/src/pi/features/sidekick/child.ts +228 -0
  92. package/src/pi/features/sidekick/command.ts +80 -0
  93. package/src/pi/features/sidekick/launcher.ts +12 -0
  94. package/src/pi/features/sidekick/prompts.ts +17 -0
  95. package/src/pi/features/sidekick/report.ts +51 -0
  96. package/src/pi/features/sidekick/service.ts +176 -0
  97. package/src/pi/features/sidekick/types.ts +99 -0
  98. package/src/pi/features/usage/changes.ts +20 -0
  99. package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
  100. package/src/pi/features/usage/types.ts +33 -0
  101. package/src/core/inference/chat.ts +0 -234
  102. package/src/core/protocol/transport.ts +0 -513
  103. package/src/pi/auth.ts +0 -145
  104. package/src/pi/devin-command.ts +0 -76
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -80
  107. package/src/pi/fusion-command.ts +0 -83
  108. package/src/pi/fusion-settings.ts +0 -44
  109. package/src/pi/lifecycle.ts +0 -217
  110. package/src/pi/messages.ts +0 -131
  111. package/src/pi/models.ts +0 -379
  112. package/src/pi/provider.ts +0 -249
  113. package/src/pi/sidekick-report.ts +0 -47
  114. package/src/pi/sidekick-runtime.ts +0 -300
  115. package/src/pi/sidekick.ts +0 -211
  116. package/src/pi/stream.ts +0 -124
@@ -1,76 +0,0 @@
1
- import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
- import { DevinPanel, PREFERENCE_ITEMS } from "./devin-panel.ts";
3
- import type { DevinPreferences, DevinSettings } from "./devin-settings.ts";
4
- import type { DevinProvider } from "./provider.ts";
5
- import { sessionUsage, usageReport, usageStatusText } from "./usage.ts";
6
-
7
- export const DEVIN_COMMAND_NAME = "devin";
8
- /** The only status key this package uses. */
9
- export const USAGE_STATUS_KEY = "devin-usage";
10
- const USAGE = "Usage: /devin, /devin recommended on|off, /devin status on|off";
11
- const ARGUMENT_KEYS: Readonly<Record<string, keyof DevinPreferences>> = { recommended: "recommendedOnly", status: "showUsageStatus" };
12
-
13
- /** The status is used in the TUI only. Turning the toggle off clears only this package's key. Values come only from the last account status and session records. */
14
- export function updateUsageStatus(ctx: ExtensionContext, provider: DevinProvider, settings: DevinSettings): void {
15
- if (ctx.mode !== "tui") return;
16
- const text = settings.read().showUsageStatus
17
- ? usageStatusText(provider.getAccountUsage(), sessionUsage(ctx.sessionManager.getBranch()), (tone, segment) => ctx.ui.theme.fg(tone, segment)) : undefined;
18
- ctx.ui.setStatus(USAGE_STATUS_KEY, text);
19
- }
20
-
21
- const onOff = (value: boolean) => value ? "on" : "off";
22
-
23
- /**
24
- * `/devin` shows usage details and display settings. Settings are saved globally and applied immediately.
25
- * The TUI gets a dedicated screen; other modes use arguments and notifications.
26
- */
27
- export function registerDevinCommand(pi: ExtensionAPI, provider: DevinProvider, settings: DevinSettings, options: { now?: () => number } = {}): void {
28
- const now = options.now ?? Date.now;
29
- const apply = async (ctx: ExtensionCommandContext, next: DevinPreferences) => {
30
- const previous = settings.read();
31
- // Only write failures are thrown to the caller. A list refresh failure after saving is reported as a warning so the screen and the saved value stay in sync.
32
- settings.write(next);
33
- if (previous.recommendedOnly !== next.recommendedOnly) {
34
- provider.rebuildModels();
35
- // Pi keeps model and availability snapshots. Rebuild them without network so `/model` and RPC lists update immediately.
36
- try {
37
- await ctx.modelRegistry.refresh({ providers: ["devin"], allowNetwork: false });
38
- } catch (error) {
39
- ctx.ui.notify(`Settings were saved, but the model list could not be rebuilt: ${error instanceof Error ? error.message : String(error)}`, "warning");
40
- }
41
- const model = ctx.model;
42
- if (model?.provider === "devin" && !provider.getModels().some((item) => item.id === model.id)) {
43
- ctx.ui.notify(`The current model ${model.name} is now hidden from the list but stays in use for this session. To restore it when reopening the session, pick a model from the list.`, "warning");
44
- }
45
- }
46
- updateUsageStatus(ctx, provider, settings);
47
- };
48
- pi.registerCommand(DEVIN_COMMAND_NAME, {
49
- description: "Show Devin usage and change display settings (recommended models only, usage in the status line); saved globally",
50
- handler: async (args, ctx) => {
51
- const report = () => usageReport(provider.getAccountUsage(), sessionUsage(ctx.sessionManager.getBranch()), provider.lifecycle.budget(), now());
52
- const tokens = args.trim().split(/\s+/).filter(Boolean);
53
- if (tokens.length) {
54
- const key = ARGUMENT_KEYS[tokens[0]];
55
- if (tokens.length !== 2 || !key || (tokens[1] !== "on" && tokens[1] !== "off")) {
56
- ctx.ui.notify(`Unknown argument. ${USAGE}`, "error");
57
- return;
58
- }
59
- const value = tokens[1] === "on";
60
- await apply(ctx, { ...settings.read(), [key]: value });
61
- ctx.ui.notify(`${PREFERENCE_ITEMS.find((item) => item.key === key)!.label}: ${onOff(value)} (saved globally)`, "info");
62
- return;
63
- }
64
- if (ctx.mode === "tui") {
65
- await ctx.ui.custom<void>((tui, theme, _keybindings, done) => new DevinPanel({
66
- theme, report, preferences: settings.read(), onChange: (next) => apply(ctx, next),
67
- requestRender: () => tui.requestRender(), done: () => done(),
68
- }));
69
- return;
70
- }
71
- const preferences = settings.read();
72
- const summary = PREFERENCE_ITEMS.map((item) => `${item.label} ${onOff(preferences[item.key])}`).join(" · ");
73
- ctx.ui.notify([...report().map((line) => line.text), `Settings: ${summary}`, USAGE].join("\n"), "info");
74
- },
75
- });
76
- }
@@ -1,50 +0,0 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
-
4
- const SCHEMA_VERSION = 1;
5
-
6
- /** Display settings toggled by `/devin`. Both default to off and never change how models are used or requested. */
7
- export interface DevinPreferences {
8
- /** Lists only the models that have a variant the catalog marks as recommended. */
9
- recommendedOnly: boolean;
10
- /** Shows quota and session usage in the TUI status line. */
11
- showUsageStatus: boolean;
12
- }
13
-
14
- export const DEFAULT_PREFERENCES: DevinPreferences = { recommendedOnly: false, showUsageStatus: false };
15
-
16
- export interface DevinSettings {
17
- read(): DevinPreferences;
18
- write(preferences: DevinPreferences): void;
19
- }
20
-
21
- export function devinSettingsPath(agentDir: string): string {
22
- return join(agentDir, "pi-devin", "settings.json");
23
- }
24
-
25
- /** Default store so SDK callers and tests never write to the real agent directory. */
26
- export function memoryDevinSettings(initial: Partial<DevinPreferences> = {}): DevinSettings {
27
- let current: DevinPreferences = { ...DEFAULT_PREFERENCES, ...initial };
28
- return { read: () => ({ ...current }), write: (preferences) => { current = { ...preferences }; } };
29
- }
30
-
31
- /** An unreadable or malformed file reads as the defaults and is not overwritten. Writes are replaced atomically within the same directory. */
32
- export function fileDevinSettings(path: string): DevinSettings {
33
- return {
34
- read() {
35
- try {
36
- const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
37
- if (value.schemaVersion === SCHEMA_VERSION && typeof value.recommendedOnly === "boolean" && typeof value.showUsageStatus === "boolean") {
38
- return { recommendedOnly: value.recommendedOnly, showUsageStatus: value.showUsageStatus };
39
- }
40
- } catch { /* A missing or corrupt file falls back to the defaults. */ }
41
- return { ...DEFAULT_PREFERENCES };
42
- },
43
- write(preferences) {
44
- mkdirSync(dirname(path), { recursive: true });
45
- const temporary = `${path}.${process.pid}.tmp`;
46
- writeFileSync(temporary, JSON.stringify({ schemaVersion: SCHEMA_VERSION, ...preferences }, null, 2) + "\n");
47
- renameSync(temporary, path);
48
- },
49
- };
50
- }
@@ -1,80 +0,0 @@
1
- import type { Model } from "@earendil-works/pi-ai";
2
- import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
- import { createDevinProvider, type DevinProvider } from "./provider.ts";
4
- import { DEVIN_STATE_ENTRY } from "./lifecycle.ts";
5
- import { registerDevinCommand, updateUsageStatus } from "./devin-command.ts";
6
- import { memoryDevinSettings, type DevinSettings } from "./devin-settings.ts";
7
- import { registerFusionCommand } from "./fusion-command.ts";
8
- import { memoryFusionSettings, type FusionSettings } from "./fusion-settings.ts";
9
- import { createSidekickTool, fusionOf, SIDEKICK_TOOL_NAME, type SidekickToolOptions } from "./sidekick.ts";
10
-
11
- type ProviderOptions = NonNullable<Parameters<typeof createDevinProvider>[0]>;
12
- export interface DevinExtensionOptions extends Omit<ProviderOptions, "fusion" | "listsAllExactModels" | "recommendedOnly"> {
13
- /** Store for the `/devin-fusion` choice. Kept in memory only when omitted. */
14
- fusionSettings?: FusionSettings;
15
- /** Store for the `/devin` display settings. Kept in memory only when omitted. */
16
- devinSettings?: DevinSettings;
17
- /** The child Pi started as the sidekick registers no Fusion model, tool or command, and no `/devin` or status line. */
18
- isFusionChild?: boolean;
19
- /** Overrides the sidekick session location and how the child starts. The default location is the Pi agent directory. */
20
- sidekick?: Partial<SidekickToolOptions>;
21
- }
22
-
23
- export function registerDevinExtension(pi: ExtensionAPI, options: DevinExtensionOptions = {}): DevinProvider {
24
- const { fusionSettings, devinSettings, isFusionChild, sidekick: sidekickOptions, ...providerOptions } = options;
25
- const settings = fusionSettings ?? memoryFusionSettings();
26
- const preferences = isFusionChild ? undefined : devinSettings ?? memoryDevinSettings();
27
- // The child Pi runs on the exact UID the lead chose, so it lists every exact UID and ignores the recommended filter.
28
- const provider = createDevinProvider({
29
- ...providerOptions, fusion: isFusionChild ? false : () => settings.read(), listsAllExactModels: isFusionChild === true,
30
- recommendedOnly: preferences ? () => preferences.read().recommendedOnly : undefined,
31
- });
32
- pi.registerProvider(provider);
33
- if (preferences) registerDevinCommand(pi, provider, preferences);
34
- const refreshStatus = (context: Parameters<typeof updateUsageStatus>[0]) => { if (preferences) updateUsageStatus(context, provider, preferences); };
35
- const sidekick = isFusionChild ? undefined
36
- : createSidekickTool(pi, provider, { agentDir: sidekickOptions?.agentDir ?? getAgentDir, runtime: sidekickOptions?.runtime });
37
- if (sidekick) {
38
- pi.registerTool(sidekick.definition);
39
- registerFusionCommand(pi, provider, settings);
40
- }
41
- // Enables the sidekick only for the Fusion model and leaves other extensions' and the user's active tools alone.
42
- const syncSidekick = (model: Model<any> | undefined) => {
43
- if (!sidekick) return;
44
- const active = pi.getActiveTools(), isActive = active.includes(SIDEKICK_TOOL_NAME);
45
- const shouldBeActive = fusionOf(provider, model) !== undefined;
46
- if (shouldBeActive && !isActive) pi.setActiveTools([...active, SIDEKICK_TOOL_NAME]);
47
- else if (!shouldBeActive && isActive) pi.setActiveTools(active.filter((name) => name !== SIDEKICK_TOOL_NAME));
48
- };
49
- pi.on("model_select", (event) => { syncSidekick(event.model); });
50
- pi.on("input", (event) => { sidekick?.onInput(event.source); });
51
- pi.on("session_start", (_event, context) => {
52
- if (context.modelRegistry.getRegisteredNativeProvider("devin") !== provider) {
53
- provider.shutdown();
54
- throw new Error("The Devin provider ID collides with another registration. Remove the duplicate extension and reload.");
55
- }
56
- provider.attachSession({
57
- getSessionId: () => context.sessionManager.getSessionId(),
58
- getBranch: () => context.sessionManager.getBranch(),
59
- append: (data) => pi.appendEntry(DEVIN_STATE_ENTRY, data),
60
- });
61
- sidekick?.onSessionStart(context.sessionManager.getSessionId());
62
- syncSidekick(context.model);
63
- refreshStatus(context);
64
- });
65
- pi.on("message_end", (event, context) => {
66
- if (event.message.role === "assistant" && event.message.provider === "devin") refreshStatus(context);
67
- });
68
- pi.on("before_agent_start", () => { provider.lifecycle.begin("agent"); });
69
- pi.on("agent_start", () => { provider.lifecycle.agentStarted(); });
70
- // Host retry or overflow recovery can continue after agent_end.
71
- pi.on("agent_settled", (_event, context) => { provider.lifecycle.finish(); refreshStatus(context); });
72
- pi.on("session_before_compact", (event) => { provider.lifecycle.beforeCompaction(event.reason); });
73
- pi.on("session_compact", (event) => { provider.lifecycle.afterCompaction(event.reason); });
74
- pi.on("session_compact_failed", (event) => { provider.lifecycle.afterCompaction(event.reason); });
75
- pi.on("session_before_tree", (event) => { if (event.preparation.userWantsSummary) provider.lifecycle.beforeTree(); });
76
- pi.on("session_tree", (_event, context) => { provider.lifecycle.treeChanged(); refreshStatus(context); });
77
- pi.on("cache_warming_decision", (_event, context) => context.model?.provider === "devin" ? { action: "stop" } : undefined);
78
- pi.on("session_shutdown", () => { sidekick?.reset(); provider.shutdown(); });
79
- return provider;
80
- }
@@ -1,83 +0,0 @@
1
- import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
- import { FUSION_MODEL_ID, type DevinModel, type FusionChoices, type FusionSelection } from "./models.ts";
3
- import { FusionPicker } from "./fusion-picker.ts";
4
- import type { FusionSettings } from "./fusion-settings.ts";
5
- import type { DevinProvider } from "./provider.ts";
6
-
7
- export const FUSION_COMMAND_NAME = "devin-fusion";
8
- const USAGE = `Usage: /${FUSION_COMMAND_NAME} or /${FUSION_COMMAND_NAME} <lead model ID> <sidekick model ID>`;
9
-
10
- function currentFusion(provider: DevinProvider): DevinModel | undefined {
11
- return (provider.getModels() as readonly DevinModel[]).find((model) => model.id === FUSION_MODEL_ID);
12
- }
13
-
14
- /** Disambiguates duplicate display names in a select list with an index, and maps the chosen label back to its value. */
15
- async function choose<T>(ctx: ExtensionCommandContext, title: string, items: readonly T[], label: (item: T) => string, isCurrent: (item: T) => boolean) {
16
- const names = items.map(label);
17
- const labels = items.map((item, index) => {
18
- const name = names.filter((other) => other === names[index]).length > 1 ? `${names[index]} (${index + 1})` : names[index];
19
- return (isCurrent(item) ? "● " : " ") + name;
20
- });
21
- const picked = await ctx.ui.select(title, labels);
22
- return picked === undefined ? undefined : items[labels.indexOf(picked)];
23
- }
24
-
25
- async function pickInteractively(ctx: ExtensionCommandContext, choices: FusionChoices, current: FusionSelection | undefined): Promise<FusionSelection | undefined> {
26
- const lead = await choose(ctx, "Devin Fusion · lead model", choices.leads, (item) => item.name, (item) => item.id === current?.leadID);
27
- if (!lead) return undefined;
28
- const groups = [...new Set(choices.sidekicks.map((item) => item.groupName))];
29
- const currentGroup = choices.sidekicks.find((item) => item.id === current?.sidekickID)?.groupName;
30
- const group = groups.length === 1 ? groups[0] : await choose(ctx, "Devin Fusion · sidekick model", groups, (item) => item, (item) => item === currentGroup);
31
- if (group === undefined) return undefined;
32
- const variants = choices.sidekicks.filter((item) => item.groupName === group);
33
- const sidekick = variants.length === 1 ? variants[0]
34
- : await choose(ctx, `Devin Fusion · sidekick variant (${group})`, variants, (item) => item.name, (item) => item.id === current?.sidekickID);
35
- return sidekick ? { leadID: lead.id, sidekickID: sidekick.id } : undefined;
36
- }
37
-
38
- /**
39
- * `/devin-fusion` only chooses the lead/sidekick pair. Whether Fusion is used is decided by picking Devin Fusion in `/model`.
40
- * If the current model is Fusion, it is re-selected with the updated model object so the thinking level range matches.
41
- */
42
- export function registerFusionCommand(pi: ExtensionAPI, provider: DevinProvider, settings: FusionSettings): void {
43
- pi.registerCommand(FUSION_COMMAND_NAME, {
44
- description: "Choose the lead and sidekick models for Devin Fusion (saved globally)",
45
- handler: async (args, ctx) => {
46
- const choices = provider.getFusionChoices();
47
- if (!choices?.leads.length || !choices.sidekicks.length) {
48
- ctx.ui.notify("No Devin models are available for Fusion. Check that the model list refreshed after /login.", "warning");
49
- return;
50
- }
51
- const before = currentFusion(provider);
52
- const tokens = args.trim().split(/\s+/).filter(Boolean);
53
- let selection: FusionSelection | undefined;
54
- if (tokens.length) {
55
- const [leadID, sidekickID] = tokens;
56
- if (tokens.length !== 2 || !choices.leads.some((item) => item.id === leadID) || !choices.sidekicks.some((item) => item.id === sidekickID)) {
57
- ctx.ui.notify(`This model cannot be used for Fusion. ${USAGE}`, "error");
58
- return;
59
- }
60
- selection = { leadID, sidekickID };
61
- } else if (ctx.mode === "tui") {
62
- selection = await ctx.ui.custom<FusionSelection | undefined>((tui, theme, _keybindings, done) => new FusionPicker({
63
- choices, current: before?.devin.fusion, theme, requestRender: () => tui.requestRender(), done,
64
- }));
65
- if (!selection) return;
66
- } else if (ctx.hasUI) {
67
- // RPC clients cannot draw the custom screen, so they get the select dialogs.
68
- selection = await pickInteractively(ctx, choices, before?.devin.fusion);
69
- if (!selection) return;
70
- } else {
71
- ctx.ui.notify(before ? `Current: ${before.name}` : `No Devin Fusion pair is set. ${USAGE}`, "info");
72
- return;
73
- }
74
- settings.write(selection);
75
- provider.rebuildModels();
76
- // Pi keeps model and availability snapshots. Rebuild them without network so `/model` and RPC lists update immediately.
77
- await ctx.modelRegistry.refresh({ providers: ["devin"], allowNetwork: false });
78
- const after = currentFusion(provider);
79
- if (after && ctx.model?.provider === "devin" && ctx.model.id === FUSION_MODEL_ID) await pi.setModel(after);
80
- ctx.ui.notify(after ? `Saved ${after.name}. Pick Devin Fusion in /model to use it.` : "The Fusion pair was saved, but it cannot be built from the current model list.", "info");
81
- },
82
- });
83
- }
@@ -1,44 +0,0 @@
1
- import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
3
- import type { FusionSelection } from "./models.ts";
4
-
5
- const SCHEMA_VERSION = 1;
6
-
7
- /** Global store for the lead and sidekick chosen with `/devin-fusion`. Every session uses the same value. */
8
- export interface FusionSettings {
9
- read(): FusionSelection | undefined;
10
- write(selection: FusionSelection): void;
11
- }
12
-
13
- export function fusionSettingsPath(agentDir: string): string {
14
- return join(agentDir, "pi-devin", "fusion.json");
15
- }
16
-
17
- /** Default store so SDK callers that register the extension directly, and tests, never write to the real agent directory. */
18
- export function memoryFusionSettings(initial?: FusionSelection): FusionSettings {
19
- let current = initial;
20
- return { read: () => current, write: (selection) => { current = { ...selection }; } };
21
- }
22
-
23
- /** An unreadable or malformed file reads as no saved value and is not overwritten. Writes are replaced atomically within the same directory. */
24
- export function fileFusionSettings(path: string): FusionSettings {
25
- return {
26
- read() {
27
- try {
28
- const value = JSON.parse(readFileSync(path, "utf8")) as Record<string, unknown>;
29
- // Legacy IDs (`family:…`, `uid:…`) are read as is. `effectiveFusion` maps them to the current catalog's IDs.
30
- if (value.schemaVersion === SCHEMA_VERSION && typeof value.leadID === "string" && value.leadID.trim() !== ""
31
- && typeof value.sidekickID === "string" && value.sidekickID.trim() !== "") {
32
- return { leadID: value.leadID, sidekickID: value.sidekickID };
33
- }
34
- } catch { /* A missing or corrupt file falls back to the default pair. */ }
35
- return undefined;
36
- },
37
- write(selection) {
38
- mkdirSync(dirname(path), { recursive: true });
39
- const temporary = `${path}.${process.pid}.tmp`;
40
- writeFileSync(temporary, JSON.stringify({ schemaVersion: SCHEMA_VERSION, leadID: selection.leadID, sidekickID: selection.sidekickID }, null, 2) + "\n");
41
- renameSync(temporary, path);
42
- },
43
- };
44
- }
@@ -1,217 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { awaitWithSignal } from "../core/protocol/transport.ts";
3
-
4
- export const DEVIN_STATE_ENTRY = "pi-devin.state.v1";
5
- export type ExecutionKind = "agent" | "pending" | "manual" | "tree" | "direct" | "recovered";
6
- export interface DevinSessionState {
7
- version: 1;
8
- sessionId: string;
9
- ownerHash?: string;
10
- cascadeId: string;
11
- trajectoryId: string;
12
- nextStepIndex: number;
13
- execution?: { id: string; used: number; settled: boolean; kind: ExecutionKind };
14
- }
15
- export interface SessionBinding {
16
- getSessionId(): string;
17
- getBranch(): readonly { type: string; customType?: string; data?: unknown }[];
18
- append(data: DevinSessionState): void;
19
- }
20
- interface Execution {
21
- data: NonNullable<DevinSessionState["execution"]>;
22
- controller: AbortController;
23
- tail: Promise<void>;
24
- cleanups: Set<() => void>;
25
- activeRequests: number;
26
- hasStarted: boolean;
27
- nextIsRecovery: boolean;
28
- hasCheckpoint: boolean;
29
- }
30
- export interface RequestLease {
31
- signal: AbortSignal;
32
- executionId: string;
33
- cascadeId: string;
34
- trajectoryId: string;
35
- sessionId: string;
36
- stepIndex: number;
37
- isAuxiliary: boolean;
38
- isRecovery: boolean;
39
- consume(): void;
40
- addCleanup(cleanup: () => void): void;
41
- release(): void;
42
- }
43
- export class ModelBudgetError extends Error {
44
- constructor(limit: number) { super(`Used all ${limit} Devin model HTTP requests in the budget.`); this.name = "ModelBudgetError"; }
45
- }
46
-
47
- /** Value of `PI_DEVIN_MAX_MODEL_CALLS`. Defaults to 8; anything other than an integer from 1 to 100 stops the extension from loading. */
48
- export function modelCallLimit(value: string | undefined): number | undefined {
49
- if (value === undefined || value === "") return undefined;
50
- if (!/^\d+$/.test(value) || Number(value) < 1 || Number(value) > 100) throw new Error("PI_DEVIN_MAX_MODEL_CALLS must be an integer from 1 to 100.");
51
- return Number(value);
52
- }
53
- const cancelled = () => new DOMException("The previous Devin run ended.", "AbortError");
54
- const isUUID = (value: unknown): value is string => typeof value === "string" && /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
55
-
56
- function restoreState(binding: SessionBinding): DevinSessionState {
57
- const sessionId = binding.getSessionId();
58
- const entry = [...binding.getBranch()].reverse().find((entry) => entry.type === "custom" && entry.customType === DEVIN_STATE_ENTRY);
59
- if (!entry) return { version: 1, sessionId, cascadeId: randomUUID(), trajectoryId: randomUUID(), nextStepIndex: 0 };
60
- const value = entry.data as DevinSessionState | undefined;
61
- if (!value || value.version !== 1 || typeof value.sessionId !== "string" || !isUUID(value.cascadeId) || !isUUID(value.trajectoryId)
62
- || !Number.isSafeInteger(value.nextStepIndex) || value.nextStepIndex < 0 || value.nextStepIndex > 2_147_483_647
63
- || (value.ownerHash !== undefined && !/^[a-f0-9]{64}$/.test(value.ownerHash))
64
- || (value.execution !== undefined && (!isUUID(value.execution.id) || !Number.isSafeInteger(value.execution.used)
65
- || value.execution.used < 0 || value.execution.used > 100 || typeof value.execution.settled !== "boolean"))) {
66
- throw new Error("The Devin session state is corrupt or an unsupported version. Start a new session.");
67
- }
68
- if (value.sessionId !== sessionId) return { version: 1, sessionId, cascadeId: randomUUID(), trajectoryId: randomUUID(), nextStepIndex: 0 };
69
- return structuredClone(value);
70
- }
71
-
72
- /** One instance owns one Pi runtime. It never builds its own tool or retry loop. */
73
- export class DevinLifecycle {
74
- private binding?: SessionBinding;
75
- private state: DevinSessionState = { version: 1, sessionId: randomUUID(), cascadeId: randomUUID(), trajectoryId: randomUUID(), nextStepIndex: 0 };
76
- private execution?: Execution;
77
- private isClosed = false;
78
- private isAuxiliary = false;
79
- readonly maxModelCalls: number;
80
- constructor(maxModelCalls = 8) {
81
- if (!Number.isSafeInteger(maxModelCalls) || maxModelCalls < 1 || maxModelCalls > 100) throw new Error("The Devin request budget must be between 1 and 100.");
82
- this.maxModelCalls = maxModelCalls;
83
- }
84
- snapshot(): DevinSessionState { return structuredClone(this.state); }
85
- /** Model requests of the running or last finished run. used is 0 when this session has no run yet. */
86
- budget(): { used: number; limit: number; isActive: boolean } {
87
- const execution = this.state.execution;
88
- return { used: execution?.used ?? 0, limit: this.maxModelCalls, isActive: execution !== undefined && !execution.settled };
89
- }
90
- private checkpoint(): void {
91
- if (this.binding && this.binding.getSessionId() !== this.state.sessionId) throw cancelled();
92
- this.binding?.append(structuredClone(this.state));
93
- }
94
- private activate(data: NonNullable<DevinSessionState["execution"]>, hasCheckpoint = false): Execution {
95
- this.state.execution = data;
96
- return this.execution = { data, controller: new AbortController(), tail: Promise.resolve(), cleanups: new Set(), activeRequests: 0, hasStarted: false, nextIsRecovery: false, hasCheckpoint };
97
- }
98
- attach(binding: SessionBinding): void {
99
- this.abort(cancelled());
100
- this.binding = binding;
101
- this.state = restoreState(binding);
102
- this.execution = undefined; this.isClosed = false; this.isAuxiliary = false;
103
- if (this.state.execution && !this.state.execution.settled) {
104
- this.activate({ ...this.state.execution, kind: "recovered" }, true);
105
- }
106
- }
107
- begin(kind: ExecutionKind): void {
108
- if (this.isClosed) throw cancelled();
109
- if (this.execution && !this.execution.data.settled) {
110
- if (kind === "agent" && ["pending", "recovered"].includes(this.execution.data.kind)) this.execution.data.kind = "agent";
111
- else if (kind === "agent" && ["manual", "tree", "direct"].includes(this.execution.data.kind) && this.execution.activeRequests === 0) this.finish();
112
- else return; // Retries and tool follow-up calls do not get a new budget.
113
- if (this.execution) return;
114
- }
115
- this.activate({ id: randomUUID(), used: 0, settled: false, kind });
116
- }
117
- agentStarted(): void {
118
- if (!this.execution) this.begin("agent");
119
- this.execution!.nextIsRecovery = this.execution!.hasStarted;
120
- this.execution!.hasStarted = true;
121
- }
122
- beforeCompaction(reason: "manual" | "threshold" | "overflow"): void {
123
- this.begin(reason === "manual" ? "manual" : "pending");
124
- this.isAuxiliary = true;
125
- }
126
- afterCompaction(reason: "manual" | "threshold" | "overflow"): void {
127
- this.isAuxiliary = false;
128
- if (reason === "manual" && this.execution?.data.kind === "manual") this.finish();
129
- }
130
- beforeTree(): void { this.begin("tree"); this.isAuxiliary = true; }
131
- finish(): void {
132
- if (!this.execution) return;
133
- this.execution.data.settled = true;
134
- this.abort(cancelled());
135
- if (this.execution.hasCheckpoint) this.checkpoint();
136
- this.execution = undefined; this.isAuxiliary = false;
137
- }
138
- abort(reason: unknown): void {
139
- const active = this.execution;
140
- if (!active) return;
141
- if (!active.controller.signal.aborted) active.controller.abort(reason);
142
- for (const cleanup of active.cleanups) { try { cleanup(); } catch { /* A cleanup error must not skip releasing other resources. */ } }
143
- }
144
- shutdown(): void {
145
- if (this.isClosed) return;
146
- this.abort(cancelled());
147
- // An unfinished budget stays in the checkpoint so a reload cannot reset the limit.
148
- this.isClosed = true;
149
- }
150
- treeChanged(): void {
151
- if (!this.binding) return;
152
- this.abort(cancelled());
153
- this.state = restoreState(this.binding);
154
- this.state.trajectoryId = randomUUID(); this.state.nextStepIndex = 0;
155
- this.state.execution = undefined; this.execution = undefined; this.isAuxiliary = false;
156
- if (this.state.ownerHash) this.checkpoint();
157
- }
158
- async acquire(ownerHash: string, callerSignal?: AbortSignal): Promise<RequestLease> {
159
- if (this.isClosed) throw cancelled();
160
- callerSignal?.throwIfAborted();
161
- if (!/^[a-f0-9]{64}$/.test(ownerHash)) throw new Error("A verified Devin account scope is required.");
162
- if (this.state.ownerHash && this.state.ownerHash !== ownerHash) throw new Error("This Pi session is bound to a different Devin account or endpoint. Start a new session.");
163
- this.state.ownerHash = ownerHash;
164
- if (!this.execution) this.begin("direct");
165
- const active = this.execution!, state = this.state;
166
- const signal = AbortSignal.any([active.controller.signal, ...(callerSignal ? [callerSignal] : [])]);
167
- const previous = active.tail;
168
- let releaseQueue!: () => void;
169
- const completed = new Promise<void>((resolve) => { releaseQueue = resolve; });
170
- active.tail = previous.then(() => completed);
171
- let hasReleased = false, hasAcquired = false;
172
- const ownCleanups = new Set<() => void>();
173
- const clean = () => { for (const cleanup of ownCleanups) { try { cleanup(); } catch {} } };
174
- const assertCurrent = () => {
175
- signal.throwIfAborted();
176
- if (this.execution !== active || this.state !== state || this.isClosed || (this.binding && this.binding.getSessionId() !== state.sessionId)) throw cancelled();
177
- };
178
- const release = () => {
179
- if (hasReleased) return;
180
- hasReleased = true;
181
- signal.removeEventListener("abort", clean);
182
- for (const cleanup of ownCleanups) { active.cleanups.delete(cleanup); try { cleanup(); } catch {} }
183
- if (hasAcquired) active.activeRequests--;
184
- releaseQueue();
185
- };
186
- try {
187
- await awaitWithSignal(previous, signal);
188
- assertCurrent();
189
- if (active.data.used >= this.maxModelCalls) throw new ModelBudgetError(this.maxModelCalls);
190
- if (state.nextStepIndex >= 2_147_483_647) throw new Error("The Devin step number limit was reached. Start a new session.");
191
- active.activeRequests++; hasAcquired = true;
192
- const stepIndex = state.nextStepIndex++;
193
- active.hasCheckpoint = true;
194
- this.checkpoint();
195
- signal.addEventListener("abort", clean, { once: true });
196
- const isAuxiliary = this.isAuxiliary;
197
- const isRecovery = !isAuxiliary && active.nextIsRecovery;
198
- if (!isAuxiliary) active.nextIsRecovery = false;
199
- return {
200
- signal, executionId: active.data.id, cascadeId: state.cascadeId, trajectoryId: state.trajectoryId,
201
- sessionId: state.sessionId, stepIndex,
202
- isAuxiliary, isRecovery,
203
- consume: () => {
204
- assertCurrent();
205
- if (active.data.used >= this.maxModelCalls) { const error = new ModelBudgetError(this.maxModelCalls); this.abort(error); throw error; }
206
- active.data.used++; this.checkpoint();
207
- },
208
- addCleanup: (cleanup) => {
209
- let hasCleaned = false;
210
- const once = () => { if (!hasCleaned) { hasCleaned = true; cleanup(); } };
211
- ownCleanups.add(once); active.cleanups.add(once); if (signal.aborted) once();
212
- },
213
- release,
214
- };
215
- } catch (error) { release(); throw error; }
216
- }
217
- }