@giladbarnea/pi-user-agents 0.0.2 → 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.
@@ -38,7 +55,7 @@ The shared runtime is extracted from the parent's `ModelRegistry` facade (`ctx.m
38
55
 
39
56
  ## Model name & alias resolution
40
57
 
41
- User-defined model aliases (`name` in `models.json` `modelOverrides`) take precedence: `-m opus` resolves to `claude-bridge/claude-opus-4-6` directly, without ambiguity. The extension checks for an exact name match before falling through to the SDK's `resolveCliModel`, which only checks `model.id` (never `model.name`) and otherwise falls back to substring matching — where "opus" collides with dozens of built-in models across the catalog.
58
+ User-defined model aliases (`name` in `models.json` `modelOverrides`) take precedence: `-m opus` resolves to `claude-bridge/claude-opus-4-6` directly, without ambiguity. Other model values must exactly match a live model ID or canonical `provider/id` reference before the extension calls the SDK's `resolveCliModel`. Partial and custom model IDs are rejected.
42
59
 
43
60
  > **Note (known issue):** `enabledModels` (i.e., “Scoped models”) currently have no special role in how the extension resolves models. They should be promoted to the same precedence level as user-defined model names in a future fix.
44
61
 
@@ -48,7 +65,7 @@ Exact names should be unique: duplicate aliases silently select the first model,
48
65
 
49
66
  ### Semantic command coloring
50
67
 
51
- While you type, the editor paints `/agent` lines from the same semantic scan that drives submission parsing (see `PARSER_SPEC.md` §11): the command in the theme's `accent`, recognized leading options in `syntaxKeyword`, and valid values (quotes included) in `syntaxString`. Invalid thinking levels, unresolved model IDs or aliases, and unknown provider or tool names use `error`, as do explicitly blocked Pi options such as `-c`/`--continue` and options typed after the task prose has begun — along with their values. Validation comes from Pi's argument parser, model resolver, and live catalogs rather than a second editor grammar. Prose, unknown dash-words, quoted spans, and escaped words stay plain. All colors come from the active Pi theme, so custom themes and hot reload apply automatically.
68
+ While you type, the editor paints `/agent` lines from the same semantic scan that drives submission parsing (see `PARSER_SPEC.md` §11): the command in the theme's `accent`, recognized leading options in `syntaxType`, and valid values (quotes included) in `syntaxString`. Invalid thinking levels, unresolved model IDs or aliases, and unknown provider or tool names use `error`, including blocked Pi options such as `-c`/`--continue` and `-p`/`--print`, plus options typed after the task prose has begun — along with their values. Validation comes from Pi's argument parser, model resolver, and live catalogs rather than a second editor grammar. Prose, unknown dash-words, quoted spans, and escaped words stay plain. All colors come from the active Pi theme, so custom themes and hot reload apply automatically.
52
69
 
53
70
  ### Eager argument completion
54
71
 
package/PARSER_SPEC.md CHANGED
@@ -45,9 +45,10 @@ leading whitespace (unchanged from today).
45
45
  | `-i`, `--isolate` | boolean | Consumed here; `isolate = true` |
46
46
  | `-j`, `--join` | boolean | Consumed here; `context = true` |
47
47
 
48
- `-m` and `--model` are one semantic option. Both resolve once through Pi's
49
- `resolveCliModel()` path, and a preceding `--provider` scopes either spelling. `-j` is the
50
- extension's join option. A model option with no value and no remaining task ends in the normal
48
+ `-m` and `--model` are one semantic option. Both require an exact live model ID, canonical
49
+ `provider/id` reference, or user-defined alias before resolving through Pi's `resolveCliModel()`
50
+ path. A preceding `--provider` scopes either spelling. `-j` is the extension's join option.
51
+ A model option with no value and no remaining task ends in the normal
51
52
  **usage error**.
52
53
 
53
54
  ### 3b. Known pi options — declared statically; added to `forwardedArgs`
@@ -59,8 +60,7 @@ extension's join option. A model option with no value and no remaining task ends
59
60
  **Boolean** (consume no value):
60
61
  `--resume`/`-r`, `--no-session`, `--no-tools`/`-nt`, `--no-builtin-tools`/`-nbt`,
61
62
  `--no-extensions`/`-ne`, `--no-skills`/`-ns`, `--no-prompt-templates`/`-np`, `--no-themes`,
62
- `--no-context-files`/`-nc`, `--verbose`, `--approve`/`-a`, `--no-approve`/`-na`, `--offline`,
63
- `--print`/`-p`.
63
+ `--no-context-files`/`-nc`, `--verbose`, `--approve`/`-a`, `--no-approve`/`-na`, `--offline`.
64
64
 
