@tt-a1i/openpi 0.3.0 → 0.4.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 (61) hide show
  1. package/README.md +87 -24
  2. package/SETUP.md +3 -3
  3. package/extensions/ask-user/index.ts +30 -14
  4. package/extensions/background-terminals/src/prompt.ts +1 -1
  5. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  6. package/extensions/capabilities/index.ts +35 -44
  7. package/extensions/capabilities/src/ui.ts +93 -0
  8. package/extensions/file-mutation-display/index.ts +34 -76
  9. package/extensions/file-mutation-display/render.ts +387 -88
  10. package/extensions/file-search/index.ts +8 -7
  11. package/extensions/file-search/src/binaries.ts +18 -18
  12. package/extensions/git-info/src/changed-files-view.ts +47 -14
  13. package/extensions/git-read/index.ts +330 -0
  14. package/extensions/git-read/src/args.ts +171 -0
  15. package/extensions/git-read/src/process.ts +81 -0
  16. package/extensions/git-read/src/prompt.ts +56 -0
  17. package/extensions/sessions/index.ts +70 -55
  18. package/extensions/setup/index.ts +6 -6
  19. package/extensions/shared/activity-status.ts +6 -5
  20. package/extensions/shared/below-editor-navigation.ts +26 -0
  21. package/extensions/shared/capability-intent.ts +53 -0
  22. package/extensions/shared/child-session.ts +7 -1
  23. package/extensions/shared/result-budget.ts +134 -0
  24. package/extensions/shared/screen-chrome.ts +133 -0
  25. package/extensions/shared/setup-config.ts +24 -5
  26. package/extensions/shared/spinner.ts +28 -0
  27. package/extensions/shared/text-projection.ts +56 -0
  28. package/extensions/shared/tool-surface.ts +13 -6
  29. package/extensions/subagents/index.ts +216 -170
  30. package/extensions/subagents/navigation.ts +52 -23
  31. package/extensions/subagents/src/agent-types.ts +37 -15
  32. package/extensions/subagents/src/backends/stub.ts +7 -0
  33. package/extensions/subagents/src/id-sequence.ts +84 -0
  34. package/extensions/subagents/src/manager.ts +620 -537
  35. package/extensions/subagents/src/prompt.ts +153 -38
  36. package/extensions/subagents/src/result-artifact.ts +142 -0
  37. package/extensions/subagents/src/result-delivery.ts +50 -5
  38. package/extensions/subagents/src/runtime.ts +8 -5
  39. package/extensions/subagents/src/ui/takeover.ts +84 -109
  40. package/extensions/subagents/src/ui/transcript.ts +76 -42
  41. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  42. package/extensions/tasks/ui.ts +79 -62
  43. package/extensions/ui-customization/footer.ts +7 -4
  44. package/extensions/user-input-fold/index.ts +185 -0
  45. package/extensions/workflows/artifacts.ts +35 -0
  46. package/extensions/workflows/controller.ts +14 -2
  47. package/extensions/workflows/coordinator.ts +64 -0
  48. package/extensions/workflows/dashboard.ts +353 -173
  49. package/extensions/workflows/handoff.ts +62 -20
  50. package/extensions/workflows/index.ts +647 -387
  51. package/extensions/workflows/model.ts +57 -15
  52. package/extensions/workflows/navigation.ts +33 -14
  53. package/extensions/workflows/prompt.ts +104 -8
  54. package/extensions/workflows/replay-safety.ts +16 -6
  55. package/extensions/workflows/result-delivery.ts +189 -0
  56. package/extensions/workflows/sandbox-child.cjs +11 -0
  57. package/package.json +1 -1
  58. package/skills/subagents/SKILL.md +2 -2
  59. package/skills/workflows/REFERENCE.md +7 -4
  60. package/skills/workflows/SKILL.md +53 -10
  61. package/extensions/subagents/src/format.ts +0 -48
@@ -4,15 +4,35 @@ import type {
4
4
  Theme,
5
5
  } from "@earendil-works/pi-coding-agent";
6
6
  import {
7
+ type Component,
7
8
  Key,
8
9
  matchesKey,
9
10
  Text,
10
- truncateToWidth,
11
- type Component,
12
11
  type TUI,
12
+ truncateToWidth,
13
+ visibleWidth,
13
14
  } from "@earendil-works/pi-tui";
