@oh-my-pi/pi-coding-agent 15.9.67 → 15.10.1

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 (266) hide show
  1. package/CHANGELOG.md +136 -0
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/dry-balance-cli.d.ts +15 -1
  4. package/dist/types/cli/gallery-cli.d.ts +43 -0
  5. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  6. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  8. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  9. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  10. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  11. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  12. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  15. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  16. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  17. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  18. package/dist/types/commands/gallery.d.ts +47 -0
  19. package/dist/types/commit/analysis/conventional.d.ts +2 -2
  20. package/dist/types/commit/analysis/summary.d.ts +2 -2
  21. package/dist/types/commit/changelog/generate.d.ts +2 -2
  22. package/dist/types/commit/changelog/index.d.ts +2 -2
  23. package/dist/types/commit/map-reduce/index.d.ts +3 -3
  24. package/dist/types/commit/map-reduce/map-phase.d.ts +2 -2
  25. package/dist/types/commit/map-reduce/reduce-phase.d.ts +2 -2
  26. package/dist/types/commit/model-selection.d.ts +10 -4
  27. package/dist/types/config/api-key-resolver.d.ts +34 -0
  28. package/dist/types/config/keybindings.d.ts +6 -1
  29. package/dist/types/config/model-id-affixes.d.ts +2 -0
  30. package/dist/types/config/model-registry.d.ts +25 -2
  31. package/dist/types/config/settings-schema.d.ts +41 -6
  32. package/dist/types/dap/config.d.ts +14 -1
  33. package/dist/types/dap/types.d.ts +10 -0
  34. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  35. package/dist/types/lsp/types.d.ts +10 -0
  36. package/dist/types/lsp/utils.d.ts +3 -2
  37. package/dist/types/main.d.ts +3 -2
  38. package/dist/types/memory-backend/index.d.ts +2 -1
  39. package/dist/types/memory-backend/resolve.d.ts +1 -1
  40. package/dist/types/memory-backend/types.d.ts +1 -1
  41. package/dist/types/modes/components/chat-block.d.ts +64 -0
  42. package/dist/types/modes/components/custom-editor.d.ts +5 -1
  43. package/dist/types/modes/components/overlay-box.d.ts +17 -0
  44. package/dist/types/modes/components/plan-review-overlay.d.ts +59 -0
  45. package/dist/types/modes/components/plan-toc.d.ts +41 -0
  46. package/dist/types/modes/components/read-tool-group.d.ts +2 -0
  47. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  48. package/dist/types/modes/components/transcript-container.d.ts +11 -0
  49. package/dist/types/modes/controllers/command-controller.d.ts +1 -0
  50. package/dist/types/modes/controllers/event-controller.d.ts +0 -1
  51. package/dist/types/modes/controllers/extension-ui-controller.d.ts +0 -1
  52. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  53. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  54. package/dist/types/modes/controllers/streaming-reveal.d.ts +22 -0
  55. package/dist/types/modes/controllers/tan-command-controller.d.ts +6 -0
  56. package/dist/types/modes/index.d.ts +5 -4
  57. package/dist/types/modes/interactive-mode.d.ts +16 -6
  58. package/dist/types/modes/setup-version.d.ts +11 -0
  59. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  60. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  61. package/dist/types/modes/theme/theme.d.ts +1 -1
  62. package/dist/types/modes/types.d.ts +19 -6
  63. package/dist/types/modes/utils/copy-targets.d.ts +21 -1
  64. package/dist/types/plan-mode/approved-plan.d.ts +27 -8
  65. package/dist/types/plan-mode/plan-protection.d.ts +4 -4
  66. package/dist/types/sdk.d.ts +3 -1
  67. package/dist/types/session/agent-session.d.ts +21 -0
  68. package/dist/types/session/messages.d.ts +12 -0
  69. package/dist/types/session/session-manager.d.ts +3 -1
  70. package/dist/types/slash-commands/types.d.ts +4 -6
  71. package/dist/types/task/executor.d.ts +14 -0
  72. package/dist/types/task/index.d.ts +1 -0
  73. package/dist/types/task/render.d.ts +3 -2
  74. package/dist/types/telemetry-export.d.ts +1 -1
  75. package/dist/types/tools/archive-reader.d.ts +5 -0
  76. package/dist/types/tools/ast-edit.d.ts +3 -0
  77. package/dist/types/tools/ast-grep.d.ts +3 -0
  78. package/dist/types/tools/bash.d.ts +1 -0
  79. package/dist/types/tools/eval-render.d.ts +1 -8
  80. package/dist/types/tools/fetch.d.ts +15 -7
  81. package/dist/types/tools/find.d.ts +8 -4
  82. package/dist/types/tools/grouped-file-output.d.ts +95 -12
  83. package/dist/types/tools/memory-render.d.ts +4 -1
  84. package/dist/types/tools/plan-mode-guard.d.ts +8 -9
  85. package/dist/types/tools/render-utils.d.ts +13 -9
  86. package/dist/types/tools/renderers.d.ts +16 -2
  87. package/dist/types/tools/search.d.ts +5 -1
  88. package/dist/types/tools/sqlite-reader.d.ts +1 -0
  89. package/dist/types/tools/todo.d.ts +3 -2
  90. package/dist/types/tools/write.d.ts +5 -0
  91. package/dist/types/tui/output-block.d.ts +16 -4
  92. package/dist/types/tui/status-line.d.ts +3 -0
  93. package/dist/types/utils/enhanced-paste.d.ts +20 -0
  94. package/dist/types/web/scrapers/github.d.ts +22 -0
  95. package/dist/types/web/search/providers/kimi.d.ts +1 -1
  96. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  97. package/dist/types/web/search/types.d.ts +1 -1
  98. package/package.json +9 -9
  99. package/scripts/dev-launch +42 -0
  100. package/scripts/dev-launch-preload.ts +19 -0
  101. package/src/auto-thinking/classifier.ts +5 -1
  102. package/src/cli/args.ts +2 -2
  103. package/src/cli/dry-balance-cli.ts +52 -17
  104. package/src/cli/gallery-cli.ts +226 -0
  105. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  106. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  107. package/src/cli/gallery-fixtures/edit.ts +194 -0
  108. package/src/cli/gallery-fixtures/fs.ts +153 -0
  109. package/src/cli/gallery-fixtures/index.ts +40 -0
  110. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  111. package/src/cli/gallery-fixtures/memory.ts +81 -0
  112. package/src/cli/gallery-fixtures/misc.ts +250 -0
  113. package/src/cli/gallery-fixtures/search.ts +213 -0
  114. package/src/cli/gallery-fixtures/shell.ts +167 -0
  115. package/src/cli/gallery-fixtures/types.ts +41 -0
  116. package/src/cli/gallery-fixtures/web.ts +158 -0
  117. package/src/cli/gallery-screenshot.ts +279 -0
  118. package/src/cli-commands.ts +1 -0
  119. package/src/commands/gallery.ts +52 -0
  120. package/src/commands/launch.ts +1 -1
  121. package/src/commit/analysis/conventional.ts +2 -2
  122. package/src/commit/analysis/summary.ts +2 -2
  123. package/src/commit/changelog/generate.ts +2 -2
  124. package/src/commit/changelog/index.ts +2 -2
  125. package/src/commit/map-reduce/index.ts +3 -3
  126. package/src/commit/map-reduce/map-phase.ts +2 -2
  127. package/src/commit/map-reduce/reduce-phase.ts +2 -2
  128. package/src/commit/model-selection.ts +33 -9
  129. package/src/commit/pipeline.ts +4 -4
  130. package/src/config/api-key-resolver.ts +58 -0
  131. package/src/config/keybindings.ts +15 -6
  132. package/src/config/model-equivalence.ts +35 -12
  133. package/src/config/model-id-affixes.ts +39 -22
  134. package/src/config/model-registry.ts +41 -18
  135. package/src/config/settings-schema.ts +28 -5
  136. package/src/config/settings.ts +31 -2
  137. package/src/dap/client.ts +14 -16
  138. package/src/dap/config.ts +41 -2
  139. package/src/dap/defaults.json +1 -0
  140. package/src/dap/session.ts +1 -0
  141. package/src/dap/types.ts +10 -0
  142. package/src/debug/index.ts +40 -54
  143. package/src/edit/renderer.ts +111 -119
  144. package/src/eval/__tests__/agent-bridge.test.ts +75 -32
  145. package/src/eval/__tests__/llm-bridge.test.ts +90 -31
  146. package/src/eval/agent-bridge.ts +34 -7
  147. package/src/eval/llm-bridge.ts +8 -3
  148. package/src/extensibility/extensions/runner.ts +1 -0
  149. package/src/extensibility/plugins/doctor.ts +0 -1
  150. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  151. package/src/goals/tools/goal-tool.ts +37 -27
  152. package/src/internal-urls/docs-index.generated.ts +10 -10
  153. package/src/lsp/client.ts +104 -55
  154. package/src/lsp/types.ts +10 -0
  155. package/src/lsp/utils.ts +3 -2
  156. package/src/main.ts +53 -56
  157. package/src/memories/index.ts +12 -5
  158. package/src/memory-backend/index.ts +13 -1
  159. package/src/memory-backend/resolve.ts +3 -5
  160. package/src/memory-backend/types.ts +1 -1
  161. package/src/mnemopi/backend.ts +5 -1
  162. package/src/modes/acp/acp-agent.ts +33 -26
  163. package/src/modes/components/assistant-message.ts +2 -9
  164. package/src/modes/components/chat-block.ts +111 -0
  165. package/src/modes/components/copy-selector.ts +1 -44
  166. package/src/modes/components/custom-editor.ts +33 -1
  167. package/src/modes/components/custom-message.ts +1 -3
  168. package/src/modes/components/execution-shared.ts +1 -2
  169. package/src/modes/components/hook-message.ts +1 -3
  170. package/src/modes/components/overlay-box.ts +108 -0
  171. package/src/modes/components/plan-review-overlay.ts +799 -0
  172. package/src/modes/components/plan-toc.ts +138 -0
  173. package/src/modes/components/read-tool-group.ts +20 -4
  174. package/src/modes/components/skill-message.ts +0 -1
  175. package/src/modes/components/status-line.ts +3 -5
  176. package/src/modes/components/tips.txt +1 -0
  177. package/src/modes/components/todo-reminder.ts +0 -2
  178. package/src/modes/components/tool-execution.ts +115 -90
  179. package/src/modes/components/transcript-container.ts +84 -24
  180. package/src/modes/components/user-message.ts +1 -2
  181. package/src/modes/controllers/command-controller-shared.ts +7 -6
  182. package/src/modes/controllers/command-controller.ts +70 -57
  183. package/src/modes/controllers/event-controller.ts +41 -40
  184. package/src/modes/controllers/extension-ui-controller.ts +10 -73
  185. package/src/modes/controllers/input-controller.ts +135 -122
  186. package/src/modes/controllers/mcp-command-controller.ts +69 -60
  187. package/src/modes/controllers/selector-controller.ts +25 -27
  188. package/src/modes/controllers/streaming-reveal.ts +212 -0
  189. package/src/modes/controllers/tan-command-controller.ts +173 -0
  190. package/src/modes/index.ts +5 -4
  191. package/src/modes/interactive-mode.ts +171 -82
  192. package/src/modes/setup-version.ts +11 -0
  193. package/src/modes/setup-wizard/index.ts +3 -2
  194. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  195. package/src/modes/setup-wizard/wizard-overlay.ts +1 -1
  196. package/src/modes/theme/theme-schema.json +1 -1
  197. package/src/modes/theme/theme.ts +8 -4
  198. package/src/modes/types.ts +19 -8
  199. package/src/modes/utils/context-usage.ts +10 -6
  200. package/src/modes/utils/copy-targets.ts +133 -27
  201. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  202. package/src/modes/utils/ui-helpers.ts +44 -46
  203. package/src/plan-mode/approved-plan.ts +66 -43
  204. package/src/plan-mode/plan-protection.ts +4 -4
  205. package/src/prompts/system/background-tan-dispatch.md +8 -0
  206. package/src/prompts/system/plan-mode-active.md +67 -58
  207. package/src/prompts/system/plan-mode-approved.md +1 -1
  208. package/src/sdk.ts +32 -60
  209. package/src/session/agent-session.ts +89 -13
  210. package/src/session/messages.ts +26 -0
  211. package/src/session/session-manager.ts +13 -5
  212. package/src/slash-commands/builtin-registry.ts +37 -10
  213. package/src/slash-commands/helpers/usage-report.ts +2 -0
  214. package/src/slash-commands/types.ts +4 -6
  215. package/src/task/executor.ts +25 -4
  216. package/src/task/index.ts +4 -0
  217. package/src/task/render.ts +212 -148
  218. package/src/telemetry-export.ts +25 -7
  219. package/src/tools/archive-reader.ts +64 -0
  220. package/src/tools/ask.ts +119 -164
  221. package/src/tools/ast-edit.ts +98 -71
  222. package/src/tools/ast-grep.ts +37 -43
  223. package/src/tools/bash.ts +50 -6
  224. package/src/tools/debug.ts +20 -8
  225. package/src/tools/eval-backends.ts +6 -17
  226. package/src/tools/eval-render.ts +21 -18
  227. package/src/tools/eval.ts +5 -4
  228. package/src/tools/fetch.ts +391 -91
  229. package/src/tools/find.ts +44 -30
  230. package/src/tools/gh-renderer.ts +81 -42
  231. package/src/tools/grouped-file-output.ts +272 -48
  232. package/src/tools/image-gen.ts +150 -103
  233. package/src/tools/inspect-image-renderer.ts +63 -41
  234. package/src/tools/inspect-image.ts +8 -1
  235. package/src/tools/job.ts +3 -4
  236. package/src/tools/memory-render.ts +4 -1
  237. package/src/tools/plan-mode-guard.ts +21 -39
  238. package/src/tools/read.ts +23 -16
  239. package/src/tools/render-utils.ts +38 -40
  240. package/src/tools/renderers.ts +16 -1
  241. package/src/tools/report-tool-issue.ts +1 -1
  242. package/src/tools/resolve.ts +14 -0
  243. package/src/tools/search-tool-bm25.ts +36 -23
  244. package/src/tools/search.ts +189 -95
  245. package/src/tools/sqlite-reader.ts +9 -12
  246. package/src/tools/todo.ts +138 -59
  247. package/src/tools/write.ts +100 -60
  248. package/src/tui/output-block.ts +60 -13
  249. package/src/tui/status-line.ts +5 -1
  250. package/src/utils/commit-message-generator.ts +9 -1
  251. package/src/utils/enhanced-paste.ts +202 -0
  252. package/src/utils/title-generator.ts +2 -1
  253. package/src/web/scrapers/github.ts +255 -3
  254. package/src/web/scrapers/youtube.ts +3 -2
  255. package/src/web/search/providers/anthropic.ts +25 -19
  256. package/src/web/search/providers/exa.ts +11 -3
  257. package/src/web/search/providers/kimi.ts +28 -17
  258. package/src/web/search/providers/parallel.ts +35 -24
  259. package/src/web/search/providers/perplexity.ts +199 -51
  260. package/src/web/search/providers/synthetic.ts +8 -6
  261. package/src/web/search/providers/tavily.ts +9 -8
  262. package/src/web/search/providers/zai.ts +8 -6
  263. package/src/web/search/render.ts +39 -54
  264. package/src/web/search/types.ts +5 -1
  265. package/dist/types/eval/__tests__/shared-executors.test.d.ts +0 -1
  266. package/src/eval/__tests__/shared-executors.test.ts +0 -609