65
65
  Notes:
66
66
  - The shared declaration is a snapshot of `pi --help`; it is maintained by hand. There is no
@@ -77,7 +77,6 @@ Notes:
77
77
  The dequoted value is stored as **one element** in `forwardedArgs`
78
78
  (e.g. `["--system-prompt", "I am psyched"]`), so pi's `parseArgs` maps it as a single value.
79
79
  If a value opens with a quote but has no matching close, fall back to the whitespace-delimited token.
80
- - `--print`/`-p` is modeled as **boolean** here (pi's optional-value form would eat prose).
81
80
 
82
81
  Value examples:
83
82
  - `--system-prompt "I am psyched"` → value `I am psyched`.
@@ -86,8 +85,8 @@ Value examples:
86
85
  - `-m "gpt56s"` → `forwardedArgs` contains `--model`, `gpt56s`.
87
86
 
88
87
  ### 3c. Blocked pi options — rejected
89
- `-c`/`--continue`, `--theme <path>`, `--models`, `--export`, `--list-models`, `-h`/`--help`,
90
- `-v`/`--version`.
88
+ `-c`/`--continue`, `-p`/`--print`, `--theme <path>`, `--models`, `--export`, `--list-models`,
89
+ `-h`/`--help`, `-v`/`--version`.
91
90
  Appearing in **args mode** (leading) → hard **error**:
92
91
  `"/agent does not support <opt>; it would disrupt the background agent run."`
93
92
 
@@ -239,10 +238,9 @@ source spans:
239
238
  - `option` — a recognized, non-blocked leading option (extension-owned or forwarded);
240
239
  - `value` — the value consumed by a leading value-taking option. A quoted value's span
241
240
  includes its surrounding quotes;
242
- - `invalid-value` — a leading closed-set value rejected by the same canonical validation
243
- used at submission and session creation: Pi's argument parser for thinking levels, the
244
- child model resolver for model IDs and user-defined aliases, and the live provider and
245
- tool catalogs;
241
+ - `invalid-value` — a leading closed-set value rejected by the same validation used at
242
+ submission and session creation: Pi's argument parser for thinking levels, exact live model
243
+ IDs or user-defined aliases, and the live provider and tool catalogs;
246
244
  - `blocked` — a blocked option in args mode, plus the value a blocked value-taking option
247
245
  would consume (`--theme ./theme.json` yields two `blocked` tokens). Unlike
248
246
  `parseAgentCommand`, the scan never throws: it records the first blocked token and keeps
@@ -259,6 +257,6 @@ prose → usage error), so parsing and coloring cannot drift.
259
257
  cursor analysis (§10) and shifts token spans into editor-line coordinates.
260
258
 
261
259
  The editor coloring layer (`editor-coloring.ts`) maps token semantics onto Pi theme
262
- variables — command `accent`, options `syntaxKeyword`, valid values `syntaxString`, and
260
+ variables — command `accent`, options `syntaxType`, valid values `syntaxString`, and
263
261
  invalid, blocked, or misplaced tokens `error` — by repainting the laid-out chunk text after
264
262
  word-wrap layout, so editing, wrapping, cursor behavior, and autocomplete are untouched.
package/README.md CHANGED
@@ -36,17 +36,26 @@ The first preserves the session's knowledge before the context window fills, wit
36
36
 
37
37
  ### 2. Watch
38
38
 
39
- Every agent gets a row in a widget under the editor: status, task, live activity, and a one-cell context meter.
39
+ Every agent gets a row in a widget under the editor: status, task, model, turn count, tool uses, elapsed time, a one-cell context meter, and the last line of live activity.
40
40
 
41
- ```text
42
- ✓ User agents
43
- ↑↓ select · Enter view · x dispose · Esc back
44
- └─ ⏺ ✓ /agent ▇ i am testing the widget now - read a ton...
45
- ⎿ Loaded several large Pi source and documentation...
46
- ```
41
+ <a href="screenshots/below-editor-widget.png">
42
+ <img src="screenshots/below-editor-widget.png" width="100%" alt="The user agents widget under the editor, showing one finished agent and its latest output">
43
+ </a>
44
+
45
+ <div align="center"><em>The widget sits under the editor and stays out of your way.</em></div>
46
+
47
+ <br>
47
48
 
48
49
  Press `←` or `↓` from the editor to focus the widget, pick an agent, press `Enter`, and its overlay opens: the full conversation, streaming live, following the tail as it grows. Scroll up to pause following; `End` resumes it. Tool calls render as proper views — `read`, `edit`, `write`, `grep`, `find`, `ls`, and `bash` each get a dedicated format, everything else a readable generic one.