15
+ import { fitNavigationSides } from "../shared/below-editor-navigation.ts";
16
+ import {
17
+ hintLine,
18
+ panelFrame,
19
+ screenTitleLine,
20
+ } from "../shared/screen-chrome.ts";
14
21
  import type { TaskItem, TaskSnapshot } from "./tasks.ts";
15
22
 
23
+ /**
24
+ * One colour per status, shared by every surface. The widget used to paint
25
+ * in-progress amber while the full list painted it accent, so the same item
26
+ * changed colour depending on where you looked at it.
27
+ */
28
+ const STATUS_COLOR = {
29
+ pending: "muted",
30
+ in_progress: "accent",
31
+ blocked: "warning",
32
+ done: "success",
33
+ dropped: "error",
34
+ } as const satisfies Record<TaskItem["status"], string>;
35
+
16
36
  const STATUS_ICON: Record<TaskItem["status"], string> = {
17
37
  pending: "○",
18
38
  in_progress: "●",
@@ -86,45 +106,41 @@ export function taskCounts(items: readonly TaskItem[]): TaskCounts {
86
106
  }
87
107
 
88
108
  /**
89
- * One-line census: `4 tasks (3 done, 1 in progress, 0 open)`.
109
+ * One-line census: `4 tasks · 3 done · 1 open`.
90
110
  *
91
- * Counts carry the weight and the words recede, so the shape of the batch
92
- * reads before any individual row does. `done`, `in progress`, and `open` are
93
- * always listed even at zero a stable set of three keeps the line from
94
- * reflowing as work moves between them. `blocked` and `dropped` are
95
- * exceptional and appear only when they are not zero.
111
+ * Colour carries the status and the total anchors the line, so nothing needs
112
+ * bold numbers alternating with dim words that zebra was the loudest thing
113
+ * on screen and said the least. Zeros are dropped: "0 in progress" costs a
114
+ * segment to tell you nothing, and a segment appearing when work starts is a
115
+ * signal, not a glitch. When one status covers everything the redundant count
116
+ * collapses to `all`, so a fresh batch reads `8 tasks · all open` rather than
117
+ * `8 tasks · 8 open`.
96
118
  *
97
119
  * Takes counts rather than items because a view often shows a bounded subset
98
120
  * of rows; the header must describe the whole batch regardless.
99
121
  */
100
122
  export function renderTaskSummary(counts: TaskCounts, theme: Theme): string {
101
- const number = (value: number) => theme.bold(theme.fg("text", String(value)));
102
- const dim = (text: string) => theme.fg("dim", text);
103
123
  // Coerced, not trusted: these counts can arrive from a tool-result record
104
124
  // persisted by an older build, where a missing key would render the literal
105
125
  // word "undefined" (or "NaN tasks") into the header.
106
126
  const count = (status: TaskItem["status"]) =>
107
127
  Number.isFinite(counts[status]) ? counts[status] : 0;
108
128
  const total = Number.isFinite(counts.total) ? counts.total : 0;
129
+ if (total <= 0) return theme.fg("dim", "no tasks");
130
+ const present = SUMMARY_ORDER.filter((status) => count(status) > 0);
109
131
  // Built segment by segment rather than by wrapping the whole line: each
110
132
  // styled run emits its own reset, so an outer color would stop applying at
111
133
  // the first inner one.
112
- const parts = SUMMARY_ORDER.filter(
113
- (status) =>
114
- status === "done" ||
115
- status === "in_progress" ||
116
- status === "pending" ||
117
- count(status) > 0,
118
- ).map((status) => `${number(count(status))} ${dim(SUMMARY_LABEL[status])}`);
134
+ const chips = present.map((status) =>
135
+ theme.fg(
136
+ STATUS_COLOR[status],
137
+ `${present.length === 1 && count(status) === total ? "all" : count(status)} ${SUMMARY_LABEL[status]}`,
138
+ ),
139
+ );
119
140
  return [
120
- number(total),
121
- " ",
122
- dim(total === 1 ? "task" : "tasks"),
123
- " ",
124
- dim("("),
125
- parts.join(dim(", ")),
126
- dim(")"),
127
- ].join("");
141
+ theme.fg("dim", `${total} ${total === 1 ? "task" : "tasks"}`),
142
+ ...chips,
143
+ ].join(theme.fg("dim", " · "));
128
144
  }
129
145
 
