@tt-a1i/openpi 0.3.1 → 0.5.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 (129) hide show
  1. package/README.md +184 -59
  2. package/SETUP.md +23 -7
  3. package/assets/openpi-launch-card-v1.webp +0 -0
  4. package/bin/openpi.js +145 -0
  5. package/extensions/ask-user/index.ts +30 -14
  6. package/extensions/background-terminals/index.ts +30 -2
  7. package/extensions/background-terminals/src/domain.ts +2 -0
  8. package/extensions/background-terminals/src/manager.ts +486 -106
  9. package/extensions/background-terminals/src/output.ts +33 -0
  10. package/extensions/background-terminals/src/prompt.ts +14 -6
  11. package/extensions/background-terminals/src/result-delivery.ts +4 -1
  12. package/extensions/background-terminals/src/ui/ps.ts +132 -129
  13. package/extensions/capabilities/index.ts +30 -42
  14. package/extensions/capabilities/src/ui.ts +93 -0
  15. package/extensions/clear-context/index.ts +83 -0
  16. package/extensions/context-pivot/index.ts +16 -6
  17. package/extensions/cron/schedule.ts +7 -1
  18. package/extensions/file-mutation-display/index.ts +34 -76
  19. package/extensions/file-mutation-display/render.ts +146 -87
  20. package/extensions/file-search/index.ts +8 -7
  21. package/extensions/file-search/src/binaries.ts +75 -59
  22. package/extensions/git-info/src/changed-files-view.ts +47 -14
  23. package/extensions/git-read/index.ts +328 -0
  24. package/extensions/git-read/src/args.ts +171 -0
  25. package/extensions/git-read/src/process.ts +81 -0
  26. package/extensions/git-read/src/prompt.ts +56 -0
  27. package/extensions/model-info/index.ts +21 -33
  28. package/extensions/model-info/session-metrics.ts +96 -0
  29. package/extensions/plan-mode/bash-policy.ts +54 -9
  30. package/extensions/plan-mode/index.ts +7 -2
  31. package/extensions/post-edit/index.ts +16 -6
  32. package/extensions/sessions/git-stats.ts +258 -72
  33. package/extensions/sessions/index.ts +222 -140
  34. package/extensions/sessions/preview-cache.ts +104 -0
  35. package/extensions/sessions/preview-loader.ts +856 -0
  36. package/extensions/sessions/sessions.ts +43 -4
  37. package/extensions/setup/index.ts +127 -131
  38. package/extensions/shared/activity-status.ts +36 -5
  39. package/extensions/shared/agent-session-page.ts +319 -0
  40. package/extensions/shared/agent-tool-renderer.ts +218 -0
  41. package/extensions/shared/agent-transcript.ts +524 -0
  42. package/extensions/shared/below-editor-navigation.ts +26 -0
  43. package/extensions/shared/capability-intent.ts +53 -0
  44. package/extensions/shared/child-session.ts +444 -22
  45. package/extensions/shared/result-budget.ts +134 -0
  46. package/extensions/shared/result-delivery.ts +34 -0
  47. package/extensions/shared/screen-chrome.ts +133 -0
  48. package/extensions/shared/setup-config.ts +97 -38
  49. package/extensions/shared/setup-episode-state.ts +1 -1
  50. package/extensions/shared/spinner.ts +28 -0
  51. package/extensions/shared/terminal-text.ts +110 -23
  52. package/extensions/shared/text-projection.ts +113 -0
  53. package/extensions/shared/tool-activity.ts +382 -0
  54. package/extensions/shared/tool-surface.ts +42 -8
  55. package/extensions/shared/transcript-viewport.ts +46 -0
  56. package/extensions/shared/web-observer-registry.ts +390 -0
  57. package/extensions/shared/worktree.ts +11 -0
  58. package/extensions/subagents/index.ts +461 -186
  59. package/extensions/subagents/navigation.ts +86 -28
  60. package/extensions/subagents/src/agent-types.ts +37 -15
  61. package/extensions/subagents/src/backend.ts +12 -1
  62. package/extensions/subagents/src/backends/pi.ts +375 -66
  63. package/extensions/subagents/src/domain.ts +5 -0
  64. package/extensions/subagents/src/id-sequence.ts +84 -0
  65. package/extensions/subagents/src/manager.ts +651 -536
  66. package/extensions/subagents/src/prompt.ts +185 -42
  67. package/extensions/subagents/src/result-artifact.ts +146 -0
  68. package/extensions/subagents/src/result-delivery.ts +7 -1
  69. package/extensions/subagents/src/runtime.ts +23 -6
  70. package/extensions/subagents/src/ui/takeover.ts +128 -337
  71. package/extensions/subagents/src/ui/transcript.ts +38 -501
  72. package/extensions/subagents/src/ui/wait-result.ts +103 -15
  73. package/extensions/suggestions/src/ui.ts +10 -4
  74. package/extensions/tasks/index.ts +0 -3
  75. package/extensions/tasks/ui.ts +79 -62
  76. package/extensions/ui-customization/footer.ts +7 -44
  77. package/extensions/ui-customization/index.ts +0 -4
  78. package/extensions/user-input-fold/index.ts +185 -0
  79. package/extensions/web/index.ts +234 -0
  80. package/extensions/workflows/artifacts.ts +147 -22
  81. package/extensions/workflows/completion-projection.ts +457 -0
  82. package/extensions/workflows/controller.ts +14 -2
  83. package/extensions/workflows/coordinator.ts +62 -0
  84. package/extensions/workflows/dashboard.ts +458 -339
  85. package/extensions/workflows/handoff.ts +121 -25
  86. package/extensions/workflows/index.ts +1042 -492
  87. package/extensions/workflows/journal.ts +148 -13
  88. package/extensions/workflows/model.ts +131 -19
  89. package/extensions/workflows/navigation.ts +61 -18
  90. package/extensions/workflows/progress-projection.ts +306 -0
  91. package/extensions/workflows/prompt.ts +166 -10
  92. package/extensions/workflows/replay-safety.ts +58 -27
  93. package/extensions/workflows/result-delivery.ts +253 -0
  94. package/extensions/workflows/retention.ts +593 -0
  95. package/extensions/workflows/runner.ts +388 -279
  96. package/extensions/workflows/sandbox-child.cjs +36 -3
  97. package/extensions/workflows/sandbox.ts +62 -8
  98. package/extensions/workflows/serialization.ts +325 -17
  99. package/extensions/workflows/tool-renderer.ts +22 -0
  100. package/extensions/workflows/transcript.ts +149 -0
  101. package/extensions/workspace-cleanup-guard/index.ts +54 -0
  102. package/extensions/workspace-cleanup-guard/workspace-provenance.ts +563 -0
  103. package/package.json +28 -8
  104. package/skills/subagents/REFERENCE.md +189 -0
  105. package/skills/subagents/SKILL.md +2 -2
  106. package/skills/workflows/REFERENCE.md +10 -5
  107. package/skills/workflows/SKILL.md +53 -10
  108. package/web/adapter/pi-adapter.ts +661 -0
  109. package/web/host/browser-launcher.ts +20 -0
  110. package/web/host/static-assets.ts +4 -0
  111. package/web/host/terminal-status.ts +38 -0
  112. package/web/host/web-host.ts +789 -0
  113. package/web/http-dispatcher.ts +125 -0
  114. package/web/protocol/types.ts +462 -0
  115. package/web/runtime/pi-runtime.ts +991 -0
  116. package/web/runtime/types.ts +71 -0
  117. package/web/runtime/web-host-lease.ts +497 -0
  118. package/web/trace.ts +18 -0
  119. package/web/ui/app.js +1398 -0
  120. package/web/ui/index.html +139 -0
  121. package/web/ui/styles.css +598 -0
  122. package/web/vite.config.mjs +34 -0
  123. package/extensions/execution-convergence/active-evidence.ts +0 -129
  124. package/extensions/execution-convergence/index.ts +0 -442
  125. package/extensions/execution-convergence/workspace-provenance.ts +0 -338
  126. package/extensions/setup/intercom-fs-helper.cjs +0 -130
  127. package/extensions/setup/intercom.ts +0 -603
  128. package/extensions/subagents/src/backends/stub.ts +0 -296
  129. package/extensions/subagents/src/format.ts +0 -48
