@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
@@ -9,6 +9,7 @@ import {
9
9
  type ExtensionContext,
10
10
  } from "@earendil-works/pi-coding-agent";
11
11
  import { formatContextUtilization } from "../shared/context-utilization.ts";
12
+ import { spinnerFrame } from "../shared/spinner.ts";
12
13
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
13
14
  import type { WorktreeCleanup } from "../shared/worktree.ts";
14
15
  import type { AcceptanceLedger } from "./acceptance.ts";
@@ -47,8 +48,31 @@ export function emptyUsage(): AgentUsage {
47
48
  };
48
49
  }
49
50
 
50
- export type AgentState = "running" | "done" | "error";
51
- export type WorkflowStatus = "running" | "completed" | "failed" | "aborted";
51
+ export type AgentState = "running" | "done" | "error" | "uncertain";
52
+ export type WorkflowStatus =
53
+ | "running"
54
+ | "completed"
55
+ | "failed"
56
+ | "aborted"
57
+ | "uncertain";
58
+
59
+ export type WorkflowDeliveryState =
60
+ | "none"
61
+ | "held-for-inline"
62
+ | "pending"
63
+ | "delivered"
64
+ | "consumed-inline";
65
+
66
+ /** Durable completion-delivery plane, independent from execution status. */
67
+ export interface WorkflowDelivery {
68
+ /** Stable per-run idempotency identity, never a transport-batch id. */
69
+ id: string;
70
+ state: WorkflowDeliveryState;
71
+ attempts: number;
72
+ updatedAt: number;
73
+ deliveredAt?: number;
74
+ lastError?: string;
75
+ }
52
76
 
53
77
  export type TranscriptRole =
54
78
  | "user"
