@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
@@ -841,19 +841,6 @@ export class SelectorController {
841
841
  });
842
842
  }
843
843
 
844
- #clearTransientSessionUi(): void {
845
- if (this.ctx.loadingAnimation) {
846
- this.ctx.loadingAnimation.stop();
847
- this.ctx.loadingAnimation = undefined;
848
- }
849
- this.ctx.statusContainer.clear();
850
- this.ctx.pendingMessagesContainer.clear();
851
- this.ctx.compactionQueuedMessages = [];
852
- this.ctx.streamingComponent = undefined;
853
- this.ctx.streamingMessage = undefined;
854
- this.ctx.pendingTools.clear();
855
- }
856
-
857
844
  #refreshSessionTerminalTitle(): void {
858
845
  const sessionManager = this.ctx.sessionManager as {
859
846
  getSessionName?: () => string | undefined;
@@ -875,7 +862,7 @@ export class SelectorController {
875
862
  }
876
863
  this.#refreshSessionTerminalTitle();
877
864
 
878
- this.#clearTransientSessionUi();
865
+ this.ctx.clearTransientSessionUi();
879
866
  this.ctx.statusLine.invalidate();
880
867
  this.ctx.statusLine.setSessionStartTime(Date.now());
881
868
  this.ctx.updateEditorTopBorder();
@@ -887,7 +874,7 @@ export class SelectorController {
887
874
  }
888
875
 
889
876
  async handleResumeSession(sessionPath: string): Promise<void> {
890
- this.#clearTransientSessionUi();
877
+ this.ctx.clearTransientSessionUi();
891
878
 
892
879
  const previousCwd = this.ctx.sessionManager.getCwd();
893
880
  // Switch session via AgentSession (emits hook and tool session events). The
@@ -1180,14 +1167,25 @@ export class SelectorController {
1180
1167
  const done = () => {
1181
1168
  hub?.dispose();
1182
1169
  overlayHandle?.hide();
1170
+ this.ctx.ui.setFocus(this.ctx.editor);
1183
1171
  this.ctx.ui.requestRender();
1184
1172
  };
1185
1173
 
1186
1174
  hub = new AgentHubOverlayComponent({
1187
1175
  observers,
1188
1176
  hubKeys,
1177
+ expandKeys: this.ctx.keybindings.getKeys("app.tools.expand"),
1189
1178
  onDone: done,
1190
1179
  requestRender: () => this.ctx.ui.requestRender(),
1180
+ registry: this.ctx.collabGuest?.agentRegistry,
1181
+ remote: this.ctx.collabGuest?.hubRemote,
1182
+ ui: this.ctx.ui,
1183
+ getTool: name => this.ctx.session.getToolByName(name),
1184
+ getMessageRenderer: type => this.ctx.session.extensionRunner?.getMessageRenderer(type),
1185
+ cwd: this.ctx.sessionManager.getCwd(),
1186
+ hideThinkingBlock: () => this.ctx.hideThinkingBlock,
1187
+ focusAgent: id => this.ctx.focusAgentSession(id),
1188
+ sessionFile: this.ctx.sessionManager.getSessionFile() ?? null,
1191
1189
  });
1192
1190
 
1193
1191
  overlayHandle = this.ctx.ui.showOverlay(hub, {
@@ -0,0 +1,112 @@
1
+ /**
2
+ * SessionFocusController - Weak retargeting primitive between the rendering/
3
+ * input layer and the AgentSession it displays.
4
+ *
5
+ * Focusing re-points the transcript, streaming event subscription, status
6
+ * line, and editor prompt/interrupt at a subagent's live AgentSession (from
7
+ * AgentRegistry) without touching the main session underneath; unfocusing
8
+ * re-attaches the main session and rebuilds the transcript from its
9
+ * authoritative state.
10
+ */
11
+
12
+ import { AgentLifecycleManager } from "../../registry/agent-lifecycle";
13
+ import { AgentRegistry, MAIN_AGENT_ID, type RegistryEvent } from "../../registry/agent-registry";
14
+ import type { AgentSession } from "../../session/agent-session";
15
+ import type { InteractiveModeContext } from "../types";
16
+
17
+ export class SessionFocusController {
18
+ #focusedAgentId: string | undefined;
19
+ /** Session currently attached while focused; undefined when unfocused. */
20
+ #attachedSession: AgentSession | undefined;
21
+ #registryUnsubscribe: (() => void) | undefined;
22
+
23
+ constructor(
24
+ private ctx: InteractiveModeContext,
25
+ private registry: AgentRegistry = AgentRegistry.global(),
26
+ private lifecycle: () => AgentLifecycleManager = () => AgentLifecycleManager.global(),
27
+ ) {}
28
+
29
+ get focusedAgentId(): string | undefined {
30
+ return this.#focusedAgentId;
31
+ }
32
+
33
+ /** Focused live session, undefined when unfocused. */
34
+ get target(): AgentSession | undefined {
35
+ return this.#attachedSession;
36
+ }
37
+
38
+ /** Focus the main view on an agent's live session. Throws an Error with a user-displayable message. */
39
+ async focusAgent(id: string): Promise<void> {
40
+ if (this.ctx.collabGuest) throw new Error("Viewing agents is unavailable in a collab session.");
41
+ if (id === MAIN_AGENT_ID) return this.unfocus();
42
+ const session = await this.lifecycle().ensureLive(id);
43
+ if (id === this.#focusedAgentId && session === this.#attachedSession) return;
44
+ this.#focusedAgentId = id;
45
+ this.#attachedSession = session;
46
+ this.#registryUnsubscribe ??= this.registry.onChange(e => this.#onRegistryEvent(e));
47
+ await this.#attach(session);
48
+ this.ctx.showStatus(`Viewing agent ${id} — Esc returns to main, ←← hops to parent`);
49
+ }
50
+
51
+ /** Focus the focused agent's parent agent, falling back to the main session. No-op when unfocused. */
52
+ async focusParent(): Promise<void> {
53
+ if (!this.#focusedAgentId) return;
54
+ const parentId = this.registry.get(this.#focusedAgentId)?.parentId;
55
+ if (parentId && parentId !== MAIN_AGENT_ID && this.registry.get(parentId)) {
56
+ return this.focusAgent(parentId);
57
+ }
58
+ return this.unfocus();
59
+ }
60
+
61
+ /** Return to the main session. No-op when unfocused. */
62
+ async unfocus(): Promise<void> {
63
+ if (!this.#focusedAgentId) return;
64
+ this.#focusedAgentId = undefined;
65
+ this.#attachedSession = undefined;
66
+ await this.#attach(this.ctx.session);
67
+ this.ctx.showStatus("Returned to main session");
68
+ }
69
+
70
+ dispose(): void {
71
+ this.#registryUnsubscribe?.();
72
+ this.#registryUnsubscribe = undefined;
73
+ }
74
+
75
+ #onRegistryEvent(event: RegistryEvent): void {
76
+ if (event.ref.id !== this.#focusedAgentId) return;
77
+ const gone = event.type === "removed";
78
+ const dead = event.type === "status_changed" && (event.ref.status === "parked" || event.ref.status === "aborted");
79
+ if (!gone && !dead) return;
80
+ void this.unfocus().then(() => {
81
+ this.ctx.showStatus(`Agent ${event.ref.id} is ${gone ? "gone" : event.ref.status}; returned to main session`);
82
+ });
83
+ }
84
+
85
+ /** Retarget core, both directions: swap subscription, transcript, and status line onto `target`. */
86
+ async #attach(target: AgentSession): Promise<void> {
87
+ this.ctx.unsubscribe?.();
88
+ this.ctx.clearTransientSessionUi();
89
+ this.ctx.eventController.resetTranscriptAnchors();
90
+ // Orphan-delta guard: when attaching mid-turn the message_start for the
91
+ // in-flight assistant message predates the attach. message_update carries
92
+ // the full accumulating message, so synthesize the missing start before
93
+ // the first orphaned update; every other handler is tolerant of unknown
94
+ // anchors (guarded by streamingComponent/pendingTools lookups).
95
+ let assistantStreamSynced = false;
96
+ this.ctx.unsubscribe = target.subscribe(async event => {
97
+ if (event.type === "message_start" && event.message.role === "assistant") {
98
+ assistantStreamSynced = true;
99
+ } else if (event.type === "message_update" && event.message.role === "assistant" && !assistantStreamSynced) {
100
+ assistantStreamSynced = true;
101
+ await this.ctx.eventController.handleEvent({ type: "message_start", message: event.message });
102
+ }
103
+ await this.ctx.eventController.handleEvent(event);
104
+ });
105
+ this.ctx.statusLine.setSession(target, this.#focusedAgentId);
106
+ this.ctx.renderInitialMessages({ clearTerminalHistory: true });
107
+ // Mid-turn attach: no agent_start will arrive; arm the loader/turn state manually.
108
+ if (target.isStreaming) await this.ctx.eventController.handleEvent({ type: "agent_start" });
109
+ this.ctx.updateEditorBorderColor();
110
+ this.ctx.ui.requestRender();
111
+ }
112
+ }
@@ -1,5 +1,6 @@
1
1
  import type { AssistantMessage } from "@oh-my-pi/pi-ai";
2
2
  import { getSegmenter } from "@oh-my-pi/pi-tui";
3
+ import { LRUCache } from "lru-cache/raw";
3
4
  import type { AssistantMessageComponent } from "../components/assistant-message";
4
5
 
5
6
  export const STREAMING_REVEAL_FRAME_MS = 1000 / 30;
@@ -15,11 +16,17 @@ type StreamingRevealControllerOptions = {
15
16
  requestRender(): void;
16
17
  };
17
18
 
19
+ const graphemeCountCache = new LRUCache<string, number>({ max: 128 });
20
+
18
21
  function countGraphemes(text: string): number {
22
+ if (text.length === 0) return 0;
23
+ const cached = graphemeCountCache.get(text);
24
+ if (cached !== undefined) return cached;
19
25
  let count = 0;
20
26
  for (const _segment of getSegmenter().segment(text)) {
21
27
  count += 1;
22
28
  }
29
+ graphemeCountCache.set(text, count);
23
30
  return count;
24
31
  }
25
32
 
@@ -49,8 +49,9 @@ import {
49
49
  } from "@oh-my-pi/pi-utils";
50
50
  import chalk from "chalk";
51
51
  import { reset as resetCapabilities } from "../capability";
52
+ import type { CollabGuestLink } from "../collab/guest";
53
+ import type { CollabHost } from "../collab/host";
52
54
  import { KeybindingsManager } from "../config/keybindings";
53
- import { MODEL_ROLES, type ModelRole } from "../config/model-roles";
54
55
  import { isSettingsInitialized, onStatusLineSessionAccentChanged, Settings, settings } from "../config/settings";
55
56
  import { clearClaudePluginRootsCache } from "../discovery/helpers";
56
57
  import type {
@@ -62,7 +63,7 @@ import type {
62
63
  ExtensionWidgetOptions,
63
64
  } from "../extensibility/extensions";
64
65
  import type { CompactOptions } from "../extensibility/extensions/types";
65
- import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
66
+ import { loadSlashCommands } from "../extensibility/slash-commands";
66
67
  import type { Goal, GoalModeState } from "../goals/state";
67
68
  import { resolveLocalUrlToPath } from "../internal-urls";
68
69
  import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
@@ -82,7 +83,7 @@ import { HistoryStorage } from "../session/history-storage";
82
83
  import type { SessionContext, SessionManager } from "../session/session-manager";
83
84
  import { getRecentSessions } from "../session/session-manager";
84
85
  import type { ShakeMode } from "../session/shake-types";
85
- import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES } from "../slash-commands/builtin-registry";
86
+ import { BUILTIN_SLASH_COMMAND_RESERVED_NAMES, BUILTIN_SLASH_COMMANDS } from "../slash-commands/builtin-registry";
86
87
  import { formatDuration } from "../slash-commands/helpers/format";
87
88
  import { STTController, type SttState } from "../stt";
88
89
  import { discoverTitleSystemPromptFile, resolvePromptInput } from "../system-prompt";
@@ -121,6 +122,7 @@ import { InputController } from "./controllers/input-controller";
121
122
  import { MCPCommandController } from "./controllers/mcp-command-controller";
122
123
  import { OmfgController } from "./controllers/omfg-controller";
123
124
  import { SelectorController } from "./controllers/selector-controller";
125
+ import { SessionFocusController } from "./controllers/session-focus-controller";
124
126
  import { SSHCommandController } from "./controllers/ssh-command-controller";
125
127
  import { TanCommandController } from "./controllers/tan-command-controller";
126
128
  import { TodoCommandController } from "./controllers/todo-command-controller";
@@ -284,10 +286,15 @@ class StatusContainer extends Container implements NativeScrollbackLiveRegion {
284
286
  * Build the anchored subagent HUD block: a bold accent "Subagents" header plus
285
287
  * one hooked row per running agent in the same `Id: description` shape the
286
288
  * inline task rows use (muted task preview when no description was given).
289
+ * Only detached background spawns are listed: a sync task call blocks the
290
+ * parent turn and its inline tool block already renders progress live, and
291
+ * eval `agent()` spawns are rendered by their own eval cell tree.
287
292
  * Returns an empty array when nothing is running so the container can clear.
288
293
  */
289
294
  export function renderSubagentHudLines(sessions: ObservableSession[], columns: number): string[] {
290
- const running = sessions.filter(session => session.kind === "subagent" && session.status === "active");
295
+ const running = sessions.filter(
296
+ session => session.kind === "subagent" && session.status === "active" && session.detached === true,
297
+ );
291
298
  if (running.length === 0) return [];
292
299
 
293
300
  const indent = " ";
@@ -397,6 +404,8 @@ export class InteractiveMode implements InteractiveModeContext {
397
404
  fileSlashCommands: Set<string> = new Set();
398
405
  skillCommands: Map<string, string> = new Map();
399
406
  oauthManualInput: OAuthManualInputManager = new OAuthManualInputManager();
407
+ collabHost?: CollabHost;
408
+ collabGuest?: CollabGuestLink;
400
409
 
401
410
  #pendingSlashCommands: SlashCommand[] = [];
402
411
  #cleanupUnsubscribe?: () => void;
@@ -423,9 +432,43 @@ export class InteractiveMode implements InteractiveModeContext {
423
432
  readonly #commandController: CommandController;
424
433
  readonly #todoCommandController: TodoCommandController;
425
434
  readonly #eventController: EventController;
435
+ get eventController(): EventController {
436
+ return this.#eventController;
437
+ }
438
+ get eventBus(): EventBus | undefined {
439
+ return this.#eventBus;
440
+ }
426
441
  readonly #extensionUiController: ExtensionUiController;
427
442
  readonly #inputController: InputController;
428
443
  readonly #selectorController: SelectorController;
444
+ readonly #focusController: SessionFocusController;
445
+ get viewSession(): AgentSession {
446
+ return this.#focusController.target ?? this.session;
447
+ }
448
+ get focusedAgentId(): string | undefined {
449
+ return this.#focusController.focusedAgentId;
450
+ }
451
+ focusAgentSession(id: string): Promise<void> {
452
+ return this.#focusController.focusAgent(id);
453
+ }
454
+ focusParentSession(): Promise<void> {
455
+ return this.#focusController.focusParent();
456
+ }
457
+ unfocusSession(): Promise<void> {
458
+ return this.#focusController.unfocus();
459
+ }
460
+ clearTransientSessionUi(): void {
461
+ if (this.loadingAnimation) {
462
+ this.loadingAnimation.stop();
463
+ this.loadingAnimation = undefined;
464
+ }
465
+ this.statusContainer.clear();
466
+ this.pendingMessagesContainer.clear();
467
+ this.compactionQueuedMessages = [];
468
+ this.streamingComponent = undefined;
469
+ this.streamingMessage = undefined;
470
+ this.pendingTools.clear();
471
+ }
429
472
  readonly #uiHelpers: UiHelpers;
430
473
  #sttController: STTController | undefined;
431
474
  #voiceAnimationInterval: NodeJS.Timeout | undefined;
@@ -551,6 +594,7 @@ export class InteractiveMode implements InteractiveModeContext {
551
594
  this.#commandController = new CommandController(this);
552
595
  this.#todoCommandController = new TodoCommandController(this);
553
596
  this.#selectorController = new SelectorController(this);
597
+ this.#focusController = new SessionFocusController(this);
554
598
  this.#inputController = new InputController(this);
555
599
  this.#observerRegistry = new SessionObserverRegistry();
556
600
  }
@@ -1146,6 +1190,12 @@ export class InteractiveMode implements InteractiveModeContext {
1146
1190
  this.editor.borderColor = theme.getThinkingBorderColor(level);
1147
1191
  }
1148
1192
  }
1193
+ if (this.focusedAgentId) {
1194
+ // Focused subagent view: faint the outline so the borrowed session is
1195
+ // visually distinct from the main one.
1196
+ const base = this.editor.borderColor;
1197
+ this.editor.borderColor = (str: string) => `\x1b[2m${base(str)}\x1b[22m`;
1198
+ }
1149
1199
  this.updateEditorTopBorder();
1150
1200
  this.ui.requestRender();
1151
1201
  }
@@ -1160,7 +1210,7 @@ export class InteractiveMode implements InteractiveModeContext {
1160
1210
  this.chatContainer.clear();
1161
1211
  // Full-history transcript: compactions render as inline dividers instead
1162
1212
  // of restarting the visible conversation (the LLM context still resets).
1163
- const context = this.session.buildTranscriptSessionContext();
1213
+ const context = this.viewSession.buildTranscriptSessionContext();
1164
1214
  this.renderSessionContext(context);
1165
1215
  // During the pre-streaming window — after `startPendingSubmission` has
1166
1216
  // optimistically rendered the user's message but before the user
@@ -2491,7 +2541,6 @@ export class InteractiveMode implements InteractiveModeContext {
2491
2541
  index: startTierIndex,
2492
2542
  segments: cycle.models.map(entry => ({
2493
2543
  label: entry.role,
2494
- color: MODEL_ROLES[entry.role as ModelRole]?.color,
2495
2544
  detail: entry.model.name || entry.model.id,
2496
2545
  })),
2497
2546
  onChange: index => {
@@ -2687,6 +2736,7 @@ export class InteractiveMode implements InteractiveModeContext {
2687
2736
  }
2688
2737
  this.#btwController.dispose();
2689
2738
  this.#omfgController.dispose();
2739
+ this.#focusController.dispose();
2690
2740
 
2691
2741
  // Emit shutdown event to hooks
2692
2742
  await this.session.dispose();
@@ -11,6 +11,13 @@ export interface ObservableSession {
11
11
  status: "active" | "completed" | "failed" | "aborted";
12
12
  sessionFile?: string;
13
13
  parentToolCallId?: string;
14
+ /**
15
+ * Spawn runs as a detached background job (parent turn not blocked on it).
16
+ * The anchored subagent HUD only lists detached spawns: sync task spawns
17
+ * and eval `agent()` spawns are already rendered live by their own inline
18
+ * tool block / eval cell.
19
+ */
20
+ detached?: boolean;
14
21
  index?: number;
15
22
  lastUpdate: number;
16
23
  /** Latest progress snapshot from the subagent executor */
@@ -146,6 +153,7 @@ export class SessionObserverRegistry {
146
153
  existing.lastUpdate = Date.now();
147
154
  existing.index = payload.index;
148
155
  existing.parentToolCallId = payload.parentToolCallId ?? existing.parentToolCallId;
156
+ existing.detached = payload.detached ?? existing.detached;
149
157
  if (payload.description) existing.description = payload.description;
150
158
  if (payload.sessionFile) existing.sessionFile = payload.sessionFile;
151
159
  } else {
@@ -158,6 +166,7 @@ export class SessionObserverRegistry {
158
166
  status,
159
167
  sessionFile: payload.sessionFile,
160
168
  parentToolCallId: payload.parentToolCallId,
169
+ detached: payload.detached,
161
170
  index: payload.index,
162
171
  lastUpdate: Date.now(),
163
172
  });
@@ -179,6 +188,7 @@ export class SessionObserverRegistry {
179
188
  existing.lastUpdate = Date.now();
180
189
  existing.index = payload.index;
181
190
  existing.parentToolCallId = payload.parentToolCallId ?? existing.parentToolCallId;
191
+ existing.detached = payload.detached ?? existing.detached;
182
192
  existing.progress = progress;
183
193
  if (progress.description) existing.description = progress.description;
184
194
  if (payload.sessionFile) existing.sessionFile = payload.sessionFile;
@@ -192,6 +202,7 @@ export class SessionObserverRegistry {
192
202
  status: "active",
193
203
  sessionFile: payload.sessionFile,
194
204
  parentToolCallId: payload.parentToolCallId,
205
+ detached: payload.detached,
195
206
  index: payload.index,
196
207
  lastUpdate: Date.now(),
197
208
  progress,
@@ -107,6 +107,7 @@ export type SymbolKey =
107
107
  | "icon.cost"
108
108
  | "icon.time"
109
109
  | "icon.pi"
110
+ | "icon.ghost"
110
111
  | "icon.agents"
111
112
  | "icon.job"
112
113
  | "icon.cache"
@@ -305,6 +306,7 @@ const UNICODE_SYMBOLS: SymbolMap = {
305
306
  "icon.cost": "💲",
306
307
  "icon.time": "⏱",
307
308
  "icon.pi": "π",
309
+ "icon.ghost": "👻",
308
310
  "icon.agents": "👥",
309
311
  "icon.job": "⚙",
310
312
  "icon.cache": "💾",
@@ -569,6 +571,8 @@ const NERD_SYMBOLS: SymbolMap = {
569
571
  "icon.time": "\uf017",
570
572
  // pick:  | alt: π ∏ ∑
571
573
  "icon.pi": "\ue22c",
574
+ // pick: 󰊠 (nf-md-ghost) | alt: 👻
575
+ "icon.ghost": "\u{f02a0}",
572
576
  // pick:  | alt: 
573
577
  "icon.agents": "\uf0c0",
574
578
  // pick: (nf-fa-gear) | alt: ⚙
@@ -802,6 +806,7 @@ const ASCII_SYMBOLS: SymbolMap = {
802
806
  "icon.cost": "$",
803
807
  "icon.time": "t:",
804
808
  "icon.pi": "pi",
809
+ "icon.ghost": "@",
805
810
  "icon.agents": "AG",
806
811
  "icon.job": "bg",
807
812
  "icon.cache": "cache",
@@ -1769,6 +1774,7 @@ export class Theme {
1769
1774
  cost: this.#symbols["icon.cost"],
1770
1775
  time: this.#symbols["icon.time"],
1771
1776
  pi: this.#symbols["icon.pi"],
1777
+ ghost: this.#symbols["icon.ghost"],
1772
1778
  agents: this.#symbols["icon.agents"],
1773
1779
  job: this.#symbols["icon.job"],
1774
1780
  cache: this.#symbols["icon.cache"],
@@ -2,6 +2,8 @@ import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { CompactionOutcome } from "@oh-my-pi/pi-agent-core/compaction";
3
3
  import type { AssistantMessage, ImageContent, Message, UsageReport } from "@oh-my-pi/pi-ai";
4
4
  import type { Component, Container, EditorTheme, Loader, Spacer, Text, TUI } from "@oh-my-pi/pi-tui";
5
+ import type { CollabGuestLink } from "../collab/guest";
6
+ import type { CollabHost } from "../collab/host";
5
7
  import type { KeybindingsManager } from "../config/keybindings";
6
8
  import type { Settings } from "../config/settings";
7
9
  import type {
@@ -19,6 +21,7 @@ import type { HistoryStorage } from "../session/history-storage";
19
21
  import type { SessionContext, SessionManager } from "../session/session-manager";
20
22
  import type { ShakeMode } from "../session/shake-types";
21
23
  import type { LspStartupServerInfo } from "../tools";
24
+ import type { EventBus } from "../utils/event-bus";
22
25
  import type { AssistantMessageComponent } from "./components/assistant-message";
23
26
  import type { BashExecutionComponent } from "./components/bash-execution";
24
27
  import type { CustomEditor } from "./components/custom-editor";
@@ -29,6 +32,7 @@ import type { HookSelectorComponent, HookSelectorOptions } from "./components/ho
29
32
  import type { StatusLineComponent } from "./components/status-line";
30
33
  import type { ToolExecutionHandle } from "./components/tool-execution";
31
34
  import type { TranscriptContainer } from "./components/transcript-container";
35
+ import type { EventController } from "./controllers/event-controller";
32
36
  import type { LoopLimitRuntime } from "./loop-limit";
33
37
  import type { OAuthManualInputManager } from "./oauth-manual-input";
34
38
  import type { Theme } from "./theme/theme";
@@ -94,6 +98,18 @@ export interface InteractiveModeContext {
94
98
  // Session access
95
99
  session: AgentSession;
96
100
  sessionManager: SessionManager;
101
+ /** Session the transcript/editor/status are attached to: the focused agent's, else `session`. */
102
+ readonly viewSession: AgentSession;
103
+ /** Id of the focused agent, undefined when the main session is attached. */
104
+ readonly focusedAgentId: string | undefined;
105
+ /** Focus the main view on an agent's live session (delegates to SessionFocusController.focusAgent). */
106
+ focusAgentSession(id: string): Promise<void>;
107
+ /** Focus the focused agent's parent session, falling back to main (delegates to focusParent). */
108
+ focusParentSession(): Promise<void>;
109
+ /** Return the view to the main session (delegates to SessionFocusController.unfocus). */
110
+ unfocusSession(): Promise<void>;
111
+ /** Clear loader, status/pending containers, streaming state, and pending tools. */
112
+ clearTransientSessionUi(): void;
97
113
  settings: Settings;
98
114
  keybindings: KeybindingsManager;
99
115
  agent: AgentSession["agent"];
@@ -101,6 +117,10 @@ export interface InteractiveModeContext {
101
117
  mcpManager?: MCPManager;
102
118
  lspServers?: LspStartupServerInfo[];
103
119
  titleSystemPrompt?: string;
120
+ collabHost?: CollabHost;
121
+ collabGuest?: CollabGuestLink;
122
+ eventController: EventController;
123
+ eventBus?: EventBus;
104
124
 
105
125
  // State
106
126
  isInitialized: boolean;
@@ -1,11 +1,13 @@
1
1
  import type { AgentMessage } from "@oh-my-pi/pi-agent-core";
2
2
  import type { AssistantMessage, ImageContent, Message } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, Spacer, Text, TruncatedText } from "@oh-my-pi/pi-tui";
4
+ import { COLLAB_PROMPT_MESSAGE_TYPE, type CollabPromptDetails } from "../../collab/protocol";
4
5
  import { settings } from "../../config/settings";
5
6
  import { getFileSnapshotStore } from "../../edit/file-snapshot-store";
6
7
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
7
8
  import { BashExecutionComponent } from "../../modes/components/bash-execution";
8
9
  import { BranchSummaryMessageComponent } from "../../modes/components/branch-summary-message";
10
+ import { CollabPromptMessageComponent } from "../../modes/components/collab-prompt-message";
9
11
  import { CompactionSummaryMessageComponent } from "../../modes/components/compaction-summary-message";
10
12
  import { CustomMessageComponent } from "../../modes/components/custom-message";
11
13
  import { DynamicBorder } from "../../modes/components/dynamic-border";
@@ -185,6 +187,11 @@ export class UiHelpers {
185
187
  this.ctx.chatContainer.addChild(component);
186
188
  break;
187
189
  }
190
+ if (message.customType === COLLAB_PROMPT_MESSAGE_TYPE) {
191
+ const component = new CollabPromptMessageComponent(message as CustomMessage<CollabPromptDetails>);
192
+ this.ctx.chatContainer.addChild(component);
193
+ break;
194
+ }
188
195
  if (message.customType === SKILL_PROMPT_MESSAGE_TYPE) {
189
196
  const component = new SkillMessageComponent(message as CustomMessage<SkillPromptDetails>);
190
197
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -226,7 +233,7 @@ export class UiHelpers {
226
233
  this.ctx.chatContainer.addChild(card);
227
234
  return [card];
228
235
  }
229
- const renderer = this.ctx.session.extensionRunner?.getMessageRenderer(message.customType);
236
+ const renderer = this.ctx.viewSession.extensionRunner?.getMessageRenderer(message.customType);
230
237
  // Both HookMessage and CustomMessage have the same structure, cast for compatibility
231
238
  const component = new CustomMessageComponent(message as CustomMessage<unknown>, renderer);
232
239
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -277,7 +284,10 @@ export class UiHelpers {
277
284
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
278
285
  const imageLinks =
279
286
  options?.imageLinks ??
280
- imageLinksForMessage(message, this.ctx.sessionManager.putBlobSync.bind(this.ctx.sessionManager));
287
+ imageLinksForMessage(
288
+ message,
289
+ this.ctx.viewSession.sessionManager.putBlobSync.bind(this.ctx.viewSession.sessionManager),
290
+ );
281
291
  const userComponent = new UserMessageComponent(textContent, isSynthetic, imageLinks);
282
292
  this.ctx.chatContainer.addChild(userComponent);
283
293
  if (options?.populateHistory && message.role === "user" && !isSynthetic) {
@@ -291,7 +301,7 @@ export class UiHelpers {
291
301
  message,
292
302
  this.ctx.hideThinkingBlock,
293
303
  () => this.ctx.ui.requestRender(),
294
- this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
304
+ this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
295
305
  this.ctx.ui.imageBudget,
296
306
  );
297
307
  this.ctx.chatContainer.addChild(assistantComponent);
@@ -372,7 +382,7 @@ export class UiHelpers {
372
382
  !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
373
383
  const errorMessage = hasErrorStop
374
384
  ? message.stopReason === "aborted"
375
- ? resolveAbortLabel(message.errorMessage, this.ctx.session.retryAttempt)
385
+ ? resolveAbortLabel(message.errorMessage, this.ctx.viewSession.retryAttempt)
376
386
  : message.errorMessage || "Error"
377
387
  : null;
378
388
 
@@ -417,7 +427,7 @@ export class UiHelpers {
417
427
 
418
428
  readGroup?.seal();
419
429
  readGroup = null;
420
- const tool = this.ctx.session.getToolByName(content.name);
430
+ const tool = this.ctx.viewSession.getToolByName(content.name);
421
431
  const renderArgs =
422
432
  "partialJson" in content
423
433
  ? { ...content.arguments, __partialJson: content.partialJson }
@@ -426,7 +436,7 @@ export class UiHelpers {
426
436
  content.name,
427
437
  renderArgs,
428
438
  {
429
- snapshots: getFileSnapshotStore(this.ctx.session),
439
+ snapshots: getFileSnapshotStore(this.ctx.viewSession),
430
440
  showImages: settings.get("terminal.showImages"),
431
441
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
432
442
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -434,7 +444,7 @@ export class UiHelpers {
434
444
  },
435
445
  tool,
436
446
  this.ctx.ui,
437
- this.ctx.sessionManager.getCwd(),
447
+ this.ctx.viewSession.sessionManager.getCwd(),
438
448
  content.id,
439
449
  );
440
450
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -543,14 +553,14 @@ export class UiHelpers {
543
553
 
544
554
  // Display always uses the full-history transcript: compactions show as
545
555
  // inline dividers instead of restarting the visible conversation.
546
- const context = this.ctx.session.buildTranscriptSessionContext();
556
+ const context = this.ctx.viewSession.buildTranscriptSessionContext();
547
557
  this.ctx.renderSessionContext(context, {
548
558
  updateFooter: true,
549
- populateHistory: true,
559
+ populateHistory: !this.ctx.focusedAgentId,
550
560
  });
551
561
 
552
562
  // Show compaction info if session was compacted
553
- const allEntries = this.ctx.sessionManager.getEntries();
563
+ const allEntries = this.ctx.viewSession.sessionManager.getEntries();
554
564
  let compactionCount = 0;
555
565
  for (const entry of allEntries) {
556
566
  if (entry.type === "compaction") {
@@ -607,7 +617,7 @@ export class UiHelpers {
607
617
 
608
618
  updatePendingMessagesDisplay(): void {
609
619
  this.ctx.pendingMessagesContainer.clear();
610
- const queuedMessages = this.ctx.session.getQueuedMessages() as QueuedMessages;
620
+ const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages;
611
621
 
612
622
  const steeringMessages: Array<{ message: string; label: string }> = [];
613
623
  for (const message of queuedMessages.steering) {
@@ -793,8 +803,8 @@ export class UiHelpers {
793
803
  }
794
804
 
795
805
  findLastAssistantMessage(): AssistantMessage | undefined {
796
- for (let i = this.ctx.session.messages.length - 1; i >= 0; i--) {
797
- const message = this.ctx.session.messages[i];
806
+ for (let i = this.ctx.viewSession.messages.length - 1; i >= 0; i--) {
807
+ const message = this.ctx.viewSession.messages[i];
798
808
  if (message?.role === "assistant") {
799
809
  return message as AssistantMessage;
800
810
  }
@@ -8,7 +8,7 @@ Background job results are delivered automatically when complete. Reach for this
8
8
  Use to inspect what's running.
9
9
 
10
10
  ## `poll: [id, …]`
11
- Block until the specified jobs finish or the wait window elapses.
11
+ Block until the specified jobs finish or the wait window elapses. Omit `poll` (with no `list`/`cancel`) to wait on ALL running jobs — NEVER enumerate ids you don't need to filter.
12
12
  - Use when you are genuinely blocked on a result and have no other work to do.
13
13
  - Returns the current snapshot when the timer elapses; running jobs remain running.
14
14
  - Completed jobs include their final output in the returned snapshot.