@giladbarnea/pi-user-agents 0.0.3 → 0.0.4

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.
package/INTERNALS.md CHANGED
@@ -30,6 +30,23 @@ A plain `<user_agent>` tag follows it. The tag contains only the model and inher
30
30
 
31
31
  One selection rule builds the body. It keeps every user message. Before each next user message, it keeps only the latest assistant response. It applies the same rule after the final user message. Tool calls, thinking, tool results, and other message roles stay out.
32
32
 
33
+ ## Child session persistence
34
+
35
+ Every dispatched agent gets a persisted `SessionManager.create(cwd, undefined, { parentSession })` rather than an in-memory one, so its conversation is an ordinary Pi session file from birth. `parentSession` is a *path*, and it records the main session that dispatched the agent — the same field `/fork` uses, surfaced by `SessionManager.list()` as `parentSessionPath`. There is no API to promote an in-memory session to disk later: `SessionManager` fixes `persist` at construction, and both `_persist()` and `_rewriteFile()` return early when it is false, so `setSessionFile()` on an in-memory manager sets a path and writes nothing.
36
+
37
+ Two ordering constraints follow from `createAgentSession`:
38
+
39
+ 1. **Build the session while its manager is still empty.** Pi writes the `model_change` and `thinking_level_change` entries only on the empty-session path. Seed messages first and those entries never appear, so a resumed child falls back to `findInitialModel` instead of the model it was dispatched with.
40
+ 2. **Route the inherited snapshot through the session manager.** Persistence runs off message events, so the old `session.agent.state.messages = inherited` assignment never reached the file. `persistInheritedMessages` appends it entry by entry, then the agent state is set to the same list.
41
+
42
+ That snapshot is already compaction-resolved, so at most one `compactionSummary` leads it. Each role takes its own append method — `appendCompaction`, `branchWithSummary`, `appendCustomMessageEntry`, `appendMessage` — because pi's own compaction and branch bookkeeping locates boundaries by entry type, and a summary written as a plain message entry would be invisible to it. `appendCompaction` wants a `firstKeptEntryId` the replay cannot know, but `buildContextEntries` only consults it for entries *before* the compaction entry; a leading compaction has none, so the value is never read.
43
+
44
+ The file itself appears on the child's first assistant message (`_persist` defers until then). An agent dispatched with `-i` that never answers therefore leaves nothing behind, while an agent that inherited context has a file immediately.
45
+
46
+ ## Detaching
47
+
48
+ `d` `d` ends the live child session and drops its widget row. The session file is untouched, and the transcript gets a `pi-user-agents-detached` custom entry naming the session id so it can be resumed later. Both detach paths funnel through `closeRunning` and `removeCompleted`, which is where the announcement is made; joining and session shutdown deliberately do not announce.
49
+
33
50
  ## Runtime sharing
34
51
 
35
52
  Child agent sessions share the parent's `ModelRuntime` instance. This is critical because extension-registered providers (like `claude-bridge`) use a global `Symbol.for` dedup guard — they register once per process and skip subsequent loads. A child session with its own fresh runtime would never receive the provider, leaving it without auth for bridge-backed models.
package/README.md CHANGED
@@ -64,9 +64,19 @@ A background agent is not fire-and-forget — it's a session you can talk to.
64
64
 
65
65
  Press `Enter` in the overlay to open the steer composer. Mid-turn, your message queues in after the current tool batch, before the next model call — exactly like steering the main agent. After the turn completes, the same composer starts **another turn** on the same live session. Follow up as many times as you need; every completed turn posts its own result card.
66
66
 
67
- `Ctrl+x` interrupts only the current turn — the agent goes idle and stays available for steering, joining, or disposal. In fact, nothing disposes an agent except you: pressing `x` twice, joining it, or ending the Pi session.
67
+ `Ctrl+x` interrupts only the current turn — the agent goes idle and stays available for steering, joining, or detaching. In fact, nothing ends an agent except you: pressing `d` twice, joining it, or ending the Pi session.
68
68
 
