@oh-my-pi/pi-coding-agent 15.11.7 → 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 (107) hide show
  1. package/CHANGELOG.md +63 -1
  2. package/dist/cli.js +8106 -7708
  3. package/dist/types/cli/args.d.ts +2 -0
  4. package/dist/types/collab/crypto.d.ts +7 -0
  5. package/dist/types/collab/guest.d.ts +23 -0
  6. package/dist/types/collab/host.d.ts +29 -0
  7. package/dist/types/collab/protocol.d.ts +113 -0
  8. package/dist/types/collab/relay-client.d.ts +22 -0
  9. package/dist/types/commands/join.d.ts +12 -0
  10. package/dist/types/config/settings-schema.d.ts +60 -5
  11. package/dist/types/export/custom-share.d.ts +1 -2
  12. package/dist/types/export/html/index.d.ts +39 -1
  13. package/dist/types/export/share.d.ts +43 -0
  14. package/dist/types/extensibility/slash-commands.d.ts +1 -11
  15. package/dist/types/main.d.ts +2 -0
  16. package/dist/types/modes/components/agent-hub.d.ts +32 -1
  17. package/dist/types/modes/components/collab-prompt-message.d.ts +10 -0
  18. package/dist/types/modes/components/hook-selector.d.ts +4 -6
  19. package/dist/types/modes/components/segment-track.d.ts +11 -6
  20. package/dist/types/modes/components/status-line/component.d.ts +10 -2
  21. package/dist/types/modes/components/status-line/types.d.ts +11 -0
  22. package/dist/types/modes/controllers/event-controller.d.ts +7 -0
  23. package/dist/types/modes/controllers/input-controller.d.ts +1 -1
  24. package/dist/types/modes/controllers/session-focus-controller.d.ts +31 -0
  25. package/dist/types/modes/interactive-mode.d.ts +16 -0
  26. package/dist/types/modes/session-observer-registry.d.ts +7 -0
  27. package/dist/types/modes/theme/theme.d.ts +2 -1
  28. package/dist/types/modes/types.d.ts +20 -0
  29. package/dist/types/session/agent-session.d.ts +13 -0
  30. package/dist/types/session/codex-auto-reset.d.ts +8 -4
  31. package/dist/types/session/session-manager.d.ts +21 -0
  32. package/dist/types/session/snapcompact-inline.d.ts +6 -3
  33. package/dist/types/slash-commands/builtin-registry.d.ts +9 -0
  34. package/dist/types/task/executor.d.ts +7 -0
  35. package/dist/types/task/types.d.ts +9 -0
  36. package/package.json +14 -13
  37. package/scripts/bench-guard.ts +71 -0
  38. package/scripts/build-binary.ts +4 -0
  39. package/scripts/bundle-dist.ts +4 -0
  40. package/scripts/generate-share-viewer.ts +34 -0
  41. package/src/cli/args.ts +2 -0
  42. package/src/cli-commands.ts +1 -0
  43. package/src/collab/crypto.ts +63 -0
  44. package/src/collab/guest.ts +450 -0
  45. package/src/collab/host.ts +556 -0
  46. package/src/collab/protocol.ts +232 -0
  47. package/src/collab/relay-client.ts +216 -0
  48. package/src/commands/join.ts +39 -0
  49. package/src/config/model-registry.ts +22 -14
  50. package/src/config/settings-schema.ts +67 -5
  51. package/src/config/settings.ts +12 -0
  52. package/src/export/custom-share.ts +1 -1
  53. package/src/export/html/index.ts +122 -17
  54. package/src/export/html/share-loader.js +102 -0
  55. package/src/export/html/template.css +745 -459
  56. package/src/export/html/template.html +6 -3
  57. package/src/export/html/template.js +240 -915
  58. package/src/export/html/tool-views.generated.js +38 -0
  59. package/src/export/share.ts +268 -0
  60. package/src/extensibility/slash-commands.ts +1 -97
  61. package/src/internal-urls/docs-index.generated.ts +74 -73
  62. package/src/main.ts +33 -11
  63. package/src/modes/components/agent-hub.ts +659 -431
  64. package/src/modes/components/assistant-message.ts +126 -6
  65. package/src/modes/components/collab-prompt-message.ts +30 -0
  66. package/src/modes/components/hook-selector.ts +4 -5
  67. package/src/modes/components/segment-track.ts +44 -7
  68. package/src/modes/components/status-line/component.ts +59 -6
  69. package/src/modes/components/status-line/presets.ts +1 -1
  70. package/src/modes/components/status-line/segments.ts +18 -1
  71. package/src/modes/components/status-line/types.ts +12 -0
  72. package/src/modes/components/tips.txt +4 -1
  73. package/src/modes/controllers/command-controller.ts +55 -96
  74. package/src/modes/controllers/event-controller.ts +45 -16
  75. package/src/modes/controllers/input-controller.ts +175 -9
  76. package/src/modes/controllers/selector-controller.ts +13 -15
  77. package/src/modes/controllers/session-focus-controller.ts +112 -0
  78. package/src/modes/controllers/streaming-reveal.ts +7 -0
  79. package/src/modes/interactive-mode.ts +56 -6
  80. package/src/modes/session-observer-registry.ts +11 -0
  81. package/src/modes/theme/theme.ts +6 -0
  82. package/src/modes/types.ts +20 -0
  83. package/src/modes/utils/ui-helpers.ts +23 -13
  84. package/src/prompts/tools/job.md +1 -1
  85. package/src/sdk.ts +239 -36
  86. package/src/session/agent-session.ts +82 -7
  87. package/src/session/codex-auto-reset.ts +23 -11
  88. package/src/session/session-manager.ts +44 -0
  89. package/src/session/snapcompact-inline.ts +9 -3
  90. package/src/slash-commands/builtin-registry.ts +261 -24
  91. package/src/task/executor.ts +14 -0
  92. package/src/task/index.ts +5 -1
  93. package/src/task/render.ts +76 -5
  94. package/src/task/types.ts +9 -0
  95. package/src/tiny/worker.ts +17 -95
  96. package/src/tools/job.ts +6 -9
  97. package/src/tools/read.ts +38 -5
  98. package/src/tools/write.ts +13 -42
  99. package/dist/tokenizers.linux-x64-gnu-xcjh3jwk.node +0 -0
  100. package/dist/types/export/html/template.generated.d.ts +0 -1
  101. package/dist/types/export/html/template.macro.d.ts +0 -5
  102. package/dist/types/tiny/compiled-runtime.d.ts +0 -35
  103. package/scripts/generate-template.ts +0 -33
  104. package/src/bun-imports.d.ts +0 -28
  105. package/src/export/html/template.generated.ts +0 -2
  106. package/src/export/html/template.macro.ts +0 -25
  107. package/src/tiny/compiled-runtime.ts +0 -179
