@oh-my-pi/pi-coding-agent 15.10.12 → 15.11.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 (125) hide show
  1. package/CHANGELOG.md +60 -3
  2. package/dist/cli.js +841 -803
  3. package/dist/types/async/index.d.ts +0 -1
  4. package/dist/types/cli/gallery-fixtures/types.d.ts +5 -0
  5. package/dist/types/config/keybindings.d.ts +6 -1
  6. package/dist/types/config/settings-schema.d.ts +56 -33
  7. package/dist/types/export/html/template.generated.d.ts +1 -1
  8. package/dist/types/extensibility/custom-tools/types.d.ts +2 -2
  9. package/dist/types/extensibility/shared-events.d.ts +2 -2
  10. package/dist/types/internal-urls/history-protocol.d.ts +14 -0
  11. package/dist/types/internal-urls/index.d.ts +1 -0
  12. package/dist/types/internal-urls/types.d.ts +1 -1
  13. package/dist/types/irc/bus.d.ts +66 -0
  14. package/dist/types/modes/components/agent-hub.d.ts +30 -0
  15. package/dist/types/modes/components/compaction-summary-message.d.ts +10 -4
  16. package/dist/types/modes/components/custom-editor.d.ts +2 -0
  17. package/dist/types/modes/components/tool-execution.d.ts +8 -0
  18. package/dist/types/modes/components/ttsr-notification.d.ts +5 -1
  19. package/dist/types/modes/components/welcome.d.ts +3 -9
  20. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  21. package/dist/types/modes/interactive-mode.d.ts +3 -2
  22. package/dist/types/modes/theme/theme.d.ts +2 -1
  23. package/dist/types/modes/types.d.ts +3 -2
  24. package/dist/types/modes/utils/ui-helpers.d.ts +1 -1
  25. package/dist/types/registry/agent-lifecycle.d.ts +51 -0
  26. package/dist/types/registry/agent-registry.d.ts +16 -5
  27. package/dist/types/session/agent-session.d.ts +35 -30
  28. package/dist/types/session/messages.d.ts +2 -4
  29. package/dist/types/session/session-history-format.d.ts +12 -0
  30. package/dist/types/session/session-manager.d.ts +21 -3
  31. package/dist/types/session/streaming-output.d.ts +23 -0
  32. package/dist/types/task/executor.d.ts +11 -2
  33. package/dist/types/task/index.d.ts +11 -4
  34. package/dist/types/task/output-manager.d.ts +0 -7
  35. package/dist/types/task/repair-args.d.ts +8 -7
  36. package/dist/types/task/types.d.ts +55 -51
  37. package/dist/types/tools/browser/tab-worker.d.ts +3 -1
  38. package/dist/types/tools/find.d.ts +0 -11
  39. package/dist/types/tools/grouped-file-output.d.ts +0 -49
  40. package/dist/types/tools/index.d.ts +1 -3
  41. package/dist/types/tools/irc.d.ts +76 -38
  42. package/dist/types/tools/job.d.ts +7 -1
  43. package/examples/extensions/with-deps/package.json +1 -0
  44. package/package.json +11 -10
  45. package/scripts/bundle-dist.ts +28 -19
  46. package/src/async/index.ts +0 -1
  47. package/src/cli/gallery-cli.ts +1 -1
  48. package/src/cli/gallery-fixtures/agentic.ts +230 -115
  49. package/src/cli/gallery-fixtures/types.ts +5 -0
  50. package/src/cli.ts +20 -6
  51. package/src/commit/agentic/tools/analyze-file.ts +38 -19
  52. package/src/config/keybindings.ts +6 -1
  53. package/src/config/settings-schema.ts +56 -40
  54. package/src/config/settings.ts +7 -0
  55. package/src/eval/__tests__/agent-bridge.test.ts +5 -3
  56. package/src/eval/agent-bridge.ts +3 -16
  57. package/src/eval/js/shared/prelude.txt +1 -1
  58. package/src/eval/py/prelude.py +5 -6
  59. package/src/export/html/template.generated.ts +1 -1
  60. package/src/export/html/template.js +38 -13
  61. package/src/extensibility/custom-tools/types.ts +2 -2
  62. package/src/extensibility/shared-events.ts +2 -2
  63. package/src/internal-urls/docs-index.generated.ts +8 -8
  64. package/src/internal-urls/history-protocol.ts +113 -0
  65. package/src/internal-urls/index.ts +1 -0
  66. package/src/internal-urls/router.ts +3 -1
  67. package/src/internal-urls/types.ts +1 -1
  68. package/src/irc/bus.ts +292 -0
  69. package/src/main.ts +8 -60
  70. package/src/modes/components/{session-observer-overlay.ts → agent-hub.ts} +586 -367
  71. package/src/modes/components/compaction-summary-message.ts +68 -32
  72. package/src/modes/components/custom-editor.ts +10 -0
  73. package/src/modes/components/tool-execution.ts +31 -1
  74. package/src/modes/components/ttsr-notification.ts +72 -30
  75. package/src/modes/components/welcome.ts +9 -33
  76. package/src/modes/controllers/event-controller.ts +65 -0
  77. package/src/modes/controllers/extension-ui-controller.ts +8 -8
  78. package/src/modes/controllers/input-controller.ts +18 -2
  79. package/src/modes/controllers/selector-controller.ts +21 -17
  80. package/src/modes/interactive-mode.ts +8 -13
  81. package/src/modes/theme/theme.ts +18 -5
  82. package/src/modes/types.ts +3 -5
  83. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  84. package/src/modes/utils/ui-helpers.ts +51 -49
  85. package/src/prompts/system/irc-incoming.md +3 -4
  86. package/src/prompts/system/orchestrate-notice.md +2 -2
  87. package/src/prompts/system/subagent-system-prompt.md +0 -5
  88. package/src/prompts/system/system-prompt.md +1 -0
  89. package/src/prompts/system/workflow-notice.md +2 -2
  90. package/src/prompts/tools/eval.md +3 -3
  91. package/src/prompts/tools/irc.md +29 -19
  92. package/src/prompts/tools/read.md +2 -2
  93. package/src/prompts/tools/task-summary.md +5 -16
  94. package/src/prompts/tools/task.md +38 -29
  95. package/src/registry/agent-lifecycle.ts +218 -0
  96. package/src/registry/agent-registry.ts +16 -5
  97. package/src/sdk.ts +29 -9
  98. package/src/session/agent-session.ts +243 -237
  99. package/src/session/messages.ts +11 -78
  100. package/src/session/session-history-format.ts +246 -0
  101. package/src/session/session-manager.ts +59 -5
  102. package/src/session/streaming-output.ts +60 -0
  103. package/src/task/executor.ts +855 -466
  104. package/src/task/index.ts +718 -794
  105. package/src/task/output-manager.ts +0 -11
  106. package/src/task/render.ts +133 -63
  107. package/src/task/repair-args.ts +21 -9
  108. package/src/task/types.ts +73 -66
  109. package/src/tools/ask.ts +4 -2
  110. package/src/tools/bash.ts +15 -5
  111. package/src/tools/browser/tab-worker.ts +26 -7
  112. package/src/tools/browser.ts +28 -1
  113. package/src/tools/find.ts +2 -27
  114. package/src/tools/grouped-file-output.ts +1 -118
  115. package/src/tools/index.ts +4 -12
  116. package/src/tools/irc.ts +596 -171
  117. package/src/tools/job.ts +41 -7
  118. package/src/tools/read.ts +57 -1
  119. package/src/tools/renderers.ts +2 -0
  120. package/src/tools/resolve.ts +4 -1
  121. package/dist/types/async/support.d.ts +0 -2
  122. package/dist/types/modes/components/session-observer-overlay.d.ts +0 -11
  123. package/dist/types/task/simple-mode.d.ts +0 -8
  124. package/src/async/support.ts +0 -5
  125. package/src/task/simple-mode.ts +0 -27