@@ -6,19 +6,20 @@
6
6
  */
7
7
  import path from "node:path";
8
8
  import type { Component } from "@oh-my-pi/pi-tui";
9
- import { Container, Text } from "@oh-my-pi/pi-tui";
9
+ import { Container, Markdown, Text } from "@oh-my-pi/pi-tui";
10
10
  import { formatNumber } from "@oh-my-pi/pi-utils";
11
11
  import { settings } from "../config/settings";
12
12
  import type { RenderResultOptions } from "../extensibility/custom-tools/types";
13
13
  import { formatContextUsage } from "../modes/components/status-line/context-thresholds";
14
- import type { Theme } from "../modes/theme/theme";
14
+ import { shimmerEnabled, shimmerText } from "../modes/theme/shimmer";
15
+ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
15
16
  import {
16
- capPreviewLines,
17
17
  formatBadge,
18
18
  formatDuration,
19
19
  formatMoreItems,
20
20
  formatStatusIcon,
21
21
  replaceTabs,
22
+ type ToolUIStatus,
22
23
  truncateToWidth,
23
24
  } from "../tools/render-utils";
24
25
  import {
@@ -29,7 +30,8 @@ import {
29
30
  type ReportFindingDetails,
30
31
  type SubmitReviewDetails,
31
32
  } from "../tools/review";
32
- import { Ellipsis, Hasher, type RenderCache, renderStatusLine } from "../tui";
33
+ import { framedBlock, renderStatusLine } from "../tui";
34
+ import { repairDoubleEncodedJsonString } from "./repair-args";
33
35
  import { subprocessToolRegistry } from "./subprocess-tool-registry";
34
36
  import type { AgentProgress, SingleResult, TaskItem, TaskParams, TaskToolDetails } from "./types";
35
37
 
@@ -507,28 +509,20 @@ function formatOutputInline(data: unknown, theme: Theme, maxWidth = 80): string
507
509
  * preview. The args stream in token by token, so the array grows over time and
508
510
  * trailing entries may be partially parsed — every field access is defensive.
509
511
  */
510
- function renderTaskItemLines(
511
- tasks: TaskItem[] | undefined,
512
- contPrefix: string,
513
- expanded: boolean,
514
- theme: Theme,
515
- ): string[] {
512
+ function renderTaskItemLines(tasks: TaskItem[] | undefined, expanded: boolean, theme: Theme): string[] {
516
513
  const items = tasks ?? [];
517
514
  if (items.length === 0) return [];
518
515
 
519
- const branch = theme.fg("dim", theme.tree.branch);
520
- const last = theme.fg("dim", theme.tree.last);
516
+ const bullet = theme.fg("dim", "•");
521
517
  const cap = expanded ? items.length : Math.min(items.length, 12);
522
518
  const truncated = cap < items.length;
523
519
 
524
520
  const lines: string[] = [];
525
521
  for (let i = 0; i < cap; i++) {
526
522
  const task = items[i] as Partial<TaskItem> | undefined;
527
- const isLastLine = !truncated && i === items.length - 1;
528
- const connector = isLastLine ? last : branch;
529
523
  const rawId = task?.id?.trim();
530
524
  const idLabel = rawId ? formatTaskId(rawId) : `#${i + 1}`;
531
- let line = `${contPrefix}${connector} ${theme.fg("accent", theme.bold(idLabel))}`;
525
+ let line = `${bullet} ${theme.fg("accent", theme.bold(idLabel))}`;
532
526
  const desc = task?.description?.trim();
533
527
  if (desc) {
534
528
  line += `: ${theme.fg("muted", truncateToWidth(replaceTabs(desc), 64))}`;
@@ -536,11 +530,42 @@ function renderTaskItemLines(
536
530
  lines.push(line);
537
531
  }
538
532
  if (truncated) {
539
- lines.push(`${contPrefix}${last} ${theme.fg("dim", formatMoreItems(items.length - cap, "agent"))}`);
533
+ lines.push(`${bullet} ${theme.fg("dim", formatMoreItems(items.length - cap, "agent"))}`);
540
534
  }
541
535
  return lines;
542
536
  }
543
537
 
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: string[] };
545
+ type ContextSectionRenderer = (width: number) => TaskRenderSection;
546
+
547
+ // Default output-block layout is: left border + one-cell content inset + right
548
+ // 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;
551
+
552
+ function contextMarkdownWidth(frameWidth: number): number {
553
+ return Math.max(1, frameWidth - CONTEXT_FRAME_INSET);
554
+ }
555
+
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();
561
+ if (!context) return undefined;
562
+
563
+ const markdown = new Markdown(context, 0, 0, getMarkdownTheme(), {
564
+ color: text => theme.fg("muted", text),
565
+ });
566
+ return width => ({ lines: markdown.render(contextMarkdownWidth(width)) });
567
+ }
568
+
544
569
  /**
545
570
  * Render the tool call arguments.
546
571
  */
@@ -549,44 +574,34 @@ export function renderCall(
549
574
  options: RenderResultOptions & { renderContext?: { hasResult?: boolean } },
550
575
  theme: Theme,
551
576
  ): Component {
552
- const lines: string[] = [];
553
- lines.push(renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme));
554
-
555
- const context = (args.context ?? "").trim();
556
- const hasContext = context.length > 0;
557
- const branch = theme.fg("dim", theme.tree.branch);
558
- const last = theme.fg("dim", theme.tree.last);
559
- const vertical = theme.fg("dim", theme.tree.vertical);
560
577
  const showIsolated = "isolated" in args && args.isolated === true;
561
- const taskCount = args.tasks?.length ?? 0;
562
-
563
- if (hasContext) {
564
- lines.push(` ${branch} ${theme.fg("dim", "Context")}`);
565
- const contextLines = context.split("\n").map(line => {
566
- const content = line ? theme.fg("muted", replaceTabs(line)) : "";
567
- return ` ${vertical} ${content}`;
568
- });
569
- lines.push(...capPreviewLines(contextLines, theme, { expanded: options.expanded, prefix: ` ${vertical} ` }));
570
- }
571
-
572
- // `Tasks` is the last child unless the isolation flag follows it.
573
- const tasksIsLast = !showIsolated;
574
- const tasksPrefix = tasksIsLast ? last : branch;
575
- lines.push(` ${tasksPrefix} ${theme.fg("dim", "Tasks")} ${theme.fg("muted", `(${taskCount})`)}`);
576
- const tasksContPrefix = tasksIsLast ? " " : ` ${vertical} `;
577
- // The per-task preview list only exists to surface dispatched agents while
578
- // the call args stream in. Once a result snapshot exists, `renderResult`
579
- // draws the same agents as progress/result lines (id + description), so
580
- // emitting the preview here would render every task twice.
581
- if (!options.renderContext?.hasResult) {
582
- lines.push(...renderTaskItemLines(args.tasks, tasksContPrefix, options.expanded, theme));
583
- }
584
-
585
- if (showIsolated) {
586
- lines.push(` ${last} ${theme.fg("dim", "Isolated")}: ${theme.fg("muted", "true")}`);
587
- }
578
+ const header = renderStatusLine({ icon: "pending", title: "Task", description: args.agent }, theme);
579
+ const contextSectionRenderer = createContextSectionRenderer(args, theme);
580
+ return framedBlock(theme, width => {
581
+ const sections: Array<{ label?: string; lines: string[]; separator?: boolean }> = [];
582
+
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.
589
+ if (!options.renderContext?.hasResult) {
590
+ sections.push({
591
+ separator: true,
592
+ lines: renderTaskItemLines(args.tasks, options.expanded, theme),
593
+ });
594
+ }
588
595
 
589
- return new Text(lines.join("\n"), 0, 0);
596
+ return {
597
+ header,
598
+ headerMeta: showIsolated ? "isolated" : undefined,
599
+ sections,
600
+ state: "pending",
601
+ borderColor: "borderMuted",
602
+ width,
603
+ };
604
+ });
590
605
  }
591
606
 
592
607
  /**
@@ -594,14 +609,13 @@ export function renderCall(
594
609
  */
595
610
  function renderAgentProgress(
596
611
  progress: AgentProgress,
597
- isLast: boolean,
612
+ prefix: string,
613
+ continuePrefix: string,
598
614
  expanded: boolean,
599
615
  theme: Theme,
600
616
  spinnerFrame?: number,
601
617
  ): string[] {
602
618
  const lines: string[] = [];
603
- const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
604
- const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
605
619
 
606
620
  const icon = getStatusIcon(progress.status, theme, spinnerFrame);
607
621
  const iconColor =
@@ -615,11 +629,24 @@ function renderAgentProgress(
615
629
  const description = progress.description?.trim();
616
630
  const displayId = formatTaskId(progress.id);
617
631
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
618
- let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
632
+ const indent = prefix ? `${prefix} ` : "";
633
+ let statusLine: string;
634
+ if (progress.status === "running") {
635
+ const bullet = theme.fg("accent", "•");
636
+ const name = shimmerEnabled()
637
+ ? shimmerText(displayId, theme)
638
+ : theme.fg("accent", description ? theme.bold(displayId) : displayId);
639
+ statusLine = `${indent}${bullet} ${name}`;
640
+ if (description) {
641
+ statusLine += theme.fg("accent", `: ${description}`);
642
+ }
643
+ } else {
644
+ statusLine = `${indent}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)}`;
645
+ }
619
646
 
620
647
  // Show retry-blocked badge so the parent immediately sees that a child
621
648
  // is sleeping on a provider 429, not silently progressing. Wins over the
622
- // generic running spinner because "we're waiting on a quota window" is
649
+ // generic running marker because "we're waiting on a quota window" is
623
650
  // the operationally meaningful state.
624
651
  if (progress.retryState && progress.status === "running") {
625
652
  statusLine += ` ${formatBadge("retrying", "warning", theme)}`;
@@ -868,10 +895,14 @@ function renderFindings(
868
895
  /**
869
896
  * Render final result for a single agent.
870
897
  */
871
- function renderAgentResult(result: SingleResult, isLast: boolean, expanded: boolean, theme: Theme): string[] {
898
+ function renderAgentResult(
899
+ result: SingleResult,
900
+ prefix: string,
901
+ continuePrefix: string,
902
+ expanded: boolean,
903
+ theme: Theme,
904
+ ): string[] {
872
905
  const lines: string[] = [];
873
- const prefix = isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch);
874
- const continuePrefix = isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `;
875
906
 
876
907
  const { warning: missingCompleteWarning, rest: outputWithoutWarning } = extractMissingYieldWarning(result.output);
877
908
  const aborted = result.aborted ?? false;
@@ -900,7 +931,7 @@ function renderAgentResult(result: SingleResult, isLast: boolean, expanded: bool
900
931
  const description = result.description?.trim();
901
932
  const displayId = formatTaskId(result.id);
902
933
  const titlePart = description ? `${theme.bold(displayId)}: ${description}` : displayId;
903
- let statusLine = `${prefix} ${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
934
+ let statusLine = `${prefix ? `${prefix} ` : ""}${theme.fg(iconColor, icon)} ${theme.fg("accent", titlePart)} ${formatBadge(
904
935
  statusText,
905
936
  iconColor,
906
937
  theme,
@@ -1044,101 +1075,123 @@ export function renderResult(
1044
1075
  result: { content: Array<{ type: string; text?: string }>; details?: TaskToolDetails },
1045
1076
  options: RenderResultOptions,
1046
1077
  theme: Theme,
1078
+ args?: TaskParams,
1047
1079
  ): Component {
1048
1080
  const fallbackText = result.content.find(c => c.type === "text")?.text ?? "";
1049
1081
  const details = result.details;
1082
+ const contextSectionRenderer = createContextSectionRenderer(args, theme);
1050
1083
 
1051
1084
  if (!details) {
1052
1085
  const text = result.content.find(c => c.type === "text")?.text || "";
1053
- return new Text(theme.fg("dim", truncateToWidth(text, 100)), 0, 0);
1086
+ const header = renderStatusLine({ icon: "success", title: "Task" }, theme);
1087
+ return framedBlock(theme, width => ({
1088
+ header,
1089
+ sections: [
1090
+ ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1091
+ ...(text ? [{ separator: true, lines: [theme.fg("dim", truncateToWidth(text, width))] }] : []),
1092
+ ],
1093
+ state: "success",
1094
+ borderColor: "borderMuted",
1095
+ width,
1096
+ }));
1054
1097
  }
1055
1098
 
1056
- let cached: RenderCache | undefined;
1099
+ const hasResults = Boolean(details.results && details.results.length > 0);
1100
+ const aborted = hasResults && details.results.some(r => r.aborted);
1101
+ const failed = hasResults && details.results.some(r => !r.aborted && r.exitCode !== 0);
1102
+ const mergeFailed = hasResults && details.results.some(r => !r.aborted && r.exitCode === 0 && Boolean(r.error));
1103
+ const isError = aborted || failed;
1104
+ const agentCount = hasResults ? details.results.length : (details.progress?.length ?? 0);
1105
+ const icon: ToolUIStatus = options.isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
1106
+ const header = renderStatusLine(
1107
+ {
1108
+ icon,
1109
+ title: "Task",
1110
+ meta: agentCount > 0 ? [`${agentCount} ${agentCount === 1 ? "agent" : "agents"}`] : undefined,
1111
+ },
1112
+ theme,
1113
+ );
1057
1114
 
1058
- return {
1059
- render(width) {
1060
- const { expanded, isPartial, spinnerFrame } = options;
1061
- const key = new Hasher()
1062
- .bool(expanded)
1063
- .bool(isPartial)
1064
- .u32(spinnerFrame ?? 0)
1065
- .u32(width)
1066
- .digest();
1067
- if (cached?.key === key) return cached.lines;
1068
-
1069
- const lines: string[] = [];
1070
-
1071
- const shouldRenderProgress =
1072
- Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
1073
- if (shouldRenderProgress && details.progress) {
1074
- details.progress.forEach((progress, i) => {
1075
- const isLast = i === details.progress!.length - 1;
1076
- lines.push(...renderAgentProgress(progress, isLast, expanded, theme, spinnerFrame));
1077
- });
1078
- } else if (details.results && details.results.length > 0) {
1079
- details.results.forEach((res, i) => {
1080
- const isLast = i === details.results.length - 1;
1081
- lines.push(...renderAgentResult(res, isLast, expanded, theme));
1082
- });
1083
-
1084
- const abortedCount = details.results.filter(r => r.aborted).length;
1085
- const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
1086
- const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
1087
- const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
1088
- let summary = `${theme.fg("dim", "Total:")} `;
1089
- if (abortedCount > 0) {
1090
- summary += theme.fg("error", `${abortedCount} aborted`);
1091
- if (successCount > 0 || mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
1092
- }
1093
- if (successCount > 0) {
1094
- summary += theme.fg("success", `${successCount} succeeded`);
1095
- if (mergeFailedCount > 0 || failCount > 0) summary += theme.sep.dot;
1096
- }
1097
- if (mergeFailedCount > 0) {
1098
- summary += theme.fg("warning", `${mergeFailedCount} merge failed`);
1099
- if (failCount > 0) summary += theme.sep.dot;
1100
- }
1101
- if (failCount > 0) {
1102
- summary += theme.fg("error", `${failCount} failed`);
1103
- }
1104
- summary += `${theme.sep.dot}${theme.fg("dim", formatDuration(details.totalDurationMs))}`;
1105
- lines.push(summary);
1106
- }
1115
+ return framedBlock(theme, width => {
1116
+ const { expanded, isPartial, spinnerFrame } = options;
1117
+ const lines: string[] = [];
1107
1118
 
1108
- if (lines.length === 0) {
1109
- const text = fallbackText.trim() ? fallbackText : "No results";
1110
- const result = [theme.fg("dim", truncateToWidth(text, width))];
1111
- cached = { key, lines: result };
1112
- return result;
1113
- }
1119
+ const shouldRenderProgress =
1120
+ Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
1121
+ if (shouldRenderProgress && details.progress) {
1122
+ details.progress.forEach(progress => {
1123
+ lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame));
1124
+ });
1125
+ } else if (details.results && details.results.length > 0) {
1126
+ details.results.forEach(res => {
1127
+ lines.push(...renderAgentResult(res, "", " ", expanded, theme));
1128
+ });
1114
1129
 
1115
- if (fallbackText.trim()) {
1116
- const summaryLines = fallbackText.split("\n");
1117
- const markerIndex = summaryLines.findIndex(
1118
- line =>
1119
- line.includes("<system-notification>") ||
1120
- line.startsWith("Applied patches:") ||
1121
- line.startsWith("No changes to apply."),
1122
- );
1123
- if (markerIndex >= 0) {
1124
- const extra = summaryLines.slice(markerIndex);
1125
- for (const line of extra) {
1126
- if (!line.trim()) continue;
1127
- lines.push(theme.fg("dim", line));
1128
- }
1130
+ const abortedCount = details.results.filter(r => r.aborted).length;
1131
+ const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
1132
+ const successCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && !r.error).length;
1133
+ const failCount = details.results.length - successCount - mergeFailedCount - abortedCount;
1134
+ const summaryParts: string[] = [];
1135
+ if (abortedCount > 0) summaryParts.push(theme.fg("error", `${abortedCount} aborted`));
1136
+ if (successCount > 0) summaryParts.push(theme.fg("success", `${successCount} succeeded`));
1137
+ if (mergeFailedCount > 0) summaryParts.push(theme.fg("warning", `${mergeFailedCount} merge failed`));
1138
+ if (failCount > 0) summaryParts.push(theme.fg("error", `${failCount} failed`));
1139
+ summaryParts.push(theme.fg("dim", formatDuration(details.totalDurationMs)));
1140
+ // Wrap the run summary in the theme's bracket glyphs (dim chrome, colored
1141
+ // counts) to match the bash tool's `[Wall: … | Exit: …]` footer.
1142
+ lines.push(
1143
+ theme.fg("dim", theme.format.bracketLeft) +
1144
+ summaryParts.join(theme.fg("dim", theme.sep.dot)) +
1145
+ theme.fg("dim", theme.format.bracketRight),
1146
+ );
1147
+ }
1148
+
1149
+ const state = isPartial ? "running" : isError ? "error" : mergeFailed ? "warning" : "success";
1150
+ const borderColor = isError ? "error" : "borderMuted";
1151
+
1152
+ if (lines.length === 0) {
1153
+ const text = fallbackText.trim() ? fallbackText : "No results";
1154
+ return {
1155
+ header,
1156
+ sections: [
1157
+ ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1158
+ { separator: true, lines: [theme.fg("dim", truncateToWidth(text, width))] },
1159
+ ],
1160
+ state,
1161
+ borderColor,
1162
+ width,
1163
+ };
1164
+ }
1165
+
1166
+ if (fallbackText.trim()) {
1167
+ const summaryLines = fallbackText.split("\n");
1168
+ const markerIndex = summaryLines.findIndex(
1169
+ line =>
1170
+ line.includes("<system-notification>") ||
1171
+ line.startsWith("Applied patches:") ||
1172
+ line.startsWith("No changes to apply."),
1173
+ );
1174
+ if (markerIndex >= 0) {
1175
+ const extra = summaryLines.slice(markerIndex);
1176
+ for (const line of extra) {
1177
+ if (!line.trim()) continue;
1178
+ lines.push(theme.fg("dim", line));
1129
1179
  }
1130
1180
  }
1181
+ }
1131
1182
 
1132
- const indented = lines.map(line =>
1133
- line.length > 0 ? truncateToWidth(` ${line}`, width, Ellipsis.Omit) : "",
1134
- );
1135
- cached = { key, lines: indented };
1136
- return indented;
1137
- },
1138
- invalidate() {
1139
- cached = undefined;
1140
- },
1141
- };
1183
+ while (lines.length > 0 && lines[0].trim() === "") lines.shift();
1184
+ return {
1185
+ header,
1186
+ sections: [
1187
+ ...(contextSectionRenderer ? [contextSectionRenderer(width)] : []),
1188
+ ...(lines.length > 0 ? [{ separator: true, lines }] : []),
1189
+ ],
1190
+ state,
1191
+ borderColor,
1192
+ width,
1193
+ };
1194
+ });
1142
1195
  }
1143
1196
 
1144
1197
  function isTaskToolDetails(value: unknown): value is TaskToolDetails {
@@ -1150,13 +1203,23 @@ function isTaskToolDetails(value: unknown): value is TaskToolDetails {
1150
1203
  );
1151
1204
  }
1152
1205
 
1206
+ // Nested subagent snapshots sit one or more levels below the frame border, so
1207
+ // they keep tree guides to convey depth (the parent prepends its own continue
1208
+ // prefix). Only the top-level agent list drops guides (the frame is its box).
1209
+ function nestedMarkers(isLast: boolean, theme: Theme): { prefix: string; continuePrefix: string } {
1210
+ return {
1211
+ prefix: isLast ? theme.fg("dim", theme.tree.last) : theme.fg("dim", theme.tree.branch),
1212
+ continuePrefix: isLast ? " " : `${theme.fg("dim", theme.tree.vertical)} `,
1213
+ };
1214
+ }
1215
+
1153
1216
  function renderNestedTaskResults(detailsList: TaskToolDetails[], expanded: boolean, theme: Theme): string[] {
1154
1217
  const lines: string[] = [];
1155
1218
  for (const details of detailsList) {
1156
1219
  if (!details.results || details.results.length === 0) continue;
1157
1220
  details.results.forEach((result, index) => {
1158
- const isLast = index === details.results.length - 1;
1159
- lines.push(...renderAgentResult(result, isLast, expanded, theme));
1221
+ const { prefix, continuePrefix } = nestedMarkers(index === details.results.length - 1, theme);
1222
+ lines.push(...renderAgentResult(result, prefix, continuePrefix, expanded, theme));
1160
1223
  });
1161
1224
  }
1162
1225
  return lines;
@@ -1178,16 +1241,16 @@ function renderNestedTaskTree(
1178
1241
  const hasResults = Boolean(details.results && details.results.length > 0);
1179
1242
  if (hasResults) {
1180
1243
  details.results.forEach((result, index) => {
1181
- const isLast = index === details.results.length - 1;
1182
- lines.push(...renderAgentResult(result, isLast, expanded, theme));
1244
+ const { prefix, continuePrefix } = nestedMarkers(index === details.results.length - 1, theme);
1245
+ lines.push(...renderAgentResult(result, prefix, continuePrefix, expanded, theme));
1183
1246
  });
1184
1247
  continue;
1185
1248
  }
1186
1249
  const inflight = details.progress;
1187
1250
  if (inflight && inflight.length > 0) {
1188
1251
  inflight.forEach((prog, index) => {
1189
- const isLast = index === inflight.length - 1;
1190
- lines.push(...renderAgentProgress(prog, isLast, expanded, theme, spinnerFrame));
1252
+ const { prefix, continuePrefix } = nestedMarkers(index === inflight.length - 1, theme);
1253
+ lines.push(...renderAgentProgress(prog, prefix, continuePrefix, expanded, theme, spinnerFrame));
1191
1254
  });
1192
1255
  }
1193
1256
  }
@@ -1208,4 +1271,5 @@ subprocessToolRegistry.register<TaskToolDetails>("task", {
1208
1271
  export const taskToolRenderer = {
1209
1272
  renderCall,
1210
1273
  renderResult,
1274
+ mergeCallAndResult: true,
1211
1275
  };
@@ -23,11 +23,7 @@
23
23
  * `sdk-trace-base@2.7` exports cleanly on Bun.
24
24
  */
25
25
  import { logger, postmortem } from "@oh-my-pi/pi-utils";
26
- import { AsyncLocalStorageContextManager } from "@opentelemetry/context-async-hooks";
27
- import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-proto";
28
- import { resourceFromAttributes } from "@opentelemetry/resources";
29
- import { BatchSpanProcessor } from "@opentelemetry/sdk-trace-base";
30
- import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
26
+ import type * as TraceNode from "@opentelemetry/sdk-trace-node";
31
27
 
32
28
  /**
33
29
  * Periodic flush interval. A long-lived `omp` process (the ACP server is
@@ -36,7 +32,8 @@ import { NodeTracerProvider } from "@opentelemetry/sdk-trace-node";
36
32
  */
37
33
  const FLUSH_INTERVAL_MS = 30_000;
38
34
 
39
- let provider: NodeTracerProvider | undefined;
35
+ let provider: TraceNode.NodeTracerProvider | undefined;
36
+ let initPromise: Promise<void> | undefined;
40
37
 
41
38
  /**
42
39
  * Whether {@link initTelemetryExport} registered a real provider. The CLI uses
@@ -53,8 +50,10 @@ export function isTelemetryExportEnabled(): boolean {
53
50
  * the OTEL kill-switches are engaged), so it is safe to call unconditionally at
54
51
  * startup.
55
52
  */
56
- export function initTelemetryExport(): void {
53
+ export async function initTelemetryExport(): Promise<void> {
57
54
  if (provider) return;
55
+ if (initPromise) return initPromise;
56
+
58
57
  // The OTEL env contract parses booleans and enum lists case-insensitively, so
59
58
  // OTEL_SDK_DISABLED=TRUE and OTEL_TRACES_EXPORTER=None must also disable export.
60
59
  if (process.env.OTEL_SDK_DISABLED?.trim().toLowerCase() === "true") return;
@@ -77,6 +76,25 @@ export function initTelemetryExport(): void {
77
76
  return;
78
77
  }
79
78
 
79
+ initPromise = registerProvider();
80
+ return initPromise;
81
+ }
82
+
83
+ async function registerProvider(): Promise<void> {
84
+ const [
85
+ { AsyncLocalStorageContextManager },
86
+ { OTLPTraceExporter },
87
+ { resourceFromAttributes },
88
+ { BatchSpanProcessor },
89
+ { NodeTracerProvider },
90
+ ] = await Promise.all([
91
+ import("@opentelemetry/context-async-hooks"),
92
+ import("@opentelemetry/exporter-trace-otlp-proto"),
93
+ import("@opentelemetry/resources"),
94
+ import("@opentelemetry/sdk-trace-base"),
95
+ import("@opentelemetry/sdk-trace-node"),
96
+ ]);
97
+
80
98
  // The exporter reads endpoint/headers/timeout from OTEL_EXPORTER_OTLP_* itself,
81
99
  // so there is nothing to thread through here.
82
100
  const exporter = new OTLPTraceExporter();
@@ -1,5 +1,9 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as os from "node:os";
3
+ import * as path from "node:path";
1
4
  import { inflateSync, strFromU8 } from "fflate";
2
5
 
6
+ import { formatBytes } from "./render-utils";
3
7
  import { ToolError } from "./tool-errors";
4
8
 
5
9
  export type ArchiveFormat = "zip" | "tar" | "tar.gz";
@@ -123,11 +127,21 @@ function getArchiveFormatFromPath(filePath: string): ArchiveFormat | undefined {
123
127
  return undefined;
124
128
  }
125
129
 
130
+ export function formatArchiveEntryLines(entries: readonly ArchiveDirectoryEntry[]): string[] {
131
+ return entries.map(entry => {
132
+ if (entry.isDirectory) return `${entry.name}/`;
133
+
134
+ const sizeSuffix = entry.size > 0 ? ` (${formatBytes(entry.size)})` : "";
135
+ return `${entry.name}${sizeSuffix}`;
136
+ });
137
+ }
138
+
126
139
  const ZIP_LOCAL_FILE_HEADER_SIGNATURE = 0x04034b50;
127
140
  const ZIP_CENTRAL_DIRECTORY_HEADER_SIGNATURE = 0x02014b50;
128
141
  const ZIP64_EOCD_SIGNATURE = 0x06064b50;
129
142
  const ZIP64_EOCD_LOCATOR_SIGNATURE = 0x07064b50;
130
143
  const ZIP_EOCD_SIGNATURE = 0x06054b50;
144
+ const ZIP_DATA_DESCRIPTOR_SIGNATURE = 0x08074b50;
131
145
  const ZIP_EOCD_MIN_LENGTH = 22;
132
146
  const ZIP_EOCD_MAX_COMMENT_LENGTH = 0xffff;
133
147
  const ZIP64_EOCD_LOCATOR_LENGTH = 20;
@@ -167,6 +181,37 @@ function readUInt32LE(bytes: Uint8Array, offset: number): number {
167
181
  return (bytes[offset]! | (bytes[offset + 1]! << 8) | (bytes[offset + 2]! << 16) | (bytes[offset + 3]! << 24)) >>> 0;
168
182
  }
169
183
 
184
+ function bytesMatchAscii(bytes: Uint8Array, offset: number, value: string): boolean {
185
+ if (bytes.byteLength < offset + value.length) return false;
186
+ for (let index = 0; index < value.length; index++) {
187
+ if (bytes[offset + index] !== value.charCodeAt(index)) return false;
188
+ }
189
+ return true;
190
+ }
191
+
192
+ export function sniffArchiveFormat(bytes: Uint8Array): ArchiveFormat | undefined {
193
+ if (bytes.byteLength >= 4) {
194
+ const signature = readUInt32LE(bytes, 0);
195
+ if (
196
+ signature === ZIP_LOCAL_FILE_HEADER_SIGNATURE ||
197
+ signature === ZIP_EOCD_SIGNATURE ||
198
+ signature === ZIP_DATA_DESCRIPTOR_SIGNATURE
199
+ ) {
200
+ return "zip";
201
+ }
202
+ }
203
+
204
+ if (bytes.byteLength >= 2 && bytes[0] === 0x1f && bytes[1] === 0x8b) {
205
+ return "tar.gz";
206
+ }
207
+
208
+ if (bytesMatchAscii(bytes, 257, "ustar")) {
209
+ return "tar";
210
+ }
211
+
212
+ return undefined;
213
+ }
214
+
170
215
  function readUInt64LEAsNumber(bytes: Uint8Array, offset: number): number {
171
216
  const value = readUInt32LE(bytes, offset) + readUInt32LE(bytes, offset + 4) * ZIP_UINT32_RANGE;
172
217
  if (!Number.isSafeInteger(value)) {
@@ -627,3 +672,22 @@ export async function openArchive(filePath: string): Promise<ArchiveReader> {
627
672
  format === "zip" ? await readZipEntries(filePath) : await readTarEntries(await Bun.file(filePath).bytes());
628
673
  return new ArchiveReader(format, entries);
629
674
  }
675
+
676
+ export async function listArchiveRoot(
677
+ bytes: Uint8Array,
678
+ format: ArchiveFormat,
679
+ opts: { limit?: number } = {},
680
+ ): Promise<string> {
681
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-archive-"));
682
+ const tempPath = path.join(tempDir, `payload.${format}`);
683
+ try {
684
+ await Bun.write(tempPath, bytes);
685
+ const archive = await openArchive(tempPath);
686
+ const entries = archive.listDirectory("");
687
+ const limitedEntries = opts.limit !== undefined && opts.limit > 0 ? entries.slice(0, opts.limit) : entries;
688
+ const lines = formatArchiveEntryLines(limitedEntries);
689
+ return lines.length > 0 ? lines.join("\n") : "(empty archive directory)";
690
+ } finally {
691
+ await fs.rm(tempDir, { recursive: true, force: true });
692
+ }
693
+ }