@buyong/pi-devin 0.1.5 → 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 (117) hide show
  1. package/README.ko.md +3 -5
  2. package/README.md +3 -5
  3. package/index.ts +6 -15
  4. package/package.json +3 -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 -166
  104. package/src/pi/devin-command.ts +0 -86
  105. package/src/pi/devin-settings.ts +0 -50
  106. package/src/pi/extension.ts +0 -89
  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 -415
  112. package/src/pi/provider.ts +0 -278
  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
  117. package/src/pi/usage-store.ts +0 -75
@@ -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
+ }
@@ -0,0 +1,45 @@
1
+ import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
2
+ import { Effect, Layer, type ManagedRuntime } from "effect";
3
+ import type { DevinError } from "../../../core/errors.ts";
4
+ import { SIDEKICK_REPORT_MESSAGE, SIDEKICK_TOOL_NAME } from "../../features/sidekick/prompts.ts";
5
+ import { SidekickHost, Sidekicks } from "../../features/sidekick/service.ts";
6
+
7
+ /** The lead Pi as the sidekick sees it. */
8
+ export const PiSidekickHost = (pi: ExtensionAPI) => Layer.succeed(SidekickHost, {
9
+ activeTools: Effect.sync(() => pi.getActiveTools()),
10
+ deliverReport: (report) => Effect.sync(() => {
11
+ pi.sendMessage({ customType: SIDEKICK_REPORT_MESSAGE, display: true, content: report.content, details: report.details }, { deliverAs: "followUp", triggerTurn: true });
12
+ }),
13
+ });
14
+
15
+ /**
16
+ * The lead's `devin_sidekick` tool. The sidekick runs as one child Pi process per lead session and uses the lead's active tools.
17
+ * By default it waits for the report; with `block: false`, or when a user message arrives while waiting, it moves to the background and delivers the report once.
18
+ */
19
+ export function sidekickTool(runtime: ManagedRuntime.ManagedRuntime<Sidekicks, DevinError>): ToolDefinition {
20
+ const sidekicks = runtime.runSync(Sidekicks);
21
+ return {
22
+ name: SIDEKICK_TOOL_NAME,
23
+ label: "Sidekick",
24
+ description: "Delegate hands-on work to your persistent sidekick, a separate coding agent that shares this workspace and your tools. "
25
+ + "Put the task context and done criteria in `message`. By default the call waits and returns the sidekick's report; "
26
+ + "set `block` to false to continue while it works, and the report arrives later as a message. "
27
+ + "Calling it while the sidekick is busy adds instructions to the running handoff.",
28
+ promptSnippet: "Delegate hands-on work to a persistent sidekick agent and get its report",
29
+ parameters: {
30
+ type: "object",
31
+ properties: {
32
+ message: { type: "string", description: "Brief for the sidekick: context, the task, and done criteria." },
33
+ block: { type: "boolean", description: "Wait for the report (default true). Use false to keep working while the sidekick runs." },
34
+ },
35
+ required: ["message"],
36
+ additionalProperties: false,
37
+ } as unknown as ToolDefinition["parameters"],
38
+ defaultActive: false,
39
+ executionMode: "sequential",
40
+ execute: (_toolCallId, params, signal, onUpdate, ctx) => runtime.runPromise(sidekicks.handoff({
41
+ params, model: ctx.model, cwd: ctx.cwd, sessionId: ctx.sessionManager.getSessionId(), isSessionSaved: Boolean(ctx.sessionManager.getSessionFile()), signal,
42
+ onProgress: (text, details) => onUpdate?.({ content: [{ type: "text", text }], details }),
43
+ })),
44
+ };
45
+ }
@@ -0,0 +1,177 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { getCurrentSystemPrompt, getCurrentTools, type AssistantMessage, type TranscriptContext } from "@earendil-works/pi-ai";
3
+ import { Effect, Predicate } from "effect";
4
+ import { DevinError } from "../../../core/errors.ts";
5
+ import { imageFromBase64, MAX_IMAGES_BYTES } from "../../../core/inference/images.ts";
6
+ import type { ChatMessage, ChatTool, ModelInfo } from "../../../core/types.ts";
7
+ import { failWith, object, requiredString } from "../../../core/validation.ts";
8
+ import { DEVIN_API } from "../../features/catalog/constants.ts";
9
+
10
+ const REPLAY_PREFIX = "devin:v1:";
11
+ const replayStrings = ["signature", "signatureType", "geminiThoughtSignature", "outputId", "thinkingId", "phase", "modelUid"] as const;
12
+ type ReplayMetadata = Pick<ChatMessage, typeof replayStrings[number] | "thinkingRedacted">;
13
+ export interface DevinTranscript { messages: ChatMessage[]; tools: ChatTool[]; systemPrompt: string }
14
+
15
+ export function encodeReplayMetadata(message: ChatMessage): string {
16
+ const metadata: ReplayMetadata = {};
17
+ for (const key of replayStrings) if (message[key] !== undefined) metadata[key] = message[key];
18
+ if (message.thinkingRedacted !== undefined) metadata.thinkingRedacted = message.thinkingRedacted;
19
+ return REPLAY_PREFIX + JSON.stringify(metadata);
20
+ }
21
+
22
+ function replayMetadata(value: string | undefined): Effect.Effect<ReplayMetadata | undefined, DevinError> {
23
+ if (!value?.startsWith(REPLAY_PREFIX)) return Effect.succeed(undefined);
24
+ return Effect.gen(function* () {
25
+ const parsed = yield* Effect.try({ try: () => JSON.parse(value.slice(REPLAY_PREFIX.length)) as unknown, catch: () => undefined }).pipe(
26
+ Effect.flatMap((json) => object(json, "Devin replay metadata")),
27
+ Effect.mapError(() => new DevinError({ message: "Invalid Devin response replay metadata." })),
28
+ );
29
+ const result: ReplayMetadata = {};
30
+ for (const key of replayStrings) {
31
+ const field = parsed[key];
32
+ if (field !== undefined && !Predicate.isString(field)) return yield* failWith("Unexpected Devin response replay metadata format.");
33
+ if (Predicate.isString(field)) result[key] = field;
34
+ }
35
+ if (parsed.thinkingRedacted !== undefined) {
36
+ if (!Predicate.isBoolean(parsed.thinkingRedacted)) return yield* failWith("Invalid Devin redacted thinking metadata.");
37
+ result.thinkingRedacted = parsed.thinkingRedacted;
38
+ }
39
+ return result;
40
+ });
41
+ }
42
+
43
+ /** Stores the native response metadata on every content block, so the next request can replay it exactly. */
44
+ export function attachReplayMetadata(output: AssistantMessage, message: ChatMessage): void {
45
+ const signature = encodeReplayMetadata(message);
46
+ for (const block of output.content) {
47
+ if (block.type === "thinking") block.thinkingSignature = signature;
48
+ else if (block.type === "text") block.textSignature = signature;
49
+ else block.thoughtSignature = signature;
50
+ }
51
+ }
52
+
53
+ const NON_VISION_USER_IMAGE_PLACEHOLDER = "(image omitted: model does not support images)";
54
+ const NON_VISION_TOOL_IMAGE_PLACEHOLDER = "(tool image omitted: model does not support images)";
55
+ const SCHEMA_MAPS = new Set(["properties", "patternProperties", "$defs", "definitions", "dependentSchemas"]);
56
+ const SCHEMA_DATA = new Set(["enum", "const", "default", "examples", "example"]);
57
+
58
+ /** Devin's own tools give every schema a single `type`; its Gemini route rejects a list such as `["integer", "null"]`. */
59
+ function toDevinToolSchema(schema: unknown): unknown {
60
+ if (Array.isArray(schema)) return schema.map(toDevinToolSchema);
61
+ if (!schema || typeof schema !== "object") return schema;
62
+ const result: Record<string, unknown> = {};
63
+ for (const [key, value] of Object.entries(schema)) {
64
+ if (SCHEMA_DATA.has(key)) result[key] = value;
65
+ else if (SCHEMA_MAPS.has(key) && Predicate.isObject(value)) {
66
+ result[key] = Object.fromEntries(Object.entries(value).map(([name, child]) => [name, toDevinToolSchema(child)]));
67
+ } else if (key === "type" && Array.isArray(value)) {
68
+ // Omitting the field still covers null; several remaining types become one branch each.
69
+ const types = value.filter((type) => type !== "null");
70
+ if (types.length === 1) result.type = types[0];
71
+ else if (types.length > 1 && !("anyOf" in schema)) result.anyOf = types.map((type) => ({ type }));
72
+ else result.type = value;
73
+ } else result[key] = toDevinToolSchema(value);
74
+ }
75
+ return result;
76
+ }
77
+
78
+ function toolDeclarations(context: TranscriptContext, model: ModelInfo): Effect.Effect<ChatTool[], DevinError> {
79
+ return Effect.gen(function* () {
80
+ const declarations = getCurrentTools(context.messages);
81
+ if (declarations.length && !model.supportsToolCalls) return yield* failWith("This model does not support the current Pi tool declarations.");
82
+ const tools: ChatTool[] = [];
83
+ for (const tool of declarations) {
84
+ // Grammar sampling is optional; like Pi's providers without grammar tools, send the JSON schema instead.
85
+ if (tool.constrainedSampling && tool.constrainedSampling.type === "json_schema" && tool.constrainedSampling.strict === "require") {
86
+ return yield* failWith(`Devin does not support this tool's forced sampling format: ${tool.name}`);
87
+ }
88
+ tools.push({ name: tool.name, description: tool.description, jsonSchemaString: JSON.stringify(toDevinToolSchema(tool.parameters)) });
89
+ }
90
+ return tools;
91
+ });
92
+ }
93
+
94
+ type ContextMessage = TranscriptContext["messages"][number];
95
+
96
+ /** A successful assistant response with its own replay metadata, never a failed or unfinished one. */
97
+ function assistantMessage(message: Extract<ContextMessage, { role: "assistant" }>): Effect.Effect<ChatMessage, DevinError> {
98
+ return Effect.gen(function* () {
99
+ const own = message.provider === "devin" && message.api === DEVIN_API;
100
+ const result: ChatMessage = { messageId: message.responseId ?? randomUUID(), source: "CHAT_MESSAGE_SOURCE_SYSTEM", prompt: "" };
101
+ let replay: ReplayMetadata | undefined;
102
+ for (const block of message.content) {
103
+ if (block.type === "text") result.prompt += block.text;
104
+ else if (block.type === "thinking") {
105
+ if (!block.redacted) result.thinking = (result.thinking ?? "") + block.thinking;
106
+ else result.thinkingRedacted = true;
107
+ } else {
108
+ result.toolCalls ??= [];
109
+ result.toolCalls.push({ id: yield* requiredString(block.id, "tool call id"), name: block.namespace ? `${block.namespace}.${block.name}` : block.name,
110
+ argumentsJson: JSON.stringify(yield* object(block.arguments, "tool arguments")) });
111
+ }
112
+ if (own) {
113
+ const metadata = yield* replayMetadata(block.type === "text" ? block.textSignature : block.type === "thinking" ? block.thinkingSignature : block.thoughtSignature);
114
+ if (metadata) {
115
+ if (replay && JSON.stringify(replay) !== JSON.stringify(metadata)) return yield* failWith("Devin replay metadata differs within one response.");
116
+ replay = metadata;
117
+ }
118
+ }
119
+ }
120
+ Object.assign(result, replay);
121
+ if (own && message.providerThinkingLevel) result.modelUid = message.providerThinkingLevel;
122
+ if (result.thinkingRedacted) result.thinking = "";
123
+ return result;
124
+ });
125
+ }
126
+
127
+ /** A user message or tool result with its images, within the per-message image budget. */
128
+ function inputMessage(message: Exclude<ContextMessage, { role: "assistant" | "system" }>, model: ModelInfo): Effect.Effect<ChatMessage, DevinError> {
129
+ return Effect.gen(function* () {
130
+ const result: ChatMessage = {
131
+ messageId: randomUUID(), source: message.role === "toolResult" ? "CHAT_MESSAGE_SOURCE_TOOL" : "CHAT_MESSAGE_SOURCE_USER", prompt: "",
132
+ ...(message.role === "toolResult" ? { toolCallId: message.toolCallId, toolResultIsError: message.isError } : {}),
133
+ };
134
+ let imageBytes = 0, isAfterPlaceholder = false;
135
+ const content = typeof message.content === "string" ? [{ type: "text" as const, text: message.content }] : message.content;
136
+ for (const block of content) {
137
+ if (block.type === "text") {
138
+ result.prompt += block.text; isAfterPlaceholder = false;
139
+ } else if (!model.supportsImages) {
140
+ // Like Pi's own providers: a text-only model gets a note instead, so earlier images do not stop the conversation.
141
+ if (!isAfterPlaceholder) result.prompt += message.role === "toolResult" ? NON_VISION_TOOL_IMAGE_PLACEHOLDER : NON_VISION_USER_IMAGE_PLACEHOLDER;
142
+ isAfterPlaceholder = true;
143
+ } else {
144
+ const converted = yield* imageFromBase64(block.data, block.mimeType);
145
+ imageBytes += converted.sizeBytes;
146
+ if (imageBytes > MAX_IMAGES_BYTES) return yield* failWith(`Images in one message must total at most ${MAX_IMAGES_BYTES} bytes.`);
147
+ (result.images ??= []).push(converted.image);
148
+ }
149
+ }
150
+ return result;
151
+ });
152
+ }
153
+
154
+ /** Pi history as Devin chat messages. Failed or aborted assistants and the results of their tool calls are not replayed. */
155
+ export function toDevinTranscript(context: TranscriptContext, model: ModelInfo): Effect.Effect<DevinTranscript, DevinError> {
156
+ return Effect.gen(function* () {
157
+ const tools = yield* toolDeclarations(context, model);
158
+ // Results follow their assistant, so only the calls of the latest failed assistant are skipped. A later failure that
159
+ // reuses an earlier call ID leaves the earlier result in place.
160
+ let failedCalls = new Set<string>();
161
+ const messages: ChatMessage[] = [];
162
+ for (const message of context.messages) {
163
+ if (message.role === "system") continue;
164
+ if (message.role === "assistant") {
165
+ const isFailed = message.stopReason === "error" || message.stopReason === "aborted";
166
+ failedCalls = new Set(isFailed ? message.content.filter((block) => block.type === "toolCall").map((block) => block.id) : []);
167
+ if (isFailed) continue;
168
+ if (message.stopReason === "pending") return yield* failWith("An unfinished assistant response cannot be sent as Devin history.");
169
+ messages.push(yield* assistantMessage(message));
170
+ continue;
171
+ }
172
+ if (message.role === "toolResult" && failedCalls.has(message.toolCallId)) continue;
173
+ messages.push(yield* inputMessage(message, model));
174
+ }
175
+ return { messages, tools, systemPrompt: getCurrentSystemPrompt(context.messages) };
176
+ });
177
+ }
@@ -0,0 +1,14 @@
1
+ import type { Provider } from "@earendil-works/pi-ai";
2
+ import type { ExtensionRuntime } from "../../app/runtime.ts";
3
+ import type { DevinAPI } from "../../features/catalog/types.ts";
4
+
5
+ /** Pi's provider contract, and nothing else: extension features reach the services through the runtime. */
6
+ export interface DevinProvider extends Provider<DevinAPI> {
7
+ refreshModels: NonNullable<Provider<DevinAPI>["refreshModels"]>;
8
+ }
9
+
10
+ /** A registered extension instance: the provider Pi holds and the runtime its handlers run on. */
11
+ export interface DevinExtension {
12
+ readonly provider: DevinProvider;
13
+ readonly runtime: ExtensionRuntime;
14
+ }
@@ -1,11 +1,13 @@
1
1
  import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
