@oh-my-pi/pi-coding-agent 15.11.8 → 15.12.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 (80) hide show
  1. package/CHANGELOG.md +36 -2
  2. package/dist/cli.js +8083 -7692
  3. package/dist/types/collab/crypto.d.ts +1 -6
  4. package/dist/types/collab/guest.d.ts +2 -0
  5. package/dist/types/collab/host.d.ts +16 -0
  6. package/dist/types/collab/protocol.d.ts +14 -1
  7. package/dist/types/config/settings-schema.d.ts +40 -5
  8. package/dist/types/export/custom-share.d.ts +1 -2
  9. package/dist/types/export/html/index.d.ts +39 -1
  10. package/dist/types/export/share.d.ts +43 -0
  11. package/dist/types/main.d.ts +2 -0
  12. package/dist/types/modes/components/agent-hub.d.ts +19 -1
  13. package/dist/types/modes/components/status-line/component.d.ts +6 -1
  14. package/dist/types/modes/components/status-line/types.d.ts +2 -0
  15. package/dist/types/modes/controllers/event-controller.d.ts +7 -0
  16. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  17. package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
  18. package/dist/types/modes/interactive-mode.d.ts +9 -0
  19. package/dist/types/modes/session-observer-registry.d.ts +7 -0
  20. package/dist/types/modes/theme/theme.d.ts +2 -1
  21. package/dist/types/modes/types.d.ts +12 -0
  22. package/dist/types/session/agent-session.d.ts +2 -0
  23. package/dist/types/session/codex-auto-reset.d.ts +8 -4
  24. package/dist/types/task/executor.d.ts +7 -0
  25. package/dist/types/task/types.d.ts +9 -0
  26. package/package.json +13 -14
  27. package/scripts/build-binary.ts +4 -0
  28. package/scripts/bundle-dist.ts +4 -0
  29. package/scripts/generate-share-viewer.ts +34 -0
  30. package/src/collab/crypto.ts +10 -4
  31. package/src/collab/guest.ts +31 -2
  32. package/src/collab/host.ts +73 -11
  33. package/src/collab/protocol.ts +48 -7
  34. package/src/commands/join.ts +1 -1
  35. package/src/config/settings-schema.ts +40 -4
  36. package/src/config/settings.ts +12 -0
  37. package/src/export/custom-share.ts +1 -1
  38. package/src/export/html/index.ts +122 -17
  39. package/src/export/html/share-loader.js +102 -0
  40. package/src/export/html/template.css +745 -459
  41. package/src/export/html/template.html +6 -3
  42. package/src/export/html/template.js +240 -915
  43. package/src/export/html/tool-views.generated.js +38 -0
  44. package/src/export/share.ts +268 -0
  45. package/src/internal-urls/docs-index.generated.ts +73 -73
  46. package/src/main.ts +22 -9
  47. package/src/modes/components/agent-hub.ts +541 -410
  48. package/src/modes/components/status-line/component.ts +38 -5
  49. package/src/modes/components/status-line/segments.ts +5 -1
  50. package/src/modes/components/status-line/types.ts +2 -0
  51. package/src/modes/components/tips.txt +3 -1
  52. package/src/modes/controllers/command-controller.ts +55 -96
  53. package/src/modes/controllers/event-controller.ts +45 -16
  54. package/src/modes/controllers/input-controller.ts +104 -4
  55. package/src/modes/controllers/selector-controller.ts +11 -15
  56. package/src/modes/controllers/session-focus-controller.ts +112 -0
  57. package/src/modes/interactive-mode.ts +44 -2
  58. package/src/modes/session-observer-registry.ts +11 -0
  59. package/src/modes/theme/theme.ts +6 -0
  60. package/src/modes/types.ts +12 -0
  61. package/src/modes/utils/ui-helpers.ts +16 -13
  62. package/src/prompts/tools/job.md +1 -1
  63. package/src/session/agent-session.ts +65 -7
  64. package/src/session/codex-auto-reset.ts +23 -11
  65. package/src/slash-commands/builtin-registry.ts +62 -35
  66. package/src/task/executor.ts +14 -0
  67. package/src/task/index.ts +5 -1
  68. package/src/task/render.ts +76 -5
  69. package/src/task/types.ts +9 -0
  70. package/src/tiny/worker.ts +17 -95
  71. package/src/tools/job.ts +6 -9
  72. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  73. package/dist/types/export/html/template.generated.d.ts +0 -1
  74. package/dist/types/export/html/template.macro.d.ts +0 -5
  75. package/dist/types/tiny/compiled-runtime.d.ts +0 -35
  76. package/scripts/generate-template.ts +0 -33
  77. package/src/bun-imports.d.ts +0 -28
  78. package/src/export/html/template.generated.ts +0 -2
  79. package/src/export/html/template.macro.ts +0 -25
  80. package/src/tiny/compiled-runtime.ts +0 -179
@@ -24,8 +24,8 @@
24
24
  * eligibility off exact limit ids (`openai-codex:primary` /
25
25
  * `openai-codex:secondary`) and `usedFraction`, never off `status`.