49
50
 
51
+ <a href="screenshots/overlay.png">
52
+ <img src="screenshots/overlay.png" width="100%" alt="The agent overlay: a rendered bash call, its output, a todo update, and the final response, over the dimmed main conversation">
53
+ </a>
54
+
55
+ <div align="center"><em>The overlay, over a main conversation that never paused.</em></div>
56
+
57
+ <br>
58
+
50
59
  The context meter is the agent's own footer gauge in one cell: it fills `▁▂▃▄▅▆▇█` against that agent's model context window and shifts color through the same stages as Pi's footer (dim, then muted at 40%, warning at 65%, error at 85%).
51
60
 
52
61
  ### 3. Steer
@@ -55,9 +64,19 @@ A background agent is not fire-and-forget — it's a session you can talk to.
55
64
 
56
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.
57
66
 
58
- `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
+
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.
59
78
 
60
- ### 4. Join — or don't
79
+ ### 5. Join — or don't
61
80
 
62
81
  This is what makes user agents different from subagents: **by default, the main agent never learns any of this happened.**
63
82
 
@@ -104,7 +123,7 @@ Everything goes through one command: `/agent [options] <task>`.
104
123
  | Editor | `←` / `↓` | Focus the agents widget |
105
124
  | Widget | `↑` `↓` · `Enter` | Select an agent · open its overlay |
106
125
  | Widget / overlay | `Ctrl+x` | Interrupt the current turn (agent stays alive) |
107
- | Widget / overlay | `x` `x` | Dispose the agent (twice to confirm) |
126
+ | Widget / overlay | `d` `d` | Detach the agent, keeping its session (twice to confirm) |
108
127
  | Widget / overlay | `Esc` | Back |
109
128
  | Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
110
129
  | Overlay | `j` | Join the conversation into the main context |
@@ -113,8 +132,9 @@ Everything goes through one command: `/agent [options] <task>`.
113
132
 
114
133
  ## Good to know
115
134
 
116
- - Result cards persist across session reloads; live agent sessions don't. After a reload you keep every card, not the steerable sessions.
117
- - Some accepted `pi` options have no effect on a background run (the session, approval, offline, API-key, and print families). They parse; they just don't do anything yet.
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`.
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.
118
138
  - `c` copies via `pbcopy`, so it's macOS-only for now.
119
139
  - The overlay caps very large tool outputs and omits thinking entries.
120
140
 
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/command-line.ts CHANGED
@@ -292,13 +292,13 @@ function booleanOptionsWithoutChildEffect(): AgentOptionDefinition[] {
292
292
  legacyBooleanOption("approve", ["--approve", "-a"]),
293
293
  legacyBooleanOption("no-approve", ["--no-approve", "-na"]),
294
294
  legacyBooleanOption("offline", ["--offline"]),
295
- legacyBooleanOption("print", ["--print", "-p"]),
296
295
  ];
297
296
  }
298
297
 
