@tt-a1i/openpi 0.1.0
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.md +643 -0
- package/SETUP.md +74 -0
- package/THIRD_PARTY_NOTICES.md +16 -0
- package/assets/openpi-package.png +0 -0
- package/assets/readme-hero-mobile.svg +72 -0
- package/assets/readme-hero.svg +118 -0
- package/assets/readme-runtime-mobile.svg +91 -0
- package/assets/readme-runtime.svg +111 -0
- package/extensions/ask-user/handoff.ts +205 -0
- package/extensions/ask-user/index.ts +1110 -0
- package/extensions/ask-user/limits.ts +89 -0
- package/extensions/ask-user/prompt.ts +76 -0
- package/extensions/background-terminals/index.ts +653 -0
- package/extensions/background-terminals/src/domain.ts +99 -0
- package/extensions/background-terminals/src/manager.ts +989 -0
- package/extensions/background-terminals/src/output.ts +84 -0
- package/extensions/background-terminals/src/prompt.ts +195 -0
- package/extensions/background-terminals/src/result-delivery.ts +43 -0
- package/extensions/background-terminals/src/runtime.ts +36 -0
- package/extensions/background-terminals/src/ui/output-view.ts +55 -0
- package/extensions/background-terminals/src/ui/ps.ts +642 -0
- package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
- package/extensions/background-terminals/src/watch.ts +192 -0
- package/extensions/context-pivot/index.ts +222 -0
- package/extensions/copy-all/index.ts +65 -0
- package/extensions/cron/index.ts +173 -0
- package/extensions/cron/schedule.ts +127 -0
- package/extensions/file-mutation-display/index.ts +105 -0
- package/extensions/file-mutation-display/render.ts +107 -0
- package/extensions/file-search/index.ts +515 -0
- package/extensions/file-search/src/args.ts +129 -0
- package/extensions/file-search/src/binaries.ts +419 -0
- package/extensions/file-search/src/output.ts +142 -0
- package/extensions/file-search/src/process.ts +309 -0
- package/extensions/file-search/src/prompt.ts +53 -0
- package/extensions/git-info/index.ts +272 -0
- package/extensions/git-info/src/changed-files-view.ts +414 -0
- package/extensions/git-info/src/process.ts +107 -0
- package/extensions/git-info/src/refresh-coordinator.ts +13 -0
- package/extensions/git-info/src/runtime.ts +28 -0
- package/extensions/goal/controller.ts +794 -0
- package/extensions/goal/index.ts +521 -0
- package/extensions/goal/prompts.ts +122 -0
- package/extensions/goal/state.ts +763 -0
- package/extensions/goal/ui.ts +158 -0
- package/extensions/model-info/index.ts +234 -0
- package/extensions/plan-mode/bash-policy.ts +313 -0
- package/extensions/plan-mode/index.ts +539 -0
- package/extensions/post-edit/index.ts +129 -0
- package/extensions/sessions/LICENSE.upstream +21 -0
- package/extensions/sessions/git-stats.ts +226 -0
- package/extensions/sessions/index.ts +1092 -0
- package/extensions/sessions/sessions.ts +385 -0
- package/extensions/setup/index.ts +408 -0
- package/extensions/shared/activity-status.ts +65 -0
- package/extensions/shared/below-editor-navigation.ts +343 -0
- package/extensions/shared/child-session.ts +352 -0
- package/extensions/shared/context-utilization.ts +47 -0
- package/extensions/shared/dashboard-state.ts +102 -0
- package/extensions/shared/plan-mode-state.ts +65 -0
- package/extensions/shared/setup-config.ts +971 -0
- package/extensions/shared/subagent-roles.ts +22 -0
- package/extensions/shared/terminal-text.ts +38 -0
- package/extensions/shared/tool-call-timeout.ts +104 -0
- package/extensions/shared/worktree.ts +526 -0
- package/extensions/subagents/index.ts +1225 -0
- package/extensions/subagents/navigation.ts +121 -0
- package/extensions/subagents/src/agent-types.ts +543 -0
- package/extensions/subagents/src/backend.ts +63 -0
- package/extensions/subagents/src/backends/pi.ts +493 -0
- package/extensions/subagents/src/backends/stub.ts +296 -0
- package/extensions/subagents/src/by-the-way.ts +21 -0
- package/extensions/subagents/src/domain.ts +271 -0
- package/extensions/subagents/src/format.ts +48 -0
- package/extensions/subagents/src/manager.ts +769 -0
- package/extensions/subagents/src/prompt.ts +190 -0
- package/extensions/subagents/src/result-delivery.ts +20 -0
- package/extensions/subagents/src/runtime.ts +51 -0
- package/extensions/subagents/src/ui/takeover.ts +615 -0
- package/extensions/subagents/src/ui/transcript.ts +293 -0
- package/extensions/subagents/src/ui/wait-result.ts +89 -0
- package/extensions/suggestions/index.ts +172 -0
- package/extensions/suggestions/src/config.ts +12 -0
- package/extensions/suggestions/src/predictor.ts +147 -0
- package/extensions/suggestions/src/prompt.ts +20 -0
- package/extensions/suggestions/src/transcript.ts +233 -0
- package/extensions/suggestions/src/ui.ts +224 -0
- package/extensions/tasks/index.ts +512 -0
- package/extensions/tasks/tasks.ts +649 -0
- package/extensions/tasks/ui.ts +421 -0
- package/extensions/turn-time/index.ts +61 -0
- package/extensions/ui-customization/footer.ts +512 -0
- package/extensions/ui-customization/index.ts +217 -0
- package/extensions/workflows/acceptance.ts +298 -0
- package/extensions/workflows/artifacts.ts +225 -0
- package/extensions/workflows/controller.ts +210 -0
- package/extensions/workflows/dashboard.ts +1226 -0
- package/extensions/workflows/index.ts +1884 -0
- package/extensions/workflows/journal.ts +188 -0
- package/extensions/workflows/meta.ts +250 -0
- package/extensions/workflows/model.ts +423 -0
- package/extensions/workflows/navigation.ts +93 -0
- package/extensions/workflows/prompt.ts +212 -0
- package/extensions/workflows/replay-safety.ts +577 -0
- package/extensions/workflows/runner.ts +786 -0
- package/extensions/workflows/sandbox-child.cjs +402 -0
- package/extensions/workflows/sandbox.ts +397 -0
- package/extensions/workflows/serialization.ts +162 -0
- package/extensions/workflows/worktree-handoff.ts +216 -0
- package/package.json +87 -0
- package/scripts/prepare-effect-tsgo.mjs +16 -0
- package/skills/background-terminals/SKILL.md +30 -0
- package/skills/subagents/SKILL.md +15 -0
- package/themes/github-dark-default.json +89 -0
|
@@ -0,0 +1,769 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SubagentManager — owns the registry of running/finished subagents.
|
|
3
|
+
*
|
|
4
|
+
* Each subagent is a scoped `SubagentSession` from a `SubagentBackend` plus a
|
|
5
|
+
* pump fiber that folds its normalized event stream into a mutable
|
|
6
|
+
* `SubagentSnapshot`. Closing a subagent's scope kills the underlying
|
|
7
|
+
* session/process and stops the pump.
|
|
8
|
+
*
|
|
9
|
+
* The manager also exposes a synchronous `SubagentReadModel` so the
|
|
10
|
+
* imperative TUI components (which render synchronously) can read snapshots
|
|
11
|
+
* and issue fire-and-forget commands without touching the Effect runtime.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {
|
|
15
|
+
Context,
|
|
16
|
+
Effect,
|
|
17
|
+
Exit,
|
|
18
|
+
Fiber,
|
|
19
|
+
Layer,
|
|
20
|
+
Result,
|
|
21
|
+
Scope,
|
|
22
|
+
Stream,
|
|
23
|
+
} from "effect";
|
|
24
|
+
import type { SubagentBackend, SubagentSession } from "./backend.ts";
|
|
25
|
+
import { BackendRegistry } from "./backend.ts";
|
|
26
|
+
import type {
|
|
27
|
+
BackendName,
|
|
28
|
+
LiveToolState,
|
|
29
|
+
RunOutcome,
|
|
30
|
+
SpawnTask,
|
|
31
|
+
SubagentEvent,
|
|
32
|
+
SubagentOrigin,
|
|
33
|
+
SubagentMeta,
|
|
34
|
+
SubagentSnapshot,
|
|
35
|
+
SubagentStatus,
|
|
36
|
+
TranscriptItem,
|
|
37
|
+
} from "./domain.ts";
|
|
38
|
+
import {
|
|
39
|
+
BackendUnavailableError,
|
|
40
|
+
ConcurrencyLimitError,
|
|
41
|
+
SendError,
|
|
42
|
+
SpawnError,
|
|
43
|
+
} from "./domain.ts";
|
|
44
|
+
|
|
45
|
+
/** Model-spawned subagents get their own pool so a user aside cannot starve it. */
|
|
46
|
+
export const MAX_RUNNING = 4;
|
|
47
|
+
/**
|
|
48
|
+
* User "by the way" asides run in a separate, smaller pool. Keeping them off the
|
|
49
|
+
* model pool means a hung btw session can never consume a slot the model needs,
|
|
50
|
+
* and the model's "max N" error can honestly reflect only the model's own pool.
|
|
51
|
+
*/
|
|
52
|
+
export const MAX_RUNNING_BTW = 2;
|
|
53
|
+
export const MAX_TRACKED = 64;
|
|
54
|
+
const STOP_TIMEOUT_MS = 5_000;
|
|
55
|
+
/** Session abort/shutdown (5s) plus bounded direct-worktree cleanup (4s). */
|
|
56
|
+
const ENTRY_CLOSE_TIMEOUT_MS = 10_000;
|
|
57
|
+
const ERROR_TEXT_MAX_LENGTH = 4_096;
|
|
58
|
+
const TRANSCRIPT_TEXT_MAX_LENGTH = 64 * 1_024;
|
|
59
|
+
const LIVE_ASSISTANT_MAX_LENGTH = 128 * 1_024;
|
|
60
|
+
const FINAL_TEXT_MAX_LENGTH = 1_024 * 1_024;
|
|
61
|
+
const MAX_TRANSCRIPT_ITEMS = 512;
|
|
62
|
+
|
|
63
|
+
function bounded(text: string) {
|
|
64
|
+
return text.slice(0, ERROR_TEXT_MAX_LENGTH);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function boundedTranscriptText(text: string) {
|
|
68
|
+
return text.slice(0, TRANSCRIPT_TEXT_MAX_LENGTH);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function appendTranscript(snapshot: MutableSnapshot, item: TranscriptItem) {
|
|
72
|
+
snapshot.transcript.push(item);
|
|
73
|
+
if (snapshot.transcript.length > MAX_TRANSCRIPT_ITEMS) {
|
|
74
|
+
snapshot.transcript.splice(
|
|
75
|
+
0,
|
|
76
|
+
snapshot.transcript.length - MAX_TRANSCRIPT_ITEMS,
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// --- Internal state -----------------------------------------------------------
|
|
82
|
+
|
|
83
|
+
/** Mutable snapshot; exposed to readers via the readonly SubagentSnapshot type. */
|
|
84
|
+
interface MutableSnapshot {
|
|
85
|
+
id: string;
|
|
86
|
+
origin: SubagentOrigin;
|
|
87
|
+
backend: BackendName;
|
|
88
|
+
title: string;
|
|
89
|
+
prompt: string;
|
|
90
|
+
cwd: string;
|
|
91
|
+
status: SubagentStatus;
|
|
92
|
+
createdAt: number;
|
|
93
|
+
settledAt?: number;
|
|
94
|
+
errorText?: string;
|
|
95
|
+
meta: SubagentMeta;
|
|
96
|
+
usage: { tokens?: number; contextWindow?: number };
|
|
97
|
+
transcript: TranscriptItem[];
|
|
98
|
+
liveAssistant?: { text: string; thinking: string };
|
|
99
|
+
liveTools: LiveToolState[];
|
|
100
|
+
queued: SubagentSnapshot["queued"];
|
|
101
|
+
finalText: string;
|
|
102
|
+
turns: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
interface Entry {
|
|
106
|
+
snapshot: MutableSnapshot;
|
|
107
|
+
session: SubagentSession;
|
|
108
|
+
scope: Scope.Closeable;
|
|
109
|
+
pump?: Fiber.Fiber<void>;
|
|
110
|
+
liveToolMap: Map<string, LiveToolState>;
|
|
111
|
+
/** Idle restart dispatched but RunStarted not folded yet; counts as running
|
|
112
|
+
* so concurrent restarts cannot race past the cap. */
|
|
113
|
+
restarting?: boolean;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// --- Read model ----------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/** Synchronous bridge for the TUI. Snapshots are live objects; do not mutate. */
|
|
119
|
+
export interface SubagentReadModel {
|
|
120
|
+
list(): ReadonlyArray<SubagentSnapshot>;
|
|
121
|
+
get(id: string): SubagentSnapshot | undefined;
|
|
122
|
+
size(): number;
|
|
123
|
+
/** Any-change notification (footer status, dashboard). */
|
|
124
|
+
subscribe(listener: () => void): () => void;
|
|
125
|
+
/** Per-subagent notification (takeover view). */
|
|
126
|
+
subscribeTo(id: string, listener: () => void): () => void;
|
|
127
|
+
/** Fire-and-forget: steer/continue a subagent (takeover input). */
|
|
128
|
+
requestSend(id: string, text: string): void;
|
|
129
|
+
/** Fire-and-forget: abort a running subagent (dashboard `x`, takeover). */
|
|
130
|
+
requestAbort(id: string): void;
|
|
131
|
+
/**
|
|
132
|
+
* Register the settle hook. `consumed` is true when an active
|
|
133
|
+
* subagent_wait/cancel is collecting the result (so it must not also be
|
|
134
|
+
* delivered as a follow-up message).
|
|
135
|
+
*/
|
|
136
|
+
setOnSettled(
|
|
137
|
+
hook: ((snap: SubagentSnapshot, consumed: boolean) => void) | undefined,
|
|
138
|
+
): void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// --- Service --------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
export interface CancelResult {
|
|
144
|
+
readonly id: string;
|
|
145
|
+
readonly title: string;
|
|
146
|
+
readonly status: SubagentStatus;
|
|
147
|
+
readonly cancelled: boolean;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export interface SubagentManagerShape {
|
|
151
|
+
spawn(
|
|
152
|
+
backend: BackendName,
|
|
153
|
+
task: SpawnTask,
|
|
154
|
+
): Effect.Effect<
|
|
155
|
+
SubagentSnapshot,
|
|
156
|
+
SpawnError | ConcurrencyLimitError | BackendUnavailableError
|
|
157
|
+
>;
|
|
158
|
+
/**
|
|
159
|
+
* Wait until all listed subagents are settled. Unknown ids are treated as
|
|
160
|
+
* settled (the tool layer validates ids first). While waiting, settles for
|
|
161
|
+
* these ids are marked "consumed". Interruption (tool abort) releases the
|
|
162
|
+
* interest and leaves the subagents running.
|
|
163
|
+
*/
|
|
164
|
+
waitFor(
|
|
165
|
+
ids: ReadonlyArray<string>,
|
|
166
|
+
onPending?: (pending: string[]) => void,
|
|
167
|
+
): Effect.Effect<void>;
|
|
168
|
+
/** Cancel running subagents; resolves when they have settled. */
|
|
169
|
+
cancel(
|
|
170
|
+
ids: ReadonlyArray<string>,
|
|
171
|
+
): Effect.Effect<ReadonlyArray<CancelResult>>;
|
|
172
|
+
send(id: string, text: string): Effect.Effect<void, SendError>;
|
|
173
|
+
get(id: string): Effect.Effect<SubagentSnapshot | undefined>;
|
|
174
|
+
readonly list: Effect.Effect<ReadonlyArray<SubagentSnapshot>>;
|
|
175
|
+
readonly disposeAll: Effect.Effect<void>;
|
|
176
|
+
readonly view: SubagentReadModel;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export class SubagentManager extends Context.Service<
|
|
180
|
+
SubagentManager,
|
|
181
|
+
SubagentManagerShape
|
|
182
|
+
>()("subagents/SubagentManager") {}
|
|
183
|
+
|
|
184
|
+
// --- Implementation --------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const makeManager = Effect.gen(function* () {
|
|
187
|
+
const registry = yield* BackendRegistry;
|
|
188
|
+
// Detached forker for sync contexts (read-model commands, pruning) that
|
|
189
|
+
// preserves the manager's services instead of using the global runtime.
|
|
190
|
+
const runDetached = Effect.runForkWith(yield* Effect.context());
|
|
191
|
+
|
|
192
|
+
const entries = new Map<string, Entry>();
|
|
193
|
+
const waitInterest = new Map<string, number>();
|
|
194
|
+
const listeners = new Set<() => void>();
|
|
195
|
+
/** One-shot nextChange waiters, swapped out before invocation so waiters
|
|
196
|
+
* re-registering during notification are not visited in the same sweep. */
|
|
197
|
+
let changeWaiters: Array<() => void> = [];
|
|
198
|
+
const idListeners = new Map<string, Set<() => void>>();
|
|
199
|
+
const cleanups = new Set<Fiber.Fiber<unknown>>();
|
|
200
|
+
let modelCounter = 0;
|
|
201
|
+
let btwCounter = 0;
|
|
202
|
+
// Reservations are tracked per pool so the model and user "by the way" asides
|
|
203
|
+
// never contend for the same slots.
|
|
204
|
+
let reservedModel = 0;
|
|
205
|
+
let reservedBtw = 0;
|
|
206
|
+
let disposed = false;
|
|
207
|
+
let onSettled:
|
|
208
|
+
((snap: SubagentSnapshot, consumed: boolean) => void) | undefined;
|
|
209
|
+
|
|
210
|
+
const notify = (id?: string) => {
|
|
211
|
+
const waiters = changeWaiters;
|
|
212
|
+
changeWaiters = [];
|
|
213
|
+
for (const waiter of waiters) waiter();
|
|
214
|
+
for (const listener of [...listeners]) {
|
|
215
|
+
try {
|
|
216
|
+
listener();
|
|
217
|
+
} catch {
|
|
218
|
+
// A failed status/render listener must not corrupt lifecycle state.
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (id) {
|
|
222
|
+
for (const listener of idListeners.get(id) ?? []) {
|
|
223
|
+
try {
|
|
224
|
+
listener();
|
|
225
|
+
} catch {
|
|
226
|
+
// Same.
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
/** Resolves on the next state change. Interruption unregisters the waiter. */
|
|
233
|
+
const nextChange = Effect.callback<void>((resume) => {
|
|
234
|
+
const waiter = () => resume(Effect.void);
|
|
235
|
+
changeWaiters.push(waiter);
|
|
236
|
+
return Effect.sync(() => {
|
|
237
|
+
const index = changeWaiters.indexOf(waiter);
|
|
238
|
+
if (index >= 0) changeWaiters.splice(index, 1);
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* A restart dispatched by `send` occupies a slot immediately, but the
|
|
244
|
+
* `RunStarted` that flips `snapshot.status` only arrives on the async pump.
|
|
245
|
+
* Every caller that asks "is this busy?" must honor that window, or a
|
|
246
|
+
* wait/cancel issued in the same turn as the restart would observe the old
|
|
247
|
+
* settled run and return (or cancel) the wrong thing.
|
|
248
|
+
*/
|
|
249
|
+
const isBusy = (entry: Entry | undefined) =>
|
|
250
|
+
entry !== undefined &&
|
|
251
|
+
(entry.snapshot.status === "running" || entry.restarting === true);
|
|
252
|
+
|
|
253
|
+
const runningCount = (origin?: SubagentOrigin) =>
|
|
254
|
+
[...entries.values()].filter(
|
|
255
|
+
(e) =>
|
|
256
|
+
isBusy(e) && (origin === undefined || e.snapshot.origin === origin),
|
|
257
|
+
).length;
|
|
258
|
+
|
|
259
|
+
/** Per-pool capacity: model asides and user "by the way" asides never mix. */
|
|
260
|
+
const poolLimit = (origin: SubagentOrigin) =>
|
|
261
|
+
origin === "btw" ? MAX_RUNNING_BTW : MAX_RUNNING;
|
|
262
|
+
const poolReserved = (origin: SubagentOrigin) =>
|
|
263
|
+
origin === "btw" ? reservedBtw : reservedModel;
|
|
264
|
+
const atPoolCapacity = (origin: SubagentOrigin) =>
|
|
265
|
+
runningCount(origin) + poolReserved(origin) >= poolLimit(origin);
|
|
266
|
+
|
|
267
|
+
const addInterest = (ids: ReadonlyArray<string>) => {
|
|
268
|
+
for (const id of ids) waitInterest.set(id, (waitInterest.get(id) ?? 0) + 1);
|
|
269
|
+
};
|
|
270
|
+
const releaseInterest = (ids: ReadonlyArray<string>) => {
|
|
271
|
+
for (const id of ids) {
|
|
272
|
+
const count = (waitInterest.get(id) ?? 1) - 1;
|
|
273
|
+
if (count <= 0) waitInterest.delete(id);
|
|
274
|
+
else waitInterest.set(id, count);
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const closeEntryScope = (entry: Entry) =>
|
|
279
|
+
Scope.close(entry.scope, Exit.void).pipe(Effect.ignore);
|
|
280
|
+
|
|
281
|
+
const pruneSettled = () => {
|
|
282
|
+
if (entries.size <= MAX_TRACKED) return;
|
|
283
|
+
const candidates = [...entries.values()]
|
|
284
|
+
.filter((e) => !isBusy(e) && !waitInterest.has(e.snapshot.id))
|
|
285
|
+
.sort(
|
|
286
|
+
(a, b) =>
|
|
287
|
+
(a.snapshot.settledAt ?? a.snapshot.createdAt) -
|
|
288
|
+
(b.snapshot.settledAt ?? b.snapshot.createdAt),
|
|
289
|
+
);
|
|
290
|
+
for (const entry of candidates) {
|
|
291
|
+
if (entries.size <= MAX_TRACKED) break;
|
|
292
|
+
entries.delete(entry.snapshot.id);
|
|
293
|
+
const fiber = runDetached(closeEntryScope(entry));
|
|
294
|
+
cleanups.add(fiber);
|
|
295
|
+
fiber.addObserver(() => cleanups.delete(fiber));
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
const settle = (entry: Entry, outcome: RunOutcome) => {
|
|
300
|
+
const s = entry.snapshot;
|
|
301
|
+
const wasRestarting = entry.restarting === true;
|
|
302
|
+
entry.restarting = false;
|
|
303
|
+
if (s.status !== "running") {
|
|
304
|
+
if (!wasRestarting) return;
|
|
305
|
+
// A cancel can clear a queued restart before RunStarted reaches the
|
|
306
|
+
// manager. Its RunSettled still belongs to the new run, not the old
|
|
307
|
+
// settled snapshot, so promote the lifecycle before applying it.
|
|
308
|
+
s.status = "running";
|
|
309
|
+
s.settledAt = undefined;
|
|
310
|
+
s.errorText = undefined;
|
|
311
|
+
}
|
|
312
|
+
s.settledAt = Date.now();
|
|
313
|
+
switch (outcome._tag) {
|
|
314
|
+
case "Completed":
|
|
315
|
+
s.status = "done";
|
|
316
|
+
s.errorText = undefined;
|
|
317
|
+
s.finalText = outcome.finalText.slice(0, FINAL_TEXT_MAX_LENGTH);
|
|
318
|
+
break;
|
|
319
|
+
case "Failed":
|
|
320
|
+
s.status = "error";
|
|
321
|
+
s.errorText = bounded(outcome.errorText);
|
|
322
|
+
// Never let a failed run report the previous run's successful output.
|
|
323
|
+
s.finalText = (outcome.partialText ?? "").slice(
|
|
324
|
+
0,
|
|
325
|
+
FINAL_TEXT_MAX_LENGTH,
|
|
326
|
+
);
|
|
327
|
+
break;
|
|
328
|
+
case "Interrupted":
|
|
329
|
+
s.status = "error";
|
|
330
|
+
s.errorText = "Run was aborted";
|
|
331
|
+
s.finalText = (outcome.partialText ?? "").slice(
|
|
332
|
+
0,
|
|
333
|
+
FINAL_TEXT_MAX_LENGTH,
|
|
334
|
+
);
|
|
335
|
+
break;
|
|
336
|
+
}
|
|
337
|
+
s.liveAssistant = undefined;
|
|
338
|
+
entry.liveToolMap.clear();
|
|
339
|
+
s.liveTools = [];
|
|
340
|
+
s.queued = [];
|
|
341
|
+
const consumed = (waitInterest.get(s.id) ?? 0) > 0;
|
|
342
|
+
notify(s.id);
|
|
343
|
+
try {
|
|
344
|
+
// During teardown, don't queue results into a shutting-down session.
|
|
345
|
+
if (!disposed) onSettled?.(s, consumed);
|
|
346
|
+
} catch {
|
|
347
|
+
// The parent session may be unavailable; settlement stays final.
|
|
348
|
+
}
|
|
349
|
+
pruneSettled();
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
const foldEvent = (entry: Entry, event: SubagentEvent) => {
|
|
353
|
+
const s = entry.snapshot;
|
|
354
|
+
switch (event._tag) {
|
|
355
|
+
case "RunStarted":
|
|
356
|
+
entry.restarting = false;
|
|
357
|
+
s.status = "running";
|
|
358
|
+
s.settledAt = undefined;
|
|
359
|
+
s.errorText = undefined;
|
|
360
|
+
break;
|
|
361
|
+
case "RunSettled":
|
|
362
|
+
settle(entry, event.outcome);
|
|
363
|
+
return; // settle() already notified
|
|
364
|
+
case "UserMessage":
|
|
365
|
+
appendTranscript(s, {
|
|
366
|
+
kind: "user",
|
|
367
|
+
text: boundedTranscriptText(event.text),
|
|
368
|
+
});
|
|
369
|
+
break;
|
|
370
|
+
case "AssistantDelta": {
|
|
371
|
+
const live = s.liveAssistant ?? { text: "", thinking: "" };
|
|
372
|
+
s.liveAssistant =
|
|
373
|
+
event.kind === "text"
|
|
374
|
+
? {
|
|
375
|
+
...live,
|
|
376
|
+
text: (live.text + event.delta).slice(
|
|
377
|
+
-LIVE_ASSISTANT_MAX_LENGTH,
|
|
378
|
+
),
|
|
379
|
+
}
|
|
380
|
+
: {
|
|
381
|
+
...live,
|
|
382
|
+
thinking: (live.thinking + event.delta).slice(
|
|
383
|
+
-LIVE_ASSISTANT_MAX_LENGTH,
|
|
384
|
+
),
|
|
385
|
+
};
|
|
386
|
+
break;
|
|
387
|
+
}
|
|
388
|
+
case "AssistantMessage":
|
|
389
|
+
appendTranscript(s, {
|
|
390
|
+
kind: "assistant",
|
|
391
|
+
parts: event.parts.map((part) =>
|
|
392
|
+
part.type === "toolCall"
|
|
393
|
+
? {
|
|
394
|
+
...part,
|
|
395
|
+
argsPreview: part.argsPreview
|
|
396
|
+
? boundedTranscriptText(part.argsPreview)
|
|
397
|
+
: undefined,
|
|
398
|
+
}
|
|
399
|
+
: { ...part, text: boundedTranscriptText(part.text) },
|
|
400
|
+
),
|
|
401
|
+
});
|
|
402
|
+
s.liveAssistant = undefined;
|
|
403
|
+
s.turns++;
|
|
404
|
+
break;
|
|
405
|
+
case "ToolStart":
|
|
406
|
+
entry.liveToolMap.set(event.toolId, {
|
|
407
|
+
toolId: event.toolId,
|
|
408
|
+
name: event.name,
|
|
409
|
+
argsPreview: event.argsPreview
|
|
410
|
+
? boundedTranscriptText(event.argsPreview)
|
|
411
|
+
: undefined,
|
|
412
|
+
});
|
|
413
|
+
s.liveTools = [...entry.liveToolMap.values()];
|
|
414
|
+
break;
|
|
415
|
+
case "ToolUpdate": {
|
|
416
|
+
const current = entry.liveToolMap.get(event.toolId);
|
|
417
|
+
if (current) {
|
|
418
|
+
entry.liveToolMap.set(event.toolId, {
|
|
419
|
+
...current,
|
|
420
|
+
outputPreview: event.outputPreview
|
|
421
|
+
? boundedTranscriptText(event.outputPreview)
|
|
422
|
+
: current.outputPreview,
|
|
423
|
+
});
|
|
424
|
+
s.liveTools = [...entry.liveToolMap.values()];
|
|
425
|
+
}
|
|
426
|
+
break;
|
|
427
|
+
}
|
|
428
|
+
case "ToolEnd":
|
|
429
|
+
entry.liveToolMap.delete(event.toolId);
|
|
430
|
+
s.liveTools = [...entry.liveToolMap.values()];
|
|
431
|
+
appendTranscript(s, {
|
|
432
|
+
kind: "toolResult",
|
|
433
|
+
toolId: event.toolId,
|
|
434
|
+
name: event.name,
|
|
435
|
+
isError: event.isError,
|
|
436
|
+
outputPreview: event.outputPreview
|
|
437
|
+
? boundedTranscriptText(event.outputPreview)
|
|
438
|
+
: undefined,
|
|
439
|
+
});
|
|
440
|
+
break;
|
|
441
|
+
case "QueueChanged":
|
|
442
|
+
s.queued = event.queued;
|
|
443
|
+
break;
|
|
444
|
+
case "UsageChanged":
|
|
445
|
+
s.usage = {
|
|
446
|
+
tokens: event.tokens ?? s.usage.tokens,
|
|
447
|
+
contextWindow: event.contextWindow ?? s.usage.contextWindow,
|
|
448
|
+
};
|
|
449
|
+
break;
|
|
450
|
+
case "MetaChanged":
|
|
451
|
+
s.meta = { ...s.meta, ...event.meta };
|
|
452
|
+
break;
|
|
453
|
+
case "BackendError":
|
|
454
|
+
s.errorText = bounded(event.message);
|
|
455
|
+
break;
|
|
456
|
+
}
|
|
457
|
+
notify(s.id);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
const spawn = (backendName: BackendName, task: SpawnTask) =>
|
|
461
|
+
Effect.gen(function* () {
|
|
462
|
+
const origin: SubagentOrigin = task.origin ?? "model";
|
|
463
|
+
// Reserve synchronously (before the first yield inside doSpawn) so
|
|
464
|
+
// parallel tool calls cannot race past the pool cap.
|
|
465
|
+
yield* Effect.suspend(
|
|
466
|
+
(): Effect.Effect<void, SpawnError | ConcurrencyLimitError> => {
|
|
467
|
+
if (disposed) {
|
|
468
|
+
return new SpawnError({
|
|
469
|
+
message: "Subagent manager is shutting down.",
|
|
470
|
+
});
|
|
471
|
+
}
|
|
472
|
+
if (atPoolCapacity(origin)) {
|
|
473
|
+
return new ConcurrencyLimitError({
|
|
474
|
+
message: `Max ${poolLimit(origin)} ${
|
|
475
|
+
origin === "btw" ? "by-the-way" : "subagent"
|
|
476
|
+
} sessions can run concurrently. Wait for one to finish before spawning another.`,
|
|
477
|
+
});
|
|
478
|
+
}
|
|
479
|
+
if (origin === "btw") reservedBtw++;
|
|
480
|
+
else reservedModel++;
|
|
481
|
+
return Effect.void;
|
|
482
|
+
},
|
|
483
|
+
);
|
|
484
|
+
|
|
485
|
+
const doSpawn = Effect.gen(function* () {
|
|
486
|
+
const backend: SubagentBackend | undefined = registry.get(backendName);
|
|
487
|
+
if (!backend) {
|
|
488
|
+
return yield* new BackendUnavailableError({
|
|
489
|
+
message: `Unknown backend "${backendName}".`,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
const scope = yield* Scope.make();
|
|
493
|
+
const session = yield* Scope.provide(backend.spawn(task), scope).pipe(
|
|
494
|
+
Effect.onError(() => Scope.close(scope, Exit.void)),
|
|
495
|
+
);
|
|
496
|
+
if (disposed) {
|
|
497
|
+
yield* Scope.close(scope, Exit.void);
|
|
498
|
+
return yield* new SpawnError({
|
|
499
|
+
message: "Subagent manager shut down while spawning.",
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
const id =
|
|
504
|
+
origin === "btw" ? `btw-${++btwCounter}` : `sa-${++modelCounter}`;
|
|
505
|
+
const meta = yield* session.meta;
|
|
506
|
+
const entry: Entry = {
|
|
507
|
+
snapshot: {
|
|
508
|
+
id,
|
|
509
|
+
origin,
|
|
510
|
+
backend: backendName,
|
|
511
|
+
title: task.title,
|
|
512
|
+
prompt: task.prompt,
|
|
513
|
+
cwd: task.cwd,
|
|
514
|
+
status: "running",
|
|
515
|
+
createdAt: Date.now(),
|
|
516
|
+
meta,
|
|
517
|
+
usage: { contextWindow: meta.contextWindow },
|
|
518
|
+
transcript: [],
|
|
519
|
+
liveTools: [],
|
|
520
|
+
queued: [],
|
|
521
|
+
finalText: "",
|
|
522
|
+
turns: 0,
|
|
523
|
+
},
|
|
524
|
+
session,
|
|
525
|
+
scope,
|
|
526
|
+
liveToolMap: new Map(),
|
|
527
|
+
};
|
|
528
|
+
entries.set(id, entry);
|
|
529
|
+
|
|
530
|
+
// Pump: fold the event stream into the snapshot. Tied to the entry
|
|
531
|
+
// scope, so closing the scope stops it. If the stream ends while the
|
|
532
|
+
// subagent still looks running, the backend died out from under us.
|
|
533
|
+
const pump = Stream.runForEach(session.events, (event) =>
|
|
534
|
+
Effect.sync(() => foldEvent(entry, event)),
|
|
535
|
+
).pipe(
|
|
536
|
+
Effect.ensuring(
|
|
537
|
+
Effect.sync(() => {
|
|
538
|
+
if (entry.snapshot.status === "running") {
|
|
539
|
+
settle(entry, {
|
|
540
|
+
_tag: "Failed",
|
|
541
|
+
errorText: "Backend event stream ended unexpectedly",
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
}),
|
|
545
|
+
),
|
|
546
|
+
);
|
|
547
|
+
entry.pump = yield* Scope.provide(Effect.forkScoped(pump), scope);
|
|
548
|
+
|
|
549
|
+
notify(id);
|
|
550
|
+
return entry.snapshot as SubagentSnapshot;
|
|
551
|
+
});
|
|
552
|
+
|
|
553
|
+
return yield* doSpawn.pipe(
|
|
554
|
+
Effect.ensuring(
|
|
555
|
+
Effect.sync(() => {
|
|
556
|
+
if (origin === "btw") reservedBtw--;
|
|
557
|
+
else reservedModel--;
|
|
558
|
+
notify();
|
|
559
|
+
}),
|
|
560
|
+
),
|
|
561
|
+
);
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
const waitFor = (
|
|
565
|
+
ids: ReadonlyArray<string>,
|
|
566
|
+
onPending?: (pending: string[]) => void,
|
|
567
|
+
) =>
|
|
568
|
+
Effect.suspend(() => {
|
|
569
|
+
const unique = [...new Set(ids)];
|
|
570
|
+
addInterest(unique);
|
|
571
|
+
const loop = Effect.gen(function* () {
|
|
572
|
+
while (true) {
|
|
573
|
+
const pending = unique.filter((id) => isBusy(entries.get(id)));
|
|
574
|
+
if (pending.length === 0) return;
|
|
575
|
+
onPending?.(pending);
|
|
576
|
+
yield* nextChange;
|
|
577
|
+
}
|
|
578
|
+
});
|
|
579
|
+
return loop.pipe(
|
|
580
|
+
Effect.ensuring(
|
|
581
|
+
Effect.sync(() => {
|
|
582
|
+
releaseInterest(unique);
|
|
583
|
+
pruneSettled();
|
|
584
|
+
}),
|
|
585
|
+
),
|
|
586
|
+
);
|
|
587
|
+
});
|
|
588
|
+
|
|
589
|
+
/** Interrupt one busy entry, including the pre-RunStarted restart window. */
|
|
590
|
+
const abortEntry = (entry: Entry) =>
|
|
591
|
+
Effect.gen(function* () {
|
|
592
|
+
if (!isBusy(entry)) return;
|
|
593
|
+
const graceful = yield* entry.session.interrupt.pipe(
|
|
594
|
+
Effect.timeout(STOP_TIMEOUT_MS),
|
|
595
|
+
Effect.result,
|
|
596
|
+
);
|
|
597
|
+
if (Result.isFailure(graceful)) {
|
|
598
|
+
// Settle before closing the scope so the pump's stream-ended
|
|
599
|
+
// fallback ("Backend event stream ended unexpectedly") cannot win
|
|
600
|
+
// the race and report the wrong terminal reason.
|
|
601
|
+
yield* Effect.sync(() => {
|
|
602
|
+
settle(entry, { _tag: "Interrupted" });
|
|
603
|
+
entry.snapshot.errorText =
|
|
604
|
+
"Abort deadline exceeded; session was force-disposed";
|
|
605
|
+
notify(entry.snapshot.id);
|
|
606
|
+
});
|
|
607
|
+
// Bound the close like disposeAll does: a stuck backend finalizer
|
|
608
|
+
// must not hang cancel after the run is already settled.
|
|
609
|
+
yield* closeEntryScope(entry).pipe(
|
|
610
|
+
Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
|
|
611
|
+
Effect.ignore,
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
|
|
616
|
+
const cancel = (ids: ReadonlyArray<string>) =>
|
|
617
|
+
Effect.suspend(() => {
|
|
618
|
+
const unique = [...new Set(ids)];
|
|
619
|
+
const running = unique
|
|
620
|
+
.map((id) => entries.get(id))
|
|
621
|
+
.filter((entry): entry is Entry => isBusy(entry));
|
|
622
|
+
const runningIds = running.map((entry) => entry.snapshot.id);
|
|
623
|
+
// Mark consumed before interrupting so cancellation does not also
|
|
624
|
+
// enqueue duplicate automatic result messages into the parent.
|
|
625
|
+
addInterest(runningIds);
|
|
626
|
+
const work = Effect.gen(function* () {
|
|
627
|
+
yield* Effect.forEach(running, abortEntry, {
|
|
628
|
+
concurrency: "unbounded",
|
|
629
|
+
});
|
|
630
|
+
while (running.some(isBusy)) yield* nextChange;
|
|
631
|
+
});
|
|
632
|
+
return work.pipe(
|
|
633
|
+
Effect.ensuring(
|
|
634
|
+
Effect.sync(() => {
|
|
635
|
+
releaseInterest(runningIds);
|
|
636
|
+
pruneSettled();
|
|
637
|
+
}),
|
|
638
|
+
),
|
|
639
|
+
Effect.map((): ReadonlyArray<CancelResult> =>
|
|
640
|
+
unique.map((id) => {
|
|
641
|
+
const snapshot = entries.get(id)?.snapshot;
|
|
642
|
+
return {
|
|
643
|
+
id,
|
|
644
|
+
title: snapshot?.title ?? "?",
|
|
645
|
+
status: snapshot?.status ?? "error",
|
|
646
|
+
cancelled: runningIds.includes(id),
|
|
647
|
+
};
|
|
648
|
+
}),
|
|
649
|
+
),
|
|
650
|
+
);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
const send = (id: string, text: string) =>
|
|
654
|
+
Effect.suspend((): Effect.Effect<void, SendError> => {
|
|
655
|
+
const entry = entries.get(id);
|
|
656
|
+
if (!entry || disposed) {
|
|
657
|
+
return new SendError({
|
|
658
|
+
message: `Subagent "${id}" is no longer tracked.`,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
661
|
+
// Restarting a settled subagent occupies a running slot again, so it
|
|
662
|
+
// must respect the same cap as spawn. Steering an already-running one
|
|
663
|
+
// does not consume additional capacity.
|
|
664
|
+
if (!isBusy(entry)) {
|
|
665
|
+
const origin = entry.snapshot.origin;
|
|
666
|
+
if (atPoolCapacity(origin)) {
|
|
667
|
+
return new SendError({
|
|
668
|
+
message: `Max ${poolLimit(origin)} ${
|
|
669
|
+
origin === "btw" ? "by-the-way" : "subagent"
|
|
670
|
+
} sessions can run concurrently; restarting "${id}" would exceed that.`,
|
|
671
|
+
});
|
|
672
|
+
}
|
|
673
|
+
// Occupy the slot synchronously: the RunStarted that flips status
|
|
674
|
+
// arrives via the async pump, and two concurrent restarts must not
|
|
675
|
+
// both pass the check in that window. Cleared by RunStarted/settle,
|
|
676
|
+
// or here when the backend rejects the send.
|
|
677
|
+
entry.restarting = true;
|
|
678
|
+
return entry.session.send(text).pipe(
|
|
679
|
+
Effect.onError(() =>
|
|
680
|
+
Effect.sync(() => {
|
|
681
|
+
entry.restarting = false;
|
|
682
|
+
notify(entry.snapshot.id);
|
|
683
|
+
}),
|
|
684
|
+
),
|
|
685
|
+
);
|
|
686
|
+
}
|
|
687
|
+
return entry.session.send(text);
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
const disposeAll = Effect.gen(function* () {
|
|
691
|
+
disposed = true;
|
|
692
|
+
const all = [...entries.values()];
|
|
693
|
+
entries.clear();
|
|
694
|
+
yield* Effect.forEach(
|
|
695
|
+
all,
|
|
696
|
+
(entry) =>
|
|
697
|
+
closeEntryScope(entry).pipe(
|
|
698
|
+
Effect.timeout(ENTRY_CLOSE_TIMEOUT_MS),
|
|
699
|
+
Effect.ignore,
|
|
700
|
+
),
|
|
701
|
+
{ concurrency: "unbounded" },
|
|
702
|
+
);
|
|
703
|
+
// Pruning cleanups are detached; bound them like everything else so a
|
|
704
|
+
// stuck backend finalizer cannot block runtime shutdown indefinitely.
|
|
705
|
+
yield* Effect.forEach(
|
|
706
|
+
[...cleanups],
|
|
707
|
+
(fiber) =>
|
|
708
|
+
Fiber.await(fiber).pipe(Effect.timeout(STOP_TIMEOUT_MS), Effect.ignore),
|
|
709
|
+
{ concurrency: "unbounded" },
|
|
710
|
+
).pipe(Effect.ignore);
|
|
711
|
+
yield* Effect.sync(() => notify());
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
const view: SubagentReadModel = {
|
|
715
|
+
list: () => [...entries.values()].map((entry) => entry.snapshot),
|
|
716
|
+
get: (id) => entries.get(id)?.snapshot,
|
|
717
|
+
size: () => entries.size,
|
|
718
|
+
subscribe: (listener) => {
|
|
719
|
+
listeners.add(listener);
|
|
720
|
+
return () => listeners.delete(listener);
|
|
721
|
+
},
|
|
722
|
+
subscribeTo: (id, listener) => {
|
|
723
|
+
let set = idListeners.get(id);
|
|
724
|
+
if (!set) {
|
|
725
|
+
set = new Set();
|
|
726
|
+
idListeners.set(id, set);
|
|
727
|
+
}
|
|
728
|
+
set.add(listener);
|
|
729
|
+
return () => {
|
|
730
|
+
set.delete(listener);
|
|
731
|
+
if (set.size === 0) idListeners.delete(id);
|
|
732
|
+
};
|
|
733
|
+
},
|
|
734
|
+
requestSend: (id, text) => {
|
|
735
|
+
runDetached(send(id, text).pipe(Effect.ignore));
|
|
736
|
+
},
|
|
737
|
+
requestAbort: (id) => {
|
|
738
|
+
const entry = entries.get(id);
|
|
739
|
+
if (!entry) return;
|
|
740
|
+
// UI-initiated aborts are not "consumed": the failed result still
|
|
741
|
+
// flows back to the parent as a follow-up message, matching v1.
|
|
742
|
+
runDetached(abortEntry(entry).pipe(Effect.ignore));
|
|
743
|
+
},
|
|
744
|
+
setOnSettled: (hook) => {
|
|
745
|
+
onSettled = hook;
|
|
746
|
+
},
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
// Safety net: disposing the ManagedRuntime tears everything down even if
|
|
750
|
+
// the extension forgot to call disposeAll explicitly.
|
|
751
|
+
yield* Effect.addFinalizer(() => disposeAll);
|
|
752
|
+
|
|
753
|
+
return SubagentManager.of({
|
|
754
|
+
spawn,
|
|
755
|
+
waitFor,
|
|
756
|
+
cancel,
|
|
757
|
+
send,
|
|
758
|
+
get: (id) => Effect.sync(() => entries.get(id)?.snapshot),
|
|
759
|
+
list: Effect.sync(() => [...entries.values()].map((e) => e.snapshot)),
|
|
760
|
+
disposeAll,
|
|
761
|
+
view,
|
|
762
|
+
});
|
|
763
|
+
});
|
|
764
|
+
|
|
765
|
+
export const SubagentManagerLive: Layer.Layer<
|
|
766
|
+
SubagentManager,
|
|
767
|
+
never,
|
|
768
|
+
BackendRegistry
|
|
769
|
+
> = Layer.effect(SubagentManager, makeManager);
|