@@ -85,15 +85,4 @@ export class AgentOutputManager {
85
85
  await this.#ensureInitialized();
86
86
  return this.#allocateUnique(id);
87
87
  }
88
-
89
- /**
90
- * Allocate unique IDs for a batch of tasks.
91
- *
92
- * @param ids Array of requested IDs
93
- * @returns Array of unique IDs in same order
94
- */
95
- async allocateBatch(ids: string[]): Promise<string[]> {
96
- await this.#ensureInitialized();
97
- return ids.map(id => this.#allocateUnique(id));
98
- }
99
88
  }
@@ -62,6 +62,7 @@ function appendAgentStats(
62
62
  line: string,
63
63
  opts: {
64
64
  toolCount?: number;
65
+ requests?: number;
65
66
  tokens: number;
66
67
  contextTokens?: number;
67
68
  contextWindow?: number;
@@ -74,6 +75,9 @@ function appendAgentStats(
74
75
  if (opts.toolCount) {
75
76
  line += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(opts.toolCount)} ${theme.icon.extensionTool}`)}`;
76
77
  }
78
+ if (opts.requests) {
79
+ line += `${theme.sep.dot}${theme.fg("dim", `${formatNumber(opts.requests)} req`)}`;
80
+ }
77
81
  // Current per-turn context — match the status line's `<pct>%/<window>` gauge (e.g. `5.1%/1M`).
78
82
  if (opts.contextTokens && opts.contextTokens > 0) {
79
83
  const ctx =
@@ -505,65 +509,106 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
505
509
  }
506
510
 
507
511
  /**
508
- * Render the per-task list (`id` + ui `description`) for the streaming call
509
- * preview. The args stream in token by token, so the array grows over time and
510
- * trailing entries may be partially parsed — every field access is defensive.
512
+ * Render the call preview lines for the single spawned agent. The
513
+ * args stream in token by token, so every field access is defensive.
511
514
  */
512
- function renderTaskItemLines(tasks: TaskItem[] | undefined, expanded: boolean, theme: Theme): string[] {
513
- const items = tasks ?? [];
514
- if (items.length === 0) return [];
515
-
515
+ function renderTaskCallLines(args: Partial<TaskParams> | undefined, theme: Theme): string[] {
516
+ if (!args) return [];
516
517
  const bullet = theme.fg("dim", "•");
517
- const cap = expanded ? items.length : Math.min(items.length, 12);
518
- const truncated = cap < items.length;
518
+ const lines: string[] = [];
519
519
 
520
+ const rawId = typeof args.id === "string" ? args.id.trim() : "";
521
+ const idLabel = rawId ? formatTaskId(rawId) : "";
522
+ const desc = typeof args.description === "string" ? args.description.trim() : "";
523
+ if (idLabel || desc) {
524
+ let line = `${bullet} ${theme.fg("accent", theme.bold(idLabel || "agent"))}`;
525
+ if (desc) {
526
+ line += `: ${theme.fg("muted", truncateToWidth(replaceTabs(desc), 64))}`;
527
+ }
528
+ lines.push(line);
529
+ }
530
+ lines.push(...renderTaskItemLines(args.tasks, theme));
531
+ return lines;
532
+ }
533
+
534
+ /**
535
+ * Render the per-item list (`id` + ui `description`) for a batch call's
536
+ * streaming preview. The args stream in token by token, so the array grows
537
+ * over time and trailing entries may be partially parsed — every field access
538
+ * is defensive.
539
+ */
540
+ function renderTaskItemLines(tasks: TaskItem[] | undefined, theme: Theme): string[] {
541
+ if (!Array.isArray(tasks) || tasks.length === 0) return [];
542
+
543
+ const bullet = theme.fg("dim", "•");
544
+ const cap = Math.min(tasks.length, 12);
520
545
  const lines: string[] = [];
521
546
  for (let i = 0; i < cap; i++) {
522
- const task = items[i] as Partial<TaskItem> | undefined;
523
- const rawId = task?.id?.trim();
547
+ const task = tasks[i] as Partial<TaskItem> | undefined;
548
+ const rawId = typeof task?.id === "string" ? task.id.trim() : "";
524
549
  const idLabel = rawId ? formatTaskId(rawId) : `#${i + 1}`;
525
550
  let line = `${bullet} ${theme.fg("accent", theme.bold(idLabel))}`;
526
- const desc = task?.description?.trim();
551
+ const desc = typeof task?.description === "string" ? task.description.trim() : "";
527
552
  if (desc) {
528
553
  line += `: ${theme.fg("muted", truncateToWidth(replaceTabs(desc), 64))}`;
529
554
  }
555
+ if (task?.isolated === true) {
556
+ line += theme.fg("dim", " [isolated]");
557
+ }
530
558
  lines.push(line);
531
559
  }
532
- if (truncated) {
533
- lines.push(`${bullet} ${theme.fg("dim", formatMoreItems(items.length - cap, "agent"))}`);
560
+ if (cap < tasks.length) {
561
+ lines.push(`${bullet} ${theme.fg("dim", formatMoreItems(tasks.length - cap, "agent"))}`);
534
562
  }
535
563
  return lines;
536
564
  }
537
565
 
538
- /**
539
- * Build the shared-context section (the `# Goal / # Constraints` background
540
- * passed to every subagent). Rendered in both the streaming call preview and
541
- * the merged result frame so the brief stays visible for the whole task
542
- * lifecycle — not just until the first progress snapshot replaces the call view.
543
- */
544
- type TaskRenderSection = { lines: readonly string[] };
545
- type ContextSectionRenderer = (width: number) => TaskRenderSection;
566
+ /** One renderable frame section: optional label, body rows, leading divider. */
567
+ type TaskRenderSection = { label?: string; lines: readonly string[]; separator?: boolean };
568
+ type AssignmentSectionRenderer = (width: number) => TaskRenderSection;
546
569
 
547
570
  // Default output-block layout is: left border + one-cell content inset + right
548
571
  // border. Render markdown at that inner width so the output block does not need
549
- // to rewrap already-rendered context lines.
550
- const CONTEXT_FRAME_INSET = 3;
572
+ // to rewrap already-rendered assignment lines.
573
+ const ASSIGNMENT_FRAME_INSET = 3;
551
574
 
552
- function contextMarkdownWidth(frameWidth: number): number {
553
- return Math.max(1, frameWidth - CONTEXT_FRAME_INSET);
575
+ /**
576
+ * Build the assignment section (the markdown brief handed to the subagent).
577
+ * Rendered in both the streaming call preview and the result frame so the
578
+ * brief stays visible for the whole task lifecycle — not just until the first
579
+ * progress snapshot replaces the call view.
580
+ */
581
+ function createAssignmentSectionRenderer(
582
+ args: Partial<TaskParams> | undefined,
583
+ theme: Theme,
584
+ ): AssignmentSectionRenderer | undefined {
585
+ // `renderResult` receives the raw tool args (unlike `renderCall`, which is
586
+ // fed through `repairTaskParams`), so undo any per-field double-encoding
587
+ // here too. The repair is idempotent on already-clean text.
588
+ const assignment = repairDoubleEncodedJsonString(typeof args?.assignment === "string" ? args.assignment : "").trim();
589
+ if (!assignment) return undefined;
590
+ return createMarkdownSectionRenderer(assignment, theme);
554
591
  }
555
592
 
556
- function createContextSectionRenderer(args: TaskParams | undefined, theme: Theme): ContextSectionRenderer | undefined {
557
- // `renderResult` receives the raw tool args (unlike `renderCall`, which is
558
- // fed through `repairTaskParams`), so undo any per-field double-encoding here
559
- // too. The repair is idempotent on already-clean text.
560
- const context = repairDoubleEncodedJsonString(args?.context ?? "").trim();
593
+ /**
594
+ * Build the shared-context section (the `# Goal / # Constraints` background a
595
+ * batch call hands every subagent). Rendered like the assignment brief so the
596
+ * shared background stays visible for the whole task lifecycle.
597
+ */
598
+ function createContextSectionRenderer(
599
+ args: Partial<TaskParams> | undefined,
600
+ theme: Theme,
601
+ ): AssignmentSectionRenderer | undefined {
602
+ const context = repairDoubleEncodedJsonString(typeof args?.context === "string" ? args.context : "").trim();
561
603
  if (!context) return undefined;
604
+ return createMarkdownSectionRenderer(context, theme);
605
+ }
562
606
 
563
- const markdown = new Markdown(context, 0, 0, getMarkdownTheme(), {
564
- color: text => theme.fg("muted", text),
607
+ function createMarkdownSectionRenderer(text: string, theme: Theme): AssignmentSectionRenderer {
608
+ const markdown = new Markdown(text, 0, 0, getMarkdownTheme(), {
609
+ color: line => theme.fg("muted", line),
565
610
  });
566
- return width => ({ lines: markdown.render(contextMarkdownWidth(width)) });
611
+ return width => ({ lines: markdown.render(Math.max(1, width - ASSIGNMENT_FRAME_INSET)) });
567
612
  }
568
613
 
569
614
  /**
@@ -576,21 +621,22 @@ export function renderCall(
576
621
  ): Component {
577
622
  const showIsolated = "isolated" in args && args.isolated === true;
578
623
  const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme);
579
- const contextSectionRenderer = createContextSectionRenderer(args, theme);
624
+ const assignmentSection = createAssignmentSectionRenderer(args, theme);
625
+ const contextSection = createContextSectionRenderer(args, theme);
580
626
  return framedBlock(theme, width => {
581
627
  const sections: Array<{ label?: string; lines: readonly string[]; separator?: boolean }> = [];
582
628
 
583
- if (contextSectionRenderer) sections.push(contextSectionRenderer(width));
584
-
585
- // The per-task preview list only exists to surface dispatched agents while
586
- // the call args stream in. Once a result snapshot exists, `renderResult`
587
- // draws the same agents as progress/result lines, so showing the Tasks
588
- // section here would just repeat the count the result frame already shows.
629
+ // The call preview only exists to surface the dispatched agent while the
630
+ // args stream in. Once a result snapshot exists, `renderResult` draws the
631
+ // same agent (and the assignment brief) itself, so showing it here would
632
+ // repeat what the result frame already shows.
589
633
  if (!options.renderContext?.hasResult) {
590
634
  sections.push({
591
635
  separator: true,
592
- lines: renderTaskItemLines(args.tasks, options.expanded, theme),
636
+ lines: renderTaskCallLines(args, theme),
593
637
  });
638
+ if (contextSection) sections.push(contextSection(width));
639
+ if (assignmentSection) sections.push(assignmentSection(width));
594
640
  }
595
641
 
596
642
  return {
@@ -631,8 +677,12 @@ function renderAgentProgress(
631
677
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
632
678
  const indent = prefix ? `${prefix} ` : "";
633
679
  let statusLine: string;
634
- if (progress.status === "running") {
635
- const bullet = theme.styledSymbol("status.done", "text");
680
+ if (progress.status === "running" || progress.status === "pending") {
681
+ // Live (or queued) agents shimmer their description so the row reads as
682
+ // in-flight even after the block freezes — the async spawn result keeps
683
+ // the agent on "pending" while the detached job runs.
684
+ const bullet =
685
+ progress.status === "running" ? theme.styledSymbol("status.done", "text") : theme.fg(iconColor, icon);
636
686
  const name = theme.fg("accent", description ? theme.bold(displayId) : displayId);
637
687
  statusLine = `${indent}${bullet} ${name}`;
638
688
  if (description) {
@@ -945,6 +995,7 @@ function renderAgentResult(
945
995
  statusLine,
946
996
  {
947
997
  tokens: result.tokens,
998
+ requests: result.requests,
948
999
  contextTokens: result.contextTokens,
949
1000
  contextWindow: result.contextWindow,
950
1001
  cost: result.usage?.cost.total ?? 0,
@@ -1073,9 +1124,11 @@ function renderAgentResult(
1073
1124
  }
1074
1125
 
1075
1126
  /**
1076
- * Order live progress entries so finished agents render first and unfinished
1077
- * (pending/running) ones stay pinned at the bottom as tasks complete. Stable
1078
- * within each group, so agents keep their dispatch order.
1127
+ * Order live progress entries so finished agents render first sorted by
1128
+ * runtime ascending, matching {@link orderResultsForDisplay} while
1129
+ * unfinished (pending/running) ones stay pinned at the bottom in dispatch
1130
+ * order. Because a finished agent's runtime is fixed, finalization renders
1131
+ * the same order and rows never reshuffle.
1079
1132
  */
1080
1133
  function orderProgressForDisplay(progress: readonly AgentProgress[]): AgentProgress[] {
1081
1134
  const finished: AgentProgress[] = [];
@@ -1083,9 +1136,19 @@ function orderProgressForDisplay(progress: readonly AgentProgress[]): AgentProgr
1083
1136
  for (const p of progress) {
1084
1137
  (p.status === "pending" || p.status === "running" ? unfinished : finished).push(p);
1085
1138
  }
1139
+ finished.sort((a, b) => a.durationMs - b.durationMs || a.index - b.index);
1086
1140
  return finished.concat(unfinished);
1087
1141
  }
1088
1142
 
1143
+ /**
1144
+ * Order finalized results by runtime ascending (tie-break: dispatch index) so
1145
+ * the finalized list matches the live-progress order produced by
1146
+ * {@link orderProgressForDisplay}.
1147
+ */
1148
+ function orderResultsForDisplay(results: readonly SingleResult[]): SingleResult[] {
1149
+ return [...results].sort((a, b) => a.durationMs - b.durationMs || a.index - b.index);
1150
+ }
1151
+
1089
1152
  /**
1090
1153
  * Render the tool result.
1091
1154
  */
@@ -1097,25 +1160,28 @@ export function renderResult(
1097
1160
  ): Component {
1098
1161
  const fallbackText = result.content.find(c => c.type === "text")?.text ?? "";
1099
1162
  const details = result.details;
1100
- const contextSectionRenderer = createContextSectionRenderer(args, theme);
1163
+ const agentLabel = args?.agent?.trim() || undefined;
1164
+ const assignmentSection = createAssignmentSectionRenderer(args, theme);
1165
+ const contextSection = createContextSectionRenderer(args, theme);
1101
1166
 
1102
1167
  if (!details) {
1103
1168
  const text = result.content.find(c => c.type === "text")?.text || "";
1104
1169
  const errored = result.isError === true;
1105
1170
  const header = errored
1106
- ? renderStatusLine({ icon: "error", title: "Task", description: args?.agent }, theme)
1171
+ ? renderStatusLine({ icon: "error", title: "Task", description: agentLabel }, theme)
1107
1172
  : renderStatusLine(
1108
1173
  {
1109
1174
  iconOverride: theme.styledSymbol("status.done", "accent"),
1110
1175
  title: "Task",
1111
- description: args?.agent,
1176
+ description: agentLabel,
1112
1177
  },
1113
1178
  theme,
1114
1179
  );
1115
1180
  return framedBlock(theme, width => ({
1116
1181
  header,
1117
1182
  sections: [
1118
- ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1183
+ ...(contextSection ? [contextSection(width)] : []),
1184
+ ...(assignmentSection ? [assignmentSection(width)] : []),
1119
1185
  ...(text ? [{ separator: true, lines: [theme.fg("dim", truncateToWidth(text, width))] }] : []),
1120
1186
  ],
1121
1187
  state: errored ? "error" : "success",
@@ -1131,12 +1197,10 @@ export function renderResult(
1131
1197
  const isError = aborted || failed;
1132
1198
  const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0);
1133
1199
  const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
1134
- // Surface the dispatched agent type (e.g. `Reviewer`) alongside the count so
1135
- // the header reads `Task 16 agents: Reviewer`. All tasks in one call share a
1136
- // single `agent` type (top-level param), so one label covers the whole batch.
1137
- const agentName = args?.agent?.trim();
1200
+ // Surface the dispatched agent type (e.g. `Reviewer`) alongside the count
1201
+ // so the header reads `Task 1 agent: Reviewer`.
1138
1202
  const countLabel = agentCount > 0 ? `${agentCount} ${agentCount === 1 ? "agent" : "agents"}` : undefined;
1139
- const metaLabel = countLabel ? (agentName ? `${countLabel}: ${agentName}` : countLabel) : agentName;
1203
+ const metaLabel = countLabel ? (agentLabel ? `${countLabel}: ${agentLabel}` : countLabel) : agentLabel;
1140
1204
  const header = renderStatusLine(
1141
1205
  {
1142
1206
  icon: icon === "success" ? undefined : icon,
@@ -1158,7 +1222,7 @@ export function renderResult(
1158
1222
  lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame));
1159
1223
  });
1160
1224
  } else if (details.results && details.results.length > 0) {
1161
- details.results.forEach(res => {
1225
+ orderResultsForDisplay(details.results).forEach(res => {
1162
1226
  lines.push(...renderAgentResult(res, "", " ", expanded, theme));
1163
1227
  });
1164
1228
 
@@ -1171,6 +1235,8 @@ export function renderResult(
1171
1235
  if (successCount > 0) summaryParts.push(theme.fg("success", `${successCount} succeeded`));
1172
1236
  if (mergeFailedCount > 0) summaryParts.push(theme.fg("warning", `${mergeFailedCount} merge failed`));
1173
1237
  if (failCount > 0) summaryParts.push(theme.fg("error", `${failCount} failed`));
1238
+ const totalRequests = details.results.reduce((sum, r) => sum + (r.requests ?? 0), 0);
1239
+ if (totalRequests > 0) summaryParts.push(theme.fg("dim", `${formatNumber(totalRequests)} req`));
1174
1240
  summaryParts.push(theme.fg("dim", formatDuration(details.totalDurationMs)));
1175
1241
  // Wrap the run summary in the theme's bracket glyphs (dim chrome, colored
1176
1242
  // counts) to match the bash tool's `[Wall: … | Exit: …]` footer.
@@ -1189,7 +1255,8 @@ export function renderResult(
1189
1255
  return {
1190
1256
  header,
1191
1257
  sections: [
1192
- ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1258
+ ...(contextSection ? [contextSection(width)] : []),
1259
+ ...(assignmentSection ? [assignmentSection(width)] : []),
1193
1260
  { separator: true, lines: [theme.fg("dim", truncateToWidth(text, width))] },
1194
1261
  ],
1195
1262
  state,
@@ -1219,7 +1286,8 @@ export function renderResult(
1219
1286
  return {
1220
1287
  header,
1221
1288
  sections: [
1222
- ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1289
+ ...(contextSection ? [contextSection(width)] : []),
1290
+ ...(assignmentSection ? [assignmentSection(width)] : []),
1223
1291
  ...(lines.length > 0 ? [{ separator: true, lines }] : []),
1224
1292
  ],
1225
1293
  state,
@@ -1252,8 +1320,9 @@ function renderNestedTaskResults(detailsList: TaskToolDetails[], expanded: boole
1252
1320
  const lines: string[] = [];
1253
1321
  for (const details of detailsList) {
1254
1322
  if (!details.results || details.results.length === 0) continue;
1255
- details.results.forEach((result, index) => {
1256
- const { prefix, continuePrefix } = nestedMarkers(index === details.results.length - 1, theme);
1323
+ const ordered = orderResultsForDisplay(details.results);
1324
+ ordered.forEach((result, index) => {
1325
+ const { prefix, continuePrefix } = nestedMarkers(index === ordered.length - 1, theme);
1257
1326
  lines.push(...renderAgentResult(result, prefix, continuePrefix, expanded, theme));
1258
1327
  });
1259
1328
  }
@@ -1275,8 +1344,9 @@ function renderNestedTaskTree(
1275
1344
  for (const details of detailsList) {
1276
1345
  const hasResults = Boolean(details.results && details.results.length > 0);
1277
1346
  if (hasResults) {
1278
- details.results.forEach((result, index) => {
1279
- const { prefix, continuePrefix } = nestedMarkers(index === details.results.length - 1, theme);
1347
+ const ordered = orderResultsForDisplay(details.results);
1348
+ ordered.forEach((result, index) => {
1349
+ const { prefix, continuePrefix } = nestedMarkers(index === ordered.length - 1, theme);
1280
1350
  lines.push(...renderAgentResult(result, prefix, continuePrefix, expanded, theme));
1281
1351
  });
1282
1352
  continue;
@@ -2,7 +2,7 @@
2
2
  * Repair double-encoded JSON string arguments for the task tool.
3
3
  *
4
4
  * Models occasionally JSON-escape a string value twice when emitting a
5
- * `task` tool call, so a `context`/`assignment` that should read
5
+ * `task` tool call, so an `assignment` that should read
6
6
  *
7
7
  * # Role
8
8
  * You are a judge … "describe this" … return —
@@ -24,7 +24,7 @@
24
24
  * string.
25
25
  *
26
26
  * This is deliberately scoped to the task tool's natural-language fields
27
- * (`context`, `assignment`, `description`). It is NOT applied to code-bearing
27
+ * (`assignment`, `description`). It is NOT applied to code-bearing
28
28
  * tools (write/edit/bash/search), where a backslash or quote is load-bearing
29
29
  * and a false-positive unescape would silently corrupt a file or command.
30
30
  */
@@ -90,15 +90,20 @@ function repairTaskItem(task: TaskItem): TaskItem {
90
90
  }
91
91
 
92
92
  /**
93
- * Repair double-encoded prose in task-tool params (`context` and each task's
94
- * `assignment`/`description`). Returns the same reference when nothing changed
95
- * so callers can cheaply skip work. Defensive against partially-streamed args
96
- * (missing/undefined fields, partial task arrays) so it is safe on the render
97
- * path as well as on execution.
93
+ * Repair double-encoded prose in task-tool params (`assignment`,
94
+ * `description`, shared `context`, and each batch task item's prose fields).
95
+ * Returns the same reference when nothing changed so callers can cheaply skip
96
+ * work. Defensive against partially-streamed args (missing/undefined fields,
97
+ * partial task arrays) so it is safe on the render path as well as on
98
+ * execution.
98
99
  */
99
100
  export function repairTaskParams(params: TaskParams): TaskParams {
100
101
  if (params === null || typeof params !== "object") return params;
101
102
 
103
+ const assignment =
104
+ typeof params.assignment === "string" ? repairDoubleEncodedJsonString(params.assignment) : params.assignment;
105
+ const description =
106
+ typeof params.description === "string" ? repairDoubleEncodedJsonString(params.description) : params.description;
102
107
  const context = typeof params.context === "string" ? repairDoubleEncodedJsonString(params.context) : params.context;
103
108
 
104
109
  let tasks = params.tasks;
@@ -112,6 +117,13 @@ export function repairTaskParams(params: TaskParams): TaskParams {
112
117
  if (changed) tasks = repaired;
113
118
  }
114
119
 
115
- if (context === params.context && tasks === params.tasks) return params;
116
- return { ...params, context, tasks };
120
+ if (
121
+ assignment === params.assignment &&
122
+ description === params.description &&
123
+ context === params.context &&
124
+ tasks === params.tasks
125
+ ) {
126
+ return params;
127
+ }
128
+ return { ...params, assignment, description, context, tasks };
117
129
  }
package/src/task/types.ts CHANGED
@@ -3,7 +3,6 @@ import type { Usage } from "@oh-my-pi/pi-ai";
3
3
  import { $env } from "@oh-my-pi/pi-utils";
4
4
  import * as z from "zod/v4";
5
5
  import type { AgentSessionEvent } from "../session/agent-session";
6
- import { getTaskSimpleModeCapabilities, type TaskSimpleMode } from "./simple-mode";
7
6
  import type { NestedRepoPatch } from "./worktree";
8
7
 
9
8
  /** Source of an agent definition */
@@ -66,84 +65,88 @@ export interface SubagentLifecyclePayload {
66
65
  index: number;
67
66
  }
68
67
 
69
- const assignmentDescription = "per-task instructions; self-contained";
70
-
71
- const createTaskItemSchema = (_contextEnabled: boolean) =>
72
- z.object({
73
- id: z.string().max(48).describe("camelcase identifier"),
74
- description: z.string().describe("ui label, not seen by subagent"),
75
- assignment: z.string().describe(assignmentDescription),
76
- });
77
-
78
- /** Single task item for parallel execution (default shape with context enabled). */
79
- export const taskItemSchema = createTaskItemSchema(true);
80
- export type TaskItem = z.infer<typeof taskItemSchema>;
81
-
82
- const createTaskSchema = (options: { isolationEnabled: boolean; simpleMode: TaskSimpleMode }) => {
83
- const { contextEnabled, customSchemaEnabled } = getTaskSimpleModeCapabilities(options.simpleMode);
84
- const itemSchema = createTaskItemSchema(contextEnabled);
85
-
86
- let schema = z.object({
87
- agent: z.string().describe("agent type"),
88
- tasks: z.array(itemSchema).describe("tasks to execute in parallel"),
89
- });
90
- if (contextEnabled) {
91
- schema = schema.extend({
92
- context: z.string().optional().describe("shared background prepended to each assignment"),
93
- });
94
- }
95
-
96
- if (customSchemaEnabled) {
97
- schema = schema.extend({
98
- schema: z.string().optional().describe("jtd schema for expected response shape"),
99
- });
100
- }
68
+ /**
69
+ * One unit of work. The single-spawn schema is `{ agent, ...taskItemSchema }`;
70
+ * the batch schema (`task.batch`) is `{ agent, context, tasks: taskItemSchema[] }`.
71
+ * When task isolation is enabled, `isolated` joins the item shape (per-item in
72
+ * batch form, top-level in the flat form via the spread).
73
+ */
74
+ const taskItemShape = {
75
+ id: z.string().max(48).optional().describe("stable agent id; default generated"),
76
+ description: z.string().optional().describe("ui label, not seen by subagent"),
77
+ assignment: z.string().describe("the work; self-contained instructions"),
78
+ };
79
+ const isolatedShape = {
80
+ isolated: z.boolean().optional().describe("run in isolated env; returns patches"),
81
+ };
82
+ const agentShape = {
83
+ agent: z.string().describe("agent type to spawn"),
84
+ };
85
+ const contextShape = {
86
+ context: z.string().describe("shared background prepended to each assignment"),
87
+ };
101
88
 
102
- if (options.isolationEnabled) {
103
- schema = schema.extend({
104
- isolated: z.boolean().optional().describe("run in isolated env; returns patches"),
105
- });
106
- }
89
+ export const taskItemSchema = z.object(taskItemShape);
90
+ const taskItemSchemaIsolated = z.object({ ...taskItemShape, ...isolatedShape });
107
91
 
108
- return schema;
109
- };
92
+ /** Single task item. Fields are optional defensively: args stream in token by token. */
93
+ export interface TaskItem {
94
+ /** Stable agent id; default = generated AdjectiveNoun. */
95
+ id?: string;
96
+ /** UI label, not seen by the subagent. */
97
+ description?: string;
98
+ /** The work; required by the schema. */
99
+ assignment?: string;
100
+ /** Run this spawn in an isolated worktree (batch form; flat form carries it top-level). */
101
+ isolated?: boolean;
102
+ }
110
103
 
111
- export const taskSchema = createTaskSchema({ isolationEnabled: true, simpleMode: "default" });
112
- export const taskSchemaNoIsolation = createTaskSchema({ isolationEnabled: false, simpleMode: "default" });
113
- const taskSchemaSchemaFree = createTaskSchema({ isolationEnabled: true, simpleMode: "schema-free" });
114
- const taskSchemaSchemaFreeNoIsolation = createTaskSchema({ isolationEnabled: false, simpleMode: "schema-free" });
115
- const taskSchemaIndependent = createTaskSchema({ isolationEnabled: true, simpleMode: "independent" });
116
- const taskSchemaIndependentNoIsolation = createTaskSchema({ isolationEnabled: false, simpleMode: "independent" });
117
- const ALL_TASK_SCHEMAS = [
118
- taskSchema,
119
- taskSchemaNoIsolation,
120
- taskSchemaSchemaFree,
121
- taskSchemaSchemaFreeNoIsolation,
122
- taskSchemaIndependent,
123
- taskSchemaIndependentNoIsolation,
124
- ] as const;
104
+ export const taskSchema = z.object({ ...agentShape, ...taskItemShape, ...isolatedShape });
105
+ const taskSchemaNoIsolation = z.object({ ...agentShape, ...taskItemShape });
106
+ const taskSchemaBatch = z.object({
107
+ ...agentShape,
108
+ ...contextShape,
109
+ tasks: z.array(taskItemSchemaIsolated).describe("tasks to spawn; one subagent per item"),
110
+ });
111
+ const taskSchemaBatchNoIsolation = z.object({
112
+ ...agentShape,
113
+ ...contextShape,
114
+ tasks: z.array(taskItemSchema).describe("tasks to spawn; one subagent per item"),
115
+ });
116
+ const ALL_TASK_SCHEMAS = [taskSchema, taskSchemaNoIsolation, taskSchemaBatch, taskSchemaBatchNoIsolation] as const;
125
117
 
126
118
  type DynamicTaskSchema = (typeof ALL_TASK_SCHEMAS)[number];
127
119
  export type TaskSchema = typeof taskSchema;
128
- /** Active task tool parameter schema for the current simple-mode / isolation flags */
120
+ /** Active task tool parameter schema for the current isolation / batch flags */
129
121
  export type TaskToolSchemaInstance = DynamicTaskSchema;
130
122
 
131
- export function getTaskSchema(options: { isolationEnabled: boolean; simpleMode: TaskSimpleMode }): DynamicTaskSchema {
132
- switch (options.simpleMode) {
133
- case "schema-free":
134
- return options.isolationEnabled ? taskSchemaSchemaFree : taskSchemaSchemaFreeNoIsolation;
135
- case "independent":
136
- return options.isolationEnabled ? taskSchemaIndependent : taskSchemaIndependentNoIsolation;
137
- default:
138
- return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
123
+ export function getTaskSchema(options: { isolationEnabled: boolean; batchEnabled: boolean }): DynamicTaskSchema {
124
+ if (options.batchEnabled) {
125
+ return options.isolationEnabled ? taskSchemaBatch : taskSchemaBatchNoIsolation;
139
126
  }
127
+ return options.isolationEnabled ? taskSchema : taskSchemaNoIsolation;
140
128
  }
141
129
 
130
+ /**
131
+ * Runtime params union over both wire shapes. The model sees exactly one shape
132
+ * (`{ agent, context, tasks[] }` when `task.batch` is on, `{ agent, ...item }`
133
+ * otherwise); runtime stays permissive so internal callers and stale
134
+ * transcripts using the flat form keep working under either setting.
135
+ */
142
136
  export interface TaskParams {
143
- agent: string;
137
+ /** Agent type; required. */
138
+ agent?: string;
139
+ /** Stable agent id (flat form); default = generated AdjectiveNoun. */
140
+ id?: string;
141
+ /** UI label (flat form), not seen by the subagent. */
142
+ description?: string;
143
+ /** The work (flat form). */
144
+ assignment?: string;
145
+ /** Batch form (`task.batch`): one subagent per item. */
146
+ tasks?: TaskItem[];
147
+ /** Batch form: shared background prepended to every assignment; required by the batch schema. */
144
148
  context?: string;
145
- schema?: string;
146
- tasks: TaskItem[];
149
+ /** Run in an isolated worktree (flat form; per-item in batch form). */
147
150
  isolated?: boolean;
148
151
  }
149
152
 
@@ -206,6 +209,8 @@ export interface AgentProgress {
206
209
  recentTools: Array<{ tool: string; args: string; endMs: number }>;
207
210
  recentOutput: string[];
208
211
  toolCount: number;
212
+ /** Count of assistant requests (assistant message_end events) across the run. Drives the soft request budget guard. */
213
+ requests: number;
209
214
  /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */
210
215
  tokens: number;
211
216
  /**
@@ -276,6 +281,8 @@ export interface SingleResult {
276
281
  durationMs: number;
277
282
  /** Cumulative input + output + cacheWrite tokens across all turns. Excludes cacheRead (re-reads cached context every turn, making cumulative sum misleading). */
278
283
  tokens: number;
284
+ /** Count of assistant requests (assistant message_end events) across the run. */
285
+ requests: number;
279
286
  /** Latest per-turn context size at task completion. See `AgentProgress.contextTokens`. */
280
287
  contextTokens?: number;
281
288
  /** Model's context window in tokens, when known. */
package/src/tools/ask.ts CHANGED
@@ -104,7 +104,7 @@ const RECOMMENDED_SUFFIX = " (Recommended)";
104
104
  const TIMEOUT_DETECTION_TOLERANCE_MS = 1_000;
105
105
 
106
106
  function getDoneOptionLabel(): string {
107
- return `${theme.symbol("tool.ask")} Done selecting`;
107
+ return `${theme.status.success} Done selecting`;
108
108
  }
109
109
 
110
110
  /** Add "(Recommended)" suffix to the option at the given index if not already present */
@@ -694,7 +694,9 @@ function normalizeRenderQuestions(raw: unknown): NonNullable<AskRenderArgs["ques
694
694
  /** Render a custom free-text answer as a status line plus indented continuation rows. */
695
695
  function renderCustomInputLines(uiTheme: Theme, customInput: string): string[] {
696
696
  const lines = customInput.split("\n");
697
- const out: string[] = [` ${uiTheme.styledSymbol("tool.ask", "accent")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`];
697
+ const out: string[] = [
698
+ ` ${uiTheme.styledSymbol("status.success", "success")} ${uiTheme.fg("toolOutput", lines[0] ?? "")}`,
699
+ ];
698
700
  for (let i = 1; i < lines.length; i++) out.push(` ${uiTheme.fg("toolOutput", lines[i])}`);
699
701
  return out;
700
702
  }