299
298
  function blockedOptions(): AgentOptionDefinition[] {
300
299
  return [
301
300
  blockedOption("continue", ["-c", "--continue"]),
301
+ blockedOption("print", ["-p", "--print"]),
302
302
  blockedOption("theme", ["--theme"], "value"),
303
303
  blockedOption("models", ["--models"]),
304
304
  blockedOption("export", ["--export"]),
@@ -11,7 +11,7 @@ export type StyleTheme = { fg(color: ThemeColor, text: string): string };
11
11
 
12
12
  export const AGENT_SEMANTIC_COLORS: Record<AgentTokenSemantic | "command", ThemeColor> = {
13
13
  command: "accent",
14
- option: "syntaxKeyword",
14
+ option: "syntaxType",
15
15
  value: "syntaxString",
16
16
  "invalid-value": "error",
17
17
  blocked: "error",
@@ -36,7 +36,7 @@ type ColoredEditor = ColoringRuntimeEditor & { [COLORED_EDITOR]?: true };
36
36
  * Map an editor line to theme-colored spans via the shared semantic scan.
37
37
  * Returns undefined for lines that are not agent commands.
38
38
  *
39
- * @example computeAgentColorSpans("/agent -m gpt56s go")?.[1] // { start: 7, end: 9, color: "syntaxKeyword" }
39
+ * @example computeAgentColorSpans("/agent -m gpt56s go")?.[1] // { start: 7, end: 9, color: "syntaxType" }
40
40
  */
41
41
  export function computeAgentColorSpans(
42
42
  line: string,
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.2",
3
+ "version": "0.0.4",
4
4
  "description": "Run, view, and control background Pi agents.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -23,6 +23,7 @@
23
23
  "files": [
24
24
  "*.ts",
25
25
  "tool-render",
26
+ "screenshots",
26
27
  "README.md",
27
28
  "PARSER_SPEC.md",
28
29
  "INTERNALS.md",
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),
@@ -239,6 +253,30 @@ export function createAgentValueValidator(
239
253
  };
240
254
  }
241
255
 
256
+ /**
257
+ * Return whether a model value exactly names a live ID, canonical reference, or user alias.
258
+ *
259
+ * @example isExactCatalogModelReference([{ provider: "openai", id: "gpt-5" } as Model], "openai/gpt-5") // true
260
+ */
261
+ function isExactCatalogModelReference(
262
+ models: readonly Model[],
263
+ value: string,
264
+ provider: string | undefined,
265
+ ): boolean {
266
+ const normalizedValue = value.toLowerCase();
267
+ const normalizedProvider = provider?.toLowerCase();
268
+ const exactAliasMatch = models.some(
269
+ (model) => model.name?.toLowerCase() === normalizedValue,
270
+ );
271
+ const exactReferenceMatch = models.some(
272
+ (model) =>
273
+ (normalizedProvider === undefined || model.provider.toLowerCase() === normalizedProvider) &&
274
+ (model.id.toLowerCase() === normalizedValue ||
275
+ `${model.provider}/${model.id}`.toLowerCase() === normalizedValue),
276
+ );
277
+ return exactAliasMatch || exactReferenceMatch;
278
+ }
279
+
242
280
  /** Resolve forwarded runtime options against the child session's model runtime. */
243
281
  export function resolveForwardedOptions(
244
282
  parsed: Args,
@@ -246,6 +284,10 @@ export function resolveForwardedOptions(
246
284
  ): ForwardedOptions {
247
285
  const forwarded: ForwardedOptions = {};
248
286
  if (parsed.model) {
287
+ if (!isExactCatalogModelReference(modelRuntime.getModels(), parsed.model, parsed.provider)) {
288
+ const display = parsed.provider ? `${parsed.provider}/${parsed.model}` : parsed.model;
289
+ throw new Error(`Model "${display}" not found in the live model catalog.`);
290
+ }
249
291
  const nameMatch = modelRuntime
250
292
  .getModels()
251
293
  .find((m) => m.name?.toLowerCase() === parsed.model!.toLowerCase());
@@ -314,6 +356,7 @@ async function runAgentLifecycle(
314
356
  forwarded: ForwardedOptions,
315
357
  inheritedMessages: AgentMessage[],
316
358
  services: AgentSessionServices,
359
+ childSessionManager: SessionManager,
317
360
  runningAgent: RunningAgent,
318
361
  widget: UserAgentWidget,
319
362
  ): Promise<void> {
@@ -326,6 +369,7 @@ async function runAgentLifecycle(
326
369
  forwarded,
327
370
  inheritedMessages,
328
371
  services,
372
+ childSessionManager,
329
373
  runningAgent,
330
374
  widget,
331
375
  );
@@ -459,12 +503,15 @@ async function createChildSession(
459
503
  forwarded: ForwardedOptions,
460
504
  inheritedMessages: AgentMessage[],
461
505
  services: AgentSessionServices,
506
+ childSessionManager: SessionManager,
462
507
  runningAgent: RunningAgent,
463
508
  widget: UserAgentWidget,
464
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.
465
512
  const { session } = await createAgentSessionFromServices({
466
513
  services,
467
- sessionManager: SessionManager.inMemory(services.cwd),
514
+ sessionManager: childSessionManager,
468
515
  model,
469
516
  thinkingLevel,
470
517
  tools: forwarded.tools,
@@ -481,12 +528,38 @@ async function createChildSession(
481
528
  await session.bindExtensions({ mode: "print" });
482
529
  logSteering(runningAgent.id, "session-bound", { streaming: session.isStreaming });
483
530
  if (inheritedMessages.length > 0) {
531
+ persistInheritedMessages(childSessionManager, inheritedMessages);
484
532
  session.agent.state.messages = inheritedMessages;
485
533
  logSteering(runningAgent.id, "context-inherited", { messageCount: inheritedMessages.length });
486
534
  }
487
535
  return session;
488
536
  }
489
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
+
490
563
  /** Parks an idle agent until the user resumes it with another instruction (resolves with it) or retires it (resolves undefined). */
491
564
  export function waitForInstruction(
492
565
  agent: RunningAgent,
Binary file
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
  );