@@ -3,8 +3,10 @@ import * as os from "node:os";
3
3
  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
- import { Snowflake, setProjectDir } from "@oh-my-pi/pi-utils";
7
- import { $ } from "bun";
6
+ import type { AutocompleteItem } from "@oh-my-pi/pi-tui";
7
+ import { APP_NAME, setProjectDir } from "@oh-my-pi/pi-utils";
8
+ import { COLLAB_GUEST_ALLOWED_COMMANDS, CollabGuestLink } from "../collab/guest";
9
+ import { CollabHost } from "../collab/host";
8
10
  import type { SettingPath, SettingValue } from "../config/settings";
9
11
  import { settings } from "../config/settings";
10
12
  import {
@@ -12,6 +14,7 @@ import {
12
14
  resolveActiveProjectRegistryPath,
13
15
  resolveOrDefaultProjectRegistryPath,
14
16
  } from "../discovery/helpers.js";
17
+ import { shareSession } from "../export/share";
15
18
  import { PluginManager } from "../extensibility/plugins";
16
19
  import {
17
20
  getInstalledPluginsRegistryPath,
@@ -21,9 +24,11 @@ import {
21
24
  MarketplaceManager,
22
25
  } from "../extensibility/plugins/marketplace";
23
26
  import { resolveMemoryBackend } from "../memory-backend";
27
+ import { theme } from "../modes/theme/theme";
24
28
  import type { InteractiveModeContext } from "../modes/types";
25
29
  import type { AgentSession, FreshSessionResult } from "../session/agent-session";
26
30
  import { formatShakeSummary, type ShakeMode } from "../session/shake-types";
31
+ import { urlHyperlinkAlways } from "../tui";
27
32
  import { getChangelogPath, parseChangelog } from "../utils/changelog";
28
33
  import { buildContextReportText } from "./helpers/context-report";
29
34
  import { formatDuration } from "./helpers/format";
@@ -42,6 +47,7 @@ import type {
42
47
  SlashCommandResult,
43
48
  SlashCommandRuntime,
44
49
  SlashCommandSpec,
50
+ SubcommandDef,
45
51
  TuiSlashCommandRuntime,
46
52
  } from "./types";
47
53
 
@@ -56,6 +62,30 @@ function refreshStatusLine(ctx: InteractiveModeContext): void {
56
62
  ctx.ui.requestRender();
57
63
  }
58
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
+
59
89
  function formatFreshSessionResult(result: FreshSessionResult): string {
60
90
  const stateLabel = result.closedProviderSessions === 1 ? "provider state" : "provider states";
61
91
  return `Fresh provider session started (${result.closedProviderSessions} ${stateLabel} pruned).`;
@@ -410,31 +440,21 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
410
440
  },
411
441
  {
412
442
  name: "share",
413
- description: "Share session as a secret GitHub gist",
443
+ description: "Share session via an encrypted link (secret gist or share server)",
414
444
  handle: async (_command, runtime) => {
415
- const tmpFile = path.join(os.tmpdir(), `${Snowflake.next()}.html`);
416
445
  try {
417
- try {
418
- await runtime.session.exportToHtml(tmpFile);
419
- } catch (err) {
420
- return usage(`Failed to export session: ${errorMessage(err)}`, runtime);
421
- }
422
- const result = await $`gh gist create --public=false ${tmpFile}`.quiet().nothrow();
423
- if (result.exitCode !== 0) {
424
- return usage(
425
- `Failed to create gist: ${result.stderr.toString("utf-8").trim() || "unknown error"}`,
426
- runtime,
427
- );
428
- }
429
- const gistUrl = result.stdout.toString("utf-8").trim();
430
- const gistId = gistUrl.split("/").pop();
431
- if (!gistId) return usage("Failed to parse gist ID from gh output", runtime);
432
- 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"));
433
455
  return commandConsumed();
434
- } catch {
435
- return usage("GitHub CLI (gh) is required for /share. Install it from https://cli.github.com/.", runtime);
436
- } finally {
437
- await fs.rm(tmpFile, { force: true }).catch(() => {});
456
+ } catch (err) {
457
+ return usage(`Failed to share session: ${errorMessage(err)}`, runtime);
438
458
  }
439
459
  },
440
460
  handleTui: async (_command, runtime) => {
@@ -442,6 +462,126 @@ const BUILTIN_SLASH_COMMAND_REGISTRY: ReadonlyArray<SlashCommandSpec> = [
442
462
  runtime.ctx.editor.setText("");
443
463
  },
444
464
  },
465
+ {
466
+ name: "collab",
467
+ description: "Share this session live via a relay",
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
+ ],
474
+ allowArgs: true,
475
+ handleTui: async (command, runtime) => {
476
+ const ctx = runtime.ctx;
477
+ ctx.editor.setText("");
478
+ const args = command.args.trim();
479
+ const [first = ""] = args.split(/\s+/, 1);
480
+ if (first === "stop") {
481
+ if (!ctx.collabHost) {
482
+ ctx.showStatus("Not hosting a collab session");
483
+ return;
484
+ }
485
+ await ctx.collabHost.stop("host stopped");
486
+ ctx.showStatus("Collab stopped");
487
+ return;
488
+ }
489
+ if (first === "status") {
490
+ if (ctx.collabHost) {
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)}`);
495
+ } else if (ctx.collabGuest) {
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
+ );
501
+ } else {
502
+ ctx.showStatus("Not in a collab session");
503
+ }
504
+ return;
505
+ }
506
+ if (ctx.collabGuest) {
507
+ ctx.showError("Already in a collab session as a guest (/leave first)");
508
+ return;
509
+ }
510
+ const view = first === "view";
511
+ if (ctx.collabHost) {
512
+ ctx.showStatus(
513
+ collabLinkHint(ctx.collabHost, view ? "Read-only collab link" : "Collab session active", view),
514
+ { dim: false },
515
+ );
516
+ return;
517
+ }
518
+ const explicitUrl = first === "start" || view ? args.slice(first.length).trim() : args;
519
+ const relayInput = explicitUrl || ctx.settings.get("collab.relayUrl") || "";
520
+ if (!relayInput) {
521
+ ctx.showError(
522
+ "No relay configured. Set collab.relayUrl in /settings or pass one: /collab relay.example.com",
523
+ );
524
+ return;
525
+ }
526
+ // Scheme-less relay args default to wss (ws:// must be spelled out for localhost).
527
+ const relayUrl = relayInput.includes("://") ? relayInput : `wss://${relayInput}`;
528
+ const host = new CollabHost(ctx);
529
+ try {
530
+ await host.start(relayUrl);
531
+ } catch (err) {
532
+ ctx.showError(`Failed to start collab session: ${errorMessage(err)}`);
533
+ return;
534
+ }
535
+ ctx.collabHost = host;
536
+ ctx.showStatus(collabLinkHint(host, "Collab session started!", view), { dim: false });
537
+ },
538
+ },
539
+ {
540
+ name: "join",
541
+ description: "Join a shared collab session",
542
+ inlineHint: "<link>",
543
+ allowArgs: true,
544
+ handleTui: async (command, runtime) => {
545
+ const ctx = runtime.ctx;
546
+ ctx.editor.setText("");
547
+ const link = command.args.trim();
548
+ if (!link) {
549
+ ctx.showError("Usage: /join <link>");
550
+ return;
551
+ }
552
+ if (ctx.collabHost) {
553
+ ctx.showError("Stop hosting first (/collab stop)");
554
+ return;
555
+ }
556
+ if (ctx.collabGuest) {
557
+ ctx.showError("Already in a collab session (/leave first)");
558
+ return;
559
+ }
560
+ try {
561
+ await new CollabGuestLink(ctx).join(link);
562
+ } catch (err) {
563
+ ctx.showError(`Failed to join collab session: ${errorMessage(err)}`);
564
+ }
565
+ },
566
+ },
567
+ {
568
+ name: "leave",
569
+ description: "Leave the collab session",
570
+ handleTui: async (_command, runtime) => {
571
+ const ctx = runtime.ctx;
572
+ ctx.editor.setText("");
573
+ if (ctx.collabGuest) {
574
+ await ctx.collabGuest.leave("left");
575
+ return;
576
+ }
577
+ if (ctx.collabHost) {
578
+ await ctx.collabHost.stop("host stopped");
579
+ ctx.showStatus("Collab stopped");
580
+ return;
581
+ }
582
+ ctx.showStatus("Not in a collab session");
583
+ },
584
+ },
445
585
  {
446
586
  name: "browser",
447
587
  description: "Toggle browser headless vs visible mode",
@@ -1853,6 +1993,70 @@ for (const command of BUILTIN_SLASH_COMMAND_REGISTRY) {
1853
1993
 
1854
1994
  export const BUILTIN_SLASH_COMMAND_RESERVED_NAMES: ReadonlySet<string> = new Set(BUILTIN_SLASH_COMMAND_LOOKUP.keys());
1855
1995
 
1996
+ /**
1997
+ * Build getArgumentCompletions from declarative subcommand definitions.
1998
+ * Returns subcommand names filtered by prefix in the dropdown.
1999
+ */
2000
+ function buildArgumentCompletions(subcommands: SubcommandDef[]): (prefix: string) => AutocompleteItem[] | null {
2001
+ return (argumentPrefix: string) => {
2002
+ if (argumentPrefix.includes(" ")) return null; // past the subcommand
2003
+ const lower = argumentPrefix.toLowerCase();
2004
+ const matches = subcommands
2005
+ .filter(s => s.name.startsWith(lower))
2006
+ .map(s => ({
2007
+ value: `${s.name} `,
2008
+ label: s.name,
2009
+ description: s.description,
2010
+ hint: s.usage,
2011
+ }));
2012
+ return matches.length > 0 ? matches : null;
2013
+ };
2014
+ }
2015
+
2016
+ /**
2017
+ * Build getInlineHint from declarative subcommand definitions.
2018
+ * Shows remaining completion + usage as dim ghost text after cursor.
2019
+ */
2020
+ function buildSubcommandInlineHint(subcommands: SubcommandDef[]): (argumentText: string) => string | null {
2021
+ return (argumentText: string) => {
2022
+ const trimmed = argumentText.trimStart();
2023
+ const spaceIndex = trimmed.indexOf(" ");
2024
+
2025
+ if (spaceIndex === -1) {
2026
+ // Still typing subcommand name — show remaining chars + usage
2027
+ const prefix = trimmed.toLowerCase();
2028
+ if (prefix.length === 0) return null;
2029
+ const match = subcommands.find(s => s.name.startsWith(prefix));
2030
+ if (!match) return null;
2031
+ const remaining = match.name.slice(prefix.length);
2032
+ return remaining + (match.usage ? ` ${match.usage}` : "");
2033
+ }
2034
+
2035
+ // Subcommand typed — show remaining usage params
2036
+ const subName = trimmed.slice(0, spaceIndex).toLowerCase();
2037
+ const afterSub = trimmed.slice(spaceIndex + 1);
2038
+ const sub = subcommands.find(s => s.name === subName);
2039
+ if (!sub?.usage) return null;
2040
+
2041
+ if (afterSub.length > 0) {
2042
+ const usageParts = sub.usage.split(" ");
2043
+ const inputParts = afterSub.trim().split(/\s+/);
2044
+ const remaining = usageParts.slice(inputParts.length);
2045
+ return remaining.length > 0 ? remaining.join(" ") : null;
2046
+ }
2047
+
2048
+ return sub.usage;
2049
+ };
2050
+ }
2051
+
2052
+ /**
2053
+ * Build getInlineHint for commands with a simple static hint string.
2054
+ * Shows the hint only when no arguments have been typed yet.
2055
+ */
2056
+ function buildStaticInlineHint(hint: string): (argumentText: string) => string | null {
2057
+ return (argumentText: string) => (argumentText.trim().length === 0 ? hint : null);
2058
+ }
2059
+
1856
2060
  /** Builtin command metadata used for slash-command autocomplete and help text. */
1857
2061
  export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BUILTIN_SLASH_COMMAND_REGISTRY.map(
1858
2062
  command => ({
@@ -1864,6 +2068,32 @@ export const BUILTIN_SLASH_COMMAND_DEFS: ReadonlyArray<BuiltinSlashCommand> = BU
1864
2068
  }),
1865
2069
  );
1866
2070
 
2071
+ /**
2072
+ * Materialized builtin slash commands with completion functions derived from
2073
+ * declarative subcommand/hint definitions.
2074
+ */
2075
+ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<
2076
+ BuiltinSlashCommand & {
2077
+ getArgumentCompletions?: (prefix: string) => AutocompleteItem[] | null;
2078
+ getInlineHint?: (argumentText: string) => string | null;
2079
+ }
2080
+ > = BUILTIN_SLASH_COMMAND_DEFS.map(cmd => {
2081
+ if (cmd.subcommands) {
2082
+ return {
2083
+ ...cmd,
2084
+ getArgumentCompletions: buildArgumentCompletions(cmd.subcommands),
2085
+ getInlineHint: buildSubcommandInlineHint(cmd.subcommands),
2086
+ };
2087
+ }
2088
+ if (cmd.inlineHint) {
2089
+ return {
2090
+ ...cmd,
2091
+ getInlineHint: buildStaticInlineHint(cmd.inlineHint),
2092
+ };
2093
+ }
2094
+ return cmd;
2095
+ });
2096
+
1867
2097
  /**
1868
2098
  * Unified registry exposed for cross-mode tooling. Each spec carries at least
1869
2099
  * one of `handle` / `handleTui`. The TUI dispatcher prefers `handleTui`; the
@@ -1890,6 +2120,13 @@ export async function executeBuiltinSlashCommand(
1890
2120
  if (parsed.args.length > 0 && !command.allowArgs) {
1891
2121
  return false;
1892
2122
  }
2123
+ // Collab guests run a read-mostly replica: session-mutating builtins are
2124
+ // host-only; the allowlist covers purely local/read-only commands.
2125
+ if (runtime.ctx.collabGuest && !COLLAB_GUEST_ALLOWED_COMMANDS[command.name]) {
2126
+ runtime.ctx.showStatus(`/${command.name} is host-only during a collab session`);
2127
+ runtime.ctx.editor.setText("");
2128
+ return true;
2129
+ }
1893
2130
  if (command.handleTui) {
1894
2131
  const result = await command.handleTui(parsed, runtime);
1895
2132
  if (result && typeof result === "object" && "prompt" in result) return result.prompt;
@@ -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
  /**