@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
@@ -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,16 +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(),
1191
1180
  registry: this.ctx.collabGuest?.agentRegistry,
1192
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,
1193
1189
  });
1194
1190
 
1195
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
+ }
@@ -122,6 +122,7 @@ import { InputController } from "./controllers/input-controller";
122
122
  import { MCPCommandController } from "./controllers/mcp-command-controller";
123
123
  import { OmfgController } from "./controllers/omfg-controller";
124
124
  import { SelectorController } from "./controllers/selector-controller";
125
+ import { SessionFocusController } from "./controllers/session-focus-controller";
125
126
  import { SSHCommandController } from "./controllers/ssh-command-controller";
126
127
  import { TanCommandController } from "./controllers/tan-command-controller";
127
128
  import { TodoCommandController } from "./controllers/todo-command-controller";
@@ -285,10 +286,15 @@ class StatusContainer extends Container implements NativeScrollbackLiveRegion {
285
286
  * Build the anchored subagent HUD block: a bold accent "Subagents" header plus
286
287
  * one hooked row per running agent in the same `Id: description` shape the
287
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.
288
292
  * Returns an empty array when nothing is running so the container can clear.
289
293
  */
290
294
  export function renderSubagentHudLines(sessions: ObservableSession[], columns: number): string[] {
291
- 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
+ );
292
298
  if (running.length === 0) return [];
293
299
 
294
300
  const indent = " ";
@@ -435,6 +441,34 @@ export class InteractiveMode implements InteractiveModeContext {
435
441
  readonly #extensionUiController: ExtensionUiController;
436
442
  readonly #inputController: InputController;
437
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
+ }
438
472
  readonly #uiHelpers: UiHelpers;
439
473
  #sttController: STTController | undefined;
440
474
  #voiceAnimationInterval: NodeJS.Timeout | undefined;
@@ -560,6 +594,7 @@ export class InteractiveMode implements InteractiveModeContext {
560
594
  this.#commandController = new CommandController(this);
561
595
  this.#todoCommandController = new TodoCommandController(this);
562
596
  this.#selectorController = new SelectorController(this);
597
+ this.#focusController = new SessionFocusController(this);
563
598
  this.#inputController = new InputController(this);
564
599
  this.#observerRegistry = new SessionObserverRegistry();
565
600
  }
@@ -1155,6 +1190,12 @@ export class InteractiveMode implements InteractiveModeContext {
1155
1190
  this.editor.borderColor = theme.getThinkingBorderColor(level);
1156
1191
  }
1157
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
+ }
1158
1199
  this.updateEditorTopBorder();
1159
1200
  this.ui.requestRender();
1160
1201
  }