69
- ### 4. Join — or don't
69
+ ### 4. Detach
70
+
71
+ Every dispatched agent gets its own Pi session on disk, exactly like the one you're sitting in. When a row has served its purpose, press `d` twice: the agent stops, its row leaves the widget, and its session file is left untouched. The transcript records which one it was:
72
+
73
+ ```
74
+ Detached session 0199c4f2-8b1a-7c3d-9e05-6a2f18d7b4ce
75
+ ```
76
+
77
+ Pick it back up whenever you like — `/resume 0199c4f2`, or `pi -r` and choose it from the list. It resumes as an ordinary Pi session with its full history, its model, and its thinking level, and from there it is a normal agent you talk to directly.
78
+
79
+ ### 5. Join — or don't
70
80
 
71
81
  This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
72
82
 
@@ -113,7 +123,7 @@ Everything goes through one command: `/agent [options] <task>`.
113
123
  | Editor | `←` / `↓` | Focus the agents widget |
114
124
  | Widget | `↑` `↓` · `Enter` | Select an agent · open its overlay |
115
125
  | Widget / overlay | `Ctrl+x` | Interrupt the current turn (agent stays alive) |
116
- | Widget / overlay | `x` `x` | Dispose the agent (twice to confirm) |
126
+ | Widget / overlay | `d` `d` | Detach the agent, keeping its session (twice to confirm) |
117
127
  | Widget / overlay | `Esc` | Back |
118
128
  | Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
119
129
  | Overlay | `j` | Join the conversation into the main context |
@@ -122,7 +132,8 @@ Everything goes through one command: `/agent [options] <task>`.
122
132
 
123
133
  ## Good to know
124
134
 
125
- - Result cards persist across session reloads; live agent sessions don't. After a reload you keep every card, not the steerable sessions.
135
+ - Every dispatch writes a real session file, so dispatched agents show up in `/resume` and `pi -r` alongside your own sessions. An agent that never answered leaves no file.
136
+ - Result cards persist across session reloads; the widget doesn't. After a reload you keep every card, and every agent's session is still on disk — but the steerable rows are gone. Reach them with `/resume`.
126
137
  - Some accepted `pi` options have no effect on a background run (the session, approval, offline, and API-key families). They parse; they just don't do anything yet.
127
138
  - `c` copies via `pbcopy`, so it's macOS-only for now.
128
139
  - The overlay caps very large tool outputs and omits thinking entries.
package/agent-viewer.ts CHANGED
@@ -32,7 +32,7 @@ import {
32
32
  } from "./tool-render/index.js";
33
33
 
34
34
  export type ViewableAgent = RunningAgent | CompletedAgent;
35
- export type AgentViewerAction = "hide" | "dispose";
35
+ export type AgentViewerAction = "hide" | "detach";
36
36
 