26
26
  *
27
- * ANTI-WASTE GATES (in evaluation order): the policy must be OFF unless opted
28
- * in; the active model must be Codex (not Spark — a Spark block lives on a
27
+ * ANTI-WASTE GATES (in evaluation order): the policy must not be set to "no";
28
+ * the active model must be Codex (not Spark — a Spark block lives on a
29
29
  * separate meter and it is unknown whether a credit even resets it); a fresh
30
30
  * usage report for the active account must confirm `limitReached`; the WEEKLY
31
31
  * (secondary) window must be genuinely exhausted — a 5h-only block self-heals
@@ -38,12 +38,14 @@
38
38
  * read-only views are passed in so the predicate itself stays deterministic.
39
39
  */
40
40
  import type { OAuthAccountIdentity, ResetCreditTarget, UsageReport } from "@oh-my-pi/pi-ai";
41
+ import type { CodexAutoRedeemMode } from "../config/settings-schema";
41
42
  import { reportMatchesActiveAccount } from "../slash-commands/helpers/active-oauth-account";
42
43
 
43
44
  /** Weekly window counts as exhausted at `usedFraction >= 0.999` (used_percent >= 99.9). */
44
45
  export const WEEKLY_EXHAUSTED_MIN_FRACTION = 0.999;
45
46
  /** A weekly reset can never be more than one window length (7d) away; +1h slack for skew. */
46
47
  export const MAX_PLAUSIBLE_REMAINING_MS = 7 * 24 * 3_600_000 + 60 * 60_000;
48
+
47
49
  /** Report must be no older than the 5-min usage cache TTL plus slack. */
48
50
  export const REPORT_FRESHNESS_MS = 10 * 60_000;
49
51
  /** Per-account cooldown that catches blockKey drift across a minute boundary. */
@@ -51,6 +53,14 @@ export const ATTEMPT_COOLDOWN_MS = 60_000;
51
53
  /** Minute bucket for blockKey, absorbing `reset_after_seconds`-derived jitter. */
52
54
  export const DEBOUNCE_BUCKET_MS = 60_000;
53
55
 
56
+ export function shouldEvaluateCodexAutoRedeem(mode: CodexAutoRedeemMode): boolean {
57
+ return mode !== "no";
58
+ }
59
+
60
+ export function shouldPromptCodexAutoRedeem(mode: CodexAutoRedeemMode): boolean {
61
+ return mode === "unset";
62
+ }
63
+
54
64
  export type CodexAutoRedeemSkipReason =
55
65
  | "disabled"
56
66
  | "wrong-provider"
@@ -83,16 +93,18 @@ export interface CodexAutoRedeemInput {
83
93
  lastAttemptAtByAccount: ReadonlyMap<string, number>;
84
94
  }
85
95
 
96
+ export interface CodexAutoRedeemRedeemDecision {
97
+ redeem: true;
98
+ target: ResetCreditTarget;
99
+ accountKey: string;
100
+ blockKey: string;
101
+ weeklyResetAtMs: number;
102
+ remainingMs: number;
103
+ availableCount: number;
104
+ }
105
+
86
106
  export type CodexAutoRedeemDecision =
87
- | {
88
- redeem: true;
89
- target: ResetCreditTarget;
90
- accountKey: string;
91
- blockKey: string;
92
- weeklyResetAtMs: number;
93
- remainingMs: number;
94
- availableCount: number;
95
- }
107
+ | CodexAutoRedeemRedeemDecision
96
108
  | { redeem: false; reason: CodexAutoRedeemSkipReason };
97
109
 
98
110
  /** Trimmed lowercase, or undefined when blank. Mirrors `normalizeIdentityValue` in active-oauth-account.ts. */
@@ -4,8 +4,7 @@ import * as path from "node:path";
4
4
  import { getOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
5
5
  import { setNextRequestDebugPath } from "@oh-my-pi/pi-ai/utils/request-debug";
6
6
  import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
7
- import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
8
- import { $ } from "bun";
7
+ import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils";
9
8
  import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
10
9
  import { CollabHost } from "../collab/host";
11
10
  import type { SettingPath, SettingValue } from "../config/settings";
@@ -15,6 +14,7 @@ import {
15
14
  resolveActiveProjectRegistryPath,
16
15
  resolveOrDefaultProjectRegistryPath,
17
16
  } from "../discovery/helpers.js";
17
+ import { shareSession } from "../export/share";
18
18
  import { PluginManager } from "../extensibility/plugins";
19
19
  import {
20
20
  getInstalledPluginsRegistryPath,
@@ -24,9 +24,11 @@ import {
24
24
  MarketplaceManager,
25
25
  } from "../extensibility/plugins/marketplace";
26
26
  import { resolveMemoryBackend } from "../memory-backend";
27
+ import { theme } from "../modes/theme/theme";
27
28
  import type { InteractiveModeContext } from "../modes/types";
28
29
  import type { AgentSession, FreshSessionResult } from "../session/agent-session";
29
30
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
31
+ import { urlHyperlinkAlways } from "../tui";
30
32
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
31
33
  import { buildContextReportText } from "./helpers/context-report";
32
34
  import { formatDuration } from "./helpers/format";
@@ -60,6 +62,30 @@ function refreshStatusLine(ctx: InteractiveModeContext): void {
60
62
  ctx.ui.requestRender();
61
63
  }
62
64
 
65
+ /** Scheme-less display form of a browser deep link: accent + underline, OSC-8 linked to the full URL. */
66
+ function collabWebLinkClickable(webLink: string): string {
67
+ const display = theme.fg("accent", `\x1b[4m${webLink.replace(/^https?:\/\//, "")}\x1b[24m`);
68
+ return urlHyperlinkAlways(webLink, display);
69
+ }
70
+
71
+ /** Join hint printed by /collab: compact terminal link + clickable browser deep link. */
72
+ function collabLinkHint(host: CollabHost, heading: string, view = false): string {
73
+ const bullet = theme.fg("accent", theme.format.bullet);
74
+ const link = view ? host.viewLink : host.link;
75
+ const webLink = view ? host.webViewLink : host.webLink;
76
+ return [
77
+ theme.fg("success", heading),
78
+ ` ${bullet} ${theme.fg("muted", view ? "Watch from another terminal:" : "Join from another terminal:")} ${APP_NAME} join "${link}"`,
79
+ ` ${bullet} ${theme.fg("muted", "or any web browser:")} ${collabWebLinkClickable(webLink)}`,
80
+ theme.fg(
81
+ "dim",
82
+ view
83
+ ? "Anyone with this link can watch the session but cannot prompt the agent."
84
+ : "Anyone with the link can read the session and prompt the agent. Read-only link: /collab view",
85
+ ),
86
+ ].join("\n");
87
+ }
88
+
63
89
  function formatFreshSessionResult(result: FreshSessionResult): string {
64
90
  const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states";
65
91
  return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`;
@@ -414,31 +440,21 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
414
440
  },
415
441
  {
416
442
  name: "share",
417
- description: "Share session as a secret GitHub gist",
443
+ description: "Share session via an encrypted link (secret gist or share server)",
418
444
  handle: async (_command, runtime) => {
419
- const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
420
445
  try {
421
- try {
422
- await runtime.session.exportToHtml(tmpFile);
423
- } catch (err) {
424
- return usage(`Failed to export session: ${errorMessage(err)}`, runtime);
425
- }
426
- const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow();
427
- if (result.exitCode !== 0) {
428
- return usage(
429
- `Failed to create gist: ${result.stderr.toString("utf-8").trim() || "unknown error"}`,
430
- runtime,
431
- );
432
- }
433
- const gistUrl = result.stdout.toString("utf-8").trim();
434
- const gistId = gistUrl.split("/").pop();
435
- if (!gistId) return usage("Failed to parse gist ID from gh output", runtime);
436
- await runtime.output(`Share URL: https://gistpreview.github.io/?${gistId}\nGist: ${gistUrl}`);
446
+ const result = await shareSession(runtime.sessionManager, {
447
+ serverUrl: runtime.settings.get("share.serverUrl"),
448
+ state: runtime.session.state,
449
+ obfuscator: runtime.settings.get("share.redactSecrets") ? runtime.session.obfuscator : undefined,
450
+ });
451
+ const lines = [`Share URL: ${result.url}`];
452
+ if (result.gistUrl) lines.push(`Gist: ${result.gistUrl}`);
453
+ if (result.truncated) lines.push("Note: large content was trimmed to fit the share size limit.");
454
+ await runtime.output(lines.join("\n"));
437
455
  return commandConsumed();
438
- } catch {
439
- return usage("GitHub CLI (gh) is required for /share. Install it from https://cli.github.com/.", runtime);
440
- } finally {
441
- await fs.rm(tmpFile, { force: true }).catch(() => {});
456
+ } catch (err) {
457
+ return usage(`Failed to share session: ${errorMessage(err)}`, runtime);
442
458
  }
443
459
  },
444
460
  handleTui: async (_command, runtime) => {
@@ -449,7 +465,12 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
449
465
  {
450
466
  name: "collab",
451
467
  description: "Share this session live via a relay",
452
- inlineHint: "[start|stop|status] [relayUrl]",
468
+ inlineHint: "[start|view|stop|status] [relayUrl]",
469
+ subcommands: [
470
+ { name: "view", description: "Share a read-only link (guests can watch, not prompt)" },
471
+ { name: "status", description: "Show link + participants" },
472
+ { name: "stop", description: "Stop sharing" },
473
+ ],
453
474
  allowArgs: true,
454
475
  handleTui: async (command, runtime) => {
455
476
  const ctx = runtime.ctx;
@@ -467,10 +488,16 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
467
488
  }
468
489
  if (first === "status") {
469
490
  if (ctx.collabHost) {
470
- const names = ctx.collabHost.participants.map(p => (p.role === "host" ? `${p.name} (host)` : p.name));
471
- ctx.showStatus(`Collab: ${names.join(", ")} ${ctx.collabHost.link}`);
491
+ const names = ctx.collabHost.participants.map(p =>
492
+ p.role === "host" ? `${p.name} (host)` : p.readOnly ? `${p.name} (view-only)` : p.name,
493
+ );
494
+ ctx.showStatus(`Collab: ${names.join(", ")} — ${collabWebLinkClickable(ctx.collabHost.webLink)}`);
472
495
  } else if (ctx.collabGuest) {
473
- ctx.showStatus("In a collab session as a guest (/leave to exit)");
496
+ ctx.showStatus(
497
+ ctx.collabGuest.readOnly
498
+ ? "In a collab session as a read-only guest (/leave to exit)"
499
+ : "In a collab session as a guest (/leave to exit)",
500
+ );
474
501
  } else {
475
502
  ctx.showStatus("Not in a collab session");
476
503
  }
@@ -480,11 +507,15 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
480
507
  ctx.showError("Already in a collab session as a guest (/leave first)");
481
508
  return;
482
509
  }
510
+ const view = first === "view";
483
511
  if (ctx.collabHost) {
484
- ctx.session.emitNotice("info", `Collab link: ${ctx.collabHost.link}`, "collab");
512
+ ctx.showStatus(
513
+ collabLinkHint(ctx.collabHost, view ? "Read-only collab link" : "Collab session active", view),
514
+ { dim: false },
515
+ );
485
516
  return;
486
517
  }
487
- const explicitUrl = first === "start" ? args.slice("start".length).trim() : args;
518
+ const explicitUrl = first === "start" || view ? args.slice(first.length).trim() : args;
488
519
  const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || "";
489
520
  if (!relayInput) {
490
521
  ctx.showError(
@@ -502,11 +533,7 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
502
533
  return;
503
534
  }
504
535
  ctx.collabHost = host;
505
- ctx.session.emitNotice(
506
- "info",
507
- `Collab link: ${host.link}\nAnyone with this link can read the session and prompt the agent.`,
508
- "collab",
509
- );
536
+ ctx.showStatus(collabLinkHint(host, "Collab session started!", view), { dim: false });
510
537
  },
511
538
  },
512
539
  {
@@ -195,6 +195,13 @@ export interface ExecutorOptions {
195
195
  index: number;
196
196
  id: string;
197
197
  parentToolCallId?: string;
198
+ /**
199
+ * Spawn runs as a detached background job (parent turn not blocked on it).
200
+ * Rides the subagent lifecycle/progress payloads so HUD-style surfaces can
201
+ * skip spawns the transcript already renders inline. See
202
+ * {@link SubagentLifecyclePayload.detached}.
203
+ */
204
+ detached?: boolean;
198
205
  modelOverride?: string | string[];
199
206
  /**
200
207
  * Active model selector of the parent session, used as an auth-aware fallback
@@ -656,6 +663,7 @@ interface RunMonitorArgs {
656
663
  onProgress?: (progress: AgentProgress) => void;
657
664
  eventBus?: EventBus;
658
665
  parentToolCallId?: string;
666
+ detached?: boolean;
659
667
  sessionFile?: string;
660
668
  /** Soft assistant-request budget; 0 disables the guard. */
661
669
  softRequestBudget: number;
@@ -836,6 +844,7 @@ function createSubagentRunMonitor(args: RunMonitorArgs): SubagentRunMonitor {
836
844
  agentSource: agent.source,
837
845
  task,
838
846
  parentToolCallId: args.parentToolCallId,
847
+ detached: args.detached,
839
848
  assignment,
840
849
  progress: { ...progress },
841
850
  sessionFile: args.sessionFile,
@@ -1413,6 +1422,7 @@ interface FinalizeRunArgs {
1413
1422
  artifactsDir?: string;
1414
1423
  eventBus?: EventBus;
1415
1424
  parentToolCallId?: string;
1425
+ detached?: boolean;
1416
1426
  sessionFile?: string;
1417
1427
  startTime: number;
1418
1428
  }
@@ -1511,6 +1521,7 @@ async function finalizeRunResult(args: FinalizeRunArgs): Promise<SingleResult> {
1511
1521
  id,
1512
1522
  agent: agent.name,
1513
1523
  parentToolCallId: args.parentToolCallId,
1524
+ detached: args.detached,
1514
1525
  agentSource: agent.source,
1515
1526
  description: args.description,
1516
1527
  status: progress.status as "completed" | "failed" | "aborted",
@@ -1677,6 +1688,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1677
1688
  onProgress,
1678
1689
  eventBus: options.eventBus,
1679
1690
  parentToolCallId: options.parentToolCallId,
1691
+ detached: options.detached,
1680
1692
  sessionFile: subtaskSessionFile,
1681
1693
  softRequestBudget,
1682
1694
  maxRuntimeMs,
@@ -1921,6 +1933,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
1921
1933
  id,
1922
1934
  agent: agent.name,
1923
1935
  parentToolCallId: options.parentToolCallId,
1936
+ detached: options.detached,
1924
1937
  agentSource: agent.source,
1925
1938
  description: options.description,
1926
1939
  status: "started",
@@ -2127,6 +2140,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
2127
2140
  artifactsDir: options.artifactsDir,
2128
2141
  eventBus: options.eventBus,
2129
2142
  parentToolCallId: options.parentToolCallId,
2143
+ detached: options.detached,
2130
2144
  sessionFile: subtaskSessionFile,
2131
2145
  startTime,
2132
2146
  });
package/src/task/index.ts CHANGED
@@ -705,6 +705,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
705
705
  undefined,
706
706
  agentId,
707
707
  progress.index,
708
+ true,
708
709
  );
709
710
  const finalText = result.content.find(part => part.type === "text")?.text ?? "(no output)";
710
711
  const singleResult = result.details?.results[0];
@@ -897,8 +898,9 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
897
898
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
898
899
  preAllocatedId?: string,
899
900
  spawnIndex = 0,
901
+ detached = false,
900
902
  ): Promise<AgentToolResult<TaskToolDetails>> {
901
- return this.#runSpawn(toolCallId, params, signal, onUpdate, preAllocatedId, spawnIndex);
903
+ return this.#runSpawn(toolCallId, params, signal, onUpdate, preAllocatedId, spawnIndex, detached);
902
904
  }
903
905
 
904
906
  /** Spawn a fresh subagent and run it to completion. */
@@ -909,6 +911,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
909
911
  onUpdate?: AgentToolUpdateCallback<TaskToolDetails>,
910
912
  preAllocatedId?: string,
911
913
  spawnIndex = 0,
914
+ detached = false,
912
915
  ): Promise<AgentToolResult<TaskToolDetails>> {
913
916
  const startTime = Date.now();
914
917
  const { agents, projectAgentsDir } = await discoverAgents(this.session.cwd);
@@ -1145,6 +1148,7 @@ export class TaskTool implements AgentTool<TaskToolSchemaInstance, TaskToolDetai
1145
1148
  description: params.description,
1146
1149
  index: spawnIndex,
1147
1150
  parentToolCallId: toolCallId,
1151
+ detached,
1148
1152
  id: agentId,
1149
1153
  taskDepth,
1150
1154
  modelOverride,
@@ -15,6 +15,7 @@ import { getMarkdownTheme, type Theme } from "../modes/theme/theme";
15
15
  import {
16
16
  formatBadge,
17
17
  formatDuration,
18
+ formatExpandHint,
18
19
  formatMoreItems,
19
20
  formatStatusIcon,
20
21
  replaceTabs,
@@ -542,6 +543,12 @@ function renderTaskCallLines(args: Partial<TaskParams> | undefined, theme: Theme
542
543
  return lines;
543
544
  }
544
545
 
546
+ /**
547
+ * Agent rows shown per collapsed task list; the rest fold into a single
548
+ * `… N more agents` summary line (expand uncaps).
549
+ */
550
+ const COLLAPSED_AGENT_LIMIT = 4;
551
+
545
552
  /**
546
553
  * Render the per-item list (`id` + ui `description`) for a batch call's
547
554
  * streaming preview. The args stream in token by token, so the array grows
@@ -552,7 +559,7 @@ function renderTaskItemLines(tasks: TaskItem[] | undefined, theme: Theme): strin
552
559
  if (!Array.isArray(tasks) || tasks.length === 0) return [];
553
560
 
554
561
  const bullet = theme.fg("dim", "•");
555
- const cap = Math.min(tasks.length, 12);
562
+ const cap = Math.min(tasks.length, COLLAPSED_AGENT_LIMIT);
556
563
  const lines: string[] = [];
557
564
  for (let i = 0; i < cap; i++) {
558
565
  const task = tasks[i] as Partial<TaskItem> | undefined;
@@ -1170,6 +1177,53 @@ function orderResultsForDisplay(results: readonly SingleResult[]): SingleResult[
1170
1177
  return [...results].sort((a, b) => a.durationMs - b.durationMs || a.index - b.index);
1171
1178
  }
1172
1179
 
1180
+ /**
1181
+ * Summary line for progress rows folded away by the collapsed cap: per-status
1182
+ * counts plus the expand hint, e.g. `… 21 more agents (18 pending · 3 done)`.
1183
+ */
1184
+ function formatHiddenProgressLine(hidden: readonly AgentProgress[], theme: Theme): string {
1185
+ const counts: Record<AgentProgress["status"], number> = {
1186
+ pending: 0,
1187
+ running: 0,
1188
+ completed: 0,
1189
+ failed: 0,
1190
+ aborted: 0,
1191
+ };
1192
+ for (const p of hidden) counts[p.status]++;
1193
+ const parts: string[] = [];
1194
+ if (counts.completed > 0) parts.push(theme.fg("dim", `${counts.completed} done`));
1195
+ if (counts.running > 0) parts.push(theme.fg("dim", `${counts.running} running`));
1196
+ if (counts.pending > 0) parts.push(theme.fg("dim", `${counts.pending} pending`));
1197
+ if (counts.failed > 0) parts.push(theme.fg("error", `${counts.failed} failed`));
1198
+ if (counts.aborted > 0) parts.push(theme.fg("error", `${counts.aborted} aborted`));
1199
+ const breakdown =
1200
+ parts.length > 0
1201
+ ? `${theme.fg("dim", " (")}${parts.join(theme.fg("dim", theme.sep.dot))}${theme.fg("dim", ")")}`
1202
+ : "";
1203
+ const hint = formatExpandHint(theme, false, true);
1204
+ return `${theme.fg("dim", formatMoreItems(hidden.length, "agent"))}${breakdown}${hint ? ` ${hint}` : ""}`;
1205
+ }
1206
+
1207
+ /**
1208
+ * Pick the agent rows that stay visible when a finalized batch is collapsed:
1209
+ * problem rows (aborted/failed/merge-failed) claim slots first so they are
1210
+ * never folded away, then fastest finishers fill the remainder. The pick is
1211
+ * filtered out of the display order, so visible rows keep the expanded layout.
1212
+ */
1213
+ function selectCollapsedResults(ordered: readonly SingleResult[]): readonly SingleResult[] {
1214
+ if (ordered.length <= COLLAPSED_AGENT_LIMIT) return ordered;
1215
+ const picked = new Set<SingleResult>();
1216
+ for (const result of ordered) {
1217
+ if (picked.size >= COLLAPSED_AGENT_LIMIT) break;
1218
+ if (result.aborted || result.exitCode !== 0 || result.error) picked.add(result);
1219
+ }
1220
+ for (const result of ordered) {
1221
+ if (picked.size >= COLLAPSED_AGENT_LIMIT) break;
1222
+ picked.add(result);
1223
+ }
1224
+ return ordered.filter(result => picked.has(result));
1225
+ }
1226
+
1173
1227
  /**
1174
1228
  * Render the tool result.
1175
1229
  */
@@ -1248,13 +1302,30 @@ export function renderResult(
1248
1302
  const shouldRenderProgress =
1249
1303
  Boolean(details.progress && details.progress.length > 0) && (isPartial || details.results.length === 0);
1250
1304
  if (shouldRenderProgress && details.progress) {
1251
- orderProgressForDisplay(details.progress).forEach(progress => {
1305
+ const ordered = orderProgressForDisplay(details.progress);
1306
+ // Collapsed view keeps the live edge: finished rows sort to the top of
1307
+ // the display order, so folding from the top keeps running/pending
1308
+ // agents (and their current-tool lines) visible while one summary line
1309
+ // stands in for everything above it.
1310
+ const visible = expanded ? ordered : ordered.slice(Math.max(0, ordered.length - COLLAPSED_AGENT_LIMIT));
1311
+ if (visible.length < ordered.length) {
1312
+ lines.push(formatHiddenProgressLine(ordered.slice(0, ordered.length - visible.length), theme));
1313
+ }
1314
+ for (const progress of visible) {
1252
1315
  lines.push(...renderAgentProgress(progress, "", " ", expanded, theme, spinnerFrame, frozen));
1253
- });
1316
+ }
1254
1317
  } else if (details.results && details.results.length > 0) {
1255
- orderResultsForDisplay(details.results).forEach(res => {
1318
+ const ordered = orderResultsForDisplay(details.results);
1319
+ const visible = expanded ? ordered : selectCollapsedResults(ordered);
1320
+ for (const res of visible) {
1256
1321
  lines.push(...renderAgentResult(res, "", " ", expanded, theme));
1257
- });
1322
+ }
1323
+ if (visible.length < ordered.length) {
1324
+ const hint = formatExpandHint(theme, false, true);
1325
+ lines.push(
1326
+ `${theme.fg("dim", formatMoreItems(ordered.length - visible.length, "agent"))}${hint ? ` ${hint}` : ""}`,
1327
+ );
1328
+ }
1258
1329
 
1259
1330
  const abortedCount = details.results.filter(r => r.aborted).length;
1260
1331
  const mergeFailedCount = details.results.filter(r => !r.aborted && r.exitCode === 0 && r.error).length;
package/src/task/types.ts CHANGED
@@ -45,6 +45,8 @@ export interface SubagentProgressPayload {
45
45
  assignment?: string;
46
46
  progress: AgentProgress;
47
47
  sessionFile?: string;
48
+ /** See {@link SubagentLifecyclePayload.detached}. */
49
+ detached?: boolean;
48
50
  }
49
51
 
50
52
  /** Payload emitted on TASK_SUBAGENT_EVENT_CHANNEL */
@@ -63,6 +65,13 @@ export interface SubagentLifecyclePayload {
63
65
  sessionFile?: string;
64
66
  parentToolCallId?: string;
65
67
  index: number;
68
+ /**
69
+ * Spawn runs as a detached background job: the parent turn keeps working
70
+ * while this agent runs. Sync task spawns (parent blocked on the call) and
71
+ * eval `agent()` bridge spawns (rendered inside their eval cell) leave this
72
+ * unset — surfaces like the subagent HUD only list detached spawns.
73
+ */
74
+ detached?: boolean;
66
75
  }
67
76
 
68
77
  /**
@@ -1,4 +1,3 @@
1
- import * as fs from "node:fs/promises";
2
1
  import { createRequire } from "node:module";
3
2
  import * as path from "node:path";
4
3
  import type {
@@ -7,10 +6,16 @@ import type {
7
6
  TextGenerationStringOutput,
8
7
  StoppingCriteria as TransformersStoppingCriteria,
9
8
  } from "@huggingface/transformers";
10
- import { getTinyModelsCacheDir, isCompiledBinary, prompt } from "@oh-my-pi/pi-utils";
9
+ import {
10
+ ensureRuntimeInstalled,
11
+ getTinyModelsCacheDir,
12
+ installRuntimeModuleResolver,
13
+ isCompiledBinary,
14
+ prompt,
15
+ resolveRuntimeModule,
16
+ } from "@oh-my-pi/pi-utils";
11
17
  import packageJson from "../../package.json" with { type: "json" };
12
18
  import tinyTitleSystemPrompt from "../prompts/system/tiny-title-system.md" with { type: "text" };
13
- import { installRuntimeModuleResolver, resolveRuntimeModule } from "./compiled-runtime";
14
19
  import { resolveTinyModelDevicePreference, type TinyModelDevice, tinyModelDeviceLoadOrder } from "./device";
15
20
  import { resolveTinyModelDtypeOverride, type TinyModelDtype } from "./dtype";
16
21
  import {
@@ -31,8 +36,6 @@ const TINY_TITLE_SYSTEM_PROMPT = prompt.render(tinyTitleSystemPrompt);
31
36
  const TRANSFORMERS_PACKAGE = "@huggingface/transformers";
32
37
  const COMPILED_TRANSFORMERS_VERSION = process.env.PI_TINY_TRANSFORMERS_VERSION;
33
38
  const sourceRequire = createRequire(import.meta.url);
34
- const INSTALL_LOCK_ATTEMPTS = 240;
35
- const INSTALL_LOCK_SLEEP_MS = 250;
36
39
 
37
40
  const tinyModelDevicePreference = resolveTinyModelDevicePreference();
38
41
  const tinyModelDtypeOverride = resolveTinyModelDtypeOverride();
@@ -97,10 +100,6 @@ function errorText(error: unknown): string {
97
100
  return error instanceof Error ? (error.stack ?? error.message) : String(error);
98
101
  }
99
102
 
100
- function isErrnoCode(error: unknown, code: string): boolean {
101
- return typeof error === "object" && error !== null && "code" in error && error.code === code;
102
- }
103
-
104
103
  function sendLog(
105
104
  transport: TinyTitleTransport,
106
105
  level: "debug" | "warn" | "error",
@@ -118,69 +117,6 @@ function getTinyTitleRuntimeDir(): string {
118
117
  );
119
118
  }
120
119
 
121
- async function acquireInstallLock(runtimeDir: string): Promise<() => Promise<void>> {
122
- const lockDir = `${runtimeDir}.lock`;
123
- await fs.mkdir(path.dirname(lockDir), { recursive: true });
124
- for (let attempt = 0; attempt < INSTALL_LOCK_ATTEMPTS; attempt++) {
125
- try {
126
- await fs.mkdir(lockDir);
127
- return async () => {
128
- await fs.rm(lockDir, { recursive: true, force: true });
129
- };
130
- } catch (error) {
131
- if (!isErrnoCode(error, "EEXIST")) throw error;
132
- await Bun.sleep(INSTALL_LOCK_SLEEP_MS);
133
- }
134
- }
135
- throw new Error(`Timed out waiting for tiny title runtime install lock: ${lockDir}`);
136
- }
137
-
138
- async function isCompiledRuntimeInstalled(runtimeDir: string): Promise<boolean> {
139
- return Bun.file(path.join(runtimeDir, "node_modules", "@huggingface", "transformers", "package.json")).exists();
140
- }
141
-
142
- async function writeRuntimeManifest(runtimeDir: string): Promise<void> {
143
- await fs.mkdir(runtimeDir, { recursive: true });
144
- await Bun.write(
145
- path.join(runtimeDir, "package.json"),
146
- `${JSON.stringify(
147
- {
148
- private: true,
149
- type: "module",
150
- dependencies: {
151
- [TRANSFORMERS_PACKAGE]: getTransformersVersionSpec(),
152
- },
153
- trustedDependencies: ["onnxruntime-node"],
154
- },
155
- null,
156
- "\t",
157
- )}\n`,
158
- );
159
- }
160
-
161
- async function readPipe(stream: ReadableStream<Uint8Array> | null): Promise<string> {
162
- if (!stream) return "";
163
- return new Response(stream).text();
164
- }
165
-
166
- async function runRuntimeInstall(runtimeDir: string): Promise<void> {
167
- const proc = Bun.spawn([process.execPath, "install", "--cwd", runtimeDir, "--production"], {
168
- env: { ...Bun.env, BUN_BE_BUN: "1" },
169
- stdout: "pipe",
170
- stderr: "pipe",
171
- });
172
- const [stdout, stderr, exitCode] = await Promise.all([
173
- readPipe(proc.stdout as ReadableStream<Uint8Array> | null),
174
- readPipe(proc.stderr as ReadableStream<Uint8Array> | null),
175
- proc.exited,
176
- ]);
177
- if (exitCode === 0) return;
178
- const output = `${stdout}\n${stderr}`.trim();
179
- throw new Error(
180
- `Failed to install tiny title runtime with ${process.execPath} install (exit ${exitCode}): ${output}`,
181
- );
182
- }
183
-
184
120
  function sendRuntimeInstallProgress(
185
121
  transport: TinyTitleTransport,
186
122
  requestId: string,
@@ -198,28 +134,6 @@ function sendRuntimeInstallProgress(
198
134
  });
199
135
  }
200
136
 
201
- async function ensureCompiledTransformersRuntime(
202
- transport: TinyTitleTransport,
203
- requestId: string,
204
- modelKey: TinyLocalModelKey,
205
- ): Promise<string> {
206
- const runtimeDir = getTinyTitleRuntimeDir();
207
- if (await isCompiledRuntimeInstalled(runtimeDir)) return runtimeDir;
208
-
209
- sendRuntimeInstallProgress(transport, requestId, modelKey, "initiate");
210
- const releaseLock = await acquireInstallLock(runtimeDir);
211
- try {
212
- if (await isCompiledRuntimeInstalled(runtimeDir)) return runtimeDir;
213
- await writeRuntimeManifest(runtimeDir);
214
- sendRuntimeInstallProgress(transport, requestId, modelKey, "download");
215
- await runRuntimeInstall(runtimeDir);
216
- sendRuntimeInstallProgress(transport, requestId, modelKey, "done");
217
- return runtimeDir;
218
- } finally {
219
- await releaseLock();
220
- }
221
- }
222
-
223
137
  /**
224
138
  * Prepare the freshly-installed compiled runtime for loading: stub `sharp`
225
139
  * (the tiny models are text-generation only, so the native image pipeline is
@@ -252,7 +166,15 @@ async function loadTransformers(
252
166
  if (transformersRuntime) return transformersRuntime;
253
167
  transformersRuntime = (async () => {
254
168
  if (!isCompiledBinary()) return configureTransformers(sourceRequire(TRANSFORMERS_PACKAGE) as TransformersRuntime);
255
- const runtimeDir = await ensureCompiledTransformersRuntime(transport, requestId, modelKey);
169
+ const runtimeDir = await ensureRuntimeInstalled({
170
+ runtimeDir: getTinyTitleRuntimeDir(),
171
+ install: {
172
+ dependencies: { [TRANSFORMERS_PACKAGE]: getTransformersVersionSpec() },
173
+ trustedDependencies: ["onnxruntime-node"],
174
+ },
175
+ probePackage: TRANSFORMERS_PACKAGE,
176
+ onPhase: phase => sendRuntimeInstallProgress(transport, requestId, modelKey, phase),
177
+ });
256
178
  const entry = await prepareCompiledRuntime(runtimeDir);
257
179
  const require_ = createRequire(entry);
258
180
  return configureTransformers(require_(entry) as TransformersRuntime);
package/src/tools/job.ts CHANGED
@@ -24,7 +24,7 @@ import {
24
24
  import { ToolError } from "./tool-errors";
25
25
 
26
26
  const jobSchema = z.object({
27
- poll: z.array(z.string()).optional().describe("job ids to wait for"),
27
+ poll: z.array(z.string()).optional().describe("job ids to wait for; omit to wait on all running jobs"),
28
28
  cancel: z.array(z.string()).optional().describe("job ids to cancel"),
29
29
  list: z.boolean().optional().describe("snapshot all jobs"),
30
30
  });
@@ -512,14 +512,11 @@ export const jobToolRenderer = {
512
512
  itemType: "job",
513
513
  renderItem: job => {
514
514
  const lines: string[] = [];
515
- const icon =
516
- job.status === "completed"
517
- ? uiTheme.styledSymbol("tool.job", "accent")
518
- : formatStatusIcon(
519
- statusToIcon(job.status),
520
- uiTheme,
521
- job.status === "running" ? options.spinnerFrame : undefined,
522
- );
515
+ const icon = formatStatusIcon(
516
+ statusToIcon(job.status),
517
+ uiTheme,
518
+ job.status === "running" ? options.spinnerFrame : undefined,
519
+ );
523
520
  const typeBadge = formatBadge(job.type, statusToColor(job.status), uiTheme);
524
521
  // Task jobs label themselves with their agent id, which is also
525
522
  // the job id — drop the id column instead of stuttering it twice.