@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
@@ -0,0 +1,28 @@
1
+ import { Context, Effect, Layer, type Scope } from "effect";
2
+ import type { HttpClient } from "effect/http";
3
+ import { makeFetchHttpClient } from "../protocol/http/fetch-client.ts";
4
+ import { makeNodeHttpClient, type HTTPTransportOptions } from "../protocol/http/node-client.ts";
5
+
6
+ export interface HttpTransportShape {
7
+ /**
8
+ * An `HttpClient` for the calling scope. The built-in transport opens its own keep-alive pool and destroys it with the scope,
9
+ * so clients never share connections and closing one never drops another's.
10
+ */
11
+ readonly open: Effect.Effect<HttpClient.HttpClient, never, Scope.Scope>;
12
+ }
13
+
14
+ /** How Devin clients and logins reach the network. */
15
+ export class HttpTransport extends Context.Service<HttpTransport, HttpTransportShape>()("pi-devin/HttpTransport") {}
16
+
17
+ /** Node's `http`/`https`: TLS 1.2 or later with certificate and host checks, HTTP/1.1 only, and a pool per opening scope. */
18
+ export function nodeTransport(options: HTTPTransportOptions = {}): HttpTransportShape {
19
+ return { open: makeNodeHttpClient(options) };
20
+ }
21
+
22
+ /** An injected `fetch`, such as the one Pi passes in stream options. */
23
+ export function fetchTransport(fetchImpl: typeof fetch): HttpTransportShape {
24
+ return { open: Effect.sync(() => makeFetchHttpClient(fetchImpl)) };
25
+ }
26
+
27
+ export const NodeHttpTransport = (options?: HTTPTransportOptions): Layer.Layer<HttpTransport> => Layer.succeed(HttpTransport, nodeTransport(options));
28
+ export const FetchHttpTransport = (fetchImpl: typeof fetch): Layer.Layer<HttpTransport> => Layer.succeed(HttpTransport, fetchTransport(fetchImpl));
@@ -0,0 +1,54 @@
1
+ import { Effect, Layer, Stream } from "effect";
2
+ import type { PlatformError } from "effect/PlatformError";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/process";
4
+ // Subpath imports: the package index also loads NodeRedis, whose `redis` peer Pi's installer does not install.
5
+ import * as NodeChildProcessSpawner from "@effect/platform-node/NodeChildProcessSpawner";
6
+ import * as NodeFileSystem from "@effect/platform-node/NodeFileSystem";
7
+ import * as NodePath from "@effect/platform-node/NodePath";
8
+ import { DevinError, toDevinError } from "../errors.ts";
9
+
10
+ /** Keeps the operating system's own message and error code, as the Node APIs reported them. */
11
+ export function fromPlatformError(error: PlatformError): DevinError {
12
+ const cause = error.reason.cause;
13
+ return cause === undefined ? new DevinError({ message: error.message }) : toDevinError(cause);
14
+ }
15
+
16
+ /** Node implementations of the process and file services this package uses. */
17
+ export const NodePlatformLayer = Layer.mergeAll(
18
+ NodeChildProcessSpawner.layer.pipe(Layer.provide(Layer.mergeAll(NodeFileSystem.layer, NodePath.layer))),
19
+ NodeFileSystem.layer,
20
+ NodePath.layer,
21
+ );
22
+
23
+ export interface CommandOutputOptions {
24
+ env: Record<string, string>;
25
+ timeoutMs: number;
26
+ maxOutputBytes: number;
27
+ }
28
+
29
+ /** Runs a fixed command without a shell input and returns its stdout, like `execFileSync` with an output bound. */
30
+ export function commandOutput(file: string, args: readonly string[], options: CommandOutputOptions): Effect.Effect<string, DevinError, ChildProcessSpawner.ChildProcessSpawner> {
31
+ return Effect.scoped(Effect.gen(function* () {
32
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
33
+ const handle = yield* spawner.spawn(ChildProcess.make(file, [...args], {
34
+ env: options.env, extendEnv: false, detached: false, stdin: "ignore", stderr: "ignore",
35
+ })).pipe(Effect.mapError(fromPlatformError));
36
+ let bytes = 0;
37
+ const chunks: Uint8Array[] = [];
38
+ yield* handle.stdout.pipe(
39
+ Stream.mapError(fromPlatformError),
40
+ Stream.runForEach((chunk) => {
41
+ bytes += chunk.byteLength;
42
+ if (bytes > options.maxOutputBytes) return Effect.fail(new DevinError({ message: `${file} output exceeds ${options.maxOutputBytes} bytes.` }));
43
+ chunks.push(chunk);
44
+ return Effect.void;
45
+ }),
46
+ );
47
+ const exitCode = yield* handle.exitCode.pipe(Effect.mapError(fromPlatformError));
48
+ if (exitCode !== 0) return yield* Effect.fail(new DevinError({ message: `${file} exited with code ${exitCode}.` }));
49
+ return Buffer.concat(chunks).toString("utf8");
50
+ })).pipe(Effect.timeoutOrElse({
51
+ duration: options.timeoutMs,
52
+ orElse: () => Effect.fail(new DevinError({ message: `${file} timed out after ${options.timeoutMs}ms.`, code: "ETIMEDOUT" })),
53
+ }));
54
+ }
package/src/core/types.ts CHANGED
@@ -1,28 +1,5 @@
1
1
  export type JsonObject = Record<string, unknown>;
