@buyong/pi-devin 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ko.md +222 -0
- package/README.md +37 -41
- package/index.ts +6 -12
- package/package.json +4 -1
- package/src/core/auth/access.ts +7 -3
- package/src/core/auth/callback-server.ts +57 -0
- package/src/core/auth/index.ts +2 -1
- package/src/core/auth/login.ts +104 -151
- package/src/core/auth/pkce.ts +32 -0
- package/src/core/auth/rest-exchange.ts +32 -30
- package/src/core/cancellation.ts +99 -0
- package/src/core/client.ts +127 -122
- package/src/core/errors.ts +115 -0
- package/src/core/index.ts +18 -4
- package/src/core/inference/accumulator.ts +174 -0
- package/src/core/inference/images.ts +22 -15
- package/src/core/inference/request.ts +59 -0
- package/src/core/inference/retry.ts +31 -31
- package/src/core/inference/state.ts +7 -3
- package/src/core/inference/stream-chat.ts +48 -0
- package/src/core/models/access.ts +4 -3
- package/src/core/models/catalog.ts +29 -20
- package/src/core/models/select.ts +14 -12
- package/src/core/models/variants.ts +85 -72
- package/src/core/protocol/connect-client.ts +288 -0
- package/src/core/protocol/device.ts +33 -34
- package/src/core/protocol/endpoint.ts +22 -0
- package/src/core/protocol/fingerprint.ts +38 -26
- package/src/core/protocol/frames.ts +101 -0
- package/src/core/protocol/http/fetch-client.ts +42 -0
- package/src/core/protocol/http/node-client.ts +130 -0
- package/src/core/protocol/http/response.ts +99 -0
- package/src/core/protocol/http.ts +41 -101
- package/src/core/protocol/protobuf.ts +93 -74
- package/src/core/redaction.ts +33 -0
- package/src/core/services/device-identity.ts +15 -0
- package/src/core/services/http-transport.ts +28 -0
- package/src/core/services/platform.ts +54 -0
- package/src/core/types.ts +2 -23
- package/src/core/validation.ts +25 -0
- package/src/pi/adapters/pi/devin-command.ts +82 -0
- package/src/pi/adapters/pi/fusion-command.ts +92 -0
- package/src/pi/adapters/pi/oauth.ts +14 -0
- package/src/pi/adapters/pi/provider.ts +22 -0
- package/src/pi/adapters/pi/registration.ts +89 -0
- package/src/pi/adapters/pi/response-stream.ts +164 -0
- package/src/pi/adapters/pi/sidekick-tool.ts +45 -0
- package/src/pi/adapters/pi/transcript.ts +177 -0
- package/src/pi/adapters/pi/types.ts +14 -0
- package/src/pi/{devin-panel.ts → adapters/pi/ui/devin-panel.ts} +11 -9
- package/src/pi/{fusion-picker.ts → adapters/pi/ui/fusion-picker.ts} +1 -1
- package/src/pi/adapters/pi/usage-status.ts +65 -0
- package/src/pi/adapters/storage/files.ts +66 -0
- package/src/pi/adapters/storage/json-file.ts +31 -0
- package/src/pi/app/config.ts +25 -0
- package/src/pi/app/environment.ts +30 -0
- package/src/pi/app/layer.ts +43 -0
- package/src/pi/app/runtime.ts +20 -0
- package/src/pi/app/session.ts +26 -0
- package/src/pi/features/accounts/service.ts +69 -0
- package/src/pi/features/accounts/types.ts +25 -0
- package/src/pi/features/auth/credential.ts +40 -0
- package/src/pi/features/auth/guard.ts +25 -0
- package/src/pi/features/auth/login.ts +116 -0
- package/src/pi/features/auth/state.ts +48 -0
- package/src/pi/features/auth/types.ts +36 -0
- package/src/pi/features/bounded.ts +4 -0
- package/src/pi/features/catalog/constants.ts +10 -0
- package/src/pi/features/catalog/fusion.ts +45 -0
- package/src/pi/features/catalog/ids.ts +79 -0
- package/src/pi/features/catalog/projection.ts +93 -0
- package/src/pi/features/catalog/remote.ts +15 -0
- package/src/pi/features/catalog/restore.ts +53 -0
- package/src/pi/features/catalog/service.ts +158 -0
- package/src/pi/features/catalog/types.ts +56 -0
- package/src/pi/features/clients/service.ts +47 -0
- package/src/pi/features/clients/types.ts +22 -0
- package/src/pi/features/default-scheduler.ts +12 -0
- package/src/pi/features/inference/request.ts +190 -0
- package/src/pi/features/inference/service.ts +33 -0
- package/src/pi/features/inference/types.ts +11 -0
- package/src/pi/features/lifetime/service.ts +19 -0
- package/src/pi/features/lifetime/types.ts +13 -0
- package/src/pi/features/runs/admission.ts +36 -0
- package/src/pi/features/runs/lifecycle.ts +207 -0
- package/src/pi/features/runs/service.ts +10 -0
- package/src/pi/features/runs/state.ts +37 -0
- package/src/pi/features/runs/types.ts +43 -0
- package/src/pi/features/settings/service.ts +46 -0
- package/src/pi/features/settings/types.ts +36 -0
- package/src/pi/features/sidekick/child.ts +228 -0
- package/src/pi/features/sidekick/command.ts +80 -0
- package/src/pi/features/sidekick/launcher.ts +12 -0
- package/src/pi/features/sidekick/prompts.ts +17 -0
- package/src/pi/features/sidekick/report.ts +51 -0
- package/src/pi/features/sidekick/service.ts +176 -0
- package/src/pi/features/sidekick/types.ts +99 -0
- package/src/pi/features/usage/changes.ts +20 -0
- package/src/pi/{usage.ts → features/usage/report.ts} +3 -34
- package/src/pi/features/usage/types.ts +33 -0
- package/src/core/inference/chat.ts +0 -234
- package/src/core/protocol/transport.ts +0 -513
- package/src/pi/auth.ts +0 -145
- package/src/pi/devin-command.ts +0 -76
- package/src/pi/devin-settings.ts +0 -50
- package/src/pi/extension.ts +0 -80
- package/src/pi/fusion-command.ts +0 -83
- package/src/pi/fusion-settings.ts +0 -44
- package/src/pi/lifecycle.ts +0 -217
- package/src/pi/messages.ts +0 -131
- package/src/pi/models.ts +0 -379
- package/src/pi/provider.ts +0 -249
- package/src/pi/sidekick-report.ts +0 -47
- package/src/pi/sidekick-runtime.ts +0 -300
- package/src/pi/sidekick.ts +0 -211
- package/src/pi/stream.ts +0 -124
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Deferred, Effect, Exit } from "effect";
|
|
2
|
+
|
|
3
|
+
interface Ticket {
|
|
4
|
+
turn: Deferred.Deferred<void>;
|
|
5
|
+
hasLeft: boolean;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface AdmissionTicket {
|
|
9
|
+
/** Waits until everyone who entered earlier has left. */
|
|
10
|
+
readonly turn: Effect.Effect<void>;
|
|
11
|
+
/** Idempotent. Also used by a request that gives up before its turn; those behind it still wait for everyone ahead of it. */
|
|
12
|
+
readonly leave: Effect.Effect<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Admits one request at a time, strictly in the order they entered. A new arrival never takes a turn ahead of a waiting one. */
|
|
16
|
+
export class AdmissionLine {
|
|
17
|
+
private readonly line: Ticket[] = [];
|
|
18
|
+
|
|
19
|
+
enter(): AdmissionTicket {
|
|
20
|
+
const ticket: Ticket = { turn: Deferred.makeUnsafe<void>(), hasLeft: false };
|
|
21
|
+
this.line.push(ticket);
|
|
22
|
+
if (this.line.length === 1) Deferred.doneUnsafe(ticket.turn, Exit.void);
|
|
23
|
+
return {
|
|
24
|
+
turn: Deferred.await(ticket.turn),
|
|
25
|
+
leave: Effect.sync(() => this.leave(ticket)),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
private leave(ticket: Ticket): void {
|
|
30
|
+
if (ticket.hasLeft) return;
|
|
31
|
+
ticket.hasLeft = true;
|
|
32
|
+
while (this.line[0]?.hasLeft) this.line.shift();
|
|
33
|
+
const front = this.line[0];
|
|
34
|
+
if (front) Deferred.doneUnsafe(front.turn, Exit.void);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Exit } from "effect";
|
|
3
|
+
import { makeCancellation, type Cancellation } from "../../../core/cancellation.ts";
|
|
4
|
+
import { Cancelled, type DevinError, type DevinFailure } from "../../../core/errors.ts";
|
|
5
|
+
import { failWith } from "../../../core/validation.ts";
|
|
6
|
+
import { AdmissionLine, type AdmissionTicket } from "./admission.ts";
|
|
7
|
+
import { freshState, MAX_STEP_INDEX, restoreState } from "./state.ts";
|
|
8
|
+
import type { DevinSessionState, ExecutionKind, ExecutionState, RequestLease, SessionBinding } from "./types.ts";
|
|
9
|
+
|
|
10
|
+
interface Execution {
|
|
11
|
+
data: ExecutionState;
|
|
12
|
+
/** Ends the run's work with a reason: a finished run or an invalidated login. */
|
|
13
|
+
cancellation: Cancellation;
|
|
14
|
+
/** Requests of one run are admitted one at a time, in order. */
|
|
15
|
+
admission: AdmissionLine;
|
|
16
|
+
cleanups: Set<Effect.Effect<void>>;
|
|
17
|
+
activeRequests: number;
|
|
18
|
+
hasStarted: boolean;
|
|
19
|
+
nextIsRecovery: boolean;
|
|
20
|
+
hasCheckpoint: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const cancelled = () => new Cancelled({ message: "The previous Devin run ended." });
|
|
24
|
+
const runQuietly = (cleanup: Effect.Effect<void>) => Effect.catchCause(cleanup, () => Effect.void);
|
|
25
|
+
|
|
26
|
+
/** Runs of one extension instance and its attached session. It never builds its own tool or retry loop. */
|
|
27
|
+
export class DevinLifecycle {
|
|
28
|
+
private binding?: SessionBinding;
|
|
29
|
+
private state: DevinSessionState = freshState(randomUUID());
|
|
30
|
+
private execution?: Execution;
|
|
31
|
+
private isClosed = false;
|
|
32
|
+
private isAuxiliary = false;
|
|
33
|
+
|
|
34
|
+
private constructor() {}
|
|
35
|
+
|
|
36
|
+
static readonly make: Effect.Effect<DevinLifecycle> = Effect.sync(() => new DevinLifecycle());
|
|
37
|
+
|
|
38
|
+
get snapshot(): Effect.Effect<DevinSessionState> {
|
|
39
|
+
return Effect.sync(() => structuredClone(this.state));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private checkpoint(): Effect.Effect<void, Cancelled> {
|
|
43
|
+
return Effect.suspend(() => {
|
|
44
|
+
if (this.binding && this.binding.getSessionId() !== this.state.sessionId) return Effect.fail(cancelled());
|
|
45
|
+
this.binding?.append(structuredClone(this.state));
|
|
46
|
+
return Effect.void;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
private activate(data: ExecutionState, hasCheckpoint = false): Execution {
|
|
51
|
+
this.state.execution = data;
|
|
52
|
+
return this.execution = {
|
|
53
|
+
data, cancellation: makeCancellation(), admission: new AdmissionLine(), cleanups: new Set(),
|
|
54
|
+
activeRequests: 0, hasStarted: false, nextIsRecovery: false, hasCheckpoint,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
attach(binding: SessionBinding): Effect.Effect<void, DevinError> {
|
|
59
|
+
return Effect.gen({ self: this }, function* () {
|
|
60
|
+
yield* this.abort(cancelled());
|
|
61
|
+
this.binding = binding;
|
|
62
|
+
this.state = yield* restoreState(binding);
|
|
63
|
+
this.execution = undefined; this.isClosed = false; this.isAuxiliary = false;
|
|
64
|
+
if (this.state.execution && !this.state.execution.settled) this.activate({ ...this.state.execution, kind: "recovered" }, true);
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
begin(kind: ExecutionKind): Effect.Effect<void, Cancelled> {
|
|
69
|
+
return Effect.gen({ self: this }, function* () {
|
|
70
|
+
if (this.isClosed) return yield* Effect.fail(cancelled());
|
|
71
|
+
const current = this.execution;
|
|
72
|
+
if (current && !current.data.settled) {
|
|
73
|
+
if (kind === "agent" && ["pending", "recovered"].includes(current.data.kind)) current.data.kind = "agent";
|
|
74
|
+
else if (kind === "agent" && ["manual", "tree", "direct"].includes(current.data.kind) && current.activeRequests === 0) yield* this.finish;
|
|
75
|
+
else return; // Retries and tool follow-up calls stay in the same run.
|
|
76
|
+
if (this.execution) return;
|
|
77
|
+
}
|
|
78
|
+
this.activate({ id: randomUUID(), settled: false, kind });
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get agentStarted(): Effect.Effect<void, Cancelled> {
|
|
83
|
+
return Effect.gen({ self: this }, function* () {
|
|
84
|
+
if (!this.execution) yield* this.begin("agent");
|
|
85
|
+
const execution = this.execution!;
|
|
86
|
+
execution.nextIsRecovery = execution.hasStarted;
|
|
87
|
+
execution.hasStarted = true;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
beforeCompaction(reason: "manual" | "threshold" | "overflow"): Effect.Effect<void, Cancelled> {
|
|
92
|
+
return Effect.andThen(this.begin(reason === "manual" ? "manual" : "pending"), Effect.sync(() => { this.isAuxiliary = true; }));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
afterCompaction(reason: "manual" | "threshold" | "overflow"): Effect.Effect<void, Cancelled> {
|
|
96
|
+
return Effect.suspend(() => {
|
|
97
|
+
this.isAuxiliary = false;
|
|
98
|
+
return reason === "manual" && this.execution?.data.kind === "manual" ? this.finish : Effect.void;
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
get beforeTree(): Effect.Effect<void, Cancelled> {
|
|
103
|
+
return Effect.andThen(this.begin("tree"), Effect.sync(() => { this.isAuxiliary = true; }));
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
get finish(): Effect.Effect<void, Cancelled> {
|
|
107
|
+
return Effect.gen({ self: this }, function* () {
|
|
108
|
+
const execution = this.execution;
|
|
109
|
+
if (!execution) return;
|
|
110
|
+
execution.data.settled = true;
|
|
111
|
+
yield* this.abort(cancelled());
|
|
112
|
+
if (execution.hasCheckpoint) yield* this.checkpoint();
|
|
113
|
+
this.execution = undefined; this.isAuxiliary = false;
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Stops the run's waiting and running requests with the reason. A cleanup failure never skips releasing other resources. */
|
|
118
|
+
abort(reason: DevinFailure): Effect.Effect<void> {
|
|
119
|
+
return Effect.suspend(() => {
|
|
120
|
+
const active = this.execution;
|
|
121
|
+
if (!active) return Effect.void;
|
|
122
|
+
active.cancellation.cancelUnsafe(reason);
|
|
123
|
+
return Effect.forEach([...active.cleanups], runQuietly, { discard: true });
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
get shutdown(): Effect.Effect<void> {
|
|
128
|
+
return Effect.suspend(() => {
|
|
129
|
+
if (this.isClosed) return Effect.void;
|
|
130
|
+
// An unfinished run stays in the checkpoint so a reload resumes it with the same execution ID.
|
|
131
|
+
return Effect.andThen(this.abort(cancelled()), Effect.sync(() => { this.isClosed = true; }));
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get treeChanged(): Effect.Effect<void, DevinError | Cancelled> {
|
|
136
|
+
return Effect.gen({ self: this }, function* () {
|
|
137
|
+
if (!this.binding) return;
|
|
138
|
+
yield* this.abort(cancelled());
|
|
139
|
+
this.state = yield* restoreState(this.binding);
|
|
140
|
+
this.state.trajectoryId = randomUUID(); this.state.nextStepIndex = 0;
|
|
141
|
+
this.state.execution = undefined; this.execution = undefined; this.isAuxiliary = false;
|
|
142
|
+
if (this.state.ownerHash) yield* this.checkpoint();
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
acquire(ownerHash: string): Effect.Effect<RequestLease, DevinFailure> {
|
|
147
|
+
return Effect.gen({ self: this }, function* () {
|
|
148
|
+
if (this.isClosed) return yield* Effect.fail(cancelled());
|
|
149
|
+
if (!/^[a-f0-9]{64}$/.test(ownerHash)) return yield* failWith("A verified Devin account scope is required.");
|
|
150
|
+
if (this.state.ownerHash && this.state.ownerHash !== ownerHash) return yield* failWith("This Pi session is bound to a different Devin account or endpoint. Start a new session.");
|
|
151
|
+
this.state.ownerHash = ownerHash;
|
|
152
|
+
if (!this.execution) yield* this.begin("direct");
|
|
153
|
+
const active = this.execution!, state = this.state;
|
|
154
|
+
const ownCleanups = new Set<Effect.Effect<void>>();
|
|
155
|
+
let isCounted = false, hasReleased = false;
|
|
156
|
+
let ticket: AdmissionTicket | undefined;
|
|
157
|
+
const release = Effect.suspend(() => {
|
|
158
|
+
if (hasReleased) return Effect.void;
|
|
159
|
+
hasReleased = true;
|
|
160
|
+
for (const cleanup of ownCleanups) active.cleanups.delete(cleanup);
|
|
161
|
+
return Effect.forEach([...ownCleanups], runQuietly, { discard: true }).pipe(Effect.andThen(Effect.suspend(() => {
|
|
162
|
+
if (isCounted) active.activeRequests--;
|
|
163
|
+
return ticket ? ticket.leave : Effect.void;
|
|
164
|
+
})));
|
|
165
|
+
});
|
|
166
|
+
const assertCurrent = Effect.suspend(() => {
|
|
167
|
+
const reason = active.cancellation.reason();
|
|
168
|
+
if (reason) return Effect.fail(reason);
|
|
169
|
+
if (this.execution !== active || this.state !== state || this.isClosed || (this.binding && this.binding.getSessionId() !== state.sessionId)) return Effect.fail(cancelled());
|
|
170
|
+
return Effect.void;
|
|
171
|
+
});
|
|
172
|
+
const admitted = Effect.gen({ self: this }, function* () {
|
|
173
|
+
// Entered inside the effect whose exit releases it, so an interruption cannot leave the line blocked.
|
|
174
|
+
ticket = active.admission.enter();
|
|
175
|
+
yield* active.cancellation.guard(ticket.turn);
|
|
176
|
+
yield* assertCurrent;
|
|
177
|
+
if (state.nextStepIndex >= MAX_STEP_INDEX) return yield* failWith("The Devin step number limit was reached. Start a new session.");
|
|
178
|
+
active.activeRequests++; isCounted = true;
|
|
179
|
+
const stepIndex = state.nextStepIndex++;
|
|
180
|
+
active.hasCheckpoint = true;
|
|
181
|
+
yield* this.checkpoint();
|
|
182
|
+
const isAuxiliary = this.isAuxiliary;
|
|
183
|
+
const isRecovery = !isAuxiliary && active.nextIsRecovery;
|
|
184
|
+
if (!isAuxiliary) active.nextIsRecovery = false;
|
|
185
|
+
const lease: RequestLease = {
|
|
186
|
+
executionId: active.data.id, cascadeId: state.cascadeId, trajectoryId: state.trajectoryId,
|
|
187
|
+
sessionId: state.sessionId, stepIndex, isAuxiliary, isRecovery,
|
|
188
|
+
cancellation: active.cancellation,
|
|
189
|
+
assertAttempt: assertCurrent,
|
|
190
|
+
addCleanup: (cleanup) => Effect.suspend(() => {
|
|
191
|
+
let hasCleaned = false;
|
|
192
|
+
const once = Effect.suspend(() => {
|
|
193
|
+
if (hasCleaned) return Effect.void;
|
|
194
|
+
hasCleaned = true;
|
|
195
|
+
return cleanup;
|
|
196
|
+
});
|
|
197
|
+
ownCleanups.add(once); active.cleanups.add(once);
|
|
198
|
+
return active.cancellation.isCancelled() ? runQuietly(once) : Effect.void;
|
|
199
|
+
}),
|
|
200
|
+
release,
|
|
201
|
+
};
|
|
202
|
+
return lease;
|
|
203
|
+
});
|
|
204
|
+
return yield* admitted.pipe(Effect.onExit((exit) => Exit.isSuccess(exit) ? Effect.void : release));
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Context, Layer } from "effect";
|
|
2
|
+
import { DevinLifecycle } from "./lifecycle.ts";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs of the attached Pi session: admission order, request leases and the stored session state.
|
|
6
|
+
* A run spans many provider calls and ends at `agent_settled`, never when one response ends.
|
|
7
|
+
*/
|
|
8
|
+
export class Runs extends Context.Service<Runs, DevinLifecycle>()("pi-devin/Runs") {}
|
|
9
|
+
|
|
10
|
+
export const RunsLive = Layer.effect(Runs, DevinLifecycle.make);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { Effect, Schema } from "effect";
|
|
3
|
+
import type { DevinError } from "../../../core/errors.ts";
|
|
4
|
+
import { failWith } from "../../../core/validation.ts";
|
|
5
|
+
import type { DevinSessionState, SessionBinding } from "./types.ts";
|
|
6
|
+
|
|
7
|
+
export const DEVIN_STATE_ENTRY = "pi-devin.state.v1";
|
|
8
|
+
export const MAX_STEP_INDEX = 2_147_483_647;
|
|
9
|
+
|
|
10
|
+
const UUID = Schema.String.check(Schema.isPattern(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/));
|
|
11
|
+
const isStoredState = Schema.is(Schema.Struct({
|
|
12
|
+
version: Schema.Literal(1),
|
|
13
|
+
sessionId: Schema.String,
|
|
14
|
+
ownerHash: Schema.optionalKey(Schema.String.check(Schema.isPattern(/^[a-f0-9]{64}$/))),
|
|
15
|
+
cascadeId: UUID,
|
|
16
|
+
trajectoryId: UUID,
|
|
17
|
+
nextStepIndex: Schema.Int.check(Schema.isBetween({ minimum: 0, maximum: MAX_STEP_INDEX })),
|
|
18
|
+
// `used` is the request budget count saved by 0.1.5 and earlier; it is accepted and dropped.
|
|
19
|
+
execution: Schema.optionalKey(Schema.Struct({ id: UUID, used: Schema.optionalKey(Schema.Int), settled: Schema.Boolean })),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
export function freshState(sessionId: string): DevinSessionState {
|
|
23
|
+
return { version: 1, sessionId, cascadeId: randomUUID(), trajectoryId: randomUUID(), nextStepIndex: 0 };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The newest state of this session's branch. Another session's state starts fresh; a corrupt one stops instead of resetting the IDs. */
|
|
27
|
+
export function restoreState(binding: SessionBinding): Effect.Effect<DevinSessionState, DevinError> {
|
|
28
|
+
return Effect.suspend(() => {
|
|
29
|
+
const sessionId = binding.getSessionId();
|
|
30
|
+
const entry = [...binding.getBranch()].reverse().find((item) => item.type === "custom" && item.customType === DEVIN_STATE_ENTRY);
|
|
31
|
+
if (!entry) return Effect.succeed(freshState(sessionId));
|
|
32
|
+
if (!isStoredState(entry.data)) return failWith("The Devin session state is corrupt or an unsupported version. Start a new session.");
|
|
33
|
+
if (entry.data.sessionId !== sessionId) return Effect.succeed(freshState(sessionId));
|
|
34
|
+
const { execution, ...state } = structuredClone(entry.data);
|
|
35
|
+
return Effect.succeed({ ...state, ...(execution ? { execution: { id: execution.id, settled: execution.settled } } : {}) } as DevinSessionState);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Effect } from "effect";
|
|
2
|
+
import type { Cancellation } from "../../../core/cancellation.ts";
|
|
3
|
+
import type { DevinFailure } from "../../../core/errors.ts";
|
|
4
|
+
|
|
5
|
+
export type ExecutionKind = "agent" | "pending" | "manual" | "tree" | "direct" | "recovered";
|
|
6
|
+
export interface ExecutionState { id: string; settled: boolean; kind: ExecutionKind }
|
|
7
|
+
|
|
8
|
+
/** The `pi-devin.state.v1` session entry. Holds no token, fingerprint or message. */
|
|
9
|
+
export interface DevinSessionState {
|
|
10
|
+
version: 1;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
ownerHash?: string;
|
|
13
|
+
cascadeId: string;
|
|
14
|
+
trajectoryId: string;
|
|
15
|
+
nextStepIndex: number;
|
|
16
|
+
execution?: ExecutionState;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Pi's current session as the run service sees it: its ID, its branch entries and a way to append a state entry. */
|
|
20
|
+
export interface SessionBinding {
|
|
21
|
+
getSessionId(): string;
|
|
22
|
+
getBranch(): readonly { type: string; customType?: string; data?: unknown }[];
|
|
23
|
+
append(data: DevinSessionState): void;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** One admitted model request of a run. */
|
|
27
|
+
export interface RequestLease {
|
|
28
|
+
executionId: string;
|
|
29
|
+
cascadeId: string;
|
|
30
|
+
trajectoryId: string;
|
|
31
|
+
sessionId: string;
|
|
32
|
+
stepIndex: number;
|
|
33
|
+
isAuxiliary: boolean;
|
|
34
|
+
isRecovery: boolean;
|
|
35
|
+
/** Ends when the run is aborted, failing with its reason. */
|
|
36
|
+
cancellation: Cancellation;
|
|
37
|
+
/** Checked before each model HTTP request: fails once the run has ended or the session has changed. */
|
|
38
|
+
assertAttempt: Effect.Effect<void, DevinFailure>;
|
|
39
|
+
/** Runs once when the run is aborted or the lease is released. */
|
|
40
|
+
addCleanup(cleanup: Effect.Effect<void>): Effect.Effect<void>;
|
|
41
|
+
/** Idempotent. Lets the next request of the run in. */
|
|
42
|
+
release: Effect.Effect<void>;
|
|
43
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Context, Effect, Layer } from "effect";
|
|
2
|
+
import type { FusionSelection } from "../catalog/types.ts";
|
|
3
|
+
import type { AccountUsage } from "../usage/types.ts";
|
|
4
|
+
import type { DevinPreferences, DisplaySettingsShape, FusionSettingsShape, UsageStoreShape } from "./types.ts";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_PREFERENCES: DevinPreferences = { recommendedOnly: false, showUsageStatus: false };
|
|
7
|
+
|
|
8
|
+
export class FusionSettings extends Context.Service<FusionSettings, FusionSettingsShape>()("pi-devin/FusionSettings") {}
|
|
9
|
+
export class DisplaySettings extends Context.Service<DisplaySettings, DisplaySettingsShape>()("pi-devin/DisplaySettings") {}
|
|
10
|
+
export class UsageStore extends Context.Service<UsageStore, UsageStoreShape>()("pi-devin/UsageStore") {}
|
|
11
|
+
|
|
12
|
+
/** A newer record replaces an older one of the same scope; another scope's record is always replaced. */
|
|
13
|
+
export const isNotOlder = (current: AccountUsage | undefined, next: AccountUsage) => !current || next.fetchedAtMs >= current.fetchedAtMs;
|
|
14
|
+
|
|
15
|
+
/** Memory stores, so SDK callers and tests never write to the real agent directory. */
|
|
16
|
+
export function memoryFusionSettings(initial?: FusionSelection): FusionSettingsShape {
|
|
17
|
+
let current = initial;
|
|
18
|
+
return {
|
|
19
|
+
read: Effect.sync(() => current),
|
|
20
|
+
write: (selection) => Effect.sync(() => { current = { ...selection }; }),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function memoryDisplaySettings(initial: Partial<DevinPreferences> = {}): DisplaySettingsShape {
|
|
25
|
+
let current: DevinPreferences = { ...DEFAULT_PREFERENCES, ...initial };
|
|
26
|
+
return {
|
|
27
|
+
read: Effect.sync(() => ({ ...current })),
|
|
28
|
+
write: (preferences) => Effect.sync(() => { current = { ...preferences }; }),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function memoryUsageStore(): UsageStoreShape {
|
|
33
|
+
let record: { scope: string; usage: AccountUsage } | undefined;
|
|
34
|
+
return {
|
|
35
|
+
read: (scope) => Effect.sync(() => record?.scope === scope ? { ...record.usage } : undefined),
|
|
36
|
+
write: (scope, usage) => Effect.sync(() => {
|
|
37
|
+
if (record?.scope !== scope || isNotOlder(record.usage, usage)) record = { scope, usage: { ...usage } };
|
|
38
|
+
}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const MemorySettings = (initial: { fusion?: FusionSelection; display?: Partial<DevinPreferences> } = {}) => Layer.mergeAll(
|
|
43
|
+
Layer.sync(FusionSettings, () => memoryFusionSettings(initial.fusion)),
|
|
44
|
+
Layer.sync(DisplaySettings, () => memoryDisplaySettings(initial.display)),
|
|
45
|
+
Layer.sync(UsageStore, memoryUsageStore),
|
|
46
|
+
);
|
|
@@ -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
|
+
}
|