2
- import type { DevinPreferences } from "./devin-settings.ts";
2
+ import { Cause, Effect, Exit } from "effect";
3
+ import type { DevinError } from "../../../../core/errors.ts";
4
+ import type { DevinPreferences } from "../../../features/settings/types.ts";
5
+ import type { UsageLine } from "../../../features/usage/types.ts";
3
6
  import type { PickerTheme } from "./fusion-picker.ts";
4
- import type { UsageLine } from "./usage.ts";
5
7
 
6
8
  export const PREFERENCE_ITEMS: readonly { key: keyof DevinPreferences; label: string; detail: string }[] = [
7
9
  { key: "recommendedOnly", label: "Recommended models only", detail: "/model shows only models with a recommended variant and Devin Fusion" },
8
- { key: "showUsageStatus", label: "Usage in status line", detail: "shows quota and session tokens in the footer" },
10
+ { key: "showUsageStatus", label: "Usage in status line", detail: "shows quota and session tokens in the footer while a Devin model is selected" },
9
11
  ];
10
12
  const HELP = "↑↓ move · space/↵ toggle · esc close";
11
13
 
@@ -14,7 +16,7 @@ export interface DevinPanelOptions {
14
16
  report: () => UsageLine[];
15
17
  preferences: DevinPreferences;
16
18
  /** Saves and applies on every toggle. On failure, shows a notice and reverts to the previous value. */
17
- onChange: (next: DevinPreferences) => Promise<void>;
19
+ onChange: (next: DevinPreferences) => Effect.Effect<void, DevinError>;
18
20
  requestRender: () => void;
19
21
  done: () => void;
20
22
  }