@@ -3,54 +3,127 @@
3
3
  import { StringEnum } from "@earendil-works/pi-ai";
4
4
  import { Type } from "typebox";
5
5
  import { effectiveChildToolAllowlist } from "../../shared/child-session.ts";
6
- import type { AgentType } from "./agent-types.ts";
6
+ import { SUBAGENT_ROLE_NAMES } from "../../shared/subagent-roles.ts";
7
+ import { type AgentType, READ_ONLY_AGENT_TOOLS } from "./agent-types.ts";
8
+ import { BACKEND_NAMES, REASONING_EFFORTS } from "./domain.ts";
7
9
  import { MAX_RUNNING } from "./manager.ts";
8
10
 
11
+ export const SUBAGENT_SCHEMA_BUDGETS = Object.freeze({
12
+ rolePurposeBytes: 240,
13
+ roleDirectoryBytes: 4 * 1024,
14
+ defaultSpawnSurfaceBytes: 2.5 * 1024,
15
+ maximumSpawnSurfaceBytes: 16 * 1024,
16
+ });
17
+
9
18
  /** Describes subagent_spawn, including the fixed concurrency cap. */
10
19
  export const SUBAGENT_SPAWN_TOOL_DESCRIPTION =
11
- "Spawn a background subagent: a fully autonomous, headless pi session with its own context window, this environment's tools and config, and normal host permissions. Fire-and-forget: this returns immediately with an id, and the subagent's final output is automatically queued back to you as a message when it settles. In an interactive session, keep working or end your turn so the user remains able to interact; do not block merely because a later step depends on the result. Children cannot orchestrate more agents/workflows or ask the user, and cannot see this conversation, so the prompt must be self-contained. Only use trusted working directories. " +
20
+ "Spawn a background in-process Pi subagent with its own context, child-safe tools, and normal host permissions. Returns immediately; its final result is delivered automatically. The child cannot see this conversation, ask the user, or orchestrate agents/workflows. Use only trusted working directories. " +
12
21
  `Max ${MAX_RUNNING} subagents can be running at once.`;