@@ -84,6 +108,8 @@ export interface AgentRecord {
84
108
  inputCallIds?: string[];
85
109
  /** Opaque same-run reference returned to the workflow script. */
86
110
  resultRef?: string;
111
+ /** Run-directory-relative authoritative result captured before projection. */
112
+ resultArtifact?: string;
87
113
  label: string;
88
114
  phase?: string;
89
115
  state: AgentState;
@@ -124,6 +150,8 @@ export interface WorkflowDetails {
124
150
  name?: string;
125
151
  description?: string;
126
152
  background: boolean;
153
+ /** Whether this run's terminal result is pending, delivered, or inline. */
154
+ delivery?: WorkflowDelivery;
127
155
  status: WorkflowStatus;
128
156
  startedAt: number;
129
157
  finishedAt?: number;
@@ -334,19 +362,31 @@ export function createUsageReader(agents: readonly AgentRecord[]) {
334
362
  };
335
363
  }
336
364
 
337
- /** Colored square state indicator (no emojis/glyphs). */
338
- export const SQUARE = "■";
339
-
340
- export function stateSquare(state: AgentState, theme: Theme): string {
341
- if (state === "done") return theme.fg("success", SQUARE);
342
- if (state === "error") return theme.fg("error", SQUARE);
343
- return theme.fg("warning", SQUARE);
365
+ /**
366
+ * One status indicator per state, shared by the transcript card, the
367
+ * dashboard, and the strips. Running spins on the package-wide cadence so
368
+ * every live view animates in step.
369
+ */
370
+ export function stateGlyph(
371
+ state: AgentState,
372
+ theme: Theme,
373
+ now: number,
374
+ ): string {
375
+ if (state === "done") return theme.fg("success", "✓");
376
+ if (state === "error") return theme.fg("error", "✗");
377
+ if (state === "uncertain") return theme.fg("warning", "?");
378
+ return theme.fg("warning", spinnerFrame(now));
344
379
  }
345
380
 
346
- export function statusSquare(status: WorkflowStatus, theme: Theme): string {
347
- if (status === "completed") return theme.fg("success", SQUARE);
348
- if (status === "running") return theme.fg("warning", SQUARE);
349
- return theme.fg("error", SQUARE);
381
+ export function statusGlyph(
382
+ status: WorkflowStatus,
383
+ theme: Theme,
384
+ now: number,
385
+ ): string {
386
+ if (status === "completed") return theme.fg("success", "✓");
387
+ if (status === "running") return theme.fg("warning", spinnerFrame(now));
388
+ if (status === "uncertain") return theme.fg("warning", "?");
389
+ return theme.fg("error", "✗");
350
390
  }
351
391
 
352
392
  export function statusWord(status: WorkflowStatus): string {
@@ -357,7 +397,7 @@ export function statusColor(
357
397
  status: WorkflowStatus,
358
398
  ): "success" | "warning" | "error" {
359
399
  if (status === "completed") return "success";
360
- if (status === "running") return "warning";
400
+ if (status === "running" || status === "uncertain") return "warning";
361
401
  return "error";
362
402
  }
363
403
 
@@ -420,13 +460,15 @@ export function aggregateUsage(agents: AgentRecord[]): AgentUsage {
420
460
  export function countStates(details: WorkflowDetails) {
421
461
  let done = 0;
422
462
  let failed = 0;
463
+ let uncertain = 0;
423
464
  let running = 0;
424
465
  for (const agent of details.agents) {
425
466
  if (agent.state === "done") done++;
426
467
  else if (agent.state === "error") failed++;
468
+ else if (agent.state === "uncertain") uncertain++;
427
469
  else running++;
428
470
  }
429
- return { done, failed, running };
471
+ return { done, failed, uncertain, running };
430
472
  }
431
473
 
432
474
  export interface PhaseGroup {
@@ -4,7 +4,9 @@ import {
4
4
  BelowEditorStripState,
5
5
  belowEditorStripInput,
6
6
  fitNavigationSides,
7
+ renderNavigationMetrics,
7
8
  } from "../shared/below-editor-navigation.ts";
9
+ import { SPINNER_INTERVAL_MS, spinnerFrame } from "../shared/spinner.ts";
8
10
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
9
11
  import {
10
12
  aggregateUsage,
@@ -12,9 +14,9 @@ import {
12
14
  formatElapsed,
13
15
  formatTokens,
14
16
  statusColor,
15
- statusSquare,
16
17
  type Theme,
17
18
  type WorkflowDetails,
19
+ type WorkflowStatus,
18
20
  } from "./model.ts";
19
21
 
20
22
  /** Workflow-named aliases preserve the public seam while sharing interaction. */
@@ -33,6 +35,17 @@ function cleanLine(value: string) {
33
35
  return sanitizeTerminalText(value).replace(/\s+/g, " ").trim();
34
36
  }
35
37
 
38
+ /**
39
+ * One status indicator per run state; doubles as the focus marker when
40
+ * selected. Running spins, in step with the dashboard and takeover headers.
41
+ */
42
+ function statusGlyph(status: WorkflowStatus, theme: Theme, now: number) {
43
+ if (status === "completed") return theme.fg("success", "✓");
44
+ if (status === "running") return theme.fg("warning", spinnerFrame(now));
45
+ if (status === "uncertain") return theme.fg("warning", "?");
46
+ return theme.fg("error", "✗");
47
+ }
48
+
36
49
  /** Live, one-line Claude-style workflow entry rendered below the editor. */
37
50
  export class WorkflowStripWidget {
38
51
  private readonly timer: ReturnType<typeof setInterval>;
@@ -51,7 +64,10 @@ export class WorkflowStripWidget {
51
64
  this.theme = theme;
52
65
  this.strip = strip;
53
66
  this.getEntry = getEntry;
54
- this.timer = setInterval(() => this.tui.requestRender(), 500);
67
+ this.timer = setInterval(
68
+ () => this.tui.requestRender(),
69
+ SPINNER_INTERVAL_MS,
70
+ );
55
71
  this.timer.unref?.();
56
72
  }
57
73
 
@@ -65,29 +81,32 @@ export class WorkflowStripWidget {
65
81
  const entry = this.getEntry();
66
82
  if (!entry || width <= 0) return [];
67
83
  const details = entry.details;
68
- const { done, failed } = countStates(details);
84
+ const { done, failed, uncertain } = countStates(details);
69
85
  const settled = done + failed;
70
86
  const usage = aggregateUsage(details.agents);
71
87
  const tokenCount = usage.input + usage.output;
72
- const marker = this.strip.focused
88
+ const glyph = this.strip.focused
73
89
  ? this.theme.fg("accent", "❯")
74
- : this.theme.fg("dim", "○");
90
+ : statusGlyph(details.status, this.theme, Date.now());
75
91
  const displayName = cleanLine(details.name ?? entry.runId) || entry.runId;
76
92
  const name = this.strip.focused
77
93
  ? this.theme.bold(this.theme.fg("accent", displayName))
78
94
  : this.theme.fg("text", displayName);
79
95
  const rawContext = details.currentPhase ?? details.description;
80
96
  const context = rawContext ? cleanLine(rawContext) : undefined;
81
- const left = ` ${marker} ${statusSquare(details.status, this.theme)} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`;
82
- const metrics = [
83
- `${settled}/${details.agents.length} agents`,
84
- formatElapsed(details.startedAt, details.finishedAt),
85
- tokenCount > 0 ? `${formatTokens(tokenCount)} tokens` : undefined,
97
+ const left = ` ${glyph} ${name}${context ? this.theme.fg("dim", ` · ${context}`) : ""}`;
98
+ const right = renderNavigationMetrics(
99
+ this.theme,
100
+ [
101
+ details.agents.length > 0
102
+ ? `${settled}/${details.agents.length} agents${uncertain ? ` · ${uncertain} uncertain` : ""}`
103
+ : undefined,
104
+ formatElapsed(details.startedAt, details.finishedAt),
105
+ tokenCount > 0 ? `${formatTokens(tokenCount)} tokens` : undefined,
106
+ ],
86
107
  this.strip.focused ? "enter open · ↑ back" : "↓ to manage",
87
- ]
88
- .filter((part): part is string => Boolean(part))
89
- .join(" · ");
90
- const right = this.theme.fg(statusColor(details.status), metrics);
108
+ details.status === "running" ? undefined : statusColor(details.status),
109
+ );
91
110
  return [fitNavigationSides(left, right, width)];
92
111
  }
93
112
  }
@@ -1,4 +1,9 @@
1
1
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
2
+ import {
3
+ allocateResultBudgets,
4
+ type ParentContextUsage,
5
+ } from "../shared/result-budget.ts";
6
+ import { projectText } from "../shared/text-projection.ts";
2
7
  import {
3
8
  countStates,
4
9
  formatElapsed,
@@ -7,13 +12,14 @@ import {
7
12
  type WorkflowDetails,
8
13
  } from "./model.ts";
9
14
 
10
- /** Model-facing schema descriptions for workflow source, arguments, and background mode. */
15
+ /** Model-facing schema descriptions for workflow source and launch policy. */
11
16
  export const WORKFLOW_PARAMETER_DESCRIPTIONS = {
12
17
  script:
13
18
  "JavaScript workflow script. May start with `export const meta = {...}`, then use phase(), agent(), parallel(), args, and a final `return`.",
14
19
  args: "Optional JSON string exposed to the script as `args` (parsed when valid JSON, otherwise passed through as the raw string).",
15
20
  background:
16
- "Run in the background: the tool returns a run id immediately and you receive a follow-up message when the workflow finishes. Defaults to false (blocking with live progress).",
21
+ "Deprecated compatibility alias: true means wait=false; false means wait=true. Do not provide both background and wait.",
22
+ wait: "Wait for the final result in this tool call. Interactive sessions default to false and deliver completion later; print/automation defaults to true. Interrupting the wait does not cancel the workflow.",
17
23
  resumeFromRunId:
18
24
  "Optional prior run id or unique suffix for safe read-only replay. See the workflows Skill for matching rules.",
19
25
  };
@@ -29,7 +35,7 @@ export const WORKFLOW_STOP_PARAMETER_DESCRIPTIONS = {
29
35
 
30
36
  /** Describes nonblocking inspection of workflow runs, mirroring subagent_check/subagent_list. */
31
37
  export const WORKFLOW_STATUS_TOOL_DESCRIPTION =
32
- "Peek at background workflow runs without blocking. With a run id, returns that run's phases, per-agent status, and result if finished; without one, lists this session's active and recently finished runs. Does not wait use background:false when you need the result inline.";
38
+ "Peek at workflow runs without blocking. With a run id, returns a bounded status and coverage summary plus the artifact location; without one, lists this session's active and recently finished runs. Does not wait, consume a completion, or repeat the full final result.";
33
39
 
34
40
  /** Model-facing schema description for the optional workflow run id to inspect. */
35
41
  export const WORKFLOW_STATUS_PARAMETER_DESCRIPTIONS = {
@@ -44,9 +50,11 @@ export const WORKFLOW_LIFECYCLE_PROMPT_SNIPPET =
44
50
  /** Compact resident contract; the workflows Skill carries the complete guide. */
45
51
  export const WORKFLOW_TOOL_DESCRIPTION = [
46
52
  "Use the workflow tool when the user explicitly requests a workflow run or when the task clearly requires multi-phase dynamic orchestration.",
47
- "Write an async JavaScript body using optional meta, phase(), log(), usage(), agent(), pipeline(), parallel(), args, and a JSON-serializable return.",
53
+ "Write an async JavaScript body using optional meta, phase(), log(), usage(), agent(), pipeline(), parallel(), args, and a JSON-serializable return. usage().limits reports the resolved concurrency and remaining call capacity.",
48
54
  "agent() returns { ok, output, structured?, ref?, error? }; always check `.ok`, use a schema for branching, and surface failed or null results.",
49
55
  "Prefer pipeline() for independent multi-stage items. Use parallel() only for a real barrier where the next step needs every prior result.",
56
+ "Interactive sessions launch in the background by default and deliver completion later. Set wait: true only when this tool call must return the final result inline.",
57
+ "Derive fan-out from independent verifiable work items and task difficulty. Concurrency is a runtime ceiling, not a target or the total-call limit; user cost, count, model, and effort constraints take precedence.",
50
58
  "For concurrent writers use isolation: 'worktree' and tell each agent to commit. Read-only work should normally stay in the shared checkout.",
51
59
  "Read the workflows Skill before a nontrivial script; it covers the restricted sandbox, full DSL, acceptance, result refs, replay, background lifecycle, limits, and examples.",
52
60
  ].join("\n");
@@ -80,11 +88,11 @@ export function buildWorkflowResultMessage(
80
88
  details: WorkflowDetails,
81
89
  runDir: string,
82
90
  ) {
83
- const { done, failed } = countStates(details);
91
+ const { done, failed, uncertain } = countStates(details);
84
92
  const elapsed = formatElapsed(details.startedAt, details.finishedAt);
85
93
  const lines = [
86
94
  `Workflow ${details.name ? `"${details.name}"` : details.runId} ${details.status} — ` +
87
- `${done}/${details.agents.length} agents ok${failed ? `, ${failed} failed` : ""} ` +
95
+ `${done}/${details.agents.length} agents ok${failed ? `, ${failed} failed` : ""}${uncertain ? `, ${uncertain} uncertain` : ""} ` +
88
96
  `across ${details.phases.length} phase(s) in ${elapsed}.`,
89
97
  `Run dir: ${shortenHome(runDir)}`,
90
98
  ];
@@ -144,7 +152,9 @@ export function buildWorkflowResultMessage(
144
152
  : "ok"
145
153
  : agent.state === "error"
146
154
  ? "FAILED"
147
- : "running";
155
+ : agent.state === "uncertain"
156
+ ? "UNCERTAIN"
157
+ : "running";
148
158
  lines.push(
149
159
  `- [${agent.label}]${agent.phase ? ` (${agent.phase})` : ""} ${status}` +
150
160
  (agent.acceptance ? ` · acceptance ${agent.acceptance.status}` : "") +
@@ -157,6 +167,31 @@ export function buildWorkflowResultMessage(
157
167
  return sanitizeTerminalText(lines.join("\n"));
158
168
  }
159
169
 
170
+ /** One bounded model projection; exact run/agent results remain in artifacts. */
171
+ export function buildProjectedWorkflowResultMessage(
172
+ details: WorkflowDetails,
173
+ runDir: string,
174
+ usage?: ParentContextUsage | null,
175
+ ) {
176
+ const full = buildWorkflowResultMessage(details, runDir);
177
+ const allocation = allocateResultBudgets(
178
+ [Buffer.byteLength(full, "utf8")],
179
+ usage,
180
+ {
181
+ maxBatchBytes: 48 * 1024,
182
+ maxResultBytes: 48 * 1024,
183
+ minResultBytes: 8 * 1024,
184
+ headroomShare: 0.25,
185
+ estimatedBytesPerToken: 4,
186
+ },
187
+ );
188
+ return projectText(full, {
189
+ maxBytes: allocation.budgets[0] ?? 8 * 1024,
190
+ maxLines: 400,
191
+ recovery: `Full workflow evidence is available in ${shortenHome(runDir)}.`,
192
+ });
193
+ }
194
+
160
195
  /** Builds the follow-up message that delivers a settled background workflow to the parent model. */
161
196
  export function buildBackgroundWorkflowFollowUp(options: {
162
197
  runId: string;
@@ -167,7 +202,47 @@ export function buildBackgroundWorkflowFollowUp(options: {
167
202
  // Sentence lead-in matching the subagent/terminal completion messages.
168
203
  const label = options.name ? `"${options.name}"` : options.runId;
169
204
  const verb = options.status === "completed" ? "finished" : options.status;
170
- return `Background workflow ${label} (${options.runId}) ${verb}.\n\n${options.result}`;
205
+ return `Background workflow ${label} (${options.runId}) ${verb}.\n\n${options.result}\n\n(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)`;
206
+ }
207
+
208
+ /** Fairly project one transport batch against the parent's current headroom. */
209
+ export function buildProjectedWorkflowCompletionBatch(
210
+ entries: readonly {
211
+ deliveryId: string;
212
+ details: WorkflowDetails;
213
+ runDir: string;
214
+ }[],
215
+ usage?: ParentContextUsage | null,
216
+ ) {
217
+ const full = entries.map(({ deliveryId, details, runDir }) =>
218
+ buildBackgroundWorkflowFollowUp({
219
+ runId: details.runId,
220
+ name: details.name,
221
+ status: details.status,
222
+ result: `${buildWorkflowResultMessage(details, runDir)}\n\nDelivery id: ${deliveryId}`,
223
+ }),
224
+ );
225
+ const separatorBytes = Math.max(0, entries.length - 1) * 2;
226
+ const allocation = allocateResultBudgets(
227
+ full.map((message) => Buffer.byteLength(message, "utf8")),
228
+ usage,
229
+ {
230
+ maxBatchBytes: 48 * 1024 - separatorBytes,
231
+ maxResultBytes: 48 * 1024,
232
+ minResultBytes: 1024,
233
+ headroomShare: 0.25,
234
+ estimatedBytesPerToken: 4,
235
+ },
236
+ );
237
+ return full
238
+ .map((message, index) =>
239
+ projectText(message, {
240
+ maxBytes: allocation.budgets[index] ?? 1024,
241
+ maxLines: 400,
242
+ recovery: `Full workflow evidence is available in ${shortenHome(entries[index]!.runDir)}; duplicate deliveries carry the same delivery id.`,
243
+ }),
244
+ )
245
+ .join("\n\n");
171
246
  }
172
247
 
173
248
  /** Builds the background-launch result and tells the parent model how to inspect or stop the run. */
@@ -182,3 +257,24 @@ export function buildBackgroundWorkflowLaunchResult(options: {
182
257
  `Its result will be delivered to you when it finishes, or use workflow_status(runId: "${options.runId}") to peek and workflow_stop(runId: "${options.runId}") to cancel; /workflows shows progress.`,
183
258
  ].join("\n");
184
259
  }
260
+
261
+ /** Pure observation: bounded status/coverage without replaying final output. */
262
+ export function buildWorkflowStatusSummary(
263
+ details: WorkflowDetails,
264
+ runDir: string,
265
+ ) {
266
+ const { done, failed, uncertain } = countStates(details);
267
+ const settled = done + failed;
268
+ return [
269
+ `Workflow ${details.name ? `"${details.name}"` : details.runId} ${details.status}.`,
270
+ details.status === "uncertain"
271
+ ? "Recovery warning: the prior owner disappeared without terminal evidence; some external effects may have occurred."
272
+ : undefined,
273
+ `Coverage: ${settled}/${details.agents.length} agents settled (${done} ok, ${failed} failed)${uncertain ? `; ${uncertain} uncertain` : ""}.`,
274
+ details.currentPhase ? `Current phase: ${details.currentPhase}` : undefined,
275
+ details.delivery ? `Delivery: ${details.delivery.state}.` : undefined,
276
+ `Artifacts: ${shortenHome(runDir)}`,
277
+ ]
278
+ .filter(Boolean)
279
+ .join("\n");
280
+ }
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
1
  import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import * as path from "node:path";
@@ -17,6 +17,9 @@ const REPLAY_FILESYSTEM_TOOL_NAMES = new Set([
17
17
  "ls",
18
18
  "fd",
19
19
  "rg",
20
+ "git_show",
21
+ "git_diff",
22
+ "git_log",
20
23
  ]);
21
24
  const REPLAY_BUILTIN_FILESYSTEM_TOOL_NAMES = new Set([
22
25
  "read",
@@ -24,7 +27,13 @@ const REPLAY_BUILTIN_FILESYSTEM_TOOL_NAMES = new Set([
24
27
  "find",
25
28
  "ls",
26
29
  ]);
27
- const REPLAY_PACKAGE_FILESYSTEM_TOOL_NAMES = new Set(["fd", "rg"]);
30
+ const REPLAY_PACKAGE_FILESYSTEM_TOOL_NAMES = new Set([
31
+ "fd",
32
+ "rg",
33
+ "git_show",
34
+ "git_diff",
35
+ "git_log",
36
+ ]);
28
37
  const GIT_OUTPUT_LIMIT = 32 * 1024 * 1024;
29
38
  const REPLAY_IDENTITY_TIMEOUT_MS = 5_000;
30
39
  const REPLAY_RESOURCE_FILE_LIMIT = 8 * 1024 * 1024;
@@ -179,12 +188,13 @@ function hasKnownReplayFilesystemImplementation(tool: {
179
188
  );
180
189
  }
181
190
  if (!REPLAY_PACKAGE_FILESYSTEM_TOOL_NAMES.has(tool.name)) return false;
191
+ // fd/rg come from file-search; the read-only git tools from git-read.
192
+ const ownerSourceSuffix = tool.name.startsWith("git_")
193
+ ? "/extensions/git-read/index.ts"
194
+ : "/extensions/file-search/index.ts";
182
195
  return (
183
196
  tool.sourceInfo?.origin === "package" &&
184
- tool.sourceInfo.path
185
- .split(path.sep)
186
- .join("/")
187
- .endsWith("/extensions/file-search/index.ts")
197
+ tool.sourceInfo.path.split(path.sep).join("/").endsWith(ownerSourceSuffix)
188
198
  );
189
199
  }
190
200
 
@@ -0,0 +1,189 @@
1
+ import type { WorkflowDetails } from "./model.ts";
2
+
3
+ export interface WorkflowCompletionEnvelope {
4
+ deliveryId: string;
5
+ runId: string;
6
+ details: WorkflowDetails;
7
+ }
8
+
9
+ export interface WorkflowDeliveryReceipt {
10
+ deliveryId: string;
11
+ delivered: boolean;
12
+ error?: string;
13
+ }
14
+
15
+ export interface WorkflowResultDeliveryOptions {
16
+ isIdle: () => boolean;
17
+ persist: (details: WorkflowDetails) => void;
18
+ deliver: (
19
+ envelopes: readonly WorkflowCompletionEnvelope[],
20
+ wake: boolean,
21
+ ) => Promise<readonly WorkflowDeliveryReceipt[]>;
22
+ }
23
+
24
+ function errorText(error: unknown) {
25
+ return error instanceof Error ? error.message : String(error);
26
+ }
27
+
28
+ /**
29
+ * Durable, per-run workflow completion delivery.
30
+ *
31
+ * Execution status remains authoritative in WorkflowDetails. This module owns
32
+ * only the orthogonal delivery plane. A transport batch is an optimization:
33
+ * every run keeps its own stable delivery id and receipt so partial success
34
+ * can be retried without duplicating siblings.
35
+ */
36
+ export function createWorkflowResultDelivery(
37
+ options: WorkflowResultDeliveryOptions,
38
+ ) {
39
+ const pending = new Map<string, WorkflowCompletionEnvelope>();
40
+ let flushing: Promise<void> | undefined;
41
+
42
+ const persistState = (
43
+ details: WorkflowDetails,
44
+ state: NonNullable<WorkflowDetails["delivery"]>["state"],
45
+ patch: Partial<NonNullable<WorkflowDetails["delivery"]>> = {},
46
+ ) => {
47
+ const delivery = details.delivery;
48
+ if (!delivery) throw new Error("Workflow delivery identity is missing");
49
+ const next = {
50
+ ...delivery,
51
+ ...patch,
52
+ state,
53
+ updatedAt: Date.now(),
54
+ };
55
+ if (patch.lastError === undefined) delete next.lastError;
56
+ details.delivery = next;
57
+ options.persist(details);
58
+ };
59
+
60
+ const enqueue = (envelope: WorkflowCompletionEnvelope) => {
61
+ pending.set(envelope.deliveryId, envelope);
62
+ };
63
+
64
+ const flush = async (wake: boolean) => {
65
+ if (flushing) return flushing;
66
+ if (pending.size === 0) return;
67
+ const envelopes = [...pending.values()];
68
+ for (const envelope of envelopes) pending.delete(envelope.deliveryId);
69
+
70
+ flushing = (async () => {
71
+ let receipts: readonly WorkflowDeliveryReceipt[];
72
+ try {
73
+ receipts = await options.deliver(envelopes, wake);
74
+ } catch (error) {
75
+ const message = errorText(error);
76
+ for (const envelope of envelopes) {
77
+ enqueue(envelope);
78
+ persistState(envelope.details, "pending", {
79
+ attempts: (envelope.details.delivery?.attempts ?? 0) + 1,
80
+ lastError: message,
81
+ });
82
+ }
83
+ return;
84
+ }
85
+
86
+ const byId = new Map(
87
+ receipts.map((receipt) => [receipt.deliveryId, receipt] as const),
88
+ );
89
+ for (const envelope of envelopes) {
90
+ const receipt = byId.get(envelope.deliveryId);
91
+ if (receipt?.delivered) {
92
+ try {
93
+ persistState(envelope.details, "delivered", {
94
+ attempts: (envelope.details.delivery?.attempts ?? 0) + 1,
95
+ deliveredAt: Date.now(),
96
+ lastError: undefined,
97
+ });
98
+ } catch (error) {
99
+ // The transport already accepted the message, but the durable
100
+ // receipt did not commit. Retain it for at-least-once recovery;
101
+ // the visible stable id lets the parent recognize a rare replay.
102
+ enqueue(envelope);
103
+ const delivery = envelope.details.delivery;
104
+ if (delivery) {
105
+ envelope.details.delivery = {
106
+ ...delivery,
107
+ state: "pending",
108
+ updatedAt: Date.now(),
109
+ lastError: `Delivery receipt persistence failed: ${errorText(error)}`,
110
+ };
111
+ }
112
+ }
113
+ continue;
114
+ }
115
+ enqueue(envelope);
116
+ persistState(envelope.details, "pending", {
117
+ attempts: (envelope.details.delivery?.attempts ?? 0) + 1,
118
+ lastError:
119
+ receipt?.error ?? "Completion delivery was not acknowledged",
120
+ });
121
+ }
122
+ })().finally(() => {
123
+ flushing = undefined;
124
+ });
125
+ return flushing;
126
+ };
127
+
128
+ return {
129
+ /** Register inline interest before the run starts. */
130
+ holdInline(details: WorkflowDetails) {
131
+ persistState(details, "held-for-inline");
132
+ },
133
+
134
+ /** Terminal won the wait/abort arbitration and will be returned inline. */
135
+ consumeInline(details: WorkflowDetails) {
136
+ if (details.delivery) pending.delete(details.delivery.id);
137
+ persistState(details, "consumed-inline", {
138
+ deliveredAt: Date.now(),
139
+ lastError: undefined,
140
+ });
141
+ },
142
+
143
+ /** Abort won the wait/terminal arbitration; deliver the result later. */
144
+ releaseInline(envelope: WorkflowCompletionEnvelope) {
145
+ persistState(envelope.details, "pending", { lastError: undefined });
146
+ enqueue(envelope);
147
+ if (options.isIdle()) void flush(true);
148
+ },
149
+
150
+ /** Queue a detached run after terminal status and pending are persisted. */
151
+ defer(envelope: WorkflowCompletionEnvelope) {
152
+ persistState(envelope.details, "pending", { lastError: undefined });
153
+ enqueue(envelope);
154
+ if (options.isIdle()) void flush(true);
155
+ },
156
+
157
+ /** Restore only explicitly pending/held new-format runs. */
158
+ restore(envelope: WorkflowCompletionEnvelope) {
159
+ const state = envelope.details.delivery?.state;
160
+ if (state !== "pending" && state !== "held-for-inline") return false;
161
+ // A process restart cannot still own the inline waiter. Deterministically
162
+ // reconstruct pending delivery from the terminal artifact.
163
+ if (state === "held-for-inline") {
164
+ persistState(envelope.details, "pending", {
165
+ lastError: "Inline waiter was not active after session restart",
166
+ });
167
+ }
168
+ enqueue(envelope);
169
+ return true;
170
+ },
171
+
172
+ parentSettled() {
173
+ return flush(true);
174
+ },
175
+
176
+ flushIfIdle() {
177
+ if (!options.isIdle()) return Promise.resolve();
178
+ return flush(true) ?? Promise.resolve();
179
+ },
180
+
181
+ size() {
182
+ return pending.size;
183
+ },
184
+
185
+ clear() {
186
+ pending.clear();
187
+ },
188
+ };
189
+ }
@@ -207,6 +207,11 @@ const BOOTSTRAP = String.raw`
207
207
  const value = raw && typeof raw === "object" ? raw[key] : undefined;
208
208
  return typeof value === "number" && Number.isFinite(value) ? value : 0;
209
209
  };
210
+ const readLimit = (key) => {
211
+ const limits = raw && typeof raw === "object" ? raw.limits : undefined;
212
+ const value = limits && typeof limits === "object" ? limits[key] : undefined;
213
+ return typeof value === "number" && Number.isFinite(value) ? value : 0;
214
+ };
210
215
  // Every field is coerced, so a missing or malformed snapshot reads as zero
211
216
  // rather than undefined: a script doing arithmetic on it gets 0, not NaN.
212
217
  return deepFreeze({
@@ -217,6 +222,12 @@ const BOOTSTRAP = String.raw`
217
222
  total: read("total"),
218
223
  cost: read("cost"),
219
224
  agents: read("agents"),
225
+ limits: {
226
+ concurrency: readLimit("concurrency"),
227
+ maxAgentCalls: readLimit("maxAgentCalls"),
228
+ callsUsed: readLimit("callsUsed"),
229
+ callsRemaining: readLimit("callsRemaining"),
230
+ },
220
231
  });
221
232
  }
222
233
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tt-a1i/openpi",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "OpenPI — a Pi-native multi-agent workbench with background execution, isolated subagents, replay-safe workflows, goals, tasks, and observable TUI",
5
5
  "license": "MIT",
6
6
  "author": "tt-a1i",