@tt-a1i/openpi 0.3.1 → 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 (60) 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 +30 -42
  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 +204 -140
  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/runtime.ts +8 -5
  38. package/extensions/subagents/src/ui/takeover.ts +84 -109
  39. package/extensions/subagents/src/ui/transcript.ts +76 -42
  40. package/extensions/subagents/src/ui/wait-result.ts +1 -1
  41. package/extensions/tasks/ui.ts +79 -62
  42. package/extensions/ui-customization/footer.ts +7 -4
  43. package/extensions/user-input-fold/index.ts +185 -0
  44. package/extensions/workflows/artifacts.ts +35 -0
  45. package/extensions/workflows/controller.ts +14 -2
  46. package/extensions/workflows/coordinator.ts +64 -0
  47. package/extensions/workflows/dashboard.ts +353 -173
  48. package/extensions/workflows/handoff.ts +62 -20
  49. package/extensions/workflows/index.ts +647 -387
  50. package/extensions/workflows/model.ts +57 -15
  51. package/extensions/workflows/navigation.ts +33 -14
  52. package/extensions/workflows/prompt.ts +104 -8
  53. package/extensions/workflows/replay-safety.ts +16 -6
  54. package/extensions/workflows/result-delivery.ts +189 -0
  55. package/extensions/workflows/sandbox-child.cjs +11 -0
  56. package/package.json +1 -1
  57. package/skills/subagents/SKILL.md +2 -2
  58. package/skills/workflows/REFERENCE.md +7 -4
  59. package/skills/workflows/SKILL.md +53 -10
  60. package/extensions/subagents/src/format.ts +0 -48
@@ -25,7 +25,6 @@
25
25
 
26
26
  import * as fs from "node:fs";
27
27
  import * as path from "node:path";