13
22
 
14
- /**
15
- * Appends the configured agent types, if any. They are a runtime resource, so
16
- * the roster has to be baked into the description at registration time.
17
- */
18
- export function buildSubagentSpawnToolDescription(
19
- agentTypes: readonly AgentType[],
20
- ) {
21
- if (agentTypes.length === 0) return SUBAGENT_SPAWN_TOOL_DESCRIPTION;
22
- return `${SUBAGENT_SPAWN_TOOL_DESCRIPTION} This environment also defines agent types (see agent_type): named presets that fix a child's system prompt, and often restrict it to a subset of tools. Prefer one when it matches the task — a type's tool restriction is enforced, not advisory.`;
23
+ /** UTF-8 bounded, whitespace-normalized text for the parent-facing roster. */
24
+ function boundedPurpose(description: string) {
25
+ const normalized = description.trim().replace(/\s+/gu, " ");
26
+ const limit = SUBAGENT_SCHEMA_BUDGETS.rolePurposeBytes;
27
+ if (Buffer.byteLength(normalized, "utf8") <= limit) return normalized;
28
+ const suffix = "…";
29
+ let used = Buffer.byteLength(suffix, "utf8");
30
+ let output = "";
31
+ for (const character of normalized) {
32
+ const bytes = Buffer.byteLength(character, "utf8");
33
+ if (used + bytes > limit) break;
34
+ output += character;
35
+ used += bytes;
36
+ }
37
+ return output.trimEnd() + suffix;
38
+ }
39
+
40
+ function compareNames(left: string, right: string) {
41
+ return left < right ? -1 : left > right ? 1 : 0;
23
42
  }
24
43
 
25
- /** Lists each agent type's enforced capabilities and reasoning default. */
44
+ /** Built-ins stay familiar; project/global additions are stable by name. */
45
+ function orderedAgentTypes(agentTypes: readonly AgentType[]) {
46
+ const builtInOrder = new Map<string, number>(
47
+ SUBAGENT_ROLE_NAMES.map((name, index) => [name, index]),
48
+ );
49
+ return [...agentTypes].sort((left, right) => {
50
+ const leftIndex = builtInOrder.get(left.name);
51
+ const rightIndex = builtInOrder.get(right.name);
52
+ if (leftIndex !== undefined || rightIndex !== undefined) {
53
+ if (leftIndex === undefined) return 1;
54
+ if (rightIndex === undefined) return -1;
55
+ return leftIndex - rightIndex;
56
+ }
57
+ return compareNames(left.name, right.name);
58
+ });
59
+ }
60
+
61
+ function capabilityClass(agentType: AgentType) {
62
+ if (agentType.tools === undefined) return "inherited-tools";
63
+ const tools = effectiveChildToolAllowlist(agentType.tools) ?? [];
64
+ if (tools.length === 0) return "no-tools";
65
+ if (tools.every((tool) => READ_ONLY_AGENT_TOOLS.includes(tool))) {
66
+ return "read-only";
67
+ }
68
+ if (
69
+ tools.some((tool) => tool === "bash" || tool === "edit" || tool === "write")
70
+ ) {
71
+ return "workspace-write";
72
+ }
73
+ // Third-party child-safe tools may still have side effects, so only claim
74
+ // the enforceable fact: this preset has a restricted tool set.
75
+ return "restricted";
76
+ }
77
+
78
+ function agentTypeSummary(agentType: AgentType) {
79
+ const effort = agentType.reasoningEffort
80
+ ? ` [default reasoning_effort: ${agentType.reasoningEffort}]`
81
+ : "";
82
+ return `"${agentType.name}" — ${boundedPurpose(agentType.description)} [${capabilityClass(agentType)}]${effort}`;
83
+ }
84
+
85
+ /** A deterministic, bounded selection index; execution details stay in Skill. */
26
86
  export function buildAgentTypeParameterDescription(
27
87
  agentTypes: readonly AgentType[],
28
88
  ) {
29
- const entries = agentTypes.map((agentType) => {
30
- const tools = agentType.tools
31
- ? (() => {
32
- const effectiveTools = effectiveChildToolAllowlist(agentType.tools);
33
- return effectiveTools?.length
34
- ? ` [only: ${effectiveTools.join(", ")}]`
35
- : " [only: no child-safe tools]";
36
- })()
37
- : "";
38
- const effort = agentType.reasoningEffort
39
- ? ` [default reasoning_effort: ${agentType.reasoningEffort}]`
40
- : " [reasoning_effort: inherits parent]";
41
- return `"${agentType.name}" — ${agentType.description}${tools}${effort}`;
42
- });
43
- return `Optional agent type: a preset that gives the child a specialized system prompt and, when listed, restricts it to exactly those child-safe tools. Omit for a general-purpose subagent with the normal tool set. Available: ${entries.join("; ")}. Model precedence: explicit spawn model > selected type file model > configured built-in role model > parent model. Reasoning precedence: explicit spawn reasoning_effort > selected type default > parent reasoning effort.`;
89
+ const ordered = orderedAgentTypes(agentTypes);
90
+ const intro =
91
+ "Optional named preset for the child prompt and capability boundary. Omit for a general-purpose child. Available: ";
92
+ const outro =
93
+ " Preset restrictions are enforced; read the Subagents Skill or role file for full details.";
94
+ const entries: string[] = [];
95
+ for (const agentType of ordered) {
96
+ const summary = agentTypeSummary(agentType);
97
+ const next = [...entries, summary];
98
+ const omitted = ordered.length - next.length;
99
+ const omission = omitted
100
+ ? `; ${omitted} presets omitted from this summary; their exact enum names remain valid.`
101
+ : ".";
102
+ const candidate = `${intro}${next.join("; ")}${omission}${outro}`;
103
+ if (
104
+ Buffer.byteLength(candidate, "utf8") >
105
+ SUBAGENT_SCHEMA_BUDGETS.roleDirectoryBytes
106
+ ) {
107
+ break;
108
+ }
109
+ entries.push(summary);
110
+ }
111
+ const omitted = ordered.length - entries.length;
112
+ const omission = omitted
113
+ ? `; ${omitted} presets omitted from this summary; their exact enum names remain valid.`
114
+ : ".";
115
+ return `${intro}${entries.join("; ")}${omission}${outro}`;
44
116
  }
45
117
 
46
118
  /** Generated schema for the dynamic agent-type roster. */
47
119
  export function createAgentTypeParameterSchema(
48
120
  agentTypes: readonly AgentType[],
49
121
  ) {
122
+ const ordered = orderedAgentTypes(agentTypes);
50
123
  return Type.Optional(
51
124
  StringEnum(
52
- agentTypes.map((agentType) => agentType.name) as [string, ...string[]],
53
- { description: buildAgentTypeParameterDescription(agentTypes) },
125
+ ordered.map((agentType) => agentType.name) as [string, ...string[]],
126
+ { description: buildAgentTypeParameterDescription(ordered) },
54
127
  ),
55
128
  );
56
129
  }
@@ -61,27 +134,69 @@ export const SUBAGENT_SPAWN_PROMPT_SNIPPET =
61
134
 
62
135
  /** Guides the parent model to delegate standalone tasks and avoid unnecessary blocking waits. */
63
136
  export const SUBAGENT_SPAWN_PROMPT_GUIDELINES = [
64
- "Reserve subagent_spawn for substantial, self-contained work; give it a complete, standalone prompt. For a single lookup or edit you can do inline, just do it — each subagent spends a fresh context window and cannot see this conversation.",
65
- "After subagent_spawn, keep working on independent work. If none remains in an interactive session, briefly tell the user the subagent is running in the background and end your turn; its result arrives automatically and you are re-invoked when it settles. Do not poll with subagent_check. Do not call subagent_wait merely because your next step depends on the result or because you have nothing else to do. Block only when the user explicitly asks you to keep the current response open for these results, or when a non-interactive automation must return them in the same invocation. Never answer from a guessed result before it arrives.",
137
+ "Delegate substantial independent work; do a single lookup or edit inline.",
138
+ "After spawning, continue independent work. In an interactive session, end your turn when none remains; automatic delivery will re-invoke you. Do not call subagent_wait merely because the next step depends on the result; use it only when the user explicitly asks to keep the response open, or the same non-interactive invocation must return the result. Never poll or guess the result.",
66
139
  ];
67
140
 
68
141
  /** Model-facing schema descriptions for subagent_spawn task and execution options. */
69
142
  export const SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS = {
70
143
  prompt:
71
144
  "Task prompt for the subagent. Must be self-contained: include all needed context, file paths, and what to report back.",
72
- name: "Short human-readable name for this subagent, shown in listings and the UI",
73
- harness:
74
- 'Optional. The only harness is "pi" (an in-process Pi session that inherits this environment), which is the default; you can omit this.',
145
+ name: "Short human-readable name shown in listings and the UI",
146
+ harness: 'Optional; "pi" is the only harness and the default.',
75
147
  workingDir:
76
- "Trusted working directory for the autonomous child (default: current working directory)",
148
+ "Trusted child working directory; defaults to the current directory",
77
149
  isolation:
78
- 'Set to "worktree" for concurrent writers and tell the child to commit. Requires Git and a clean checkout. Read the subagents Skill for lifecycle, merge location, and costs.',
150
+ 'Use "worktree" for concurrent writers and tell the child to commit. See the Subagents Skill for lifecycle details.',
79
151
  model:
80
- 'Optional model override, as "provider/model-id" or a bare id resolved against the current provider. Precedence: explicit spawn model > selected type file model > configured built-in role model > parent model. Never guess a model name.',
152
+ 'Optional "provider/model-id" or current-provider model override. Omit to use the preset, configured role, or parent default. Never guess a model name.',
81
153
  reasoningEffort:
82
- "Optional thinking level for the child. Precedence: explicit spawn reasoning_effort > selected type default > parent reasoning effort.",
154
+ "Optional child thinking level. Honor the user's requested level. Otherwise choose a level supported by the resolved child model based on the selected role and task difficulty. An explicit value overrides a role default.",
83
155
  };
84
156
 
157
+ /** The exact name/description/wire-schema source used by registration/tests. */
158
+ export function createSubagentSpawnToolSurface(
159
+ agentTypes: readonly AgentType[],
160
+ ) {
161
+ return {
162
+ description: SUBAGENT_SPAWN_TOOL_DESCRIPTION,
163
+ parameters: Type.Object({
164
+ agent_type: createAgentTypeParameterSchema(agentTypes),
165
+ prompt: Type.String({
166
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.prompt,
167
+ }),
168
+ name: Type.String({
169
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.name,
170
+ }),
171
+ harness: Type.Optional(
172
+ StringEnum(BACKEND_NAMES, {
173
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.harness,
174
+ }),
175
+ ),
176
+ working_dir: Type.Optional(
177
+ Type.String({
178
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.workingDir,
179
+ }),
180
+ ),
181
+ isolation: Type.Optional(
182
+ StringEnum(["worktree"] as const, {
183
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.isolation,
184
+ }),
185
+ ),
186
+ model: Type.Optional(
187
+ Type.String({
188
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.model,
189
+ }),
190
+ ),
191
+ reasoning_effort: Type.Optional(
192
+ StringEnum(REASONING_EFFORTS, {
193
+ description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort,
194
+ }),
195
+ ),
196
+ }),
197
+ };
198
+ }
199
+
85
200
  /** Builds the subagent_spawn result that tells the parent model how to continue or inspect the child. */
86
201
  export function buildSubagentSpawnResult(options: {
87
202
  id: string;
@@ -170,8 +285,11 @@ export const SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS = {
170
285
  export const SUBAGENT_LIST_TOOL_DESCRIPTION =
171
286
  "List all subagents (running and finished) with their status.";
172
287
 
173
- /** Builds the child completion/failure wrapper injected into the parent model's context. */
174
- export function buildSubagentResultMessage(options: {
288
+ const SUBAGENT_RESULT_TRANSPORT_INSTRUCTION =
289
+ "(This result is already shown to the user. Act on it and relay only the decisions or next steps — do not repeat it verbatim.)";
290
+
291
+ /** Builds the user-visible child completion/failure projection. */
292
+ export function buildSubagentResultDisplayMessage(options: {
175
293
  id: string;
176
294
  title: string;
177
295
  status: "running" | "done" | "error";
@@ -182,9 +300,34 @@ export function buildSubagentResultMessage(options: {
182
300
  let text = `Subagent ${options.id} "${options.title}" ${verb}.`;
183
301
  if (options.errorText) text += `\nError: ${options.errorText}`;
184
302
  text += `\n\n${options.output}`;
303
+ return text;
304
+ }
305
+
306
+ /** Builds the child completion/failure wrapper injected into the parent model's context. */
307
+ export function buildSubagentResultMessage(options: {
308
+ id: string;
309
+ title: string;
310
+ status: "running" | "done" | "error";
311
+ errorText?: string;
312
+ output: string;
313
+ }) {
314
+ let text = buildSubagentResultDisplayMessage(options);
185
315
  // This message is already displayed to the user, so tell the parent to act on
186
316
  // it rather than reprint it verbatim.
187
- text +=
188
- "\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.)";
317
+ text += `\n\n${SUBAGENT_RESULT_TRANSPORT_INSTRUCTION}`;
189
318
  return text;
190
319
  }
320
+
321
+ /** Remove the transport-only suffix from results persisted before the split. */
322
+ export function stripSubagentResultTransportInstruction(content: string) {
323
+ const separator = `\n\n${SUBAGENT_RESULT_TRANSPORT_INSTRUCTION}`;
324
+ const withoutBatchedSeparators = content.replaceAll(
325
+ `${separator}\n\nSubagent `,
326
+ "\n\nSubagent ",
327
+ );
328
+ return (
329
+ withoutBatchedSeparators.endsWith(separator)
330
+ ? withoutBatchedSeparators.slice(0, -separator.length)
331
+ : withoutBatchedSeparators
332
+ ).trimEnd();
333
+ }
@@ -0,0 +1,146 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ formatSize,
6
+ truncateHead,
7
+ truncateTail,
8
+ } from "@earendil-works/pi-coding-agent";
9
+
10
+ const HEAD_SHARE = 0.75;
11
+ const RESULT_ARTIFACT_DIR = ["cache", "openpi", "subagent-results"];
12
+
13
+ export interface ResultProjectionOptions {
14
+ readonly maxBytes: number;
15
+ readonly maxLines: number;
16
+ readonly writeArtifact: (content: string) => string;
17
+ }
18
+
19
+ export interface ResultProjection {
20
+ readonly text: string;
21
+ readonly truncated: boolean;
22
+ readonly artifactPath?: string;
23
+ readonly artifactSaveFailed?: boolean;
24
+ }
25
+
26
+ function sliceStartToUtf8Bytes(content: string, maxBytes: number) {
27
+ const bytes = Buffer.from(content, "utf8");
28
+ if (bytes.length <= maxBytes) return content;
29
+ let end = maxBytes;
30
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--;
31
+ return bytes.subarray(0, end).toString("utf8");
32
+ }
33
+
34
+ function ensureDirectory(parent: string, name: string) {
35
+ const directory = path.join(parent, name);
36
+ try {
37
+ mkdirSync(directory, { mode: 0o700 });
38
+ } catch (error) {
39
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
40
+ }
41
+ const stat = lstatSync(directory);
42
+ if (!stat.isDirectory() || stat.isSymbolicLink()) {
43
+ throw new Error(`Unsafe result artifact directory: ${directory}`);
44
+ }
45
+ return directory;
46
+ }
47
+
48
+ /**
49
+ * Persist one immutable, content-addressed final answer below Pi's cache.
50
+ * Model-authored titles and paths never participate in the filename.
51
+ */
52
+ export function persistResultArtifact(agentDir: string, content: string) {
53
+ let directory = path.resolve(agentDir);
54
+ for (const segment of RESULT_ARTIFACT_DIR) {
55
+ directory = ensureDirectory(directory, segment);
56
+ }
57
+
58
+ const digest = createHash("sha256").update(content).digest("hex");
59
+ const artifactPath = path.join(directory, `${digest}.txt`);
60
+ try {
61
+ writeFileSync(artifactPath, content, {
62
+ encoding: "utf8",
63
+ flag: "wx",
64
+ mode: 0o600,
65
+ });
66
+ } catch (error) {
67
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
68
+ const stat = lstatSync(artifactPath);
69
+ if (
70
+ !stat.isFile() ||
71
+ stat.isSymbolicLink() ||
72
+ readFileSync(artifactPath, "utf8") !== content
73
+ ) {
74
+ throw new Error(`Result artifact collision: ${artifactPath}`);
75
+ }
76
+ }
77
+ return artifactPath;
78
+ }
79
+
80
+ /**
81
+ * Build the single model-visible projection used by automatic delivery and
82
+ * explicit waits. Short answers pass through byte-for-byte. Long answers keep
83
+ * both decision context at the start and verdict/evidence at the end, while a
84
+ * plain-text artifact preserves the exact final answer for Pi's native read.
85
+ */
86
+ export function projectResult(
87
+ content: string,
88
+ options: ResultProjectionOptions,
89
+ ): ResultProjection {
90
+ const probe = truncateHead(content, {
91
+ maxBytes: options.maxBytes,
92
+ maxLines: options.maxLines,
93
+ });
94
+ if (!probe.truncated) return { text: content, truncated: false };
95
+
96
+ const headLines = Math.max(1, Math.floor(options.maxLines * HEAD_SHARE));
97
+ const tailLines = Math.max(1, options.maxLines - headLines);
98
+
99
+ let artifactPath: string | undefined;
100
+ let artifactSaveFailed = false;
101
+ try {
102
+ artifactPath = options.writeArtifact(content);
103
+ } catch {
104
+ // Delivery is more important than the optional recovery cache. The footer
105
+ // below stays explicit so a failed write never advertises a false path.
106
+ artifactSaveFailed = true;
107
+ }
108
+
109
+ let bodyBudget = options.maxBytes;
110
+ let text = "";
111
+ for (let attempt = 0; attempt < 8; attempt++) {
112
+ const headBytes = Math.max(1, Math.floor(bodyBudget * HEAD_SHARE));
113
+ const tailBytes = Math.max(1, bodyBudget - headBytes);
114
+ const headResult = truncateHead(content, {
115
+ maxBytes: headBytes,
116
+ maxLines: headLines,
117
+ });
118
+ const tailResult = truncateTail(content, {
119
+ maxBytes: tailBytes,
120
+ maxLines: tailLines,
121
+ });
122
+ const head =
123
+ headResult.content || sliceStartToUtf8Bytes(content, headBytes);
124
+ const tail = tailResult.content;
125
+ const shownBytes =
126
+ Buffer.byteLength(head, "utf8") + Buffer.byteLength(tail, "utf8");
127
+ const recovery = artifactPath
128
+ ? `Full final answer: ${JSON.stringify(artifactPath)}\nUse Pi's read tool with path=${JSON.stringify(artifactPath)}, offset=${Math.max(1, headResult.outputLines + 1)}, limit=200 to inspect the omitted middle; adjust offset to continue.`
129
+ : "Full final answer could not be saved; only the head and tail above are available.";
130
+ const footer =
131
+ `[Output truncated: showing ${formatSize(shownBytes)} of ${formatSize(probe.totalBytes)} ` +
132
+ `across the head and tail (${probe.totalLines} total lines).\n${recovery}]`;
133
+ text = `${head}\n\n[... middle omitted ...]\n\n${tail}\n\n${footer}`;
134
+
135
+ const overflow = Buffer.byteLength(text, "utf8") - options.maxBytes;
136
+ if (overflow <= 0 || bodyBudget <= overflow + 2) break;
137
+ bodyBudget -= overflow;
138
+ }
139
+
140
+ return {
141
+ text,
142
+ truncated: true,
143
+ ...(artifactPath ? { artifactPath } : {}),
144
+ ...(artifactSaveFailed ? { artifactSaveFailed: true } : {}),
145
+ };
146
+ }
@@ -1,3 +1,5 @@
1
+ import type { ConsumableResultDeliveryQueue } from "../../shared/result-delivery.ts";
2
+
1
3
  export interface SubagentResultDeliveryOptions<T> {
2
4
  /** True only when the parent has no run or queued continuation in flight. */
3
5
  readonly isIdle: () => boolean;
@@ -46,7 +48,7 @@ export function createSubagentResultDelivery<T extends { id: string }>(
46
48
  }
47
49
  };
48
50
 
49
- return {
51
+ const queue = {
50
52
  defer(result: T) {
51
53
  pending.set(result.id, result);
52
54
  if (options.isIdle()) flush();
@@ -61,5 +63,9 @@ export function createSubagentResultDelivery<T extends { id: string }>(
61
63
  clear() {
62
64
  pending.clear();
63
65
  },
66
+ size() {
67
+ return pending.size;
68
+ },
64
69
  };
70
+ return queue satisfies ConsumableResultDeliveryQueue<T>;
65
71
  }
@@ -11,19 +11,36 @@ import { BackendRegistry, type SubagentBackend } from "./backend.ts";
11
11
  import { piBackend } from "./backends/pi.ts";
12
12
  import type { BackendName } from "./domain.ts";
13
13
 
14
+ /**
15
+ * Test-only injection seam for extension-level tests that drive real spawns
16
+ * without a child pi session: production never sets it. The underscore-prefixed
17
+ * setter name makes any accidental production use self-evidently wrong.
18
+ */
19
+ let testBackends: readonly SubagentBackend[] | undefined;
20
+
21
+ /** Test-only: replace the backends the manager can spawn against. */
22
+ export function __setSubagentTestBackends(
23
+ backends: readonly SubagentBackend[] | undefined,
24
+ ) {
25
+ testBackends = backends;
26
+ }
27
+
14
28
  const BackendRegistryLive = Layer.sync(BackendRegistry, () => {
15
- const backends: SubagentBackend[] = [piBackend];
29
+ const backends: readonly SubagentBackend[] = testBackends ?? [piBackend];
16
30
  return new Map<BackendName, SubagentBackend>(
17
31
  backends.map((backend) => [backend.name, backend]),
18
32
  );
19
33
  });
20
34
 
21
- import { SubagentManagerLive } from "./manager.ts";
35
+ import {
36
+ makeSubagentManagerLayer,
37
+ type SubagentManagerConfig,
38
+ } from "./manager.ts";
22
39
 
23
- const AppLayer = SubagentManagerLive.pipe(Layer.provide(BackendRegistryLive));
24
-
25
- export function createSubagentRuntime() {
26
- return ManagedRuntime.make(AppLayer);
40
+ export function createSubagentRuntime(config: SubagentManagerConfig = {}) {
41
+ return ManagedRuntime.make(
42
+ makeSubagentManagerLayer(config).pipe(Layer.provide(BackendRegistryLive)),
43
+ );
27
44
  }
28
45
 
29
46
  export type SubagentRuntime = ReturnType<typeof createSubagentRuntime>;