@@ -1169,7 +1210,7 @@ export class InteractiveMode implements InteractiveModeContext {
1169
1210
  this.chatContainer.clear();
1170
1211
  // Full-history transcript: compactions render as inline dividers instead
1171
1212
  // of restarting the visible conversation (the LLM context still resets).
1172
- const context = this.session.buildTranscriptSessionContext();
1213
+ const context = this.viewSession.buildTranscriptSessionContext();
1173
1214
  this.renderSessionContext(context);
1174
1215
  // During the pre-streaming window — after `startPendingSubmission` has
1175
1216
  // optimistically rendered the user's message but before the user
@@ -2695,6 +2736,7 @@ export class InteractiveMode implements InteractiveModeContext {
2695
2736
  }
2696
2737
  this.#btwController.dispose();
2697
2738
  this.#omfgController.dispose();
2739
+ this.#focusController.dispose();
2698
2740
 
2699
2741
  // Emit shutdown event to hooks
2700
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"],
@@ -98,6 +98,18 @@ export interface InteractiveModeContext {
98
98
  // Session access
99
99
  session: AgentSession;
100
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;
101
113
  settings: Settings;
102
114
  keybindings: KeybindingsManager;
103
115
  agent: AgentSession["agent"];
@@ -233,7 +233,7 @@ export class UiHelpers {
233
233
  this.ctx.chatContainer.addChild(card);
234
234
  return [card];
235
235
  }
236
- const renderer = this.ctx.session.extensionRunner?.getMessageRenderer(message.customType);
236
+ const renderer = this.ctx.viewSession.extensionRunner?.getMessageRenderer(message.customType);
237
237
  // Both HookMessage and CustomMessage have the same structure, cast for compatibility
238
238
  const component = new CustomMessageComponent(message as CustomMessage<unknown>, renderer);
239
239
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -284,7 +284,10 @@ export class UiHelpers {
284
284
  const isSynthetic = message.role === "developer" ? true : (message.synthetic ?? false);
285
285
  const imageLinks =
286
286
  options?.imageLinks ??
287
- 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
+ );
288
291
  const userComponent = new UserMessageComponent(textContent, isSynthetic, imageLinks);
289
292
  this.ctx.chatContainer.addChild(userComponent);
290
293
  if (options?.populateHistory && message.role === "user" && !isSynthetic) {
@@ -298,7 +301,7 @@ export class UiHelpers {
298
301
  message,
299
302
  this.ctx.hideThinkingBlock,
300
303
  () => this.ctx.ui.requestRender(),
301
- this.ctx.session.extensionRunner?.getAssistantThinkingRenderers(),
304
+ this.ctx.viewSession.extensionRunner?.getAssistantThinkingRenderers(),
302
305
  this.ctx.ui.imageBudget,
303
306
  );
304
307
  this.ctx.chatContainer.addChild(assistantComponent);
@@ -379,7 +382,7 @@ export class UiHelpers {
379
382
  !isAbortedSilently && (message.stopReason === "aborted" || message.stopReason === "error");
380
383
  const errorMessage = hasErrorStop
381
384
  ? message.stopReason === "aborted"
382
- ? resolveAbortLabel(message.errorMessage, this.ctx.session.retryAttempt)
385
+ ? resolveAbortLabel(message.errorMessage, this.ctx.viewSession.retryAttempt)
383
386
  : message.errorMessage || "Error"
384
387
  : null;
385
388
 
@@ -424,7 +427,7 @@ export class UiHelpers {
424
427
 
425
428
  readGroup?.seal();
426
429
  readGroup = null;
427
- const tool = this.ctx.session.getToolByName(content.name);
430
+ const tool = this.ctx.viewSession.getToolByName(content.name);
428
431
  const renderArgs =
429
432
  "partialJson" in content
430
433
  ? { ...content.arguments, __partialJson: content.partialJson }
@@ -433,7 +436,7 @@ export class UiHelpers {
433
436
  content.name,
434
437
  renderArgs,
435
438
  {
436
- snapshots: getFileSnapshotStore(this.ctx.session),
439
+ snapshots: getFileSnapshotStore(this.ctx.viewSession),
437
440
  showImages: settings.get("terminal.showImages"),
438
441
  editFuzzyThreshold: settings.get("edit.fuzzyThreshold"),
439
442
  editAllowFuzzy: settings.get("edit.fuzzyMatch"),
@@ -441,7 +444,7 @@ export class UiHelpers {
441
444
  },
442
445
  tool,
443
446
  this.ctx.ui,
444
- this.ctx.sessionManager.getCwd(),
447
+ this.ctx.viewSession.sessionManager.getCwd(),
445
448
  content.id,
446
449
  );
447
450
  component.setExpanded(this.ctx.toolOutputExpanded);
@@ -550,14 +553,14 @@ export class UiHelpers {
550
553
 
551
554
  // Display always uses the full-history transcript: compactions show as
552
555
  // inline dividers instead of restarting the visible conversation.
553
- const context = this.ctx.session.buildTranscriptSessionContext();
556
+ const context = this.ctx.viewSession.buildTranscriptSessionContext();
554
557
  this.ctx.renderSessionContext(context, {
555
558
  updateFooter: true,
556
- populateHistory: true,
559
+ populateHistory: !this.ctx.focusedAgentId,
557
560
  });
558
561
 
559
562
  // Show compaction info if session was compacted
560
- const allEntries = this.ctx.sessionManager.getEntries();
563
+ const allEntries = this.ctx.viewSession.sessionManager.getEntries();
561
564
  let compactionCount = 0;
562
565
  for (const entry of allEntries) {
563
566
  if (entry.type === "compaction") {
@@ -614,7 +617,7 @@ export class UiHelpers {
614
617
 
615
618
  updatePendingMessagesDisplay(): void {
616
619
  this.ctx.pendingMessagesContainer.clear();
617
- const queuedMessages = this.ctx.session.getQueuedMessages() as QueuedMessages;
620
+ const queuedMessages = this.ctx.viewSession.getQueuedMessages() as QueuedMessages;
618
621
 
619
622
  const steeringMessages: Array<{ message: string; label: string }> = [];
620
623
  for (const message of queuedMessages.steering) {
@@ -800,8 +803,8 @@ export class UiHelpers {
800
803
  }
801
804
 
802
805
  findLastAssistantMessage(): AssistantMessage | undefined {
803
- for (let i = this.ctx.session.messages.length - 1; i >= 0; i--) {
804
- 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];
805
808
  if (message?.role === "assistant") {
806
809
  return message as AssistantMessage;
807
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.
@@ -100,6 +100,7 @@ import { modelsAreEqual } from "@oh-my-pi/pi-catalog/models";
100
100
  import { countTokens, MacOSPowerAssertion } from "@oh-my-pi/pi-natives";
101
101
  import {
102
102
  extractRetryHint,
103
+ formatDuration,
103
104
  getAgentDbPath,
104
105
  getInstallId,
105
106
  isBunTestRuntime,
@@ -240,7 +241,13 @@ import { normalizeModelContextImages } from "../utils/image-loading";
240
241
  import { buildNamedToolChoice } from "../utils/tool-choice";
241
242
  import type { AuthStorage } from "./auth-storage";
242
243
  import type { ClientBridge, ClientBridgePermissionOption, ClientBridgePermissionOutcome } from "./client-bridge";
243
- import { defaultCodexAutoRedeemCoordinator, evaluateCodexAutoRedeem } from "./codex-auto-reset";
244
+ import {
245
+ type CodexAutoRedeemRedeemDecision,
246
+ defaultCodexAutoRedeemCoordinator,
247
+ evaluateCodexAutoRedeem,
248
+ shouldEvaluateCodexAutoRedeem,
249
+ shouldPromptCodexAutoRedeem,
250
+ } from "./codex-auto-reset";
244
251
  import {
245
252
  type BashExecutionMessage,
246
253
  type CustomMessage,
@@ -1418,6 +1425,11 @@ export class AgentSession {
1418
1425
  return this.#ttsrManager;
1419
1426
  }
1420
1427
 
1428
+ /** Secret obfuscator, when secrets are configured; /share redaction reuses it. */
1429
+ get obfuscator(): SecretObfuscator | undefined {
1430
+ return this.#obfuscator;
1431
+ }
1432
+
1421
1433
  /** Whether a TTSR abort is pending (stream was aborted to inject rules) */
1422
1434
  get isTtsrAbortPending(): boolean {
1423
1435
  return this.#ttsrAbortPending;
@@ -10193,20 +10205,63 @@ export class AgentSession {
10193
10205
  signal,
10194
10206
  });
10195
10207
  }
10208
+ async #confirmCodexAutoRedeem(decision: CodexAutoRedeemRedeemDecision): Promise<boolean> {
10209
+ const runner = this.#extensionRunner;
10210
+ if (!runner?.hasUI()) {
10211
+ this.emitNotice(
10212
+ "warning",
10213
+ "Codex saved reset is eligible, but auto-redeem is unset and no prompt UI is available. Run `/usage reset` or set codexResets.autoRedeem.",
10214
+ "codex-auto-reset",
10215
+ );
10216
+ return false;
10217
+ }
10218
+
10219
+ const who = decision.target.email ?? decision.target.accountId ?? "the active account";
10220
+ const resetLabel = decision.availableCount === 1 ? "reset" : "resets";
10221
+ try {
10222
+ const choice = await runner
10223
+ .getUIContext()
10224
+ .select(
10225
+ `Do you wanna redeem your reset?\n${who} is blocked by the weekly Codex limit for about ${formatDuration(decision.remainingMs)}. Spend 1 of ${decision.availableCount} saved ${resetLabel}?`,
10226
+ [
10227
+ {
10228
+ label: "Yes",
10229
+ description: "Redeem now and remember yes for future eligible Codex weekly blocks.",
10230
+ },
10231
+ {
10232
+ label: "No",
10233
+ description: "Do not auto-redeem saved Codex resets.",
10234
+ },
10235
+ ],
10236
+ );
10237
+ if (choice === "Yes") {
10238
+ this.settings.set("codexResets.autoRedeem", "yes");
10239
+ return true;
10240
+ }
10241
+ if (choice === "No") {
10242
+ this.settings.set("codexResets.autoRedeem", "no");
10243
+ }
10244
+ } catch (error) {
10245
+ logger.warn("codex-auto-reset prompt failed", { error: String(error) });
10246
+ }
10247
+ return false;
10248
+ }
10196
10249
 
10197
10250
  /**
10198
10251
  * Auto-redeem hook for {@link AgentSession.#handleRetryableError}'s
10199
10252
  * usage-limit branch. Returns `true` only when a saved Codex reset was
10200
- * actually spent (so the caller retries immediately). Opt-in, reactive, and
10201
- * heavily gated see `./codex-auto-reset` and the design in
10202
- * `local://autoreset-spec.md`. Per-account in-flight dedup lets concurrent
10203
- * sessions adopt one redeem instead of double-spending.
10253
+ * actually spent (so the caller retries immediately). The "unset" mode is
10254
+ * reactive but asks before spending; "yes" skips that prompt, and "no" avoids
10255
+ * the eligibility IO entirely. The decision remains heavily gated — see
10256
+ * `./codex-auto-reset` and the design in `local://autoreset-spec.md`.
10257
+ * Per-account in-flight dedup lets concurrent sessions adopt one redeem
10258
+ * instead of double-spending.
10204
10259
  */
10205
10260
  async #maybeAutoRedeemCodexReset(coordinator = defaultCodexAutoRedeemCoordinator): Promise<boolean> {
10206
10261
  const cfg = this.settings.getGroup("codexResets");
10207
10262
  const model = this.model;
10208
10263
  // Cheap exits before any IO.
10209
- if (!cfg.autoRedeem || !model || model.provider !== "openai-codex") return false;
10264
+ if (!shouldEvaluateCodexAutoRedeem(cfg.autoRedeem) || !model || model.provider !== "openai-codex") return false;
10210
10265
  const authStorage = this.#modelRegistry.authStorage;
10211
10266
  // Capture identity BEFORE awaits: markUsageLimitReached leaves the
10212
10267
  // usage-limit session credential sticky, so this names the blocked account.
@@ -10223,7 +10278,7 @@ export class AgentSession {
10223
10278
  provider: model.provider,
10224
10279
  modelId: model.id,
10225
10280
  settings: {
10226
- autoRedeem: cfg.autoRedeem,
10281
+ autoRedeem: true,
10227
10282
  minBlockedMinutes: Math.max(0, cfg.minBlockedMinutes),
10228
10283
  keepCredits: Math.max(0, Math.trunc(cfg.keepCredits)),
10229
10284
  },
@@ -10236,6 +10291,9 @@ export class AgentSession {
10236
10291
  logger.debug("codex-auto-reset: skipped", { reason: decision.reason });
10237
10292
  return false;
10238
10293
  }
10294
+ if (shouldPromptCodexAutoRedeem(cfg.autoRedeem) && !(await this.#confirmCodexAutoRedeem(decision))) {
10295
+ return false;
10296
+ }
10239
10297
  // Commit the attempt BEFORE acting so this block can never re-enter.
10240
10298
  coordinator.attemptedBlockKeys.add(decision.blockKey);
10241
10299
  coordinator.lastAttemptAtByAccount.set(decision.accountKey, Date.now());