28
- import { StringEnum } from "@earendil-works/pi-ai";
29
28
  import type {
30
29
  ExtensionAPI,
31
30
  ExtensionCommandContext,
@@ -37,7 +36,6 @@ import {
37
36
  DEFAULT_MAX_BYTES,
38
37
  DEFAULT_MAX_LINES,
39
38
  defineTool,
40
- formatSize,
41
39
  getAgentDir,
42
40
  getMarkdownTheme,
43
41
  keyHint,
@@ -46,41 +44,70 @@ import {
46
44
  import { Markdown, Text } from "@earendil-works/pi-tui";
47
45
  import { Type } from "typebox";
48
46
  import {
47
+ formatActivityStatus,
48
+ hasActivity,
49
+ unreadActivityCounts,
50
+ } from "../shared/activity-status.ts";
51
+ import {
52
+ BelowEditorNavigationEditor,
53
+ BelowEditorStripState,
54
+ } from "../shared/below-editor-navigation.ts";
55
+ import {
56
+ effectiveChildToolAllowlist,
57
+ resolveStandaloneChildProjectTrust,
58
+ } from "../shared/child-session.ts";
59
+ import { formatContextUtilization } from "../shared/context-utilization.ts";
60
+ import {
61
+ registerEditorLayer,
62
+ removeEditorLayer,
63
+ } from "../shared/editor-layers.ts";
64
+ import {
65
+ PLAN_MODE_CHANNEL,
66
+ type PlanModeState,
67
+ planModeAllowsDeclaredTools,
68
+ planModeChildTools,
69
+ } from "../shared/plan-mode-state.ts";
70
+ import { loadSetupConfig } from "../shared/setup-config.ts";
71
+ import {
72
+ OPENPI_TOOL_SURFACE,
73
+ patchOwnedTools,
74
+ } from "../shared/tool-surface.ts";
75
+ import {
76
+ createWorktree,
77
+ reclaimWorktree,
78
+ type Worktree,
79
+ } from "../shared/worktree.ts";
80
+ import {
81
+ normalizeSubagentTitle,
82
+ SubagentStripWidget,
83
+ selectSubagentStripEntry,
84
+ } from "./navigation.ts";
85
+ import {
86
+ type AgentType,
49
87
  formatAgentTypeDiagnostics,
50
88
  loadAgentTypes,
51
89
  roleModelForAgentType,
52
90
  selectSubagentModel,
53
- type AgentType,
54
91
  } from "./src/agent-types.ts";
55
92
  import { deriveBtwTitle, isModelVisible } from "./src/by-the-way.ts";
56
93
  import {
57
94
  BACKEND_NAMES,
58
95
  formatElapsed,
59
96
  latestText,
60
- REASONING_EFFORTS,
61
97
  type SubagentSnapshot,
62
98
  } from "./src/domain.ts";
63
99
  import {
64
- formatActivityStatus,
65
- hasActivity,
66
- unreadActivityCounts,
67
- } from "../shared/activity-status.ts";
68
- import {
69
- OPENPI_TOOL_SURFACE,
70
- patchOwnedTools,
71
- } from "../shared/tool-surface.ts";
72
- import {
73
- registerEditorLayer,
74
- removeEditorLayer,
75
- } from "../shared/editor-layers.ts";
76
- import { formatContextUtilization } from "./src/format.ts";
100
+ restoreSubagentIdCounters,
101
+ SUBAGENT_ID_WATERMARK_ENTRY_TYPE,
102
+ type SubagentIdCounters,
103
+ subagentIdWatermark,
104
+ } from "./src/id-sequence.ts";
77
105
  import { SubagentManager, type SubagentManagerShape } from "./src/manager.ts";
78
106
  import {
79
107
  buildSubagentResultMessage,
80
- createAgentTypeParameterSchema,
81
108
  buildSubagentSendResult,
82
109
  buildSubagentSpawnResult,
83
- buildSubagentSpawnToolDescription,
110
+ createSubagentSpawnToolSurface,
84
111
  SUBAGENT_CANCEL_PARAMETER_DESCRIPTIONS,
85
112
  SUBAGENT_CANCEL_TOOL_DESCRIPTION,
86
113
  SUBAGENT_CHECK_PARAMETER_DESCRIPTIONS,
@@ -88,44 +115,22 @@ import {
88
115
  SUBAGENT_LIST_TOOL_DESCRIPTION,
89
116
  SUBAGENT_SEND_PARAMETER_DESCRIPTIONS,
90
117
  SUBAGENT_SEND_TOOL_DESCRIPTION,
91
- SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS,
92
118
  SUBAGENT_SPAWN_PROMPT_GUIDELINES,
93
119
  SUBAGENT_SPAWN_PROMPT_SNIPPET,
94
- SUBAGENT_SPAWN_TOOL_DESCRIPTION,
95
120
  SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS,
96
121
  SUBAGENT_WAIT_TOOL_DESCRIPTION,
97
122
  } from "./src/prompt.ts";
98
- import { createSubagentResultDelivery } from "./src/result-delivery.ts";
123
+ import { persistResultArtifact, projectResult } from "./src/result-artifact.ts";
99
124
  import {
100
- effectiveChildToolAllowlist,
101
- resolveStandaloneChildProjectTrust,
102
- } from "../shared/child-session.ts";
103
- import {
104
- BelowEditorNavigationEditor,
105
- BelowEditorStripState,
106
- } from "../shared/below-editor-navigation.ts";
107
- import { loadSetupConfig } from "../shared/setup-config.ts";
108
- import {
109
- PLAN_MODE_CHANNEL,
110
- planModeAllowsDeclaredTools,
111
- planModeChildTools,
112
- type PlanModeState,
113
- } from "../shared/plan-mode-state.ts";
114
- import {
115
- createWorktree,
116
- reclaimWorktree,
117
- type Worktree,
118
- } from "../shared/worktree.ts";
125
+ allocateResultBudgets,
126
+ type ParentContextUsage,
127
+ } from "../shared/result-budget.ts";
128
+ import { createSubagentResultDelivery } from "./src/result-delivery.ts";
119
129
  import {
120
130
  createSubagentRuntime,
121
131
  runTool,
122
132
  type SubagentRuntime,
123
133
  } from "./src/runtime.ts";
124
- import {
125
- normalizeSubagentTitle,
126
- selectSubagentStripEntry,
127
- SubagentStripWidget,
128
- } from "./navigation.ts";
129
134
  import { openSubagentPicker, openSubagentTakeover } from "./src/ui/takeover.ts";
130
135
  import {
131
136
  buildWaitResultPreview,
@@ -134,8 +139,13 @@ import {
134
139
  } from "./src/ui/wait-result.ts";
135
140
 
136
141
  const SUBAGENT_OUTPUT_MAX_BYTES = 24 * 1024;
142
+ const AUTOMATIC_OUTPUT_MAX_BYTES = 48 * 1024;
143
+ const AUTOMATIC_MIN_RESULT_BYTES = 2 * 1024;
137
144
  const WAIT_OUTPUT_MAX_BYTES = 48 * 1024;
138
145
  const WAIT_PER_AGENT_MAX_BYTES = 16 * 1024;
146
+ const WAIT_MIN_RESULT_BYTES = 512;
147
+ const RESULT_HEADROOM_SHARE = 0.5;
148
+ const ESTIMATED_BYTES_PER_TOKEN = 4;
139
149
 
140
150
  interface SpawnResultDetails {
141
151
  readonly id?: string;
@@ -189,36 +199,71 @@ function describeSubagent(snap: SubagentSnapshot) {
189
199
  return `${snap.id} [${snap.status}] "${snap.title}" (${details.join(", ")})`;
190
200
  }
191
201
 
192
- function truncatedOutput(
202
+ export function truncatedOutput(
193
203
  snap: SubagentSnapshot,
194
204
  maxBytes = SUBAGENT_OUTPUT_MAX_BYTES,
205
+ writeArtifact: (content: string) => string = (content) =>
206
+ persistResultArtifact(getAgentDir(), content),
195
207
  ): string {
196
208
  const output = snap.finalText || "(no output)";
197
- const truncation = truncateHead(output, {
209
+ return projectResult(output, {
198
210
  maxBytes: Math.min(maxBytes, DEFAULT_MAX_BYTES),
199
211
  maxLines: Math.min(600, DEFAULT_MAX_LINES),
200
- });
201
- let text = truncation.content;
202
- if (truncation.truncated) {
203
- text += `\n\n[Output truncated: ${formatSize(truncation.outputBytes)} of ${formatSize(truncation.totalBytes)} shown. Full transcript in session file: ${snap.meta.sessionFilePath ?? "?"}]`;
204
- }
205
- return text;
212
+ writeArtifact,
213
+ }).text;
206
214
  }
207
215
 
208
216
  export function createSubagentResultDispatcher(
209
217
  pi: ExtensionAPI,
210
- outputFor: (snap: SubagentSnapshot) => string = truncatedOutput,
218
+ outputFor: (
219
+ snap: SubagentSnapshot,
220
+ maxBytes: number,
221
+ ) => string = truncatedOutput,
222
+ getContextUsage: () => ParentContextUsage | undefined = () => undefined,
211
223
  ) {
212
224
  return (snaps: readonly SubagentSnapshot[]) => {
213
225
  if (snaps.length === 0) return;
226
+ const emptyMessages = snaps.map((snap) =>
227
+ buildSubagentResultMessage({
228
+ id: snap.id,
229
+ title: snap.title,
230
+ status: snap.status,
231
+ errorText: snap.errorText,
232
+ output: "",
233
+ }),
234
+ );
235
+ const wrapperBytes =
236
+ emptyMessages.reduce(
237
+ (sum, message) => sum + Buffer.byteLength(message, "utf8"),
238
+ 0,
239
+ ) +
240
+ Math.max(0, snaps.length - 1) * 2;
241
+ const projectionBatchBytes = Math.max(
242
+ AUTOMATIC_MIN_RESULT_BYTES * snaps.length,
243
+ AUTOMATIC_OUTPUT_MAX_BYTES - wrapperBytes,
244
+ );
245
+ const allocation = allocateResultBudgets(
246
+ snaps.map((snap) =>
247
+ Buffer.byteLength(snap.finalText || "(no output)", "utf8"),
248
+ ),
249
+ getContextUsage(),
250
+ {
251
+ maxBatchBytes: projectionBatchBytes,
252
+ maxResultBytes: SUBAGENT_OUTPUT_MAX_BYTES,
253
+ minResultBytes: AUTOMATIC_MIN_RESULT_BYTES,
254
+ headroomShare: RESULT_HEADROOM_SHARE,
255
+ estimatedBytesPerToken: ESTIMATED_BYTES_PER_TOKEN,
256
+ fixedBytes: wrapperBytes,
257
+ },
258
+ );
214
259
  const content = snaps
215
- .map((snap) =>
260
+ .map((snap, index) =>
216
261
  buildSubagentResultMessage({
217
262
  id: snap.id,
218
263
  title: snap.title,
219
264
  status: snap.status,
220
265
  errorText: snap.errorText,
221
- output: outputFor(snap),
266
+ output: outputFor(snap, allocation.budgets[index]!),
222
267
  }),
223
268
  )
224
269
  .join("\n\n");
@@ -277,7 +322,7 @@ function renderSubagentResult(
277
322
  }
278
323
 
279
324
  const failed = details.status === "error";
280
- const icon = failed ? theme.fg("error", "x") : theme.fg("success", "");
325
+ const icon = failed ? theme.fg("error", "x") : theme.fg("success", "");
281
326
  const header =
282
327
  `${icon} ` +
283
328
  theme.fg("accent", theme.bold(`subagent ${details.id ?? "?"}`)) +
@@ -306,6 +351,10 @@ function renderSubagentResult(
306
351
  export default function (pi: ExtensionAPI) {
307
352
  let runtime: SubagentRuntime | undefined;
308
353
  let managerPromise: Promise<SubagentManagerShape> | undefined;
354
+ let restoredIdCounters: SubagentIdCounters = {
355
+ modelCounter: 0,
356
+ btwCounter: 0,
357
+ };
309
358
  let sessionContext: ExtensionContext | undefined;
310
359
  let ui: ExtensionUIContext | undefined;
311
360
  let unsubStatus: (() => void) | undefined;
@@ -321,7 +370,11 @@ export default function (pi: ExtensionAPI) {
321
370
  let requestWidgetRender: (() => void) | undefined;
322
371
  let navigationLayerRegistered = false;
323
372
  let dashboardOpen = false;
324
- const dispatchResults = createSubagentResultDispatcher(pi);
373
+ const dispatchResults = createSubagentResultDispatcher(
374
+ pi,
375
+ truncatedOutput,
376
+ () => sessionContext?.getContextUsage(),
377
+ );
325
378
  const resultDelivery = createSubagentResultDelivery<SubagentSnapshot>({
326
379
  isIdle: () => sessionContext?.isIdle() === true,
327
380
  // Every unconsumed fire-and-forget result must reach the parent. The
@@ -329,16 +382,19 @@ export default function (pi: ExtensionAPI) {
329
382
  deliver: dispatchResults,
330
383
  });
331
384
  pi.on("agent_settled", () => resultDelivery.parentSettled());
332
- const hideLifecycleTools = () =>
333
- patchOwnedTools(pi, "subagents", {
334
- disable: OPENPI_TOOL_SURFACE.subagents.deferred,
335
- });
336
- const showLifecycleTools = () =>
385
+ const registerStableToolFamily = () =>
337
386
  patchOwnedTools(pi, "subagents", {
338
- enable: OPENPI_TOOL_SURFACE.subagents.deferred,
387
+ enable: OPENPI_TOOL_SURFACE.subagents.entry,
339
388
  });
340
389
 
341
- const getRuntime = () => (runtime ??= createSubagentRuntime());
390
+ const getRuntime = () =>
391
+ (runtime ??= createSubagentRuntime({
392
+ initialModelCounter: restoredIdCounters.modelCounter,
393
+ initialBtwCounter: restoredIdCounters.btwCounter,
394
+ }));
395
+
396
+ const persistId = (id: string) =>
397
+ pi.appendEntry(SUBAGENT_ID_WATERMARK_ENTRY_TYPE, subagentIdWatermark(id));
342
398
 
343
399
  /** Resolve the manager service once per runtime and wire the extension hooks. */
344
400
  const getManager = () => {
@@ -392,9 +448,12 @@ export default function (pi: ExtensionAPI) {
392
448
  manager.view.list(),
393
449
  settledAcknowledgedAt,
394
450
  );
451
+ // In the TUI the below-editor strip already reports the same activity and
452
+ // carries the manage affordance, so a footer status line would repeat it.
453
+ const tui = sessionContext?.mode === "tui";
395
454
  ui.setStatus(
396
455
  "subagents",
397
- hasActivity(counts)
456
+ !tui && hasActivity(counts)
398
457
  ? formatActivityStatus(ui.theme, "subagents", counts)
399
458
  : undefined,
400
459
  );
@@ -493,8 +552,11 @@ export default function (pi: ExtensionAPI) {
493
552
  };
494
553
 
495
554
  pi.on("session_start", (_event, ctx) => {
555
+ restoredIdCounters = restoreSubagentIdCounters(
556
+ ctx.sessionManager.getBranch(),
557
+ );
496
558
  refreshAgentTypes(ctx.cwd, ctx.isProjectTrusted());
497
- hideLifecycleTools();
559
+ registerStableToolFamily();
498
560
  sessionContext = ctx;
499
561
  settledAcknowledgedAt = 0;
500
562
  if (ctx.hasUI) ui = ctx.ui;
@@ -592,57 +654,22 @@ export default function (pi: ExtensionAPI) {
592
654
  agentTypes = loaded.agentTypes;
593
655
  agentTypeDiagnostics = loaded.diagnostics;
594
656
  agentTypeList = [...agentTypes.values()];
595
- subagentSpawnTool.description =
596
- buildSubagentSpawnToolDescription(agentTypeList);
597
- subagentSpawnTool.parameters = createSubagentSpawnParameters();
657
+ const surface = createSubagentSpawnToolSurface(agentTypeList);
658
+ subagentSpawnTool.description = surface.description;
659
+ subagentSpawnTool.parameters = surface.parameters;
598
660
  registerSubagentSpawnTool();
599
661
  };
600
662
 
601
663
  // --- Tools -------------------------------------------------------------
602
664
 
603
- const createSubagentSpawnParameters = () =>
604
- Type.Object({
605
- agent_type: createAgentTypeParameterSchema(agentTypeList),
606
- prompt: Type.String({
607
- description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.prompt,
608
- }),
609
- name: Type.String({
610
- description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.name,
611
- }),
612
- harness: Type.Optional(
613
- StringEnum(BACKEND_NAMES, {
614
- description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.harness,
615
- }),
616
- ),
617
- working_dir: Type.Optional(
618
- Type.String({
619
- description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.workingDir,
620
- }),
621
- ),
622
- isolation: Type.Optional(
623
- StringEnum(["worktree"] as const, {
624
- description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.isolation,
625
- }),
626
- ),
627
- model: Type.Optional(
628
- Type.String({
629
- description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.model,
630
- }),
631
- ),
632
- reasoning_effort: Type.Optional(
633
- StringEnum(REASONING_EFFORTS, {
634
- description: SUBAGENT_SPAWN_PARAMETER_DESCRIPTIONS.reasoningEffort,
635
- }),
636
- ),
637
- });
665
+ const initialSpawnSurface = createSubagentSpawnToolSurface(agentTypeList);
638
666
 
639
667
  const subagentSpawnTool = defineTool({
640
668
  name: "subagent_spawn",
641
669
  label: "Spawn Subagent",
642
- description: buildSubagentSpawnToolDescription(agentTypeList),
670
+ ...initialSpawnSurface,
643
671
  promptSnippet: SUBAGENT_SPAWN_PROMPT_SNIPPET,
644
672
  promptGuidelines: SUBAGENT_SPAWN_PROMPT_GUIDELINES,
645
- parameters: createSubagentSpawnParameters(),
646
673
  async execute(_toolCallId, params, signal, _onUpdate, ctx) {
647
674
  // Only one backend exists; harness is optional and defaults to it.
648
675
  const harness = params.harness ?? BACKEND_NAMES[0];
@@ -767,8 +794,7 @@ export default function (pi: ExtensionAPI) {
767
794
  if (worktree) await reclaimWorktree(cwd, worktree).catch(() => {});
768
795
  throw error;
769
796
  }
770
-
771
- showLifecycleTools();
797
+ persistId(snap.id);
772
798
 
773
799
  return {
774
800
  content: [
@@ -813,8 +839,11 @@ export default function (pi: ExtensionAPI) {
813
839
  const meta = [details.harness, details.model]
814
840
  .filter(Boolean)
815
841
  .join(" \u00b7 ");
842
+ // A spawn is an event, not a state, so it speaks in the activity rows'
843
+ // verb language ("Wrote" / "Ran" / "Spawned") rather than a glyph; the
844
+ // strip's spinner carries the running state from here on.
816
845
  return new Text(
817
- `${theme.fg("success", "\u25cf")} ${theme.bold(details.title ?? details.id)} ${theme.fg("dim", meta)}`,
846
+ `${theme.fg("toolTitle", "Spawned")} ${theme.bold(details.title ?? details.id)} ${theme.fg("dim", meta)}`,
818
847
  0,
819
848
  0,
820
849
  );
@@ -834,7 +863,7 @@ export default function (pi: ExtensionAPI) {
834
863
  description: SUBAGENT_WAIT_PARAMETER_DESCRIPTIONS.ids,
835
864
  }),
836
865
  }),
837
- async execute(_toolCallId, params, signal, onUpdate) {
866
+ async execute(_toolCallId, params, signal, onUpdate, ctx) {
838
867
  const manager = await getManager();
839
868
  const ids = [...new Set(params.ids)];
840
869
  if (ids.length === 0)
@@ -870,33 +899,66 @@ export default function (pi: ExtensionAPI) {
870
899
  // deferred automatic delivery now that the tool is returning the result.
871
900
  resultDelivery.consume(ids);
872
901
 
873
- const sections: string[] = [];
874
- let remainingBytes = WAIT_OUTPUT_MAX_BYTES;
875
- for (const id of ids) {
902
+ const entries: Array<
903
+ | { readonly id: string; readonly section: string }
904
+ | {
905
+ readonly id: string;
906
+ readonly snap: SubagentSnapshot;
907
+ readonly header: string;
908
+ }
909
+ > = ids.map((id) => {
876
910
  const snap = manager.view.get(id);
877
- if (!snap) {
878
- sections.push(`## ${id}\n\n(no longer tracked)`);
879
- continue;
880
- }
911
+ if (!snap) return { id, section: `## ${id}\n\n(no longer tracked)` };
881
912
  const verb = snap.status === "error" ? "failed" : "finished";
882
- let section = `## ${snap.id} "${snap.title}" ${verb}`;
883
- if (snap.errorText) section += `\nError: ${snap.errorText}`;
884
- const headerBytes = Buffer.byteLength(section, "utf8") + 2;
885
- const outputBudget = Math.max(
886
- 512,
887
- Math.min(WAIT_PER_AGENT_MAX_BYTES, remainingBytes - headerBytes),
913
+ let header = `## ${snap.id} "${snap.title}" ${verb}`;
914
+ if (snap.errorText) header += `\nError: ${snap.errorText}`;
915
+ return { id, snap, header };
916
+ });
917
+ const separatorsBytes = Math.max(0, entries.length - 1) * 7;
918
+ const fixedBytes =
919
+ separatorsBytes +
920
+ entries.reduce(
921
+ (sum, entry) =>
922
+ sum +
923
+ Buffer.byteLength(
924
+ "section" in entry ? entry.section : `${entry.header}\n\n`,
925
+ "utf8",
926
+ ),
927
+ 0,
888
928
  );
889
- section += `\n\n${truncatedOutput(snap, outputBudget)}`;
890
- const sectionBytes = Buffer.byteLength(section, "utf8");
891
- if (sectionBytes > remainingBytes) {
892
- sections.push(
893
- `## ${snap.id} "${snap.title}"\n\n[omitted: total wait output limit reached]`,
894
- );
895
- break;
896
- }
897
- sections.push(section);
898
- remainingBytes -= sectionBytes;
899
- }
929
+ const resultEntries = entries.filter(
930
+ (
931
+ entry,
932
+ ): entry is {
933
+ readonly id: string;
934
+ readonly snap: SubagentSnapshot;
935
+ readonly header: string;
936
+ } => "snap" in entry,
937
+ );
938
+ const projectionBatchBytes = Math.max(
939
+ WAIT_MIN_RESULT_BYTES * resultEntries.length,
940
+ WAIT_OUTPUT_MAX_BYTES - fixedBytes,
941
+ );
942
+ const allocation = allocateResultBudgets(
943
+ resultEntries.map(({ snap }) =>
944
+ Buffer.byteLength(snap.finalText || "(no output)", "utf8"),
945
+ ),
946
+ ctx.getContextUsage(),
947
+ {
948
+ maxBatchBytes: projectionBatchBytes,
949
+ maxResultBytes: WAIT_PER_AGENT_MAX_BYTES,
950
+ minResultBytes: WAIT_MIN_RESULT_BYTES,
951
+ headroomShare: RESULT_HEADROOM_SHARE,
952
+ estimatedBytesPerToken: ESTIMATED_BYTES_PER_TOKEN,
953
+ fixedBytes,
954
+ },
955
+ );
956
+ let resultIndex = 0;
957
+ const sections = entries.map((entry) => {
958
+ if ("section" in entry) return entry.section;
959
+ const outputBudget = allocation.budgets[resultIndex++]!;
960
+ return `${entry.header}\n\n${truncatedOutput(entry.snap, outputBudget)}`;
961
+ });
900
962
 
901
963
  const combined = sections.join("\n\n---\n\n");
902
964
  const bounded = truncateHead(combined, {
@@ -1152,11 +1214,12 @@ export default function (pi: ExtensionAPI) {
1152
1214
  (entry, _options, theme) => {
1153
1215
  const data = entry.data;
1154
1216
  const failed = data?.status === "error";
1217
+ const icon = failed ? theme.fg("error", "x") : theme.fg("success", "✓");
1155
1218
  return new Text(
1156
- `${theme.fg(failed ? "error" : "success", "\u25cf")} ` +
1219
+ `${icon} ${theme.fg("accent", data?.title ?? "?")}` +
1157
1220
  theme.fg(
1158
- "muted",
1159
- `Agent "${data?.title ?? "?"}" ${failed ? "failed" : "finished"} \u00b7 ${data?.elapsed ?? "?"}`,
1221
+ "dim",
1222
+ ` ${failed ? "failed" : "finished"} · ${data?.elapsed ?? "?"}`,
1160
1223
  ),
1161
1224
  1,
1162
1225
  0,
@@ -1169,7 +1232,7 @@ export default function (pi: ExtensionAPI) {
1169
1232
  (entry, { expanded }, theme) => {
1170
1233
  const data = entry.data;
1171
1234
  const failed = data?.status === "error";
1172
- const icon = failed ? theme.fg("error", "x") : theme.fg("success", "");
1235
+ const icon = failed ? theme.fg("error", "x") : theme.fg("success", "");
1173
1236
  const header =
1174
1237
  `${icon} ` +
1175
1238
  theme.fg("accent", theme.bold(`by the way · ${data?.title ?? "?"}`)) +
@@ -1253,6 +1316,7 @@ export default function (pi: ExtensionAPI) {
1253
1316
  );
1254
1317
  return;
1255
1318
  }
1319
+ persistId(snap.id);
1256
1320
 
1257
1321
  await openSubagentTakeover(ctx, manager.view, snap.id, {
1258
1322
  badge: "by the way",
@@ -2,15 +2,17 @@ import type { Theme } from "@earendil-works/pi-coding-agent";
2
2
  import type { TUI } from "@earendil-works/pi-tui";
3
3
  import {
4
4
  fitNavigationSides,
5
+ renderNavigationMetrics,
5
6
  type BelowEditorStripState,
6
7
  } from "../shared/below-editor-navigation.ts";
7
8
  import {
8
9
  unreadActivityCounts,
9
10
  type ActivityCounts,
10
11
  } from "../shared/activity-status.ts";
12
+ import { spinnerFrame } from "../shared/spinner.ts";
11
13
  import { sanitizeTerminalText } from "../shared/terminal-text.ts";
12
14
  import { formatElapsed, type SubagentSnapshot } from "./src/domain.ts";
13
- import { formatContextUtilization } from "./src/format.ts";
15
+ import { contextPercent } from "../shared/context-utilization.ts";
14
16
 
15
17
  export interface SubagentStripEntry {
16
18
  snapshot: SubagentSnapshot;
@@ -58,8 +60,15 @@ function statusColor(status: SubagentSnapshot["status"]) {
58
60
  return "error" as const;
59
61
  }
60
62
 
61
- function statusSquare(snapshot: SubagentSnapshot, theme: Theme) {
62
- return theme.fg(statusColor(snapshot.status), "■");
63
+ /**
64
+ * One status indicator per run state; doubles as the focus marker when
65
+ * selected. Running spins, in step with the dashboard and takeover headers.
66
+ */
67
+ function statusGlyph(snapshot: SubagentSnapshot, theme: Theme, now: number) {
68
+ if (snapshot.status === "running")
69
+ return theme.fg("warning", spinnerFrame(now));
70
+ if (snapshot.status === "done") return theme.fg("success", "✓");
71
+ return theme.fg("error", "✗");
63
72
  }
64
73
 
65
74
  /** One-line subagent manager entry with the same affordance as Workflow. */
@@ -94,28 +103,48 @@ export class SubagentStripWidget {
94
103
  const entry = this.getEntry();
95
104
  if (!entry || width <= 0) return [];
96
105
  const { snapshot, counts } = entry;
97
- const marker = this.strip.focused
106
+ const glyph = this.strip.focused
98
107
  ? this.theme.fg("accent", "❯")
99
- : this.theme.fg("dim", "○");
100
- const titleText = normalizeSubagentTitle(snapshot.title, snapshot.id);
101
- const title = this.strip.focused
102
- ? this.theme.bold(this.theme.fg("accent", titleText))
103
- : this.theme.fg("text", titleText);
104
- const model = snapshot.meta.modelLabel
105
- ? cleanLine(snapshot.meta.modelLabel)
106
- : undefined;
107
- const left = ` ${marker} ${statusSquare(snapshot, this.theme)} ${title}${model ? this.theme.fg("dim", ` · ${model}`) : ""}`;
108
- const settled = counts.done + counts.failed;
109
- const total = counts.running + settled;
110
- const metrics = [
111
- `${settled}/${total} agents`,
112
- formatElapsed(snapshot),
113
- formatContextUtilization(snapshot.usage),
108
+ : statusGlyph(snapshot, this.theme, Date.now());
109
+ // A name only means something when it names the only active subagent; with
110
+ // several, an aggregate label is honest and the counts carry the detail.
111
+ const total = counts.running + counts.done + counts.failed;
112
+ const single = total === 1;
113
+ const labelText = single
114
+ ? normalizeSubagentTitle(snapshot.title, snapshot.id)
115
+ : "subagents";
116
+ const label = this.strip.focused
117
+ ? this.theme.bold(this.theme.fg("accent", labelText))
118
+ : this.theme.fg("text", labelText);
119
+ // The footer already shows the session model; the takeover view keeps the
120
+ // per-subagent model, so the one-line strip stays title-only.
121
+ const left = ` ${glyph} ${label}`;
122
+ // Worded counts read at a glance; the selected run's own state comes
123
+ // first so the emphasis colour always lands on the matching count. A lone
124
+ // subagent needs no count — the glyph and label already say it.
125
+ const donePart = counts.done > 0 ? `${counts.done} done` : undefined;
126
+ const failedPart =
127
+ counts.failed > 0 ? `${counts.failed} failed` : undefined;
128
+ const activity = single
129
+ ? []
130
+ : counts.running > 0
131
+ ? [`${counts.running} running`]
132
+ : snapshot.status === "error"
133
+ ? [failedPart, donePart]
134
+ : [donePart, failedPart];
135
+ const percent = contextPercent(snapshot.usage);
136
+ const right = renderNavigationMetrics(
137
+ this.theme,
138
+ [
139
+ ...activity,
140
+ formatElapsed(snapshot),
141
+ percent === undefined ? undefined : `${percent}% ctx`,
142
+ ],
114
143
  this.strip.focused ? "enter open · ↑ back" : "↓ to manage",
115
- ]
116
- .filter((part): part is string => Boolean(part))
117
- .join(" · ");
118
- const right = this.theme.fg(statusColor(snapshot.status), metrics);
144
+ single || snapshot.status === "running"
145
+ ? undefined
146
+ : statusColor(snapshot.status),
147
+ );
119
148
  return [fitNavigationSides(left, right, width)];
120
149
  }
121
150
  }