@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.
- package/README.ko.md +3 -5
- package/README.md +3 -5
- package/index.ts +6 -15
- package/package.json +3 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -166
- package/src/pi/devin-command.ts +0 -86
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -89
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -415
- package/src/pi/provider.ts +0 -278
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
- package/src/pi/usage-store.ts +0 -75
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
3
|
+
import type { FusionSelection } from "../catalog/types.ts";
|
|
4
|
+
import type { AccountUsage } from "../usage/types.ts";
|
|
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
|
+
/** Global store for the lead and sidekick chosen with `/devin-fusion`. Every session uses the same value. */
|
|
15
|
+
export interface FusionSettingsShape {
|
|
16
|
+
/** Never fails: a missing or unreadable store reads as no saved pair. */
|
|
17
|
+
readonly read: Effect.Effect<FusionSelection | undefined>;
|
|
18
|
+
write(selection: FusionSelection): Effect.Effect<void, DevinError>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface DisplaySettingsShape {
|
|
22
|
+
/** Never fails: a missing or unreadable store reads as the defaults. */
|
|
23
|
+
readonly read: Effect.Effect<DevinPreferences>;
|
|
24
|
+
write(preferences: DevinPreferences): Effect.Effect<void, DevinError>;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Last account quota received by any Pi process that shares the agent directory, such as subagents and the Fusion sidekick.
|
|
29
|
+
* Keyed by the credential scope hash. No token, account ID or raw response is stored.
|
|
30
|
+
*/
|
|
31
|
+
export interface UsageStoreShape {
|
|
32
|
+
/** Never fails: a missing or unreadable record reads as none. */
|
|
33
|
+
read(scope: string): Effect.Effect<AccountUsage | undefined>;
|
|
34
|
+
/** Replaces the record unless the stored one for the same scope was fetched later. */
|
|
35
|
+
write(scope: string, usage: AccountUsage): Effect.Effect<void, DevinError>;
|
|
36
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
import type { ChildProcess } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { StringDecoder } from "node:string_decoder";
|
|
4
|
+
import { Deferred, Effect, Exit } from "effect";
|
|
5
|
+
import { toDevinError, type DevinError } from "../../../core/errors.ts";
|
|
6
|
+
import { launchArgs } from "./command.ts";
|
|
7
|
+
import type { Handoff, HandoffProgress, HandoffReport, SidekickLauncherShape, SidekickLaunch } from "./types.ts";
|
|
8
|
+
|
|
9
|
+
const STDERR_TAIL = 2048;
|
|
10
|
+
const RECENT_TOOLS = 5;
|
|
11
|
+
const KILL_GRACE_MS = 2000;
|
|
12
|
+
|
|
13
|
+
export interface SidekickChildOptions {
|
|
14
|
+
launcher: SidekickLauncherShape;
|
|
15
|
+
childEnv: Record<string, string>;
|
|
16
|
+
onProgress?: () => void;
|
|
17
|
+
/** Runs work that child process events start, in a fiber its owner tracks. */
|
|
18
|
+
fork: (effect: Effect.Effect<void>) => void;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface PendingHandoff {
|
|
22
|
+
id: string;
|
|
23
|
+
startedAt: number;
|
|
24
|
+
toolCalls: number;
|
|
25
|
+
recentTools: string[];
|
|
26
|
+
lastError?: string;
|
|
27
|
+
isAborted: boolean;
|
|
28
|
+
done: Deferred.Deferred<HandoffReport>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const errorMessage = (error: unknown) => error instanceof Error ? error.message : String(error);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Sidekick child Pi RPC process bound to one lead session. Started on the first handoff; keeps its context for the rest of the session.
|
|
35
|
+
* A handoff during a run adds instructions with `steer`; when the child run ends (`agent_settled`), the last assistant text is returned as the report.
|
|
36
|
+
*/
|
|
37
|
+
export class SidekickChild {
|
|
38
|
+
readonly launch: SidekickLaunch;
|
|
39
|
+
private readonly options: SidekickChildOptions;
|
|
40
|
+
private child: ChildProcess | undefined;
|
|
41
|
+
private pending: PendingHandoff | undefined;
|
|
42
|
+
private stderrTail = "";
|
|
43
|
+
private readonly waiting = new Map<string, Deferred.Deferred<string | undefined>>();
|
|
44
|
+
|
|
45
|
+
constructor(launch: SidekickLaunch, options: SidekickChildOptions) {
|
|
46
|
+
this.launch = launch;
|
|
47
|
+
this.options = options;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
isBusy(): boolean { return this.pending !== undefined; }
|
|
51
|
+
isAlive(): boolean { return this.child !== undefined && this.child.exitCode === null && this.child.signalCode === null; }
|
|
52
|
+
|
|
53
|
+
progress(): HandoffProgress | undefined {
|
|
54
|
+
const current = this.pending;
|
|
55
|
+
return current ? { toolCalls: current.toolCalls, recentTools: [...current.recentTools], elapsedMs: Date.now() - current.startedAt } : undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Starts a handoff, or steers the running one. Fails only when the child cannot be started. */
|
|
59
|
+
handoff(message: string): Effect.Effect<Handoff, DevinError> {
|
|
60
|
+
return Effect.gen({ self: this }, function* () {
|
|
61
|
+
const current = this.pending;
|
|
62
|
+
if (current) {
|
|
63
|
+
this.send({ type: "steer", message });
|
|
64
|
+
return { id: current.id, done: current.done, isSteer: true };
|
|
65
|
+
}
|
|
66
|
+
if (!this.isAlive()) yield* this.start;
|
|
67
|
+
const id = randomUUID();
|
|
68
|
+
const done = Deferred.makeUnsafe<HandoffReport>();
|
|
69
|
+
this.pending = { id, startedAt: Date.now(), toolCalls: 0, recentTools: [], isAborted: false, done };
|
|
70
|
+
this.send({ id, type: "prompt", message });
|
|
71
|
+
return { id, done, isSteer: false };
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
get abort(): Effect.Effect<void> {
|
|
76
|
+
return Effect.sync(() => {
|
|
77
|
+
if (!this.pending) return;
|
|
78
|
+
this.pending.isAborted = true;
|
|
79
|
+
this.send({ type: "abort" });
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Stops the child: its handoff ends as aborted and every RPC wait is released. Termination continues in the background. */
|
|
84
|
+
get close(): Effect.Effect<void> {
|
|
85
|
+
return Effect.sync(() => this.closeChild());
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
private closeChild(): void {
|
|
89
|
+
const child = this.child;
|
|
90
|
+
this.child = undefined;
|
|
91
|
+
if (this.pending) this.finish("aborted", "", "Stopped the sidekick process.");
|
|
92
|
+
for (const waiter of this.waiting.values()) Deferred.doneUnsafe(waiter, Exit.succeed(undefined));
|
|
93
|
+
this.waiting.clear();
|
|
94
|
+
if (!child || child.exitCode !== null || child.signalCode !== null) return;
|
|
95
|
+
child.stdin?.end();
|
|
96
|
+
const timer = setTimeout(() => { if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); }, KILL_GRACE_MS);
|
|
97
|
+
timer.unref();
|
|
98
|
+
child.once("close", () => clearTimeout(timer));
|
|
99
|
+
child.kill("SIGTERM");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private get start(): Effect.Effect<void, DevinError> {
|
|
103
|
+
return Effect.gen({ self: this }, function* () {
|
|
104
|
+
const { launcher } = this.options;
|
|
105
|
+
const base = yield* launcher.resolveCommand;
|
|
106
|
+
const args = [...base.args, ...launchArgs(this.launch, launcher.packageRoot)];
|
|
107
|
+
const child = yield* Effect.try({
|
|
108
|
+
try: () => launcher.spawn(base.command, args, {
|
|
109
|
+
cwd: this.launch.cwd,
|
|
110
|
+
env: { ...launcher.env, ...this.options.childEnv },
|
|
111
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
112
|
+
}),
|
|
113
|
+
catch: toDevinError,
|
|
114
|
+
});
|
|
115
|
+
this.child = child;
|
|
116
|
+
this.stderrTail = "";
|
|
117
|
+
this.listen(child);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private listen(child: ChildProcess): void {
|
|
122
|
+
const decoder = new StringDecoder("utf8");
|
|
123
|
+
let buffer = "";
|
|
124
|
+
child.stdout?.on("data", (chunk: Buffer) => {
|
|
125
|
+
if (this.child !== child) return;
|
|
126
|
+
buffer += decoder.write(chunk);
|
|
127
|
+
let index: number;
|
|
128
|
+
while ((index = buffer.indexOf("\n")) >= 0) {
|
|
129
|
+
let line = buffer.slice(0, index);
|
|
130
|
+
buffer = buffer.slice(index + 1);
|
|
131
|
+
if (line.endsWith("\r")) line = line.slice(0, -1);
|
|
132
|
+
if (!line) continue;
|
|
133
|
+
let record: Record<string, unknown>;
|
|
134
|
+
try { record = JSON.parse(line) as Record<string, unknown>; } catch { continue; }
|
|
135
|
+
this.handle(record);
|
|
136
|
+
if (this.child !== child) return;
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
child.stderr?.on("data", (chunk: Buffer) => { if (this.child === child) this.stderrTail = (this.stderrTail + chunk.toString("utf8")).slice(-STDERR_TAIL); });
|
|
140
|
+
child.stdin?.on("error", (error) => this.fail(child, error.message));
|
|
141
|
+
child.on("error", (error) => this.fail(child, error.message));
|
|
142
|
+
child.on("close", () => this.fail(child, this.stderrTail.trim() || "The sidekick process exited."));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Transport errors settle the handoff and all RPC waits once, even if the failed child closes after its replacement starts. */
|
|
146
|
+
private fail(child: ChildProcess | undefined, error: string): void {
|
|
147
|
+
if (this.child !== child) return;
|
|
148
|
+
this.finish(this.pending?.isAborted ? "aborted" : "error", "", error);
|
|
149
|
+
this.closeChild();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
private send(value: Record<string, unknown>): void {
|
|
153
|
+
const child = this.child;
|
|
154
|
+
try {
|
|
155
|
+
if (!child?.stdin?.writable) throw new Error("Cannot write to the sidekick process.");
|
|
156
|
+
child.stdin.write(JSON.stringify(value) + "\n");
|
|
157
|
+
} catch (error) {
|
|
158
|
+
this.fail(child, errorMessage(error));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Sends the request at once; the returned wait is released with `undefined` when the child stops. */
|
|
163
|
+
private request(type: string): Effect.Effect<string | undefined> {
|
|
164
|
+
const id = randomUUID();
|
|
165
|
+
const waiter = Deferred.makeUnsafe<string | undefined>();
|
|
166
|
+
this.waiting.set(id, waiter);
|
|
167
|
+
this.send({ id, type });
|
|
168
|
+
return Deferred.await(waiter);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
private handle(record: Record<string, unknown>): void {
|
|
172
|
+
const current = this.pending;
|
|
173
|
+
switch (record.type) {
|
|
174
|
+
case "response": {
|
|
175
|
+
const waiter = typeof record.id === "string" ? this.waiting.get(record.id) : undefined;
|
|
176
|
+
if (waiter) {
|
|
177
|
+
this.waiting.delete(record.id as string);
|
|
178
|
+
const data = record.data as { text?: unknown } | undefined;
|
|
179
|
+
Deferred.doneUnsafe(waiter, Exit.succeed(typeof data?.text === "string" ? data.text : undefined));
|
|
180
|
+
} else if (current && record.command === "prompt" && record.success === false) {
|
|
181
|
+
this.finish("error", "", String(record.error ?? "The sidekick did not receive the instructions."));
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
case "extension_ui_request":
|
|
186
|
+
// The sidekick never talks to the user. Dialogs that need an answer are returned as cancelled.
|
|
187
|
+
if (["select", "confirm", "input", "editor"].includes(String(record.method))) {
|
|
188
|
+
this.send({ type: "extension_ui_response", id: record.id, cancelled: true });
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
case "tool_execution_start":
|
|
192
|
+
if (!current) return;
|
|
193
|
+
current.toolCalls++;
|
|
194
|
+
current.recentTools = [...current.recentTools, String(record.toolName ?? "tool")].slice(-RECENT_TOOLS);
|
|
195
|
+
this.options.onProgress?.();
|
|
196
|
+
return;
|
|
197
|
+
case "message_end": {
|
|
198
|
+
const message = record.message as { role?: unknown; stopReason?: unknown; errorMessage?: unknown } | undefined;
|
|
199
|
+
if (current && message?.role === "assistant") {
|
|
200
|
+
current.lastError = message.stopReason === "error" && typeof message.errorMessage === "string" ? message.errorMessage : undefined;
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
case "agent_settled":
|
|
205
|
+
if (current) this.options.fork(this.complete(current, record.aborted === true));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private complete(current: PendingHandoff, isAborted: boolean): Effect.Effect<void> {
|
|
211
|
+
return Effect.map(this.request("get_last_assistant_text"), (text) => {
|
|
212
|
+
if (this.pending !== current) return;
|
|
213
|
+
if (isAborted || current.isAborted) this.finish("aborted", text ?? "");
|
|
214
|
+
else if (current.lastError) this.finish("error", text ?? "", current.lastError);
|
|
215
|
+
else this.finish("completed", text ?? "");
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private finish(status: HandoffReport["status"], text: string, error?: string): void {
|
|
220
|
+
const current = this.pending;
|
|
221
|
+
if (!current) return;
|
|
222
|
+
this.pending = undefined;
|
|
223
|
+
Deferred.doneUnsafe(current.done, Exit.succeed({
|
|
224
|
+
id: current.id, status, text, toolCalls: current.toolCalls, durationMs: Date.now() - current.startedAt, ...(error ? { error } : {}),
|
|
225
|
+
}));
|
|
226
|
+
this.options.onProgress?.();
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync } from "node:fs";
|
|
2
|
+
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { Effect } from "effect";
|
|
5
|
+
import { toDevinError, type DevinError } from "../../../core/errors.ts";
|
|
6
|
+
import type { SidekickCommand, SidekickLaunch } from "./types.ts";
|
|
7
|
+
|
|
8
|
+
const PI_PACKAGE = "@earendil-works/pi-coding-agent";
|
|
9
|
+
|
|
10
|
+
/** This package root. Passed to the child Pi with `-e` so it uses the same extension copy as the lead. Pi loads a path only once. */
|
|
11
|
+
export const PACKAGE_ROOT = dirname(fileURLToPath(new URL("../../../../package.json", import.meta.url)));
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
function piPackageRoot(start: string): string | undefined {
|
|
15
|
+
let directory = dirname(start);
|
|
16
|
+
while (directory !== dirname(directory)) {
|
|
17
|
+
const manifest = join(directory, "package.json");
|
|
18
|
+
if (existsSync(manifest)) {
|
|
19
|
+
try { if ((JSON.parse(readFileSync(manifest, "utf8")) as { name?: unknown }).name === PI_PACKAGE) return directory; } catch { /* try the next parent */ }
|
|
20
|
+
}
|
|
21
|
+
directory = dirname(directory);
|
|
22
|
+
}
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const EXTENSION_FLAGS = new Set(["-e", "--extension"]);
|
|
27
|
+
const EXTENSION_SWITCHES = new Set(["--no-extensions", "-ne", "--no-mcp"]);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Passes only the extension selection from the lead CLI arguments to the child, so tools from extensions loaded only with `-e` work for the sidekick too.
|
|
31
|
+
* Local paths become absolute paths from the lead's working directory. Source specifiers such as `builtin:` and `npm:` stay as they are.
|
|
32
|
+
*/
|
|
33
|
+
export function extensionArgs(argv: readonly string[], cwd: string): string[] {
|
|
34
|
+
const forwarded: string[] = [];
|
|
35
|
+
for (let index = 0; index < argv.length; index++) {
|
|
36
|
+
const value = argv[index];
|
|
37
|
+
if (EXTENSION_SWITCHES.has(value)) forwarded.push(value);
|
|
38
|
+
else if (EXTENSION_FLAGS.has(value) && index + 1 < argv.length) {
|
|
39
|
+
const target = argv[++index];
|
|
40
|
+
forwarded.push("-e", /^[a-z][a-z0-9+.-]*:/i.test(target) || isAbsolute(target) ? target : resolve(cwd, target));
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return forwarded;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function findPiCommand(): SidekickCommand {
|
|
47
|
+
const candidates: { path: string; isRunningCLI: boolean }[] = [];
|
|
48
|
+
try { if (process.argv[1]) candidates.push({ path: realpathSync(process.argv[1]), isRunningCLI: true }); } catch { /* missing path */ }
|
|
49
|
+
try { candidates.push({ path: fileURLToPath(import.meta.resolve(PI_PACKAGE)), isRunningCLI: false }); } catch { /* cannot resolve */ }
|
|
50
|
+
for (const candidate of candidates) {
|
|
51
|
+
const root = piPackageRoot(candidate.path);
|
|
52
|
+
if (!root) continue;
|
|
53
|
+
const manifest = JSON.parse(readFileSync(join(root, "package.json"), "utf8")) as { bin?: string | Record<string, string> };
|
|
54
|
+
const bin = typeof manifest.bin === "string" ? manifest.bin : manifest.bin?.pi;
|
|
55
|
+
if (bin && existsSync(join(root, bin))) {
|
|
56
|
+
const execArgv = process.execArgv.filter((value) => !/^--(inspect|debug)/.test(value));
|
|
57
|
+
const forwarded = candidate.isRunningCLI ? extensionArgs(process.argv.slice(2), process.cwd()) : [];
|
|
58
|
+
return { command: process.execPath, args: [...execArgv, join(root, bin), ...forwarded] };
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw new Error("Could not find a Pi CLI to run the sidekick.");
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Starts the child with the same Node, exec options and Pi CLI as the lead Pi. The running CLI is preferred so extension selection arguments carry over;
|
|
66
|
+
* when Pi is embedded through the SDK, the installed Pi package's bin is used. Inspect options are not passed, to avoid port conflicts.
|
|
67
|
+
*/
|
|
68
|
+
export const resolvePiCommand: Effect.Effect<SidekickCommand, DevinError> = Effect.try({ try: findPiCommand, catch: toDevinError });
|
|
69
|
+
|
|
70
|
+
export function launchArgs(launch: SidekickLaunch, packageRoot: string): string[] {
|
|
71
|
+
return [
|
|
72
|
+
"--mode", "rpc",
|
|
73
|
+
...(launch.sessionFile ? ["--session", launch.sessionFile] : ["--no-session"]),
|
|
74
|
+
"--model", `devin/${launch.modelID}`,
|
|
75
|
+
...(launch.thinking ? ["--thinking", launch.thinking] : []),
|
|
76
|
+
"--append-system-prompt", launch.systemPrompt,
|
|
77
|
+
"-e", packageRoot,
|
|
78
|
+
...(launch.tools.length ? ["--tools", launch.tools.join(",")] : ["--no-tools"]),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Context, Layer } from "effect";
|
|
3
|
+
import { PACKAGE_ROOT, resolvePiCommand } from "./command.ts";
|
|
4
|
+
import type { SidekickLauncherShape } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
/** How sidekick child Pi processes start. */
|
|
7
|
+
export class SidekickLauncher extends Context.Service<SidekickLauncher, SidekickLauncherShape>()("pi-devin/SidekickLauncher") {}
|
|
8
|
+
|
|
9
|
+
/** The same Node and Pi CLI as the lead, this process's environment and this package's own copy. */
|
|
10
|
+
export const NodeSidekickLauncher = Layer.sync(SidekickLauncher, () => ({
|
|
11
|
+
resolveCommand: resolvePiCommand, spawn, env: process.env, packageRoot: PACKAGE_ROOT,
|
|
12
|
+
} satisfies SidekickLauncherShape));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export const SIDEKICK_TOOL_NAME = "devin_sidekick";
|
|
2
|
+
/** Marker set on the child Pi started as the sidekick. The child's extension does not register Fusion again. */
|
|
3
|
+
export const FUSION_CHILD_ENV = "PI_DEVIN_FUSION_CHILD";
|
|
4
|
+
/** Custom message type that delivers a background handoff report to the lead. */
|
|
5
|
+
export const SIDEKICK_REPORT_MESSAGE = "devin-sidekick-report";
|
|
6
|
+
/** Appended by the provider to Fusion lead requests only, so a custom system prompt cannot drop it. */
|
|
7
|
+
export const FUSION_LEAD_GUIDANCE = [
|
|
8
|
+
`Fusion mode: you are the lead. Plan, talk to the user, and decide; delegate hands-on exploration, implementation, and verification to the \`${SIDEKICK_TOOL_NAME}\` tool with clear context and done criteria.`,
|
|
9
|
+
`The sidekick is a separate agent in this workspace with the same tools, and it keeps its context across handoffs. \`${SIDEKICK_TOOL_NAME}\` waits for the report by default; pass \`block: false\` to keep working while it runs, and its report arrives later as a message. Calling \`${SIDEKICK_TOOL_NAME}\` while it is busy adds instructions to the running handoff.`,
|
|
10
|
+
"Review its report critically, decide the next step, and do not redo work it already reported. Present the combined result as your own work.",
|
|
11
|
+
].join("\n");
|
|
12
|
+
export const SIDEKICK_SYSTEM_PROMPT = [
|
|
13
|
+
"You are the sidekick: a hands-on software engineer paired with a lead agent in the same workspace.",
|
|
14
|
+
"The lead sends you briefs with context and done criteria and may add instructions while you work. Do the work with the available tools: explore, implement, and verify.",
|
|
15
|
+
"You cannot talk to the user. When you finish, reply without tool calls with a concise report: what you did, how you verified it, and anything unresolved or risky.",
|
|
16
|
+
"Your conversation persists across briefs from the same lead session, so build on what you already know.",
|
|
17
|
+
].join("\n");
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Effect, FileSystem } from "effect";
|
|
3
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
4
|
+
import { fromPlatformError } from "../../../core/services/platform.ts";
|
|
5
|
+
import type { BoundedReport } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
/** Upper bound on the sidekick report the lead receives. The Devin CLI analysis records no sidekick report limit, so it is fixed here. */
|
|
8
|
+
export const SIDEKICK_REPORT_TOKEN_LIMIT = 131_072;
|
|
9
|
+
/** Pi's context estimate (`estimateTokens`) counts 4 characters per token, so the limit uses the same unit. */
|
|
10
|
+
const CHARS_PER_TOKEN = 4;
|
|
11
|
+
export const SIDEKICK_REPORT_CHAR_LIMIT = SIDEKICK_REPORT_TOKEN_LIMIT * CHARS_PER_TOKEN;
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff;
|
|
15
|
+
|
|
16
|
+
/** Keeps the beginning of the report within `maxChars` without splitting a surrogate pair. */
|
|
17
|
+
export function truncateReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): { text: string; isTruncated: boolean } {
|
|
18
|
+
if (text.length <= maxChars) return { text, isTruncated: false };
|
|
19
|
+
const end = isHighSurrogate(text.charCodeAt(maxChars - 1)) ? maxChars - 1 : maxChars;
|
|
20
|
+
return { text: text.slice(0, end), isTruncated: true };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Writes the complete report to a new private temporary directory and returns the file path. */
|
|
24
|
+
function preserveReport(text: string): Effect.Effect<string, DevinError, FileSystem.FileSystem> {
|
|
25
|
+
return Effect.gen(function* () {
|
|
26
|
+
const fs = yield* FileSystem.FileSystem;
|
|
27
|
+
const directory = yield* fs.makeTempDirectory({ prefix: "pi-devin-sidekick-report-" });
|
|
28
|
+
const path = join(directory, "report.md");
|
|
29
|
+
yield* fs.writeFileString(path, text, { mode: 0o600 });
|
|
30
|
+
return path;
|
|
31
|
+
}).pipe(Effect.mapError(fromPlatformError));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Bounds the report before it reaches the lead and keeps the complete text in a private temporary file.
|
|
36
|
+
* Never fails: background reports are delivered from a detached fiber, so a failed write only changes the notice.
|
|
37
|
+
*/
|
|
38
|
+
export function boundReport(text: string, maxChars = SIDEKICK_REPORT_CHAR_LIMIT): Effect.Effect<BoundedReport, never, FileSystem.FileSystem> {
|
|
39
|
+
return Effect.gen(function* () {
|
|
40
|
+
const result = truncateReport(text, maxChars);
|
|
41
|
+
if (!result.isTruncated) return { text };
|
|
42
|
+
const saved = yield* Effect.result(preserveReport(text));
|
|
43
|
+
const fullReportPath = saved._tag === "Success" ? saved.success : undefined;
|
|
44
|
+
const notice = `\n\n[The sidekick report exceeded about ${SIDEKICK_REPORT_TOKEN_LIMIT} tokens and was cut.` + (saved._tag === "Success"
|
|
45
|
+
? ` Full report: ${saved.success}]`
|
|
46
|
+
: ` The full report could not be saved: ${saved.failure.message}]`);
|
|
47
|
+
// The notice is part of the same budget, including a filesystem error if preservation failed.
|
|
48
|
+
const suffix = truncateReport(notice, maxChars).text;
|
|
49
|
+
return { text: truncateReport(text, maxChars - suffix.length).text + suffix, ...(fullReportPath ? { fullReportPath } : {}) };
|
|
50
|
+
});
|
|
51
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { Context, Deferred, Effect, Exit, FiberSet, FileSystem, Layer, Predicate } from "effect";
|
|
3
|
+
import { toDevinFailure, type DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
import { failWith } from "../../../core/validation.ts";
|
|
5
|
+
import { ExtensionConfig } from "../../app/config.ts";
|
|
6
|
+
import { fusionOf } from "../catalog/fusion.ts";
|
|
7
|
+
import { ModelCatalog } from "../catalog/service.ts";
|
|
8
|
+
import { onDefaultScheduler } from "../default-scheduler.ts";
|
|
9
|
+
import { SidekickChild } from "./child.ts";
|
|
10
|
+
import { SidekickLauncher } from "./launcher.ts";
|
|
11
|
+
import { FUSION_CHILD_ENV, SIDEKICK_SYSTEM_PROMPT, SIDEKICK_TOOL_NAME } from "./prompts.ts";
|
|
12
|
+
import { boundReport } from "./report.ts";
|
|
13
|
+
import type { BoundedReport, Handoff, HandoffReport, HandoffResult, SidekickDetails, SidekickHostShape, SidekickLaunch, SidekicksShape } from "./types.ts";
|
|
14
|
+
|
|
15
|
+
/** What the sidekick needs from the lead Pi. Provided by the Pi adapter. */
|
|
16
|
+
export class SidekickHost extends Context.Service<SidekickHost, SidekickHostShape>()("pi-devin/SidekickHost") {}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The Fusion sidekick: one child Pi per lead session, started on the first handoff and kept for the rest of the session.
|
|
20
|
+
* Owns the children, which call owns each report, and the fibers that deliver background reports.
|
|
21
|
+
*/
|
|
22
|
+
export class Sidekicks extends Context.Service<Sidekicks, SidekicksShape>()("pi-devin/Sidekicks") {}
|
|
23
|
+
|
|
24
|
+
const launchKey = (launch: SidekickLaunch) => JSON.stringify([launch.cwd, launch.modelID, launch.thinking, launch.sessionFile, launch.tools]);
|
|
25
|
+
const shortID = (id: string) => id.slice(0, 8);
|
|
26
|
+
const reportPath = (fullReportPath: string | undefined) => fullReportPath ? { fullReportPath } : {};
|
|
27
|
+
const nextMicrotask = Effect.promise(() => Promise.resolve());
|
|
28
|
+
/** Stops before work starts when the call was already cancelled, as `signal.throwIfAborted()` does. */
|
|
29
|
+
const checkAborted = (signal: AbortSignal | undefined): Effect.Effect<void, DevinFailure> => signal?.aborted ? Effect.fail(toDevinFailure(signal.reason)) : Effect.void;
|
|
30
|
+
|
|
31
|
+
interface ReportDelivery {
|
|
32
|
+
owner: "background" | "blocking" | "delivered";
|
|
33
|
+
scheduled: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type WaitOutcome = "done" | "aborted" | "interrupted";
|
|
37
|
+
|
|
38
|
+
export const SidekicksLive = Layer.effect(Sidekicks, Effect.gen(function* () {
|
|
39
|
+
const host = yield* SidekickHost;
|
|
40
|
+
const launcher = yield* SidekickLauncher;
|
|
41
|
+
const catalog = yield* ModelCatalog;
|
|
42
|
+
const config = yield* ExtensionConfig;
|
|
43
|
+
const fileSystem = yield* FileSystem.FileSystem;
|
|
44
|
+
const fibers = yield* FiberSet.make<void, never>();
|
|
45
|
+
const runInFibers = yield* FiberSet.runtime(fibers)<never>();
|
|
46
|
+
const children = new Map<string, SidekickChild>();
|
|
47
|
+
// Steers share the same report; completed handoffs must not be retained by delivery bookkeeping.
|
|
48
|
+
const deliveries = new WeakMap<Deferred.Deferred<HandoffReport>, ReportDelivery>();
|
|
49
|
+
const waits = new Set<() => void>();
|
|
50
|
+
// The tool runs sequentially, so at most one blocking call shows progress at a time.
|
|
51
|
+
let progressListener: (() => void) | undefined;
|
|
52
|
+
|
|
53
|
+
/** Bound and preserve the complete report, including status, errors and any background delivery heading. */
|
|
54
|
+
const reportText = (report: HandoffReport, heading = ""): Effect.Effect<BoundedReport> => {
|
|
55
|
+
const body = report.text.trim();
|
|
56
|
+
const reason = report.error ?? (report.status === "aborted" ? "cancelled." : "failed.");
|
|
57
|
+
const text = report.status === "completed" ? body || "(The sidekick finished without a report.)"
|
|
58
|
+
: [`The sidekick task ${report.status === "aborted" ? "stopped" : "failed"}: ${reason}`, body].filter(Boolean).join("\n\n");
|
|
59
|
+
return boundReport(heading + text).pipe(Effect.provideService(FileSystem.FileSystem, fileSystem));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const deliverLater = (handoff: Handoff, child: SidekickChild, delivery: ReportDelivery) => Effect.suspend(() => {
|
|
63
|
+
if (delivery.scheduled || delivery.owner === "delivered") return Effect.void;
|
|
64
|
+
delivery.scheduled = true;
|
|
65
|
+
// A blocking call owns the report unless user input moves it back to the background.
|
|
66
|
+
const canDeliver = () => delivery.owner === "background" && [...children.values()].includes(child);
|
|
67
|
+
return Effect.asVoid(FiberSet.run(fibers, Effect.gen(function* () {
|
|
68
|
+
const report = yield* Deferred.await(handoff.done);
|
|
69
|
+
if (report.status === "aborted" || !canDeliver()) return;
|
|
70
|
+
const { text, fullReportPath } = yield* reportText(report, `Sidekick report for background handoff ${shortID(report.id)}:\n\n`);
|
|
71
|
+
if (!canDeliver()) return;
|
|
72
|
+
delivery.owner = "delivered";
|
|
73
|
+
yield* host.deliverReport({
|
|
74
|
+
content: text,
|
|
75
|
+
details: { sidekickModel: child.launch.modelID, handoffId: report.id, status: report.status, toolCalls: report.toolCalls, durationMs: report.durationMs, isSteer: false,
|
|
76
|
+
...reportPath(fullReportPath) },
|
|
77
|
+
});
|
|
78
|
+
}).pipe(Effect.ensuring(Effect.sync(() => { delivery.scheduled = false; }))), { startImmediately: true }));
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const closeAll = (entries: readonly [string, SidekickChild][]) => Effect.forEach(entries, ([id, child]) => Effect.andThen(child.close, Effect.sync(() => {
|
|
82
|
+
children.delete(id);
|
|
83
|
+
})), { discard: true });
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
handoff: (request) => Effect.gen(function* () {
|
|
87
|
+
const { signal } = request;
|
|
88
|
+
yield* checkAborted(signal);
|
|
89
|
+
const fusion = fusionOf(yield* catalog.models, request.model);
|
|
90
|
+
if (!fusion) return yield* failWith("The sidekick is only available with the Devin Fusion model.");
|
|
91
|
+
const input = Predicate.isObject(request.params) ? request.params as { message?: unknown; block?: unknown } : undefined;
|
|
92
|
+
const message = Predicate.isString(input?.message) ? input.message.trim() : "";
|
|
93
|
+
if (!message) return yield* failWith("A message for the sidekick is required.");
|
|
94
|
+
// The lead list hides exact UIDs covered by groups, so the level comes from the Fusion candidates.
|
|
95
|
+
const sidekickChoice = (yield* catalog.fusionChoices)?.sidekicks.find((item) => item.id === fusion.sidekickID);
|
|
96
|
+
if (!sidekickChoice) return yield* failWith("The sidekick model is not in the current Devin model list. Refresh the model list.");
|
|
97
|
+
const { sessionId } = request;
|
|
98
|
+
const launch: SidekickLaunch = {
|
|
99
|
+
cwd: request.cwd, modelID: fusion.sidekickID, thinking: sidekickChoice.level,
|
|
100
|
+
sessionFile: request.isSessionSaved ? join(config.agentDir(), "pi-devin", "sidekick", `${sessionId}.jsonl`) : undefined,
|
|
101
|
+
tools: (yield* host.activeTools).filter((name) => name !== SIDEKICK_TOOL_NAME),
|
|
102
|
+
systemPrompt: SIDEKICK_SYSTEM_PROMPT,
|
|
103
|
+
};
|
|
104
|
+
let child = children.get(sessionId);
|
|
105
|
+
// When the pair or tools change, only an idle process is restarted. Context continues through the same session file.
|
|
106
|
+
if (child && !child.isBusy() && launchKey(child.launch) !== launchKey(launch)) { yield* child.close; child = undefined; }
|
|
107
|
+
if (!child) {
|
|
108
|
+
child = new SidekickChild(launch, {
|
|
109
|
+
launcher, childEnv: { [FUSION_CHILD_ENV]: "1" }, onProgress: () => progressListener?.(),
|
|
110
|
+
fork: (effect) => { runInFibers(onDefaultScheduler(effect)); },
|
|
111
|
+
});
|
|
112
|
+
children.set(sessionId, child);
|
|
113
|
+
}
|
|
114
|
+
const active = child;
|
|
115
|
+
yield* checkAborted(signal);
|
|
116
|
+
const handoff = yield* active.handoff(message);
|
|
117
|
+
let delivery = deliveries.get(handoff.done);
|
|
118
|
+
if (!delivery) {
|
|
119
|
+
delivery = { owner: "background", scheduled: false };
|
|
120
|
+
deliveries.set(handoff.done, delivery);
|
|
121
|
+
}
|
|
122
|
+
const owned = delivery;
|
|
123
|
+
const details = (status: SidekickDetails["status"], report?: HandoffReport): SidekickDetails => ({
|
|
124
|
+
sidekickModel: launch.modelID, handoffId: handoff.id, status, toolCalls: report?.toolCalls ?? active.progress()?.toolCalls ?? 0,
|
|
125
|
+
...(report ? { durationMs: report.durationMs } : {}), isSteer: handoff.isSteer,
|
|
126
|
+
});
|
|
127
|
+
const backgroundResult = (reason: string) => Effect.as(deliverLater(handoff, active, owned), {
|
|
128
|
+
content: [{ type: "text" as const, text: `${reason} The report will arrive as a message. (handoff ${shortID(handoff.id)})` }], details: details("background"),
|
|
129
|
+
} satisfies HandoffResult);
|
|
130
|
+
if (input?.block === false) {
|
|
131
|
+
return yield* backgroundResult(handoff.isSteer ? "Added instructions to the running sidekick task." : "The sidekick started working in the background.");
|
|
132
|
+
}
|
|
133
|
+
owned.owner = "blocking";
|
|
134
|
+
// The first event decides the outcome. The report is observed one microtask after it settles, as a promise continuation
|
|
135
|
+
// would, so user input in the same tick still moves the handoff to the background.
|
|
136
|
+
const decided = Deferred.makeUnsafe<WaitOutcome>();
|
|
137
|
+
const decide = (outcome: WaitOutcome) => () => { Deferred.doneUnsafe(decided, Exit.succeed(outcome)); };
|
|
138
|
+
const interrupt = decide("interrupted"), abort = decide("aborted");
|
|
139
|
+
const listen = Effect.gen(function* () {
|
|
140
|
+
waits.add(interrupt);
|
|
141
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
142
|
+
progressListener = () => {
|
|
143
|
+
const progress = active.progress();
|
|
144
|
+
if (progress) request.onProgress?.(`sidekick: ${progress.toolCalls} tool calls${progress.recentTools.length ? ` · ${progress.recentTools.join(", ")}` : ""}`, details("running"));
|
|
145
|
+
};
|
|
146
|
+
if (signal?.aborted) abort();
|
|
147
|
+
yield* FiberSet.run(fibers, Deferred.await(handoff.done).pipe(Effect.andThen(nextMicrotask), Effect.andThen(Effect.sync(decide("done")))), { startImmediately: true });
|
|
148
|
+
});
|
|
149
|
+
const stopListening = Effect.sync(() => {
|
|
150
|
+
progressListener = undefined;
|
|
151
|
+
waits.delete(interrupt);
|
|
152
|
+
signal?.removeEventListener("abort", abort);
|
|
153
|
+
});
|
|
154
|
+
const outcome = yield* Effect.acquireUseRelease(listen, () => Deferred.await(decided), () => stopListening);
|
|
155
|
+
if (outcome === "interrupted") {
|
|
156
|
+
owned.owner = "background";
|
|
157
|
+
return yield* backgroundResult("A user message arrived, so the sidekick task moved to the background.");
|
|
158
|
+
}
|
|
159
|
+
owned.owner = "delivered";
|
|
160
|
+
if (outcome === "aborted") {
|
|
161
|
+
yield* active.abort;
|
|
162
|
+
return yield* failWith("Cancelled the sidekick task.");
|
|
163
|
+
}
|
|
164
|
+
const report = yield* Deferred.await(handoff.done);
|
|
165
|
+
const { text, fullReportPath } = yield* reportText(report);
|
|
166
|
+
if (report.status !== "completed") return yield* failWith(text);
|
|
167
|
+
return { content: [{ type: "text" as const, text }], details: { ...details(report.status, report), ...reportPath(fullReportPath) } } satisfies HandoffResult;
|
|
168
|
+
}),
|
|
169
|
+
userInput: (source) => Effect.sync(() => {
|
|
170
|
+
if (source === "extension") return;
|
|
171
|
+
for (const interrupt of [...waits]) interrupt();
|
|
172
|
+
}),
|
|
173
|
+
sessionStarted: (sessionId) => Effect.suspend(() => closeAll([...children].filter(([id]) => id !== sessionId))),
|
|
174
|
+
reset: Effect.suspend(() => closeAll([...children])),
|
|
175
|
+
} satisfies SidekicksShape;
|
|
176
|
+
}));
|