@@ -48,14 +50,14 @@ export class DevinPanel {
48
50
  const key = PREFERENCE_ITEMS[this.cursor].key, previous = this.preferences;
49
51
  this.preferences = { ...previous, [key]: !previous[key] };
50
52
  this.isSaving = true; this.notice = undefined; this.changed();
51
- try {
52
- await this.options.onChange(this.preferences);
53
- } catch (error) {
53
+ const exit = await Effect.runPromiseExit(this.options.onChange(this.preferences));
54
+ if (Exit.isFailure(exit)) {
54
55
  this.preferences = previous;
56
+ // A thrown host error is reported with its own message, like a failed save.
57
+ const error = Cause.squash(exit.cause);
55
58
  this.notice = `Could not save: ${error instanceof Error ? error.message : String(error)}`;
56
- } finally {
57
- this.isSaving = false; this.changed();
58
59
  }
60
+ this.isSaving = false; this.changed();
59
61
  }
60
62
 
61
63
  render(width: number): string[] {
@@ -1,6 +1,6 @@
1
1
  import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import { decodeKittyPrintable, fuzzyFilter, matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
3
- import type { FusionChoices, FusionSelection } from "./models.ts";
3
+ import type { FusionChoices, FusionSelection } from "../../../features/catalog/types.ts";
4
4
 
5
5
  export type PickerTheme = Pick<Theme, "fg" | "bold">;
6
6
  interface Item { id: string; name: string; recommended?: true }
@@ -0,0 +1,65 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Context, Effect, Layer } from "effect";
3
+ import { ExtensionConfig } from "../../app/config.ts";
4
+ import { Accounts } from "../../features/accounts/service.ts";
5
+ import { ModelCatalog } from "../../features/catalog/service.ts";
6
+ import { DisplaySettings } from "../../features/settings/service.ts";
7
+ import { UsageChanges } from "../../features/usage/changes.ts";
8
+ import { sessionUsage, usageStatusText } from "../../features/usage/report.ts";
9
+ import type { AccountsShape } from "../../features/accounts/types.ts";
10
+ import type { ModelCatalogShape } from "../../features/catalog/types.ts";
11
+ import type { AccountUsage } from "../../features/usage/types.ts";
12
+
13
+ /** The only status key this package uses. */
14
+ export const USAGE_STATUS_KEY = "devin-usage";
15
+
16
+ export interface UsageStatusShape {
17
+ /** Newest quota for the credential of the last model refresh. Makes no request. */
18
+ readonly accountUsage: Effect.Effect<AccountUsage | undefined>;
19
+ /**
20
+ * Draws the status in the TUI only, while the session's current model is a Devin model, from the last account status and
21
+ * the session records. `pendingMessage` is a response Pi has not stored yet. Another provider's model or turning the toggle
22
+ * off clears only this package's key; `/devin` reports usage whatever the model.
23
+ */
24
+ readonly update: (context: ExtensionContext, pendingMessage?: unknown) => Effect.Effect<void>;
25
+ /** The session whose status line follows quota changes that happen outside event handlers. */
26
+ readonly bind: (context: ExtensionContext | undefined) => Effect.Effect<void>;
27
+ }
28
+
29
+ export class UsageStatus extends Context.Service<UsageStatus, UsageStatusShape>()("pi-devin/UsageStatus") {}
30
+
31
+ /** Newest quota for the credential of the last model refresh: the account `/devin` and the status line show. Makes no request. */
32
+ export const currentAccountUsage = (catalog: ModelCatalogShape, accounts: AccountsShape): Effect.Effect<AccountUsage | undefined> =>
33
+ Effect.flatMap(catalog.currentScope, (scope) => scope ? accounts.usage(scope) : Effect.succeed(undefined));
34
+
35
+ /** Pi stores a message only after every `message_end` handler ran, so a just-finished response is counted explicitly. */
36
+ function branchWith(context: ExtensionContext, pendingMessage: unknown): readonly unknown[] {
37
+ const branch = context.sessionManager.getBranch();
38
+ if (!pendingMessage || branch.some((entry) => entry.type === "message" && entry.message === pendingMessage)) return branch;
39
+ return [...branch, { type: "message", message: pendingMessage }];
40
+ }
41
+
42
+ export const UsageStatusLive = Layer.effect(UsageStatus, Effect.gen(function* () {
43
+ const config = yield* ExtensionConfig;
44
+ const settings = yield* DisplaySettings;
45
+ const accounts = yield* Accounts;
46
+ const catalog = yield* ModelCatalog;
47
+ const changes = yield* UsageChanges;
48
+ let bound: ExtensionContext | undefined;
49
+ const accountUsage = currentAccountUsage(catalog, accounts);
50
+ // The sidekick child shows no status line.
51
+ const update = (context: ExtensionContext, pendingMessage?: unknown) => config.isFusionChild ? Effect.void : Effect.gen(function* () {
52
+ if (context.mode !== "tui") return;
53
+ const isShown = context.model?.provider === "devin" && (yield* settings.read).showUsageStatus;
54
+ const text = isShown
55
+ ? usageStatusText(yield* accountUsage, sessionUsage(branchWith(context, pendingMessage)), (tone, segment) => context.ui.theme.fg(tone, segment)) : undefined;
56
+ context.ui.setStatus(USAGE_STATUS_KEY, text);
57
+ });
58
+ // The session may be replaced or reloaded meanwhile; its next session_start redraws.
59
+ yield* changes.subscribe(Effect.suspend(() => bound ? update(bound) : Effect.void));
60
+ return {
61
+ accountUsage,
62
+ update,
63
+ bind: (context) => Effect.sync(() => { bound = context; }),
64
+ } satisfies UsageStatusShape;
65
+ }));