2
2
 
3
- export function object(value: unknown, label: string): JsonObject {
4
- if (!value || typeof value !== "object" || Array.isArray(value)) {
5
- throw new Error(`${label}: a JSON object is required.`);
6
- }
7
- return value as JsonObject;
8
- }
9
-
10
- export function requiredString(value: unknown, label: string): string {
11
- if (typeof value !== "string" || value.length === 0) {
12
- throw new Error(`${label}: a non-empty string is required.`);
13
- }
14
- return value;
15
- }
16
-
17
- export function tokenCount(value: unknown, label: string): number {
18
- if (typeof value !== "number" && !(typeof value === "string" && /^\d+$/.test(value))) {
19
- throw new Error(`${label}: invalid token count.`);
20
- }
21
- const count = Number(value);
22
- if (!Number.isSafeInteger(count) || count < 0) throw new Error(`${label}: invalid token count.`);
23
- return count;
24
- }
25
-
26
3
  export interface Credential {
27
4
  version: 1;
28
5
  sessionToken: string;
@@ -87,6 +64,8 @@ export interface ChatResult {
87
64
  stopFrameIndex?: number;
88
65
  lastUsageFrameIndex?: number;
89
66
  idlessToolDeltaCount: number;
67
+ /** Tool call ID → why its arguments cannot run, in Pi's `argumentsError` wording. */
68
+ toolArgumentErrors: Readonly<Record<string, string>>;
90
69
  durationMs: number;
91
70
  firstDeltaMs?: number;
92
71
  actualModelUid?: string;
@@ -0,0 +1,25 @@
1
+ import { Effect, Predicate } from "effect";
2
+ import { DevinError } from "./errors.ts";
3
+ import type { JsonObject } from "./types.ts";
4
+
5
+ export function object(value: unknown, label: string): Effect.Effect<JsonObject, DevinError> {
6
+ return Predicate.isObject(value) ? Effect.succeed(value as JsonObject)
7
+ : Effect.fail(new DevinError({ message: `${label}: a JSON object is required.` }));
8
+ }
9
+
10
+ export function requiredString(value: unknown, label: string): Effect.Effect<string, DevinError> {
11
+ return Predicate.isString(value) && value.length > 0 ? Effect.succeed(value)
12
+ : Effect.fail(new DevinError({ message: `${label}: a non-empty string is required.` }));
13
+ }
14
+
15
+ export function tokenCount(value: unknown, label: string): Effect.Effect<number, DevinError> {
16
+ const isCountLike = Predicate.isNumber(value) || (Predicate.isString(value) && /^\d+$/.test(value));
17
+ const count = Number(value);
18
+ return isCountLike && Number.isSafeInteger(count) && count >= 0 ? Effect.succeed(count)
19
+ : Effect.fail(new DevinError({ message: `${label}: invalid token count.` }));
20
+ }
21
+
22
+ /** Fails with a message-only error. */
23
+ export function failWith(message: string): Effect.Effect<never, DevinError> {
24
+ return Effect.fail(new DevinError({ message }));
25
+ }
@@ -0,0 +1,82 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Effect } from "effect";
3
+ import { toDevinFailure, type DevinError, type DevinFailure } from "../../../core/errors.ts";
4
+ import { ExtensionConfig } from "../../app/config.ts";
5
+ import type { ExtensionRuntime } from "../../app/runtime.ts";
6
+ import { ModelCatalog } from "../../features/catalog/service.ts";
7
+ import { DisplaySettings } from "../../features/settings/service.ts";
8
+ import type { DevinPreferences } from "../../features/settings/types.ts";
9
+ import { sessionUsage, usageReport } from "../../features/usage/report.ts";
10
+ import { DevinPanel, PREFERENCE_ITEMS } from "./ui/devin-panel.ts";
11
+ import { UsageStatus } from "./usage-status.ts";
12
+
13
+ export const DEVIN_COMMAND_NAME = "devin";
14
+ const USAGE = "Usage: /devin, /devin recommended on|off, /devin status on|off";
15
+ const ARGUMENT_KEYS: Readonly<Record<string, keyof DevinPreferences>> = { recommended: "recommendedOnly", status: "showUsageStatus" };
16
+
17
+ const onOff = (value: boolean) => value ? "on" : "off";
18
+
19
+ /** Saves the settings and applies them: a changed filter rebuilds Pi's model snapshots without network. Only a failed save fails. */
20
+ const applyPreferences = (ctx: ExtensionCommandContext, next: DevinPreferences): Effect.Effect<void, DevinError, DisplaySettings | ModelCatalog | UsageStatus> => Effect.gen(function* () {
21
+ const settings = yield* DisplaySettings;
22
+ const catalog = yield* ModelCatalog;
23
+ const previous = yield* settings.read;
24
+ // A list refresh failure after saving is reported as a warning so the screen and the saved value stay in sync.
25
+ yield* settings.write(next);
26
+ if (previous.recommendedOnly !== next.recommendedOnly) {
27
+ yield* catalog.rebuild;
28
+ // Pi keeps model and availability snapshots. Rebuild them without network so `/model` and RPC lists update immediately.
29
+ yield* Effect.tryPromise({ try: () => ctx.modelRegistry.refresh({ providers: ["devin"], allowNetwork: false }), catch: toDevinFailure }).pipe(
30
+ Effect.catch((error) => Effect.sync(() => ctx.ui.notify(`Settings were saved, but the model list could not be rebuilt: ${error.message}`, "warning"))),
31
+ );
32
+ const model = ctx.model;
33
+ if (model?.provider === "devin" && !(yield* catalog.models).some((item) => item.id === model.id)) {
34
+ 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");
35
+ }
36
+ }
37
+ yield* (yield* UsageStatus).update(ctx);
38
+ });
39
+
40
+ const devinCommand = (args: string, ctx: ExtensionCommandContext, runtime: ExtensionRuntime): Effect.Effect<void, DevinFailure, DisplaySettings | ModelCatalog | UsageStatus | ExtensionConfig> => Effect.gen(function* () {
41
+ const settings = yield* DisplaySettings;
42
+ const status = yield* UsageStatus;
43
+ const { now } = yield* ExtensionConfig;
44
+ const report = () => runtime.runSync(Effect.gen(function* () {
45
+ return usageReport(yield* status.accountUsage, sessionUsage(ctx.sessionManager.getBranch()), now());
46
+ }));
47
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
48
+ if (tokens.length) {
49
+ const key = ARGUMENT_KEYS[tokens[0]];
50
+ if (tokens.length !== 2 || !key || (tokens[1] !== "on" && tokens[1] !== "off")) {
51
+ ctx.ui.notify(`Unknown argument. ${USAGE}`, "error");
52
+ return;
53
+ }
54
+ const value = tokens[1] === "on";
55
+ yield* applyPreferences(ctx, { ...(yield* settings.read), [key]: value });
56
+ ctx.ui.notify(`${PREFERENCE_ITEMS.find((item) => item.key === key)!.label}: ${onOff(value)} (saved globally)`, "info");
57
+ return;
58
+ }
59
+ if (ctx.mode === "tui") {
60
+ const preferences = yield* settings.read;
61
+ const services = yield* Effect.context<DisplaySettings | ModelCatalog | UsageStatus>();
62
+ yield* Effect.tryPromise({ try: () => ctx.ui.custom<void>((tui, theme, _keybindings, done) => new DevinPanel({
63
+ theme, report, preferences, onChange: (next) => applyPreferences(ctx, next).pipe(Effect.provideContext(services)),
64
+ requestRender: () => tui.requestRender(), done: () => done(),
65
+ })), catch: toDevinFailure });
66
+ return;
67
+ }
68
+ const preferences = yield* settings.read;
69
+ const summary = PREFERENCE_ITEMS.map((item) => `${item.label} ${onOff(preferences[item.key])}`).join(" · ");
70
+ ctx.ui.notify([...report().map((line) => line.text), `Settings: ${summary}`, USAGE].join("\n"), "info");
71
+ });
72
+
73
+ /**
74
+ * `/devin` shows usage details and display settings. Settings are saved globally and applied immediately.
75
+ * The TUI gets a dedicated screen; other modes use arguments and notifications.
76
+ */
77
+ export function registerDevinCommand(pi: ExtensionAPI, runtime: ExtensionRuntime): void {
78
+ pi.registerCommand(DEVIN_COMMAND_NAME, {
79
+ description: "Show Devin usage and change display settings (recommended models only, usage in the status line); saved globally",
80
+ handler: (args, ctx) => runtime.runPromise(devinCommand(args, ctx, runtime)),
81
+ });
82
+ }
@@ -0,0 +1,92 @@
1
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { Effect } from "effect";
3
+ import { toDevinFailure, type DevinFailure } from "../../../core/errors.ts";
4
+ import type { ExtensionRuntime } from "../../app/runtime.ts";
5
+ import { FUSION_MODEL_ID } from "../../features/catalog/constants.ts";
6
+ import { ModelCatalog } from "../../features/catalog/service.ts";
7
+ import type { DevinModel, FusionChoices, FusionSelection } from "../../features/catalog/types.ts";
8
+ import { FusionSettings } from "../../features/settings/service.ts";
9
+ import { FusionPicker } from "./ui/fusion-picker.ts";
10
+
11
+ export const FUSION_COMMAND_NAME = "devin-fusion";
12
+ const USAGE = `Usage: /${FUSION_COMMAND_NAME} or /${FUSION_COMMAND_NAME} <lead model ID> <sidekick model ID>`;
13
+
14
+ const host = <A>(evaluate: () => Promise<A>) => Effect.tryPromise({ try: evaluate, catch: toDevinFailure });
15
+
16
+ const currentFusion = (models: readonly DevinModel[]) => models.find((model) => model.id === FUSION_MODEL_ID);
17
+
18
+ /** Disambiguates duplicate display names in a select list with an index, and maps the chosen label back to its value. */
19
+ function choose<T>(ctx: ExtensionCommandContext, title: string, items: readonly T[], label: (item: T) => string, isCurrent: (item: T) => boolean): Effect.Effect<T | undefined, DevinFailure> {
20
+ const names = items.map(label);
21
+ const labels = items.map((item, index) => {
22
+ const name = names.filter((other) => other === names[index]).length > 1 ? `${names[index]} (${index + 1})` : names[index];
23
+ return (isCurrent(item) ? "● " : " ") + name;
24
+ });
25
+ return Effect.map(host(() => ctx.ui.select(title, labels)), (picked) => picked === undefined ? undefined : items[labels.indexOf(picked)]);
26
+ }
27
+
28
+ function pickInteractively(ctx: ExtensionCommandContext, choices: FusionChoices, current: FusionSelection | undefined): Effect.Effect<FusionSelection | undefined, DevinFailure> {
29
+ return Effect.gen(function* () {
30
+ const lead = yield* choose(ctx, "Devin Fusion · lead model", choices.leads, (item) => item.name, (item) => item.id === current?.leadID);
31
+ if (!lead) return undefined;
32
+ const groups = [...new Set(choices.sidekicks.map((item) => item.groupName))];
33
+ const currentGroup = choices.sidekicks.find((item) => item.id === current?.sidekickID)?.groupName;
34
+ const group = groups.length === 1 ? groups[0] : yield* choose(ctx, "Devin Fusion · sidekick model", groups, (item) => item, (item) => item === currentGroup);
35
+ if (group === undefined) return undefined;
36
+ const variants = choices.sidekicks.filter((item) => item.groupName === group);
37
+ const sidekick = variants.length === 1 ? variants[0]
38
+ : yield* choose(ctx, `Devin Fusion · sidekick variant (${group})`, variants, (item) => item.name, (item) => item.id === current?.sidekickID);
39
+ return sidekick ? { leadID: lead.id, sidekickID: sidekick.id } : undefined;
40
+ });
41
+ }
42
+
43
+ const fusionCommand = (args: string, ctx: ExtensionCommandContext, pi: ExtensionAPI): Effect.Effect<void, DevinFailure, ModelCatalog | FusionSettings> => Effect.gen(function* () {
44
+ const catalog = yield* ModelCatalog;
45
+ const settings = yield* FusionSettings;
46
+ const choices = yield* catalog.fusionChoices;
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(yield* catalog.models);
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 = yield* host(() => 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 = yield* 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
+ yield* settings.write(selection);
75
+ yield* catalog.rebuild;
76
+ // Pi keeps model and availability snapshots. Rebuild them without network so `/model` and RPC lists update immediately.
77
+ yield* host(() => ctx.modelRegistry.refresh({ providers: ["devin"], allowNetwork: false }));
78
+ const after = currentFusion(yield* catalog.models);
79
+ if (after && ctx.model?.provider === "devin" && ctx.model.id === FUSION_MODEL_ID) yield* host(() => 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
+ /**
84
+ * `/devin-fusion` only chooses the lead/sidekick pair. Whether Fusion is used is decided by picking Devin Fusion in `/model`.
85
+ * If the current model is Fusion, it is re-selected with the updated model object so the thinking level range matches.
86
+ */
87
+ export function registerFusionCommand(pi: ExtensionAPI, runtime: ExtensionRuntime): void {
88
+ pi.registerCommand(FUSION_COMMAND_NAME, {
89
+ description: "Choose the lead and sidekick models for Devin Fusion (saved globally)",
90
+ handler: (args, ctx) => runtime.runPromise(fusionCommand(args, ctx, pi)),
91
+ });
92
+ }
@@ -0,0 +1,14 @@
1
+ import type { OAuthAuth } from "@earendil-works/pi-ai";
2
+ import type { ProviderRuntime } from "../../app/runtime.ts";
3
+ import { AccountLogin } from "../../features/auth/login.ts";
4
+
5
+ /** Pi's OAuth contract over the sign-in service. Promises reject with the original failure, so Pi sees its name and message. */
6
+ export function makeOAuthAuth(runtime: ProviderRuntime): OAuthAuth {
7
+ const accountLogin = runtime.runSync(AccountLogin);
8
+ return {
9
+ name: "Devin account",
10
+ login: (interaction) => runtime.runPromise(accountLogin.login(interaction)),
11
+ refresh: (current, signal) => runtime.runPromise(accountLogin.refresh(current, signal)),
12
+ toAuth: (current) => runtime.runPromise(accountLogin.toAuth(current)),
13
+ };
14
+ }
@@ -0,0 +1,22 @@
1
+ import type { ProviderRuntime } from "../../app/runtime.ts";
2
+ import { ModelCatalog } from "../../features/catalog/service.ts";
3
+ import { Inference } from "../../features/inference/service.ts";
4
+ import { makeOAuthAuth } from "./oauth.ts";
5
+ import type { DevinProvider } from "./types.ts";
6
+
7
+ /**
8
+ * The provider Pi registers. Building it builds the runtime's layers, which perform no I/O; work starts with Pi's first refresh or request.
9
+ * Pi's synchronous calls run synchronous effects; `stream` returns at once while the request runs in a fiber the inference service owns.
10
+ */
11
+ export function makeDevinProvider(runtime: ProviderRuntime): DevinProvider {
12
+ const catalog = runtime.runSync(ModelCatalog);
13
+ const inference = runtime.runSync(Inference);
14
+ return {
15
+ id: "devin", name: "Devin", auth: { oauth: makeOAuthAuth(runtime) },
16
+ getModels: () => runtime.runSync(catalog.models),
17
+ filterModels: (models, credential) => runtime.runSync(catalog.filter(models, credential)),
18
+ refreshModels: (context) => runtime.runPromise(catalog.refresh(context)),
19
+ stream: (model, context, options) => runtime.runSync(inference.start(model, context, options)),
20
+ streamSimple: (model, context, options) => runtime.runSync(inference.start(model, context, options)),
21
+ };
22
+ }
@@ -0,0 +1,89 @@
1
+ import type { Model } from "@earendil-works/pi-ai";
2
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
+ import { Effect, type Layer } from "effect";
4
+ import { failWith } from "../../../core/validation.ts";
5
+ import { ExtensionConfig } from "../../app/config.ts";
6
+ import type { ExtensionEnvironment } from "../../app/layer.ts";
7
+ import { makeExtensionRuntime, type ExtensionServices } from "../../app/runtime.ts";
8
+ import { attachSession, shutdownProvider } from "../../app/session.ts";
9
+ import { fusionOf } from "../../features/catalog/fusion.ts";
10
+ import { ModelCatalog } from "../../features/catalog/service.ts";
11
+ import { Runs } from "../../features/runs/service.ts";
12
+ import { DEVIN_STATE_ENTRY } from "../../features/runs/state.ts";
13
+ import { SIDEKICK_TOOL_NAME } from "../../features/sidekick/prompts.ts";
14
+ import { Sidekicks } from "../../features/sidekick/service.ts";
15
+ import { registerDevinCommand } from "./devin-command.ts";
16
+ import { registerFusionCommand } from "./fusion-command.ts";
17
+ import { makeDevinProvider } from "./provider.ts";
18
+ import { PiSidekickHost, sidekickTool } from "./sidekick-tool.ts";
19
+ import type { DevinExtension } from "./types.ts";
20
+ import { UsageStatus } from "./usage-status.ts";
21
+
22
+ /**
23
+ * Registers one extension instance: its runtime, the provider, and, for the lead, the sidekick tool, `/devin` and `/devin-fusion`.
24
+ * Pi's event handlers run their effects synchronously and return after the services finished, so the next event sees the result.
25
+ * A failed layer build throws here, before anything is registered.
26
+ */
27
+ export function registerDevinExtension(pi: ExtensionAPI, environment: Layer.Layer<ExtensionEnvironment>): DevinExtension {
28
+ const runtime = makeExtensionRuntime(environment, PiSidekickHost(pi));
29
+ const run = <A, E>(effect: Effect.Effect<A, E, ExtensionServices>) => runtime.runSync(effect);
30
+ const { isFusionChild } = run(ExtensionConfig);
31
+ const isLead = !isFusionChild;
32
+ const provider = makeDevinProvider(runtime);
33
+ const catalog = run(ModelCatalog), runs = run(Runs), sidekicks = run(Sidekicks), status = run(UsageStatus);
34
+ pi.registerProvider(provider);
35
+ if (isLead) {
36
+ registerDevinCommand(pi, runtime);
37
+ pi.registerTool(sidekickTool(runtime));
38
+ registerFusionCommand(pi, runtime);
39
+ }
40
+ // Enables the sidekick only for the Fusion model and leaves other extensions' and the user's active tools alone.
41
+ const syncSidekick = (model: Model<any> | undefined) => {
42
+ if (!isLead) return;
43
+ const active = pi.getActiveTools(), isActive = active.includes(SIDEKICK_TOOL_NAME);
44
+ const shouldBeActive = fusionOf(run(catalog.models), model) !== undefined;
45
+ if (shouldBeActive && !isActive) pi.setActiveTools([...active, SIDEKICK_TOOL_NAME]);
46
+ else if (!shouldBeActive && isActive) pi.setActiveTools(active.filter((name) => name !== SIDEKICK_TOOL_NAME));
47
+ };
48
+ // Pi sets the new model before this event, so the status line follows it at once.
49
+ pi.on("model_select", (event, context) => {
50
+ syncSidekick(event.model);
51
+ run(status.update(context));
52
+ });
53
+ pi.on("input", (event) => { if (isLead) run(sidekicks.userInput(event.source)); });
54
+ pi.on("session_start", (_event, context) => {
55
+ if (context.modelRegistry.getRegisteredNativeProvider("devin") !== provider) {
56
+ run(shutdownProvider);
57
+ run(failWith("The Devin provider ID collides with another registration. Remove the duplicate extension and reload."));
58
+ }
59
+ run(attachSession({
60
+ getSessionId: () => context.sessionManager.getSessionId(),
61
+ getBranch: () => context.sessionManager.getBranch(),
62
+ append: (data) => pi.appendEntry(DEVIN_STATE_ENTRY, data),
63
+ }));
64
+ if (isLead) run(sidekicks.sessionStarted(context.sessionManager.getSessionId()));
65
+ syncSidekick(context.model);
66
+ run(Effect.andThen(status.bind(context), status.update(context)));
67
+ });
68
+ pi.on("message_end", (event, context) => {
69
+ if (event.message.role === "assistant" && event.message.provider === "devin") run(status.update(context, event.message));
70
+ });
71
+ // Subagents and other sessions record quota in the shared store; any model's turn picks it up.
72
+ pi.on("turn_end", (_event, context) => { run(status.update(context)); });
73
+ pi.on("before_agent_start", () => { run(runs.begin("agent")); });
74
+ pi.on("agent_start", () => { run(runs.agentStarted); });
75
+ // Host retry or overflow recovery can continue after agent_end.
76
+ pi.on("agent_settled", (_event, context) => { run(Effect.andThen(runs.finish, status.update(context))); });
77
+ pi.on("session_before_compact", (event) => { run(runs.beforeCompaction(event.reason)); });
78
+ pi.on("session_compact", (event) => { run(runs.afterCompaction(event.reason)); });
79
+ pi.on("session_compact_failed", (event) => { run(runs.afterCompaction(event.reason)); });
80
+ pi.on("session_before_tree", (event) => { if (event.preparation.userWantsSummary) run(runs.beforeTree); });
81
+ pi.on("session_tree", (_event, context) => { run(Effect.andThen(runs.treeChanged, status.update(context))); });
82
+ pi.on("cache_warming_decision", (_event, context) => context.model?.provider === "devin" ? { action: "stop" } : undefined);
83
+ pi.on("session_shutdown", () => {
84
+ run(status.bind(undefined));
85
+ if (isLead) run(sidekicks.reset);
86
+ run(shutdownProvider);
87
+ });
88
+ return { provider, runtime };
89
+ }
@@ -0,0 +1,164 @@
1
+ import { createAssistantMessageEventStream, type AssistantMessage, type Model, type ToolCall } from "@earendil-works/pi-ai";
2
+ import { Effect, Predicate } from "effect";
3
+ import type { DevinError } from "../../../core/errors.ts";
4
+ import type { ChatAccumulator } from "../../../core/inference/accumulator.ts";
5
+ import type { ChatMessage, ChatResult, JsonObject } from "../../../core/types.ts";
6
+ import { failWith } from "../../../core/validation.ts";
7
+ import { DEVIN_API } from "../../features/catalog/constants.ts";
8
+ import type { DevinAPI } from "../../features/catalog/types.ts";
9
+ import { attachReplayMetadata } from "./transcript.ts";
10
+
11
+ const USAGE_FIELDS = ["inputTokens", "outputTokens", "cacheReadTokens", "cacheWriteTokens"];
12
+
13
+ /** Pi's event stream for one Devin response: one start, balanced content events and exactly one terminal event. */
14
+ export class DevinResponseStream {
15
+ readonly stream = createAssistantMessageEventStream();
16
+ readonly message: AssistantMessage;
17
+ private active?: { type: "text" | "thinking"; index: number };
18
+ private readonly tools = new Map<string, { index: number; characters: number; hasEnded: boolean }>();
19
+ private hasTerminated = false;
20
+ private usageModelUid?: string;
21
+ private hasUsageReported = false;
22
+
23
+ constructor(model: Model<DevinAPI>, requestedUID: string) {
24
+ this.message = {
25
+ role: "assistant", api: DEVIN_API, provider: "devin", model: model.id, providerThinkingLevel: requestedUID,
26
+ content: [], stopReason: "pending", timestamp: Date.now(),
27
+ usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } },
28
+ };
29
+ this.stream.push({ type: "start", partial: this.message });
30
+ }
31
+
32
+ private closeText(): void {
33
+ if (!this.active) return;
34
+ const block = this.message.content[this.active.index];
35
+ if (block.type === "text") this.stream.push({ type: "text_end", contentIndex: this.active.index, content: block.text, partial: this.message });
36
+ else if (block.type === "thinking") this.stream.push({ type: "thinking_end", contentIndex: this.active.index, content: block.thinking, partial: this.message });
37
+ this.active = undefined;
38
+ }
39
+
40
+ private text(type: "text" | "thinking", delta: string, redacted = false): void {
41
+ if (!this.active || this.active.type !== type) {
42
+ this.closeText();
43
+ const index = this.message.content.length;
44
+ this.message.content.push(type === "text" ? { type, text: "" } : { type, thinking: "", ...(redacted ? { redacted: true } : {}) });
45
+ this.active = { type, index };
46
+ this.stream.push({ type: type === "text" ? "text_start" : "thinking_start", contentIndex: index, partial: this.message });
47
+ }
48
+ const block = this.message.content[this.active.index];
49
+ if (!delta) return;
50
+ if (block.type === "text") {
51
+ block.text += delta;
52
+ this.stream.push({ type: "text_delta", contentIndex: this.active.index, delta, partial: this.message });
53
+ } else if (block.type === "thinking") {
54
+ block.thinking += delta;
55
+ this.stream.push({ type: "thinking_delta", contentIndex: this.active.index, delta, partial: this.message });
56
+ }
57
+ }
58
+
59
+ /** Emits the content events of one frame the accumulator already accepted. */
60
+ accept(data: JsonObject, accumulator: ChatAccumulator): Effect.Effect<void, DevinError> {
61
+ return Effect.suspend(() => {
62
+ if (this.hasTerminated) return failWith("Cannot add a delta to a finished Pi response.");
63
+ if (accumulator.message.thinkingRedacted) {
64
+ for (const block of this.message.content) if (block.type === "thinking") { block.thinking = ""; block.redacted = true; }
65
+ if (!this.message.content.some((block) => block.type === "thinking")) this.text("thinking", "", true);
66
+ } else if (Predicate.isString(data.deltaThinking) && data.deltaThinking) this.text("thinking", data.deltaThinking);
67
+ if (Predicate.isString(data.deltaText) && data.deltaText) this.text("text", data.deltaText);
68
+ for (const call of accumulator.calls.values()) this.toolDelta(call.id, call.name, call.argumentsJson);
69
+ if (accumulator.message.messageId) this.message.responseId = accumulator.message.messageId;
70
+ if (Predicate.isString(data.actualModelUid)) this.message.responseModel = data.actualModelUid;
71
+ if (Predicate.isObject(data.usage)) {
72
+ const usage = data.usage as JsonObject;
73
+ if (Predicate.isString(usage.modelUid)) this.usageModelUid = usage.modelUid;
74
+ if (USAGE_FIELDS.some((key) => usage[key] !== undefined)) this.hasUsageReported = true;
75
+ }
76
+ this.message.usage = { ...accumulator.usage, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } };
77
+ return Effect.void;
78
+ });
79
+ }
80
+
81
+ private toolDelta(id: string, name: string, argumentsJson: string): void {
82
+ let state = this.tools.get(id);
83
+ if (!state) {
84
+ this.closeText();
85
+ const index = this.message.content.length;
86
+ this.message.content.push({ type: "toolCall", id, name: "", arguments: {} });
87
+ state = { index, characters: 0, hasEnded: false }; this.tools.set(id, state);
88
+ this.stream.push({ type: "toolcall_start", contentIndex: index, partial: this.message });
89
+ }
90
+ const block = this.message.content[state.index] as ToolCall;
91
+ block.name = name;
92
+ try {
93
+ const value = JSON.parse(argumentsJson) as unknown;
94
+ if (Predicate.isObject(value)) block.arguments = value as ToolCall["arguments"];
95
+ } catch { /* Partial arguments stay as the last complete object until the call ends. */ }
96
+ const delta = argumentsJson.slice(state.characters);
97
+ state.characters = argumentsJson.length;
98
+ if (delta) this.stream.push({ type: "toolcall_delta", contentIndex: state.index, delta, partial: this.message });
99
+ }
100
+
101
+ private closeContent(): void {
102
+ this.closeText();
103
+ for (const state of this.tools.values()) if (!state.hasEnded) {
104
+ state.hasEnded = true;
105
+ this.stream.push({ type: "toolcall_end", contentIndex: state.index, toolCall: this.message.content[state.index] as ToolCall, partial: this.message });
106
+ }
107
+ }
108
+
109
+ private provenance(native: ChatMessage, usageReported: boolean): void {
110
+ this.message.providerThinkingLevel = native.modelUid ?? this.message.providerThinkingLevel;
111
+ attachReplayMetadata(this.message, native);
112
+ this.message.diagnostics = [{ type: "devin_provenance", timestamp: Date.now(), details: {
113
+ requestedUID: this.message.providerThinkingLevel ?? "", ...(this.message.responseModel ? { responseUID: this.message.responseModel } : {}),
114
+ ...(this.usageModelUid ? { usageUID: this.usageModelUid } : {}), costsUnknown: true, usageReported,
115
+ } }];
116
+ }
117
+
118
+ /** The server redacted the response: everything shown so far is cleared and closed. */
119
+ get redact(): Effect.Effect<void> {
120
+ return Effect.sync(() => {
121
+ for (const block of this.message.content) {
122
+ if (block.type === "text") block.text = "";
123
+ else if (block.type === "thinking") { block.thinking = ""; block.redacted = true; }
124
+ else block.arguments = {};
125
+ }
126
+ this.closeContent(); this.message.content = []; this.tools.clear();
127
+ });
128
+ }
129
+
130
+ finish(result: ChatResult): Effect.Effect<void> {
131
+ return Effect.sync(() => {
132
+ if (this.hasTerminated) return;
133
+ if (!this.message.content.length && (result.message.signature || result.message.geminiThoughtSignature || result.message.outputId)) this.text("text", "");
134
+ this.provenance(result.message, result.usageReported);
135
+ // Pi runs no call whose arguments are invalid, reports the error to the model and lets it call again.
136
+ for (const [id, error] of Object.entries(result.toolArgumentErrors)) {
137
+ const state = this.tools.get(id);
138
+ if (!state) continue;
139
+ const block = this.message.content[state.index] as ToolCall;
140
+ block.arguments = {}; block.argumentsError = error;
141
+ }
142
+ this.closeContent();
143
+ this.hasTerminated = true;
144
+ this.message.rawStopReason = result.stopReason;
145
+ // At the output limit Pi runs none of the calls, since their arguments may be cut off, and asks the model to repeat them.
146
+ this.message.stopReason = result.stopReason === "STOP_REASON_MAX_TOKENS" ? "length" : result.message.toolCalls?.length ? "toolUse" : "stop";
147
+ this.stream.push({ type: "done", reason: this.message.stopReason, message: this.message });
148
+ this.stream.end(this.message);
149
+ });
150
+ }
151
+
152
+ fail(errorMessage: string, isAborted: boolean, native?: ChatMessage): Effect.Effect<void> {
153
+ return Effect.sync(() => {
154
+ if (this.hasTerminated) return;
155
+ if (native) this.provenance(native, this.hasUsageReported);
156
+ this.closeContent();
157
+ this.hasTerminated = true;
158
+ this.message.stopReason = isAborted ? "aborted" : "error";
159
+ this.message.errorMessage = errorMessage;
160
+ this.stream.push({ type: "error", reason: this.message.stopReason, error: this.message });
161
+ this.stream.end(this.message);
162
+ });
163
+ }
164
+ }