@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.
Files changed (114) hide show
  1. package/README.md +643 -0
  2. package/SETUP.md +74 -0
  3. package/THIRD_PARTY_NOTICES.md +16 -0
  4. package/assets/openpi-package.png +0 -0
  5. package/assets/readme-hero-mobile.svg +72 -0
  6. package/assets/readme-hero.svg +118 -0
  7. package/assets/readme-runtime-mobile.svg +91 -0
  8. package/assets/readme-runtime.svg +111 -0
  9. package/extensions/ask-user/handoff.ts +205 -0
  10. package/extensions/ask-user/index.ts +1110 -0
  11. package/extensions/ask-user/limits.ts +89 -0
  12. package/extensions/ask-user/prompt.ts +76 -0
  13. package/extensions/background-terminals/index.ts +653 -0
  14. package/extensions/background-terminals/src/domain.ts +99 -0
  15. package/extensions/background-terminals/src/manager.ts +989 -0
  16. package/extensions/background-terminals/src/output.ts +84 -0
  17. package/extensions/background-terminals/src/prompt.ts +195 -0
  18. package/extensions/background-terminals/src/result-delivery.ts +43 -0
  19. package/extensions/background-terminals/src/runtime.ts +36 -0
  20. package/extensions/background-terminals/src/ui/output-view.ts +55 -0
  21. package/extensions/background-terminals/src/ui/ps.ts +642 -0
  22. package/extensions/background-terminals/src/ui/tool-result.ts +146 -0
  23. package/extensions/background-terminals/src/watch.ts +192 -0
  24. package/extensions/context-pivot/index.ts +222 -0
  25. package/extensions/copy-all/index.ts +65 -0
  26. package/extensions/cron/index.ts +173 -0
  27. package/extensions/cron/schedule.ts +127 -0
  28. package/extensions/file-mutation-display/index.ts +105 -0
  29. package/extensions/file-mutation-display/render.ts +107 -0
  30. package/extensions/file-search/index.ts +515 -0
  31. package/extensions/file-search/src/args.ts +129 -0
  32. package/extensions/file-search/src/binaries.ts +419 -0
  33. package/extensions/file-search/src/output.ts +142 -0
  34. package/extensions/file-search/src/process.ts +309 -0
  35. package/extensions/file-search/src/prompt.ts +53 -0
  36. package/extensions/git-info/index.ts +272 -0
  37. package/extensions/git-info/src/changed-files-view.ts +414 -0
  38. package/extensions/git-info/src/process.ts +107 -0
  39. package/extensions/git-info/src/refresh-coordinator.ts +13 -0
  40. package/extensions/git-info/src/runtime.ts +28 -0
  41. package/extensions/goal/controller.ts +794 -0
  42. package/extensions/goal/index.ts +521 -0
  43. package/extensions/goal/prompts.ts +122 -0
  44. package/extensions/goal/state.ts +763 -0
  45. package/extensions/goal/ui.ts +158 -0
  46. package/extensions/model-info/index.ts +234 -0
  47. package/extensions/plan-mode/bash-policy.ts +313 -0
  48. package/extensions/plan-mode/index.ts +539 -0
  49. package/extensions/post-edit/index.ts +129 -0
  50. package/extensions/sessions/LICENSE.upstream +21 -0
  51. package/extensions/sessions/git-stats.ts +226 -0
  52. package/extensions/sessions/index.ts +1092 -0
  53. package/extensions/sessions/sessions.ts +385 -0
  54. package/extensions/setup/index.ts +408 -0
  55. package/extensions/shared/activity-status.ts +65 -0
  56. package/extensions/shared/below-editor-navigation.ts +343 -0
  57. package/extensions/shared/child-session.ts +352 -0
  58. package/extensions/shared/context-utilization.ts +47 -0
  59. package/extensions/shared/dashboard-state.ts +102 -0
  60. package/extensions/shared/plan-mode-state.ts +65 -0
  61. package/extensions/shared/setup-config.ts +971 -0
  62. package/extensions/shared/subagent-roles.ts +22 -0
  63. package/extensions/shared/terminal-text.ts +38 -0
  64. package/extensions/shared/tool-call-timeout.ts +104 -0
  65. package/extensions/shared/worktree.ts +526 -0
  66. package/extensions/subagents/index.ts +1225 -0
  67. package/extensions/subagents/navigation.ts +121 -0
  68. package/extensions/subagents/src/agent-types.ts +543 -0
  69. package/extensions/subagents/src/backend.ts +63 -0
  70. package/extensions/subagents/src/backends/pi.ts +493 -0
  71. package/extensions/subagents/src/backends/stub.ts +296 -0
  72. package/extensions/subagents/src/by-the-way.ts +21 -0
  73. package/extensions/subagents/src/domain.ts +271 -0
  74. package/extensions/subagents/src/format.ts +48 -0
  75. package/extensions/subagents/src/manager.ts +769 -0
  76. package/extensions/subagents/src/prompt.ts +190 -0
  77. package/extensions/subagents/src/result-delivery.ts +20 -0
  78. package/extensions/subagents/src/runtime.ts +51 -0
  79. package/extensions/subagents/src/ui/takeover.ts +615 -0
  80. package/extensions/subagents/src/ui/transcript.ts +293 -0
  81. package/extensions/subagents/src/ui/wait-result.ts +89 -0
  82. package/extensions/suggestions/index.ts +172 -0
  83. package/extensions/suggestions/src/config.ts +12 -0
  84. package/extensions/suggestions/src/predictor.ts +147 -0
  85. package/extensions/suggestions/src/prompt.ts +20 -0
  86. package/extensions/suggestions/src/transcript.ts +233 -0
  87. package/extensions/suggestions/src/ui.ts +224 -0
  88. package/extensions/tasks/index.ts +512 -0
  89. package/extensions/tasks/tasks.ts +649 -0
  90. package/extensions/tasks/ui.ts +421 -0
  91. package/extensions/turn-time/index.ts +61 -0
  92. package/extensions/ui-customization/footer.ts +512 -0
  93. package/extensions/ui-customization/index.ts +217 -0
  94. package/extensions/workflows/acceptance.ts +298 -0
  95. package/extensions/workflows/artifacts.ts +225 -0
  96. package/extensions/workflows/controller.ts +210 -0
  97. package/extensions/workflows/dashboard.ts +1226 -0
  98. package/extensions/workflows/index.ts +1884 -0
  99. package/extensions/workflows/journal.ts +188 -0
  100. package/extensions/workflows/meta.ts +250 -0
  101. package/extensions/workflows/model.ts +423 -0
  102. package/extensions/workflows/navigation.ts +93 -0
  103. package/extensions/workflows/prompt.ts +212 -0
  104. package/extensions/workflows/replay-safety.ts +577 -0
  105. package/extensions/workflows/runner.ts +786 -0
  106. package/extensions/workflows/sandbox-child.cjs +402 -0
  107. package/extensions/workflows/sandbox.ts +397 -0
  108. package/extensions/workflows/serialization.ts +162 -0
  109. package/extensions/workflows/worktree-handoff.ts +216 -0
  110. package/package.json +87 -0
  111. package/scripts/prepare-effect-tsgo.mjs +16 -0
  112. package/skills/background-terminals/SKILL.md +30 -0
  113. package/skills/subagents/SKILL.md +15 -0
  114. package/themes/github-dark-default.json +89 -0
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Scripted stub sessions, test-only: they stand in for the pi backend so the
3
+ * manager can be driven without a real child session. A stub session:
4
+ *
5
+ * - streams a plausible turn (thinking deltas, one fake tool cycle, text
6
+ * deltas, usage ramp, a final assistant message, RunSettled) over a few
7
+ * seconds so streaming UI, wait, and the footer counters are observable;
8
+ * - supports send() while running (queued-steer rendering) and while idle
9
+ * (fresh run);
10
+ * - supports interrupt (RunSettled Interrupted -> status "error", matching v1);
11
+ * - fails the run when the prompt starts with "FAIL:", and refuses to spawn
12
+ * at all when it starts with "SPAWNFAIL:" (error-path testing);
13
+ * - appends every event to a JSONL "session file" in tmpdir so the
14
+ * "full transcript in session file" pointers resolve.
15
+ */
16
+
17
+ import * as fs from "node:fs";
18
+ import * as os from "node:os";
19
+ import * as path from "node:path";
20
+ import type { Cause, Scope } from "effect";
21
+ import { Duration, Effect, Fiber, Queue, Ref, Stream } from "effect";
22
+ import type { SubagentBackend, SubagentSession } from "../backend.ts";
23
+ import type {
24
+ BackendName,
25
+ QueuedMessage,
26
+ SpawnTask,
27
+ SubagentEvent,
28
+ SubagentMeta,
29
+ } from "../domain.ts";
30
+ import { SendError, SpawnError } from "../domain.ts";
31
+
32
+ export interface StubProfile {
33
+ readonly backend: BackendName;
34
+ readonly defaultModelLabel: string;
35
+ readonly contextWindow: number;
36
+ readonly toolName: string;
37
+ /** Delay between scripted events; varies per backend so streams differ. */
38
+ readonly cadenceMs: number;
39
+ }
40
+
41
+ const STUB_DIR = path.join(os.tmpdir(), "subagents-stub");
42
+ let sessionCounter = 0;
43
+
44
+ export function makeStubBackend(profile: StubProfile): SubagentBackend {
45
+ return {
46
+ name: profile.backend,
47
+ spawn: (task) =>
48
+ task.prompt.startsWith("SPAWNFAIL:")
49
+ ? Effect.fail(new SpawnError({ message: "stub refused to spawn" }))
50
+ : makeStubSession(profile, task),
51
+ };
52
+ }
53
+
54
+ function firstLine(text: string): string {
55
+ return (
56
+ text
57
+ .split("\n")
58
+ .find((line) => line.trim())
59
+ ?.trim() ?? ""
60
+ );
61
+ }
62
+
63
+ function chunked(text: string, size: number): string[] {
64
+ const chunks: string[] = [];
65
+ for (let i = 0; i < text.length; i += size)
66
+ chunks.push(text.slice(i, i + size));
67
+ return chunks;
68
+ }
69
+
70
+ const makeStubSession = (
71
+ profile: StubProfile,
72
+ task: SpawnTask,
73
+ ): Effect.Effect<SubagentSession, never, Scope.Scope> =>
74
+ Effect.gen(function* () {
75
+ const sessionId = `stub-${profile.backend}-${++sessionCounter}`;
76
+ const sessionFile = path.join(STUB_DIR, `${sessionId}.jsonl`);
77
+
78
+ const state = {
79
+ meta: {
80
+ backend: profile.backend,
81
+ modelLabel: task.model ?? profile.defaultModelLabel,
82
+ contextWindow: profile.contextWindow,
83
+ sessionFilePath: sessionFile,
84
+ } satisfies SubagentMeta as SubagentMeta,
85
+ pending: [] as string[],
86
+ turnCount: 0,
87
+ closed: false,
88
+ /** True between the driver dequeuing a prompt and registering its turn fiber. */
89
+ dispatching: false,
90
+ };
91
+
92
+ const events = yield* Queue.make<SubagentEvent, Cause.Done>();
93
+ const inbox = yield* Queue.make<string, Cause.Done>();
94
+ const activeTurn = yield* Ref.make<Fiber.Fiber<void> | undefined>(
95
+ undefined,
96
+ );
97
+
98
+ const emit = (event: SubagentEvent) =>
99
+ Effect.suspend(() => {
100
+ try {
101
+ fs.appendFileSync(sessionFile, `${JSON.stringify(event)}\n`);
102
+ } catch {
103
+ // The fake session file is best-effort.
104
+ }
105
+ if (event._tag === "MetaChanged") {
106
+ state.meta = { ...state.meta, ...event.meta };
107
+ }
108
+ return Queue.offer(events, event);
109
+ }).pipe(Effect.asVoid);
110
+
111
+ const pause = Effect.sleep(Duration.millis(profile.cadenceMs));
112
+
113
+ const runTurn = (userText: string, turn: number) =>
114
+ Effect.gen(function* () {
115
+ yield* emit({ _tag: "RunStarted" });
116
+ const failing = userText.trimStart().startsWith("FAIL:");
117
+
118
+ const thinking = "Looking at the task and planning an approach...";
119
+ for (const delta of chunked(thinking, 16)) {
120
+ yield* emit({ _tag: "AssistantDelta", kind: "thinking", delta });
121
+ yield* pause;
122
+ }
123
+
124
+ const toolId = `${sessionId}-tool-${turn}`;
125
+ const argsPreview = `{"command":"ls ${task.cwd}"}`;
126
+ yield* emit({
127
+ _tag: "AssistantMessage",
128
+ parts: [
129
+ { type: "thinking", text: thinking },
130
+ {
131
+ type: "text",
132
+ text: `I'll run ${profile.toolName} to look around first.`,
133
+ },
134
+ { type: "toolCall", toolId, name: profile.toolName, argsPreview },
135
+ ],
136
+ });
137
+ yield* emit({
138
+ _tag: "ToolStart",
139
+ toolId,
140
+ name: profile.toolName,
141
+ argsPreview,
142
+ });
143
+ yield* pause;
144
+ yield* emit({
145
+ _tag: "ToolUpdate",
146
+ toolId,
147
+ outputPreview: "src docs package.json",
148
+ });
149
+ yield* pause;
150
+ yield* emit({
151
+ _tag: "ToolEnd",
152
+ toolId,
153
+ name: profile.toolName,
154
+ isError: false,
155
+ outputPreview: "src docs package.json",
156
+ });
157
+ yield* emit({
158
+ _tag: "UsageChanged",
159
+ tokens: Math.min(profile.contextWindow, 2400 * (turn + 1)),
160
+ contextWindow: profile.contextWindow,
161
+ });
162
+
163
+ if (failing) {
164
+ yield* pause;
165
+ yield* emit({
166
+ _tag: "RunSettled",
167
+ outcome: {
168
+ _tag: "Failed",
169
+ errorText: `[stub:${profile.backend}] task failed as requested by FAIL: prefix`,
170
+ },
171
+ });
172
+ return;
173
+ }
174
+
175
+ const finalText =
176
+ `[stub:${profile.backend}] completed: ${firstLine(userText).slice(0, 200)}\n\n` +
177
+ `This is a stubbed ${profile.backend} subagent turn ${turn + 1}. ` +
178
+ `The real backend integration will replace this scripted output.`;
179
+ for (const delta of chunked(finalText, 24)) {
180
+ yield* emit({ _tag: "AssistantDelta", kind: "text", delta });
181
+ yield* pause;
182
+ }
183
+ yield* emit({
184
+ _tag: "AssistantMessage",
185
+ parts: [{ type: "text", text: finalText }],
186
+ });
187
+ yield* emit({
188
+ _tag: "UsageChanged",
189
+ tokens: Math.min(profile.contextWindow, 2400 * (turn + 1) + 900),
190
+ contextWindow: profile.contextWindow,
191
+ });
192
+ yield* emit({
193
+ _tag: "RunSettled",
194
+ outcome: { _tag: "Completed", finalText },
195
+ });
196
+ });
197
+
198
+ const queuedView = (): ReadonlyArray<QueuedMessage> =>
199
+ state.pending.map((text) => ({ text, kind: "steer" as const }));
200
+
201
+ // Driver: one turn at a time, in submission order. Turns run as child
202
+ // fibers so interrupt() stops the turn without killing the driver.
203
+ const driver = Effect.gen(function* () {
204
+ while (true) {
205
+ const text = yield* Queue.take(inbox);
206
+ state.dispatching = true;
207
+ state.pending.shift();
208
+ yield* emit({ _tag: "QueueChanged", queued: queuedView() });
209
+ yield* emit({ _tag: "UserMessage", text });
210
+ const turn = state.turnCount++;
211
+ const fiber = yield* Effect.forkChild(
212
+ runTurn(text, turn).pipe(
213
+ Effect.onInterrupt(() =>
214
+ emit({
215
+ _tag: "RunSettled",
216
+ outcome: { _tag: "Interrupted" },
217
+ }).pipe(Effect.ignore),
218
+ ),
219
+ ),
220
+ );
221
+ yield* Ref.set(activeTurn, fiber);
222
+ state.dispatching = false;
223
+ yield* Fiber.await(fiber);
224
+ yield* Ref.set(activeTurn, undefined);
225
+ }
226
+ });
227
+ yield* Effect.forkScoped(driver.pipe(Effect.ignore));
228
+
229
+ yield* Effect.addFinalizer(() =>
230
+ Effect.gen(function* () {
231
+ state.closed = true;
232
+ yield* Queue.end(inbox).pipe(Effect.ignore);
233
+ yield* Queue.end(events).pipe(Effect.ignore);
234
+ }),
235
+ );
236
+
237
+ const submit = (text: string) =>
238
+ Effect.gen(function* () {
239
+ if (state.closed) {
240
+ return yield* new SendError({
241
+ message: "Subagent session is closed.",
242
+ });
243
+ }
244
+ state.pending.push(text);
245
+ const busy = (yield* Ref.get(activeTurn)) !== undefined;
246
+ if (busy) {
247
+ // Show the queued steer line until the driver picks it up.
248
+ yield* emit({ _tag: "QueueChanged", queued: queuedView() });
249
+ }
250
+ yield* Queue.offer(inbox, text);
251
+ });
252
+
253
+ // Announce metadata, then kick off the initial run.
254
+ yield* Effect.try(() => fs.mkdirSync(STUB_DIR, { recursive: true })).pipe(
255
+ Effect.ignore, // The fake session file directory is best-effort.
256
+ );
257
+ yield* emit({ _tag: "MetaChanged", meta: state.meta });
258
+ // The session cannot be closed yet, so the initial submit cannot fail.
259
+ yield* submit(task.prompt).pipe(Effect.orDie);
260
+
261
+ return {
262
+ meta: Effect.sync(() => state.meta),
263
+ events: Stream.fromQueue(events),
264
+ send: submit,
265
+ interrupt: Effect.gen(function* () {
266
+ // Drop queued prompts so interrupting cannot immediately start
267
+ // another turn, then stop the active turn. A prompt may be mid-flight
268
+ // between the driver dequeuing it and registering its fiber, so wait
269
+ // that window out instead of silently missing the turn.
270
+ const cleared = yield* Queue.clear(inbox).pipe(
271
+ Effect.orElseSucceed(() => []),
272
+ );
273
+ state.pending = [];
274
+ yield* emit({ _tag: "QueueChanged", queued: [] });
275
+ while (true) {
276
+ const fiber = yield* Ref.get(activeTurn);
277
+ if (fiber) {
278
+ yield* Fiber.interrupt(fiber);
279
+ return;
280
+ }
281
+ if (!state.dispatching) {
282
+ // No turn ever started. If we cancelled queued prompts, the run
283
+ // still needs a terminal event or it would look running forever.
284
+ if (cleared.length > 0) {
285
+ yield* emit({
286
+ _tag: "RunSettled",
287
+ outcome: { _tag: "Interrupted" },
288
+ });
289
+ }
290
+ return;
291
+ }
292
+ yield* Effect.sleep(Duration.millis(5));
293
+ }
294
+ }),
295
+ } satisfies SubagentSession;
296
+ });
@@ -0,0 +1,21 @@
1
+ import type { SubagentOrigin } from "./domain.ts";
2
+
3
+ export const BTW_TITLE_MAX_LENGTH = 60;
4
+
5
+ /** Build a compact dashboard title from the first non-empty prompt line. */
6
+ export function deriveBtwTitle(prompt: string) {
7
+ const firstLine = prompt
8
+ .split("\n")
9
+ .find((line) => line.trim())
10
+ ?.trim();
11
+ const title = firstLine?.replace(/\s+/g, " ") ?? "";
12
+ if (!title) return "by the way";
13
+ const codePoints = Array.from(title);
14
+ if (codePoints.length <= BTW_TITLE_MAX_LENGTH) return title;
15
+ return `${codePoints.slice(0, BTW_TITLE_MAX_LENGTH - 1).join("")}…`;
16
+ }
17
+
18
+ /** User asides remain visible in the dashboard but hidden from model tools. */
19
+ export function isModelVisible(snap: { readonly origin: SubagentOrigin }) {
20
+ return snap.origin === "model";
21
+ }
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Domain model for subagents.
3
+ *
4
+ * Everything downstream of a backend (manager, tools, UI) speaks only these
5
+ * types. A backend translates its native stream (pi session events) into the
6
+ * normalized `SubagentEvent` union.
7
+ */
8
+
9
+ import type { ModelRegistry } from "@earendil-works/pi-coding-agent";
10
+ import { Data } from "effect";
11
+
12
+ export const BACKEND_NAMES = ["pi"] as const;
13
+ export type BackendName = (typeof BACKEND_NAMES)[number];
14
+
15
+ /** Who initiated the session. User asides stay out of model-facing tooling. */
16
+ export type SubagentOrigin = "model" | "btw";
17
+
18
+ /**
19
+ * Reasoning-effort scale; these are pi's thinking levels, used directly as the
20
+ * level. Omitted = inherit the parent session's level.
21
+ */
22
+ export const REASONING_EFFORTS = [
23
+ "off",
24
+ "minimal",
25
+ "low",
26
+ "medium",
27
+ "high",
28
+ "xhigh",
29
+ "max",
30
+ ] as const;
31
+ export type ReasoningEffort = (typeof REASONING_EFFORTS)[number];
32
+
33
+ export type SubagentStatus = "running" | "done" | "error";
34
+
35
+ /** Parent-session context resolved by the tool layer and passed opaquely. */
36
+ export interface ParentContext {
37
+ readonly parentCwd: string;
38
+ readonly projectTrusted: boolean;
39
+ /** Parent pi model, for the pi backend's "inherit" default. */
40
+ readonly inheritedModel?: { readonly provider: string; readonly id: string };
41
+ readonly inheritedThinkingLevel?: string;
42
+ /** Parent model registry; required by the pi backend to resolve models. */
43
+ readonly modelRegistry?: ModelRegistry;
44
+ }
45
+
46
+ export interface SpawnTask {
47
+ /** Omitted for normal tool-driven spawns. */
48
+ readonly origin?: SubagentOrigin;
49
+ readonly prompt: string;
50
+ readonly title: string;
51
+ readonly cwd: string;
52
+ /**
53
+ * Model hint: "provider/model-id", or a bare model id resolved against the
54
+ * parent's provider. Omitted = inherit the parent model.
55
+ */
56
+ readonly model?: string;
57
+ /** Thinking level for the child; omitted inherits the parent level. */
58
+ readonly reasoningEffort?: ReasoningEffort;
59
+ /**
60
+ * Agent-type fields, resolved by the tool layer. The backend applies them
61
+ * verbatim; it does not know where they came from.
62
+ */
63
+ readonly appendSystemPrompt?: readonly string[];
64
+ /**
65
+ * Tool allowlist for the child. Composed with (never replacing) the child
66
+ * denylist, so this can only narrow. Omitted = the normal child tool set.
67
+ */
68
+ readonly tools?: readonly string[];
69
+ /** Agent type that supplied the above, for the session label. */
70
+ readonly agentTypeName?: string;
71
+ /**
72
+ * Isolated git worktree this child runs in, created by the tool layer. The
73
+ * backend only reclaims it when the session scope closes; it does not know
74
+ * how it was made.
75
+ */
76
+ readonly worktree?: {
77
+ readonly path: string;
78
+ readonly branch: string;
79
+ /** Repository the worktree belongs to, for the reclaim call. */
80
+ readonly repoCwd: string;
81
+ /** Creation-time commit; reclaim measures "produced nothing" against it. */
82
+ readonly baseSha?: string;
83
+ };
84
+ readonly parent: ParentContext;
85
+ }
86
+
87
+ export interface SubagentMeta {
88
+ readonly backend: BackendName;
89
+ /** Display label, e.g. "seal/kimi-k3". */
90
+ readonly modelLabel?: string;
91
+ /** Context window capacity for utilization display, when known. */
92
+ readonly contextWindow?: number;
93
+ /** Child session file on disk. */
94
+ readonly sessionFilePath?: string;
95
+ }
96
+
97
+ // --- Transcript ------------------------------------------------------------
98
+
99
+ export type TranscriptPart =
100
+ | { readonly type: "text"; readonly text: string }
101
+ | {
102
+ readonly type: "thinking";
103
+ readonly text: string;
104
+ readonly redacted?: boolean;
105
+ }
106
+ | {
107
+ readonly type: "toolCall";
108
+ readonly toolId: string;
109
+ readonly name: string;
110
+ readonly argsPreview?: string;
111
+ };
112
+
113
+ export type TranscriptItem =
114
+ | { readonly kind: "user"; readonly text: string }
115
+ | {
116
+ readonly kind: "assistant";
117
+ readonly parts: ReadonlyArray<TranscriptPart>;
118
+ }
119
+ | {
120
+ readonly kind: "toolResult";
121
+ readonly toolId: string;
122
+ readonly name: string;
123
+ readonly isError: boolean;
124
+ readonly outputPreview?: string;
125
+ };
126
+
127
+ export interface LiveToolState {
128
+ readonly toolId: string;
129
+ readonly name: string;
130
+ readonly argsPreview?: string;
131
+ readonly outputPreview?: string;
132
+ readonly done?: boolean;
133
+ readonly isError?: boolean;
134
+ }
135
+
136
+ export interface QueuedMessage {
137
+ readonly text: string;
138
+ readonly kind: "steer" | "follow-up";
139
+ }
140
+
141
+ // --- Events ------------------------------------------------------------------
142
+
143
+ export type RunOutcome =
144
+ | { readonly _tag: "Completed"; readonly finalText: string }
145
+ | {
146
+ readonly _tag: "Failed";
147
+ readonly errorText: string;
148
+ readonly partialText?: string;
149
+ }
150
+ | { readonly _tag: "Interrupted"; readonly partialText?: string };
151
+
152
+ /**
153
+ * Normalized activity stream. Previews (`argsPreview`, `outputPreview`) stay
154
+ * bounded, pre-flattened strings; the UI may compact known argument fields but
155
+ * never retains arbitrary raw tool payloads.
156
+ */
157
+ export type SubagentEvent =
158
+ // lifecycle (a session can run multiple turns via send())
159
+ | { readonly _tag: "RunStarted" }
160
+ | { readonly _tag: "RunSettled"; readonly outcome: RunOutcome }
161
+ // transcript building blocks
162
+ | { readonly _tag: "UserMessage"; readonly text: string }
163
+ | {
164
+ readonly _tag: "AssistantDelta";
165
+ readonly kind: "text" | "thinking";
166
+ readonly delta: string;
167
+ }
168
+ | {
169
+ readonly _tag: "AssistantMessage";
170
+ readonly parts: ReadonlyArray<TranscriptPart>;
171
+ }
172
+ | {
173
+ readonly _tag: "ToolStart";
174
+ readonly toolId: string;
175
+ readonly name: string;
176
+ readonly argsPreview?: string;
177
+ }
178
+ | {
179
+ readonly _tag: "ToolUpdate";
180
+ readonly toolId: string;
181
+ readonly outputPreview?: string;
182
+ }
183
+ | {
184
+ readonly _tag: "ToolEnd";
185
+ readonly toolId: string;
186
+ readonly name: string;
187
+ readonly isError: boolean;
188
+ readonly outputPreview?: string;
189
+ }
190
+ // bookkeeping
191
+ | {
192
+ readonly _tag: "QueueChanged";
193
+ readonly queued: ReadonlyArray<QueuedMessage>;
194
+ }
195
+ | {
196
+ readonly _tag: "UsageChanged";
197
+ readonly tokens?: number;
198
+ readonly contextWindow?: number;
199
+ }
200
+ | { readonly _tag: "MetaChanged"; readonly meta: Partial<SubagentMeta> }
201
+ /** Non-fatal diagnostics. Fatal failures arrive as a RunSettled outcome. */
202
+ | { readonly _tag: "BackendError"; readonly message: string };
203
+
204
+ // --- Snapshot ---------------------------------------------------------------
205
+
206
+ /**
207
+ * The manager folds `SubagentEvent`s into one snapshot per subagent. This is
208
+ * everything the tools, footer status, and both TUI views read.
209
+ */
210
+ export interface SubagentSnapshot {
211
+ readonly id: string;
212
+ readonly origin: SubagentOrigin;
213
+ readonly backend: BackendName;
214
+ readonly title: string;
215
+ readonly prompt: string;
216
+ readonly cwd: string;
217
+ readonly status: SubagentStatus;
218
+ readonly createdAt: number;
219
+ readonly settledAt?: number;
220
+ readonly errorText?: string;
221
+ readonly meta: SubagentMeta;
222
+ readonly usage: { readonly tokens?: number; readonly contextWindow?: number };
223
+ readonly transcript: ReadonlyArray<TranscriptItem>;
224
+ /** Streaming assistant buffers, cleared when the finalized message lands. */
225
+ readonly liveAssistant?: { readonly text: string; readonly thinking: string };
226
+ readonly liveTools: ReadonlyArray<LiveToolState>;
227
+ readonly queued: ReadonlyArray<QueuedMessage>;
228
+ /** Final text of the most recent completed run (v1 `finalOutput`). */
229
+ readonly finalText: string;
230
+ /** Count of finalized assistant messages (for subagent_check). */
231
+ readonly turns: number;
232
+ }
233
+
234
+ /** Final text, or the live streaming buffer while a run is active (v1 `latestOutput`). */
235
+ export function latestText(snap: SubagentSnapshot) {
236
+ const live = snap.liveAssistant?.text.trim();
237
+ if (live) return live;
238
+ return snap.finalText;
239
+ }
240
+
241
+ export function formatElapsed(snap: SubagentSnapshot) {
242
+ const end = snap.settledAt ?? Date.now();
243
+ const totalSeconds = Math.max(0, Math.round((end - snap.createdAt) / 1000));
244
+ const minutes = Math.floor(totalSeconds / 60);
245
+ const seconds = totalSeconds % 60;
246
+ return minutes > 0
247
+ ? `${minutes}m${seconds.toString().padStart(2, "0")}s`
248
+ : `${seconds}s`;
249
+ }
250
+
251
+ // --- Errors -------------------------------------------------------------------
252
+
253
+ export class SpawnError extends Data.TaggedError("SpawnError")<{
254
+ readonly message: string;
255
+ }> {}
256
+
257
+ export class BackendUnavailableError extends Data.TaggedError(
258
+ "BackendUnavailableError",
259
+ )<{
260
+ readonly message: string;
261
+ }> {}
262
+
263
+ export class ConcurrencyLimitError extends Data.TaggedError(
264
+ "ConcurrencyLimitError",
265
+ )<{
266
+ readonly message: string;
267
+ }> {}
268
+
269
+ export class SendError extends Data.TaggedError("SendError")<{
270
+ readonly message: string;
271
+ }> {}
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Formatting helpers (self-contained copy of the v1 shared
3
+ * context-utilization helper).
4
+ */
5
+
6
+ export interface ContextUtilization {
7
+ /** Current conversation context occupancy; undefined while unknown. */
8
+ tokens?: number | null;
9
+ /** Capacity of the model currently serving the conversation. */
10
+ contextWindow?: number | null;
11
+ }
12
+
13
+ function usableTokens(value: number | null | undefined) {
14
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
15
+ ? value
16
+ : undefined;
17
+ }
18
+
19
+ function usableCapacity(value: number | null | undefined) {
20
+ return typeof value === "number" && Number.isFinite(value) && value > 0
21
+ ? value
22
+ : undefined;
23
+ }
24
+
25
+ export function contextPercent(usage: ContextUtilization) {
26
+ const tokens = usableTokens(usage.tokens);
27
+ const capacity = usableCapacity(usage.contextWindow);
28
+ if (tokens === undefined || capacity === undefined) return undefined;
29
+ return Math.round(Math.min(100, Math.max(0, (tokens / capacity) * 100)));
30
+ }
31
+
32
+ export function formatCompactTokens(count: number) {
33
+ if (count < 1000) return Math.round(count).toString();
34
+ if (count < 10000) return `${(count / 1000).toFixed(1)}k`;
35
+ if (count < 1000000) return `${Math.round(count / 1000)}k`;
36
+ return `${(count / 1000000).toFixed(1)}M`;
37
+ }
38
+
39
+ /**
40
+ * Render `%/capacity`. If occupancy is unknown, retain the useful capacity
41
+ * as `?%/capacity`; with no valid capacity, omit the statistic entirely.
42
+ */
43
+ export function formatContextUtilization(usage: ContextUtilization) {
44
+ const capacity = usableCapacity(usage.contextWindow);
45
+ if (capacity === undefined) return "";
46
+ const percent = contextPercent(usage);
47
+ return `${percent === undefined ? "?" : percent}%/${formatCompactTokens(capacity)}`;
48
+ }