37
37
  export type ActivityDescription = {
38
38
  text: string;
@@ -141,7 +141,7 @@ function truncateMiddle(
141
141
 
142
142
  const VIEWER_CHROME_LINES = 6;
143
143
  const VIEWER_MIN_VIEWPORT = 3;
144
- const VIEWER_HEIGHT_PCT = 70;
144
+ const VIEWER_HEIGHT_PCT = 85;
145
145
 
146
146
  /** Focus-capturing overlay that shows an agent's live or finished response. */
147
147
  export class AgentViewer implements Component {
@@ -149,7 +149,7 @@ export class AgentViewer implements Component {
149
149
  private autoScroll = true;
150
150
  private lastWidth = 0;
151
151
  private justCopied = false;
152
- private confirmingDispose = false;
152
+ private confirmingDetach = false;
153
153
  private composer: Input | undefined;
154
154
  /** Rich tool rendering is far too costly to repeat on every 100 ms widget tick. */
155
155
  private renderedContent: { key: string; lines: string[] } | undefined;
@@ -166,22 +166,22 @@ export class AgentViewer implements Component {
166
166
 
167
167
  handleInput(data: string): void {
168
168
  if (matchesKey(data, "ctrl+x") && isLiveAgent(this.agent)) {
169
- this.confirmingDispose = false;
169
+ this.confirmingDetach = false;
170
170
  this.interrupt();
171
171
  this.tui.requestRender();
172
172
  return;
173
173
  }
174
174
  if (this.composer) {
175
- this.confirmingDispose = false;
175
+ this.confirmingDetach = false;
176
176
  this.composer.handleInput(data);
177
177
  this.tui.requestRender();
178
178
  return;
179
179
  }
180
- if (matchesKey(data, "x")) {
181
- this.requestDispose();
180
+ if (matchesKey(data, "d")) {
181
+ this.requestDetach();
182
182
  return;
183
183
  }
184
- this.confirmingDispose = false;
184
+ this.confirmingDetach = false;
185
185
  if (matchesKey(data, "escape") || matchesKey(data, "q")) {
186
186
  this.done("hide");
187
187
  return;
@@ -253,6 +253,7 @@ export class AgentViewer implements Component {
253
253
  renderAgentContextMeter(this.agent, th),
254
254
  th.fg("dim", status),
255
255
  ...stats.map((stat) => th.fg("dim", stat)),
256
+ th.fg("dim", this.agent.sessionId),
256
257
  ]
257
258
  .filter(Boolean)
258
259
  .join(` ${th.fg("dim", "·")} `);
@@ -310,8 +311,8 @@ export class AgentViewer implements Component {
310
311
  mainContext ? th.fg("dim", mainContext) : "",
311
312
  isLiveAgent(this.agent) ? th.fg("dim", "Esc hide") : "",
312
313
  th.fg(
313
- this.confirmingDispose ? "error" : "dim",
314
- this.confirmingDispose ? "x again to confirm" : "x dispose",
314
+ this.confirmingDetach ? "error" : "dim",
315
+ this.confirmingDetach ? "d again to confirm" : "d detach",
315
316
  ),
316
317
  ].filter(Boolean),
317
318
  );
@@ -325,12 +326,12 @@ export class AgentViewer implements Component {
325
326
 
326
327
  dispose(): void {}
327
328
 
328
- private requestDispose(): void {
329
- if (this.confirmingDispose) {
330
- this.done("dispose");
329
+ private requestDetach(): void {
330
+ if (this.confirmingDetach) {
331
+ this.done("detach");
331
332
  return;
332
333
  }
333
- this.confirmingDispose = true;
334
+ this.confirmingDetach = true;
334
335
  this.tui.requestRender();
335
336
  }
336
337
 
package/index.ts CHANGED
@@ -4,15 +4,22 @@ import type {
4
4
  } from "@earendil-works/pi-coding-agent";
5
5
  import { registerAgentAutocomplete } from "./autocomplete.js";
6
6
  import { handleAgentCommand } from "./runner.js";
7
- import type { AgentCommandDetails, ExtensionAPI, RunningAgent } from "./shared.js";
8
- import { MESSAGE_TYPE } from "./shared.js";
7
+ import type {
8
+ AgentCommandDetails,
9
+ DetachedEntryData,
10
+ ExtensionAPI,
11
+ RunningAgent,
12
+ } from "./shared.js";
13
+ import { DETACHED_ENTRY_TYPE, MESSAGE_TYPE } from "./shared.js";
9
14
  import { registerUserAgentRenderer } from "./transcript.js";
10
15
  import { UserAgentWidget } from "./widget.js";
11
16
 
12
17
  export default function userAgent(pi: ExtensionAPI): void {
13
18
  const runningAgents = new Set<RunningAgent>();
14
- const widget = new UserAgentWidget(runningAgents, (message) =>
15
- pi.sendMessage(message, { triggerTurn: true }),
19
+ const widget = new UserAgentWidget(
20
+ runningAgents,
21
+ (message) => pi.sendMessage(message, { triggerTurn: true }),
22
+ (sessionId) => pi.appendEntry<DetachedEntryData>(DETACHED_ENTRY_TYPE, { sessionId }),
16
23
  );
17
24
  let shuttingDown = false;
18
25
  let nextAgentNumber = 0;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giladbarnea/pi-user-agents",
3
- "version": "0.0.3",
3
+ "version": "0.0.4",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",
package/runner.ts CHANGED
@@ -96,7 +96,18 @@ async function startUserAgent(
96
96
  services.modelRuntime.getModel(selectedModel.provider, selectedModel.id) ?? selectedModel;
97
97
  const thinkingLevel = forwarded.thinkingLevel ?? pi.getThinkingLevel();
98
98
  const inheritedMessages = parsed.isolate ? [] : buildInheritedMessages(ctx);
99
- const runningAgent = createRunningAgent(nextAgentNumber(), command, model, parsed, invocation);
99
+ // A real session file from birth: the child outlives the widget row and stays resumable.
100
+ const childSessionManager = SessionManager.create(services.cwd, undefined, {
101
+ parentSession: ctx.sessionManager.getSessionFile(),
102
+ });
103
+ const runningAgent = createRunningAgent(
104
+ nextAgentNumber(),
105
+ childSessionManager.getSessionId(),
106
+ command,
107
+ model,
108
+ parsed,
109
+ invocation,
110
+ );
100
111
 
101
112
  runningAgents.add(runningAgent);
102
113
  logSteering(runningAgent.id, "agent-created", { command, taskLength: parsed.task.length });
@@ -117,6 +128,7 @@ async function startUserAgent(
117
128
  forwarded,
118
129
  inheritedMessages,
119
130
  services,
131
+ childSessionManager,
120
132
  runningAgent,
121
133
  widget,
122
134
  ).finally(() => {
@@ -129,6 +141,7 @@ async function startUserAgent(
129
141
 
130
142
  function createRunningAgent(
131
143
  sequenceNumber: number,
144
+ sessionId: string,
132
145
  command: AgentCommandName,
133
146
  model: Model,
134
147
  parsed: ParsedAgentCommand,
@@ -136,6 +149,7 @@ function createRunningAgent(
136
149
  ): RunningAgent {
137
150
  return {
138
151
  id: `user-${sequenceNumber.toString(36)}`,
152
+ sessionId,
139
153
  command,
140
154
  inheritedContext: !parsed.isolate,
141
155
  model: formatModel(model),
@@ -342,6 +356,7 @@ async function runAgentLifecycle(
342
356
  forwarded: ForwardedOptions,
343
357
  inheritedMessages: AgentMessage[],
344
358
  services: AgentSessionServices,
359
+ childSessionManager: SessionManager,
345
360
  runningAgent: RunningAgent,
346
361
  widget: UserAgentWidget,
347
362
  ): Promise<void> {
@@ -354,6 +369,7 @@ async function runAgentLifecycle(
354
369
  forwarded,
355
370
  inheritedMessages,
356
371
  services,
372
+ childSessionManager,
357
373
  runningAgent,
358
374
  widget,
359
375
  );
@@ -487,12 +503,15 @@ async function createChildSession(
487
503
  forwarded: ForwardedOptions,
488
504
  inheritedMessages: AgentMessage[],
489
505
  services: AgentSessionServices,
506
+ childSessionManager: SessionManager,
490
507
  runningAgent: RunningAgent,
491
508
  widget: UserAgentWidget,
492
509
  ): Promise<AgentSession> {
510
+ // Built while the session is still empty: that is the only path on which pi records the
511
+ // model and thinking level, and without those entries a resumed child falls back to defaults.
493
512
  const { session } = await createAgentSessionFromServices({
494
513
  services,
495
- sessionManager: SessionManager.inMemory(services.cwd),
514
+ sessionManager: childSessionManager,
496
515
  model,
497
516
  thinkingLevel,
498
517
  tools: forwarded.tools,
@@ -509,12 +528,38 @@ async function createChildSession(
509
528
  await session.bindExtensions({ mode: "print" });
510
529
  logSteering(runningAgent.id, "session-bound", { streaming: session.isStreaming });
511
530
  if (inheritedMessages.length > 0) {
531
+ persistInheritedMessages(childSessionManager, inheritedMessages);
512
532
  session.agent.state.messages = inheritedMessages;
513
533
  logSteering(runningAgent.id, "context-inherited", { messageCount: inheritedMessages.length });
514
534
  }
515
535
  return session;
516
536
  }
517
537
 
538
+ /**
539
+ * Mirror the inherited snapshot into the child's session file so a resumed child sees the
540
+ * same conversation the live one saw. Each role goes through its own append method: the
541
+ * snapshot is already compaction-resolved, so its summary leads the child's own branch.
542
+ */
543
+ export function persistInheritedMessages(
544
+ sessionManager: SessionManager,
545
+ messages: readonly AgentMessage[],
546
+ ): void {
547
+ for (const message of messages) {
548
+ if (message.role === "compactionSummary")
549
+ sessionManager.appendCompaction(message.summary, "", message.tokensBefore);
550
+ else if (message.role === "branchSummary")
551
+ sessionManager.branchWithSummary(sessionManager.getLeafId(), message.summary);
552
+ else if (message.role === "custom")
553
+ sessionManager.appendCustomMessageEntry(
554
+ message.customType,
555
+ message.content,
556
+ message.display,
557
+ message.details,
558
+ );
559
+ else sessionManager.appendMessage(message);
560
+ }
561
+ }
562
+
518
563
  /** Parks an idle agent until the user resumes it with another instruction (resolves with it) or retires it (resolves undefined). */
519
564
  export function waitForInstruction(
520
565
  agent: RunningAgent,
package/shared.ts CHANGED
@@ -40,6 +40,10 @@ export type AgentEntryData = {
40
40
  details: AgentCommandDetails;
41
41
  };
42
42
 
43
+ export type DetachedEntryData = {
44
+ sessionId: string;
45
+ };
46
+
43
47
  export type AgentResultMessage = {
44
48
  customType: string;
45
49
  content: string;
@@ -67,6 +71,8 @@ export type AgentCommandDetails = {
67
71
 
68
72
  export type RunningAgent = {
69
73
  id: string;
74
+ /** The child's own Pi session, resumable with `/resume <sessionId>` after the agent is detached. */
75
+ sessionId: string;
70
76
  command: AgentCommandName;
71
77
  inheritedContext: boolean;
72
78
  model: string;
@@ -106,6 +112,7 @@ export type RunningAgent = {
106
112
 
107
113
  export type CompletedAgent = {
108
114
  id: string;
115
+ sessionId: string;
109
116
  command: AgentCommandName;
110
117
  modelLabel: string;
111
118
  task: string;
@@ -123,6 +130,7 @@ export type CompletedAgent = {
123
130
  };
124
131
 
125
132
  export const MESSAGE_TYPE = "pi-user-agents";
133
+ export const DETACHED_ENTRY_TYPE = "pi-user-agents-detached";
126
134
  export const WIDGET_KEY = "pi-user-agents";
127
135
  export const STEERING_LOG_PATH = `${tmpdir()}/pi-user-agents-steer.log`;
128
136
 
package/transcript.ts CHANGED
@@ -6,6 +6,7 @@ import type {
6
6
  AgentEntryData,
7
7
  AgentMessage,
8
8
  AgentResultMessage,
9
+ DetachedEntryData,
9
10
  ExtensionAPI,
10
11
  ExtensionCommandContext,
11
12
  RunningAgent,
@@ -13,6 +14,7 @@ import type {
13
14
  } from "./shared.js";
14
15
  import {
15
16
  contextLabel,
17
+ DETACHED_ENTRY_TYPE,
16
18
  escapeAttribute,
17
19
  extractTag,
18
20
  formatMs,
@@ -212,6 +214,11 @@ export function registerUserAgentRenderer(pi: ExtensionAPI): void {
212
214
  )
213
215
  : undefined,
214
216
  );
217
+ pi.registerEntryRenderer<DetachedEntryData>(DETACHED_ENTRY_TYPE, (entry, _state, theme) =>
218
+ entry.data
219
+ ? new Text(theme.fg("dim", `Detached session ${entry.data.sessionId}`), 0, 0)
220
+ : undefined,
221
+ );
215
222
  }
216
223
 
217
224
  function liveAgentCard(
package/widget.ts CHANGED
@@ -49,12 +49,13 @@ export class UserAgentWidget {
49
49
  private active = false;
50
50
  private selectedIndex = 0;
51
51
  private viewerOpen = false;
52
- private disposeConfirmationTarget: ViewableAgent | undefined;
52
+ private detachConfirmationTarget: ViewableAgent | undefined;
53
53
  private readonly completedAgents: CompletedAgent[] = [];
54
54
 
55
55
  constructor(
56
56
  private readonly runningAgents: Set<RunningAgent>,
57
57
  private readonly sendJoinedResult: (message: AgentResultMessage) => void,
58
+ private readonly announceDetached: (sessionId: string) => void,
58
59
  ) {}
59
60
 
60
61
  setUI(ui: UIContext): void {
@@ -63,7 +64,7 @@ export class UserAgentWidget {
63
64
  this.ui = ui;
64
65
  this.widgetRegistered = false;
65
66
  this.tui = undefined;
66
- this.disposeConfirmationTarget = undefined;
67
+ this.detachConfirmationTarget = undefined;
67
68
  this.inputUnsub = ui.onTerminalInput((data) => this.handleKey(data));
68
69
  }
69
70
 
@@ -74,6 +75,7 @@ export class UserAgentWidget {
74
75
  ): void {
75
76
  this.completedAgents.push({
76
77
  id: agent.id,
78
+ sessionId: agent.sessionId,
77
79
  command: agent.command,
78
80
  modelLabel: agent.modelLabel,
79
81
  task: agent.task,
@@ -140,7 +142,7 @@ export class UserAgentWidget {
140
142
  this.widgetRegistered = false;
141
143
  this.tui = undefined;
142
144
  this.active = false;
143
- this.disposeConfirmationTarget = undefined;
145
+ this.detachConfirmationTarget = undefined;
144
146
  }
145
147
 
146
148
  private clear(): void {
@@ -155,7 +157,7 @@ export class UserAgentWidget {
155
157
  }
156
158
  this.active = false;
157
159
  this.selectedIndex = 0;
158
- this.disposeConfirmationTarget = undefined;
160
+ this.detachConfirmationTarget = undefined;
159
161
  }
160
162
 
161
163
  private runningAgentsForWidget(): RunningAgent[] {
@@ -199,13 +201,13 @@ export class UserAgentWidget {
199
201
  }
200
202
 
201
203
  if (matchesKey(data, "down")) {
202
- this.disposeConfirmationTarget = undefined;
204
+ this.detachConfirmationTarget = undefined;
203
205
  this.selectedIndex = Math.min(entries.length - 1, this.selectedIndex + 1);
204
206
  this.update();
205
207
  return { consume: true };
206
208
  }
207
209
  if (matchesKey(data, "up")) {
208
- this.disposeConfirmationTarget = undefined;
210
+ this.detachConfirmationTarget = undefined;
209
211
  if (this.selectedIndex === 0) {
210
212
  this.deactivate();
211
213
  return { consume: true };
@@ -225,17 +227,17 @@ export class UserAgentWidget {
225
227
  selected?.kind === "running" &&
226
228
  isLiveAgent(selected.agent)
227
229
  ) {
228
- this.disposeConfirmationTarget = undefined;
230
+ this.detachConfirmationTarget = undefined;
229
231
  this.interruptRunning(selected.agent);
230
232
  return { consume: true };
231
233
  }
232
234
  if (matchesKey(data, "enter")) {
233
- this.disposeConfirmationTarget = undefined;
235
+ this.detachConfirmationTarget = undefined;
234
236
  if (selected) this.openSelected(selected.agent);
235
237
  return { consume: true };
236
238
  }
237
- if (matchesKey(data, "x") && selected) {
238
- if (!this.confirmDispose(selected.agent)) return { consume: true };
239
+ if (matchesKey(data, "d") && selected) {
240
+ if (!this.confirmDetach(selected.agent)) return { consume: true };
239
241
  if (selected.kind === "running") this.closeRunning(selected.agent);
240
242
  else this.dismissCompleted(selected.agent);
241
243
  return { consume: true };
@@ -245,12 +247,12 @@ export class UserAgentWidget {
245
247
  return undefined;
246
248
  }
247
249
 
248
- private confirmDispose(target: ViewableAgent): boolean {
249
- if (this.disposeConfirmationTarget === target) {
250
- this.disposeConfirmationTarget = undefined;
250
+ private confirmDetach(target: ViewableAgent): boolean {
251
+ if (this.detachConfirmationTarget === target) {
252
+ this.detachConfirmationTarget = undefined;
251
253
  return true;
252
254
  }
253
- this.disposeConfirmationTarget = target;
255
+ this.detachConfirmationTarget = target;
254
256
  this.update();
255
257
  return false;
256
258
  }
@@ -258,7 +260,7 @@ export class UserAgentWidget {
258
260
  private deactivate(): void {
259
261
  this.active = false;
260
262
  this.selectedIndex = 0;
261
- this.disposeConfirmationTarget = undefined;
263
+ this.detachConfirmationTarget = undefined;
262
264
  this.update();
263
265
  }
264
266
 
@@ -290,7 +292,7 @@ export class UserAgentWidget {
290
292
  if (isRunningAgent(agent)) this.interruptRunning(agent);
291
293
  },
292
294
  ),
293
- { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "70%" } },
295
+ { overlay: true, overlayOptions: { anchor: "center", width: "90%", maxHeight: "85%" } },
294
296
  )
295
297
  .then(
296
298
  (action) => this.onViewerClosed(agent, action),
@@ -298,10 +300,12 @@ export class UserAgentWidget {
298
300
  );
299
301
  }
300
302
 
303
+ /** Detaching ends the live session and leaves its session file on disk, untouched. */
301
304
  private closeRunning(agent: RunningAgent): void {
302
305
  agent.aborted = true;
303
306
  void agent.session?.abort();
304
307
  agent.retire?.();
308
+ this.announceDetached(agent.sessionId);
305
309
  this.update();
306
310
  }
307
311
 
@@ -368,12 +372,12 @@ export class UserAgentWidget {
368
372
 
369
373
  private onViewerClosed(agent: ViewableAgent, action: AgentViewerAction): void {
370
374
  this.viewerOpen = false;
371
- if (action === "dispose" && isRunningAgent(agent) && this.runningAgents.has(agent)) {
375
+ if (action === "detach" && isRunningAgent(agent) && this.runningAgents.has(agent)) {
372
376
  this.closeRunning(agent);
373
377
  return;
374
378
  }
375
379
  const completedAgent = this.completedAgents.find((candidate) => candidate.id === agent.id);
376
- if (action === "dispose" && completedAgent) this.removeCompleted(completedAgent);
380
+ if (action === "detach" && completedAgent) this.removeCompleted(completedAgent);
377
381
  else this.update();
378
382
  }
379
383
 
@@ -384,6 +388,7 @@ export class UserAgentWidget {
384
388
  private removeCompleted(agent: CompletedAgent): void {
385
389
  const index = this.completedAgents.indexOf(agent);
386
390
  if (index >= 0) this.completedAgents.splice(index, 1);
391
+ this.announceDetached(agent.sessionId);
387
392
  const total = this.entries().length;
388
393
  if (total === 0) {
389
394
  this.deactivate();
@@ -413,18 +418,18 @@ export class UserAgentWidget {
413
418
  lines.push(truncateToWidth(` ${theme.fg("dim", "←/↓ select agent")}`, width));
414
419
  } else {
415
420
  const selected = entries[this.selectedIndex];
416
- const confirmingDispose = this.disposeConfirmationTarget === selected?.agent;
421
+ const confirmingDetach = this.detachConfirmationTarget === selected?.agent;
417
422
  const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
418
423
  if (
419
- !confirmingDispose &&
424
+ !confirmingDetach &&
420
425
  selected?.kind === "running" &&
421
426
  isLiveAgent(selected.agent)
422
427
  )
423
428
  segments.push(theme.fg("accent", "Ctrl+x interrupt"));
424
429
  segments.push(
425
430
  theme.fg(
426
- confirmingDispose ? "error" : "accent",
427
- confirmingDispose ? "x again to confirm" : "x dispose",
431
+ confirmingDetach ? "error" : "accent",
432
+ confirmingDetach ? "d again to confirm" : "d detach",
428
433
  ),
429
434
  theme.fg("accent", "Esc back"),
430
435
  );