130
146
  export interface TaskToolDetails {
@@ -150,16 +166,7 @@ export function renderTaskRows(
150
166
  // (all of them) — would otherwise shift every subject sideways by a column.
151
167
  const idWidth = Math.max(...items.map((item) => `T${item.id}`.length), 3);
152
168
  return items.flatMap((item) => {
153
- const color =
154
- item.status === "done"
155
- ? "success"
156
- : item.status === "blocked"
157
- ? "warning"
158
- : item.status === "dropped"
159
- ? "error"
160
- : item.status === "in_progress"
161
- ? "accent"
162
- : "muted";
169
+ const color = STATUS_COLOR[item.status];
163
170
  // No `[status]` text: the icon, its color, and the subject's own weight
164
171
  // already say it, and repeating it in words crowded every row.
165
172
  const id = `T${item.id}`.padStart(idWidth);
@@ -215,17 +222,23 @@ export function renderTaskWidget(
215
222
 
216
223
  const hasOverflow = actionable.length > TASK_WIDGET_LIMIT;
217
224
  const toggleHint = hasOverflow
218
- ? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}`
225
+ ? ` · ctrl+shift+t ${expanded ? "collapse" : "show all"}`
219
226
  : "";
220
227
  // Same census as the full list and the /tasks screen. Counted over `tracked`
221
228
  // rather than every item, because the widget deliberately hides dropped work
222
229
  // and a total that included it would not add up against the rows shown.
230
+ //
231
+ // Hints sit on the right edge instead of trailing the census, so the eye lands
232
+ // on state first and the keystrokes stay out of the way until wanted. They are
233
+ // dropped rather than truncated when the terminal is too narrow to hold both.
234
+ const label =
235
+ theme.fg("accent", "◆ ") + theme.fg("text", theme.bold("Tasks"));
236
+ const left = `${label} ${renderTaskSummary(taskCounts(tracked), theme)}`;
237
+ const hint = theme.fg("dim", `/tasks${toggleHint}`);
223
238
  const header =
224
- theme.fg("accent", "◆ ") +
225
- theme.fg("text", theme.bold("Tasks")) +
226
- " " +
227
- renderTaskSummary(taskCounts(tracked), theme) +
228
- theme.fg("dim", ` · /tasks${toggleHint}`);
239
+ visibleWidth(left) + visibleWidth(hint) + 3 <= width
240
+ ? fitNavigationSides(left, hint, width)
241
+ : left;
229
242
  const visible = expanded
230
243
  ? actionable
231
244
  : actionable.slice(0, TASK_WIDGET_LIMIT);
@@ -235,12 +248,7 @@ export function renderTaskWidget(
235
248
  const idWidth = Math.max(...visible.map((i) => `T${i.id}`.length), 3);
236
249
  const lines = [truncateToWidth(header, width)];
237
250
  for (const [index, item] of visible.entries()) {
238
- const color =
239
- item.status === "in_progress"
240
- ? "warning"
241
- : item.status === "blocked"
242
- ? "error"
243
- : "muted";
251
+ const color = STATUS_COLOR[item.status];
244
252
  const branch = index === visible.length - 1 && hidden === 0 ? "╰─" : "├─";
245
253
  lines.push(
246
254
  truncateToWidth(
@@ -379,27 +387,36 @@ class TasksScreen implements Component {
379
387
  }
380
388
 
381
389
  render(width: number) {
382
- const body = renderTaskRows(this.snapshot.items, this.theme, width - 4);
383
- const rows = Math.max(8, (this.tui.terminal.rows || 30) - 8);
390
+ const theme = this.theme;
391
+ const counts = taskCounts(this.snapshot.items);
392
+ const body = renderTaskRows(this.snapshot.items, theme, width - 4);
393
+ // Title (1) + frame (2) + hint (1): one row more chrome than the old bare
394
+ // rule, so the body gives one back and the screen keeps its total height.
395
+ const rows = Math.max(8, (this.tui.terminal.rows || 30) - 9);
384
396
  const maxOffset = Math.max(0, body.length - rows);
385
397
  this.offset = Math.min(this.offset, maxOffset);
386
398
  const visible = body.slice(this.offset, this.offset + rows);
387
- const lines = [
388
- truncateToWidth(
389
- `${this.theme.fg("accent", this.theme.bold("Session tasks"))} ${renderTaskSummary(taskCounts(this.snapshot.items), this.theme)}`,
399
+ // Framed like /subagents, /ps, and /workflows rather than a bare rule: a
400
+ // full-screen view of a list is the same object in each of them, and it
401
+ // should not look like a different control here.
402
+ return [
403
+ screenTitleLine(theme, "Session tasks", "", width),
404
+ ...panelFrame(theme, {
405
+ label: renderTaskSummary(counts, theme),
406
+ rows: visible.map((line) => ` ${line}`),
390
407
  width,
391
- ),
392
- this.theme.fg("border", "─".repeat(Math.max(0, width))),
393
- ...visible.map((line) => truncateToWidth(` ${line}`, width)),
394
- ];
395
- while (lines.length < rows + 2) lines.push("");
396
- lines.push(
397
- truncateToWidth(
398
- this.theme.fg("dim", "j/k or ↑/↓ scroll · pgup/pgdn page · esc close"),
408
+ height: rows + 2,
409
+ }),
410
+ hintLine(
411
+ theme,
412
+ [
413
+ ["j/k or ↑/↓", "scroll"],
414
+ ["pgup/pgdn", "page"],
415
+ ["esc", "close"],
416
+ ],
399
417
  width,
400
418
  ),
401
- );
402
- return lines;
419
+ ];
403
420
  }
404
421
 
405
422
  invalidate() {}
@@ -28,6 +28,9 @@ const ESCAPE_PATTERN = /\u001b(?:[()][0-2A-Z]|[ -/]*[@-~])/g;
28
28
 
29
29
  const RESET = "\x1b[0m";
30
30
  const POWERLINE_ARROW = "\ue0b0"; //  — decorative; text remains readable without Nerd Font
31
+ const MODEL_ICON = "\uec10"; // Codicon: sparkle
32
+ const CONTEXT_ICON = "\uebe4"; // Codicon: pie-chart
33
+ const DIRECTORY_ICON = "\uea83"; // Codicon: folder
31
34
 
32
35
  /** Higher = keep longer when the line is too narrow. */
33
36
  const PRIORITY: Record<FooterItem, number> = {
@@ -177,11 +180,11 @@ export function buildSegmentCatalog(
177
180
  : modelInfo.modelId;
178
181
 
179
182
  return {
180
- cwd: { text: formatDirectory(cwd), tone: "text" },
181
- model: { text: modelText, tone: "muted" },
183
+ cwd: { text: `${DIRECTORY_ICON} ${formatDirectory(cwd)}`, tone: "text" },
184
+ model: { text: `${MODEL_ICON} ${modelText}`, tone: "muted" },
182
185
  thinking: { text: modelInfo.thinking, tone: "muted" },
183
186
  context: {
184
- text: contextText,
187
+ text: contextText ? `${CONTEXT_ICON} ${contextText}` : "",
185
188
  tone: contextTone(modelInfo.contextPercent),
186
189
  },
187
190
  cache: {
@@ -199,7 +202,7 @@ export function buildSegmentCatalog(
199
202
  : `~${Math.round(modelInfo.tokensPerSecond)} tok/s`,
200
203
  tone: "muted",
201
204
  },
202
- git: { text: gitInfo.branch ?? "", tone: "muted" },
205
+ git: { text: gitInfo.branch ? `⎇ ${gitInfo.branch}` : "", tone: "muted" },
203
206
  pr: {
204
207
  text: gitInfo.pullRequest
205
208
  ? formatPullRequest(gitInfo.pullRequest.number, gitInfo.pullRequest.url)
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Fold very long user messages in the transcript display.
3
+ *
4
+ * A pasted log, stack trace, or whole file can wipe out several screens of
5
+ * chat. When a finalized user message exceeds the fold thresholds, the
6
+ * display keeps a short preview of the prose and of each fenced code block
7
+ * and closes with a marker line stating how much was folded.
8
+ *
9
+ * A fold that would hide nothing (or almost nothing) is skipped and the
10
+ * message rendered in full: hiding a handful of lines costs a marker row
11
+ * while saving almost no screen space, and char-heavy messages with few
12
+ * long lines do not shrink on screen when logical lines are removed —
13
+ * folding must hide at least MIN_FOLDED_LINES lines to earn its keep.
14
+ *
15
+ * This is display-only. The transformer runs through Pi's
16
+ * `registerMarkdownTransformer` hook, which changes only what the TUI
17
+ * renders: the session file and the model context keep the full message
18
+ * untouched, so the model always receives the complete paste. The pure
19
+ * `foldUserMessage` helper never mutates its input and has no side effects.
20
+ */
21
+
22
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
23
+
24
+ /** Fold a user message longer than this many lines. */
25
+ const MAX_LINES = 20;
26
+ /** Fold a user message longer than this many characters. */
27
+ const MAX_CHARS = 1_200;
28
+ /** Prose lines kept when a message is folded. */
29
+ const PROSE_PREVIEW_LINES = 12;
30
+ /** Content lines kept per fenced code block when a message is folded. */
31
+ const BLOCK_PREVIEW_LINES = 4;
32
+ /** Character budget for the prose part of a folded message. */
33
+ const PROSE_PREVIEW_CHARS = 1_200;
34
+ /** Total rendered lines before the fold marker, across prose and code blocks. */
35
+ const MAX_PREVIEW_LINES = 20;
36
+ /** Only fold when at least this many lines would be hidden. */
37
+ const MIN_FOLDED_LINES = 8;
38
+
39
+ type Segment =
40
+ | { kind: "prose"; lines: string[] }
41
+ | { kind: "code"; open: string; content: string[]; close: string };
42
+
43
+ const FENCE_OPEN = /^ {0,3}`{3,}/;
44
+ const FENCE_CLOSE = /^ {0,3}`{3,}[ \t]*$/;
45
+
46
+ function countLines(markdown: string) {
47
+ const parts = markdown.split("\n");
48
+ // A trailing newline ends the last line; it does not open a new one.
49
+ return parts.at(-1) === "" ? parts.length - 1 : parts.length;
50
+ }
51
+
52
+ function splitLines(markdown: string) {
53
+ const lines = markdown.split("\n");
54
+ if (lines.at(-1) === "") lines.pop();
55
+ return lines;
56
+ }
57
+
58
+ function parseSegments(lines: string[]): Segment[] {
59
+ const segments: Segment[] = [];
60
+ let prose: string[] = [];
61
+ let i = 0;
62
+ while (i < lines.length) {
63
+ if (!FENCE_OPEN.test(lines[i])) {
64
+ prose.push(lines[i]);
65
+ i += 1;
66
+ continue;
67
+ }
68
+ if (prose.length > 0) {
69
+ segments.push({ kind: "prose", lines: prose });
70
+ prose = [];
71
+ }
72
+ const open = lines[i];
73
+ const content: string[] = [];
74
+ let close: string | undefined;
75
+ let j = i + 1;
76
+ while (j < lines.length && close === undefined) {
77
+ if (FENCE_CLOSE.test(lines[j])) close = lines[j];
78
+ else content.push(lines[j]);
79
+ j += 1;
80
+ }
81
+ if (close === undefined) {
82
+ // Unterminated fence: fold the whole message conservatively as text.
83
+ return [{ kind: "prose", lines }];
84
+ }
85
+ segments.push({ kind: "code", open, content, close });
86
+ i = j;
87
+ }
88
+ if (prose.length > 0) segments.push({ kind: "prose", lines: prose });
89
+ return segments;
90
+ }
91
+
92
+ /**
93
+ * Return the Markdown Pi should render instead of a long user message.
94
+ * Messages at or below both thresholds — and messages whose fold would
95
+ * hide fewer than MIN_FOLDED_LINES lines — are returned unchanged. Pure:
96
+ * the input string is never modified, and the model still sees the original.
97
+ */
98
+ export function foldUserMessage(markdown: string): string {
99
+ const totalLines = countLines(markdown);
100
+ if (totalLines <= MAX_LINES && markdown.length <= MAX_CHARS) return markdown;
101
+
102
+ const preview: string[] = [];
103
+ let proseLinesLeft = PROSE_PREVIEW_LINES;
104
+ let proseCharsLeft = PROSE_PREVIEW_CHARS;
105
+ let linesShown = 0;
106
+
107
+ for (const segment of parseSegments(splitLines(markdown))) {
108
+ const remainingLines = MAX_PREVIEW_LINES - preview.length;
109
+ if (remainingLines <= 0) break;
110
+ if (segment.kind === "code") {
111
+ if (segment.content.length === 0) {
112
+ if (remainingLines < 2) break;
113
+ preview.push(segment.open, segment.close);
114
+ linesShown += 2;
115
+ continue;
116
+ }
117
+ // A partial block needs opening/closing fences plus an ellipsis. If that
118
+ // cannot fit, stop before the block instead of emitting broken Markdown.
119
+ if (remainingLines < 3) break;
120
+ let shown = Math.min(
121
+ segment.content.length,
122
+ BLOCK_PREVIEW_LINES,
123
+ remainingLines - 2,
124
+ );
125
+ const truncated = () => shown < segment.content.length;
126
+ while (truncated() && shown + 3 > remainingLines) shown -= 1;
127
+ preview.push(segment.open, ...segment.content.slice(0, shown));
128
+ if (truncated()) preview.push("…");
129
+ preview.push(segment.close);
130
+ linesShown += 2 + shown;
131
+ continue;
132
+ }
133
+ for (const line of segment.lines) {
134
+ if (proseLinesLeft <= 0 || preview.length >= MAX_PREVIEW_LINES) break;
135
+ const cost = line.length + (preview.length > 0 ? 1 : 0);
136
+ if (preview.length > 0 && cost > proseCharsLeft) break;
137
+ if (preview.length === 0 && line.length > proseCharsLeft) {
138
+ // A single giant first line is the only case that cuts mid-line.
139
+ preview.push(`${line.slice(0, proseCharsLeft)}…`);
140
+ proseLinesLeft = 0;
141
+ proseCharsLeft = 0;
142
+ break;
143
+ }
144
+ preview.push(line);
145
+ linesShown += 1;
146
+ proseLinesLeft -= 1;
147
+ proseCharsLeft -= cost;
148
+ }
149
+ }
150
+
151
+ const foldedLines = totalLines - linesShown;
152
+ if (foldedLines < MIN_FOLDED_LINES) {
153
+ // Folding must earn its keep. Hiding fewer lines than MIN_FOLDED_LINES
154
+ // costs a marker row, hides content, and saves almost nothing on screen
155
+ // (char-heavy messages with few long lines wrap regardless), so render
156
+ // the message in full instead.
157
+ return markdown;
158
+ }
159
+ const noun = foldedLines === 1 ? "line" : "lines";
160
+ preview.push(
161
+ `… folded ${foldedLines} ${noun} · full content was sent to the model`,
162
+ );
163
+ return preview.join("\n");
164
+ }
165
+
166
+ /**
167
+ * The registered transformer: fold only finalized user messages and leave
168
+ * assistant text, thinking blocks, and streaming updates untouched.
169
+ */
170
+ export function transformUserMarkdown(
171
+ markdown: string,
172
+ context: { messageType: string; isStreaming: boolean },
173
+ ): string {
174
+ if (context.messageType !== "user" || context.isStreaming) return markdown;
175
+ try {
176
+ return foldUserMessage(markdown);
177
+ } catch {
178
+ // Display-only: a folding bug must never break rendering.
179
+ return markdown;
180
+ }
181
+ }
182
+
183
+ export default function (pi: ExtensionAPI) {
184
+ pi.registerMarkdownTransformer(transformUserMarkdown);
185
+ }
@@ -11,6 +11,7 @@ import {
11
11
  } from "./journal.ts";
12
12
  import {
13
13
  safeStringify,
14
+ toSerializable,
14
15
  truncateUtf8,
15
16
  writeFileAtomic,
16
17
  } from "./serialization.ts";
@@ -100,6 +101,40 @@ function writeRunFile(runDir: string, name: string, content: string) {
100
101
  writeFileAtomic(path.join(runDir, name), content);
101
102
  }
102
103
 
104
+ /** Persist one successful child result before any handoff/context projection. */
105
+ export function persistWorkflowAgentResult(
106
+ runDir: string,
107
+ index: number,
108
+ result: { output: string; structured?: unknown },
109
+ ) {
110
+ const artifact = path.join(
111
+ "agent-results",
112
+ `agent-${String(index).padStart(4, "0")}.json`,
113
+ );
114
+ writeRunFile(
115
+ runDir,
116
+ artifact,
117
+ JSON.stringify(
118
+ toSerializable(
119
+ {
120
+ output: result.output,
121
+ ...(result.structured !== undefined
122
+ ? { structured: result.structured }
123
+ : {}),
124
+ },
125
+ {
126
+ maxDepth: 32,
127
+ maxNodes: 100_000,
128
+ maxStringBytes: 2 * 1024 * 1024,
129
+ },
130
+ ),
131
+ null,
132
+ 2,
133
+ ),
134
+ );
135
+ return artifact;
136
+ }
137
+
103
138
  export function persistWorkflowJson(
104
139
  runDir: string,
105
140
  details: WorkflowDetails,
@@ -83,6 +83,7 @@ export class RunController {
83
83
  private readonly abortController = new AbortController();
84
84
  private readonly semaphore: Semaphore;
85
85
  private readonly maxAgentCalls: number;
86
+ private readonly concurrency: number;
86
87
  private readonly tasks = new Set<Promise<unknown>>();
87
88
  private callCount = 0;
88
89
  private sealed = false;
@@ -95,9 +96,11 @@ export class RunController {
95
96
  concurrency = DEFAULT_WORKFLOW_CONCURRENCY,
96
97
  maxAgentCalls = DEFAULT_WORKFLOW_MAX_AGENT_CALLS,
97
98
  ) {
98
- this.semaphore = new Semaphore(
99
- Math.max(1, Math.min(MAX_WORKFLOW_CONCURRENCY, Math.floor(concurrency))),
99
+ this.concurrency = Math.max(
100
+ 1,
101
+ Math.min(MAX_WORKFLOW_CONCURRENCY, Math.floor(concurrency)),
100
102
  );
103
+ this.semaphore = new Semaphore(this.concurrency);
101
104
  this.maxAgentCalls = Math.max(
102
105
  1,
103
106
  Math.min(MAX_WORKFLOW_AGENT_CALLS, Math.floor(maxAgentCalls)),
@@ -121,6 +124,15 @@ export class RunController {
121
124
  return this.callCount;
122
125
  }
123
126
 
127
+ capacity() {
128
+ return {
129
+ concurrency: this.concurrency,
130
+ maxAgentCalls: this.maxAgentCalls,
131
+ callsUsed: this.callCount,
132
+ callsRemaining: Math.max(0, this.maxAgentCalls - this.callCount),
133
+ };
134
+ }
135
+
124
136
  schedule<T>(
125
137
  task: (signal: AbortSignal) => Promise<T>,
126
138
  invocationSignal?: AbortSignal,
@@ -0,0 +1,64 @@
1
+ export interface WorkflowLaunchPolicyInput {
2
+ wait?: boolean;
3
+ background?: boolean;
4
+ }
5
+
6
+ export interface WorkflowLaunchPolicy {
7
+ wait: boolean;
8
+ detached: boolean;
9
+ }
10
+
11
+ /** Resolve legacy/background and host capability without silently changing semantics. */
12
+ export function resolveWorkflowLaunchPolicy(
13
+ input: WorkflowLaunchPolicyInput,
14
+ canDeliverLater: boolean,
15
+ ): WorkflowLaunchPolicy {
16
+ if (
17
+ input.wait !== undefined &&
18
+ input.background !== undefined &&
19
+ input.wait === input.background
20
+ ) {
21
+ throw new Error(
22
+ "wait and background conflict: background is the deprecated inverse of wait",
23
+ );
24
+ }
25
+ const wait =
26
+ input.wait ??
27
+ (input.background !== undefined ? !input.background : !canDeliverLater);
28
+ if (!wait && !canDeliverLater) {
29
+ throw new Error(
30
+ "This host cannot deliver a workflow result later; use wait: true",
31
+ );
32
+ }
33
+ return { wait, detached: !wait };
34
+ }
35
+
36
+ /**
37
+ * Arbitrate an inline wait against caller cancellation without transferring
38
+ * ownership of the workflow run to the wait signal.
39
+ */
40
+ export async function waitForWorkflowCompletion(
41
+ completion: Promise<unknown>,
42
+ signal?: AbortSignal,
43
+ ): Promise<"terminal" | "aborted"> {
44
+ if (!signal) {
45
+ await completion.catch(() => {});
46
+ return "terminal";
47
+ }
48
+ if (signal.aborted) return "aborted";
49
+
50
+ let onAbort: (() => void) | undefined;
51
+ const aborted = new Promise<"aborted">((resolve) => {
52
+ onAbort = () => resolve("aborted");
53
+ signal.addEventListener("abort", onAbort, { once: true });
54
+ });
55
+ const terminal = completion.then(
56
+ () => "terminal" as const,
57
+ () => "terminal" as const,
58
+ );
59
+ try {
60
+ return await Promise.race([terminal, aborted]);
61
+ } finally {
62
+ if (onAbort) signal.removeEventListener("abort", onAbort);
63
+ }
64
+ }