@giladbarnea/pi-user-agents 0.0.8 → 0.0.9

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
@@ -73,10 +73,20 @@ The file itself appears on the child's first assistant message (`_persist` defer
73
73
 
74
74
  The session file does not persist the dispatch options, so every dispatch writes a `pi-user-agents-dispatch` custom entry into the child — the forwarded pi tokens, the plain task, and the isolate flag. At attach the record replays through the same projections dispatch used (`buildChildResourceLoaderOptions`, `resolveToolOptions`), so `--tools`, `--no-extensions`, `--system-prompt` and friends survive the round trip instead of silently widening back to pi defaults. The model and thinking level deliberately come from the file, not the record: reattachment is pi's own resume path — `SessionManager.open` plus `createAgentSessionFromServices` on a non-empty manager takes `createAgentSession`'s has-existing-session branch — and the file reflects any later changes. A record-less child (dispatched before the record existed, or `/fork`-made) still attaches, with pi's default tools and resources.
75
75
 
76
- The dispatch preamble — built at the dispatch call site, sent verbatim by `runChildTurns` — doubles as the boundary marker: `splitAtDispatchBoundary` finds the first user message carrying it and recovers the child's own conversation for the overlay and the inherited base whose fingerprint re-arms the rebase fast-forward. The fingerprint compares content, not file bookkeeping: rebuilding custom, compaction, and branch summaries from a session file re-stamps their timestamps (and a branch summary's `fromId`), so `conversationFingerprint` drops those fields or a round-tripped base would never equal main's live context. Without a recognizable boundary (a fork child, or a child whose compaction summarized it away) the whole context attaches and the base is a sentinel that never matches, so `r` stays withheld.
76
+ The dispatch preamble — built at the dispatch call site, sent verbatim by `runChildTurns`, or its pane counterpart for `-h` children — doubles as the boundary marker: `splitAtDispatchBoundary` finds the first user message carrying either and recovers the child's own conversation for the overlay and the inherited base whose fingerprint re-arms the rebase fast-forward. The fingerprint compares content, not file bookkeeping: rebuilding custom, compaction, and branch summaries from a session file re-stamps their timestamps (and a branch summary's `fromId`), so `conversationFingerprint` drops those fields or a round-tripped base would never equal main's live context. Without a recognizable boundary (a fork child, or a child whose compaction summarized it away) the whole context attaches and the base is a sentinel that never matches, so `r` stays withheld.
77
77
 
78
78
  The attached agent parks idle first (`runAttachedTurns` waits for an instruction before running the shared turn loop), and its latest persisted response seeds `pendingSquashMessage`, so steering, `s`, and `r` work immediately without a new turn. A successful attach leaves an `Attached session …` breadcrumb — the detach line's counterpart — and both success states end with a "View agent?" confirmation that opens the overlay.
79
79
 
80
+ ## Herdr
81
+
82
+ `h` and `-h` hand a child session to a Pi running in a new [herdr](https://herdr.dev) pane. Both go through `openPiInHerdrPane` (`herdr.ts`), a thin wrapper over the `herdr` CLI: `pane layout --current` picks the split direction (a wide caller pane splits right, a narrow or tall one down), `pane split --current --cwd <cwd> --no-focus` creates the pane without taking the user's focus, and `agent start <name> --kind pi --pane <id> -- <pi args>` starts pi there and returns once it is ready for input. The agent name is `agent-` plus the first 13 characters of the child's session id, which hold a v7 id's complete millisecond timestamp, so names stay unique among live agents. Every command runs with the caller's environment, so `--current` resolves the pane this Pi lives in; both actions are available only under `HERDR_ENV=1`, and the widget hint hides otherwise.
83
+
84
+ Both paths hand the pane's Pi `forwardedArgsForPane(...)`, the dispatch's forwarded tokens minus two groups: the *inert* options (`role: "inert"` in the option table — the session, approval, offline, and API-key families), which a background run ignores but a real `pi` process would obey (`--no-session` would run the pane ephemeral, `--fork` would open another file), and `--model`, `--provider`, `--thinking`, which the pane's Pi gets explicitly or from its file.
85
+
86
+ **The move (`h`)** is attach in reverse, for another process, in three steps ordered so that failures cost as little as possible. First the delivery resolves the file — like `/agent-attach`, from the session directory listing by exact id; a child that has not answered yet has no file and the move stops here — and splits the pane; a herdr failure at this point leaves the agent untouched. Then the widget ends the agent here: abort, retire, wait for `finished` so the child session is disposed, and keep its row as a completed snapshot. Only then does pi start in the pane, so the file has one writer at a time; the dispatch record's forwarded args replay as CLI flags and the model and thinking level come from the file, as on any resume. A live agent has to be interrupted first. Once the agent is ended, its snapshot row survives whatever happens next — with `herdrPane` set on success, so it renders `herdr pane w1:p3` in place of the status word and withholds `h`, or without it on failure, still squashable. This is the squash pattern: the row persists as the record, and no transcript breadcrumb is written; `d` on the row announces `Detached session …` like any other, which stays true — the file is on disk.
87
+
88
+ **The dispatch (`-h`)** never creates an in-process child session, and branches off before the child resource load that only a background child needs. The child's session manager is built as usual (dispatch record, inherited snapshot) plus explicit `model_change` and `thinking_level_change` entries appended after the snapshot: pi resumes a session with the model of whichever comes last on the path, a `model_change` entry or an assistant message, and writes `model_change` itself only on its empty-session path, which the pane's Pi never takes for an inherited context. Without the entries a later `/agent-attach` or `/resume` would take the model of main's last answer, or the default. `flushSessionFile` then writes the file to disk, because pi defers a session file until its first assistant message and a snapshot with no assistant message (`-i`, or a main session that has not answered yet) would otherwise exist only in memory; when the snapshot already flushed it, the file is left alone. The pane's Pi runs `pi <forwarded args> --provider <p> --model <id> --thinking <level> --session <file> "<pane preamble + task>"`: the flags repeat the model and thinking level because an isolated child's file has no messages, and pi then takes the flags over the file. The positional prompt carries `PANE_DISPATCH_PREAMBLE`, which tells the child it is interactive in a pane rather than an ephemeral background process; `splitAtDispatchBoundary` accepts either preamble, so attach recognizes the child later. The widget row is a snapshot from birth, with nothing to squash; `-h` and `-s` conflict at parse time.
89
+
80
90
  ## Runtime sharing
81
91
 
82
92
  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/PARSER_SPEC.md CHANGED
@@ -11,6 +11,7 @@ from the declaration in `command-line.ts`.
11
11
  type ParsedAgentCommand = {
12
12
  isolate: boolean; // -i / --isolate
13
13
  squash: boolean; // -s / --squash
14
+ herdr: boolean; // -h / --herdr
14
15
  forwardedArgs: string[]; // recognized pi option tokens (+values) in input order, e.g. ["--thinking","high"]
15
16
  task: string; // the prose prompt (backslash escapes applied, quotes kept). Never empty.
16
17
  warnings: string[]; // advisory messages (see §5). May be empty.
@@ -44,10 +45,12 @@ leading whitespace (unchanged from today).
44
45
  | `-m`, `--model` | value | Added to `forwardedArgs` as canonical `--model <value>` |
45
46
  | `-i`, `--isolate` | boolean | Consumed here; `isolate = true` |
46
47
  | `-s`, `--squash` | boolean | Consumed here; `squash = true` |
48
+ | `-h`, `--herdr` | boolean | Consumed here; `herdr = true` |
47
49
 
48
50
  `-m` and `--model` are one semantic option. Both require an exact live model ID, canonical
49
51
  `provider/id` reference, or user-defined alias before resolving through Pi's `resolveCliModel()`
50
- path. A preceding `--provider` scopes either spelling. `-s` is the extension's squash option.
52
+ path. A preceding `--provider` scopes either spelling. `-s` is the extension's squash option and
53
+ `-h` its herdr option; the two conflict, and a submission carrying both is a hard **error**.
51
54
  A model option with no value and no remaining task ends in the normal
52
55
  **usage error**.
53
56
 
@@ -65,6 +68,10 @@ A model option with no value and no remaining task ends in the normal
65
68
  Notes:
66
69
  - The shared declaration is a snapshot of `pi --help`; it is maintained by hand. There is no
67
70
  runtime dependency on pi's parser for recognition or arity.
71
+ - The session, approval, offline, and API-key families are declared `inert`: they are forwarded
72
+ to a background run, where they have no effect, and `forwardedArgsForPane` drops them (with
73
+ `--model`, `--provider`, and `--thinking`) from the command line of a Pi started in a herdr pane,
74
+ where a real `pi` process would obey them.
68
75
  - **Reading a value** (applies to every value-taking option, including both model spellings):
69
76
  the value is the token immediately after the option. If that token **begins with a quote char**
70
77
  (§6a) and a matching close quote appears later, the value is the text between the quotes —
@@ -86,7 +93,7 @@ Value examples:
86
93
 
87
94
  ### 3c. Blocked pi options — rejected
88
95
  `-c`/`--continue`, `-p`/`--print`, `--theme <path>`, `--models`, `--export`, `--list-models`,
89
- `-h`/`--help`, `-v`/`--version`.
96
+ `--help`, `-v`/`--version`. (`-h` is the extension's herdr option, §3a.)
90
97
  Appearing in **args mode** (leading) → hard **error**:
91
98
  `"/agent does not support <opt>; it would disrupt the background agent run."`
92
99
 
@@ -171,6 +178,7 @@ recognition and arity come from the shared declaration.
171
178
  - Bare `-m` (no value) → usage error.
172
179
  - Empty task after consuming options → usage error.
173
180
  - Blocked option leading → hard error; in prose → warning.
181
+ - `-h` together with `-s` → hard error.
174
182
  - No `--` options terminator (backslash/quotes handle "task starts with a dash").
175
183
  - Value-taking option with no following token → the option is still recorded (no value); no crash.
176
184
  - Multiple known options in prose → one warning per occurrence, in order.
package/README.md CHANGED
@@ -124,6 +124,20 @@ Rebase is a fast-forward, in the git sense: the agent forked from the main conve
124
124
 
125
125
  Delivering the rebase switches the session in place — same file, same session id, transcript redrawn — so `r` is withheld while any agent is mid-turn, and parked agents are detached first, each leaving its `Detached session …` line to `/resume` from. When siblings would be detached, the first `r` warns with the count and a second `r` confirms.
126
126
 
127
+ ### 7. Or give it a pane — herdr
128
+
129
+ When Pi runs inside [herdr](https://herdr.dev), the terminal multiplexer for coding agents, an agent does not have to stay in the background at all. Press `h` on any idle or finished agent and its session opens in a new pane beside you, as an ordinary interactive Pi: same model, same thinking level, same `--tools` and the rest of its dispatch options. Talk to it there.
130
+
131
+ The row stays in the widget, labeled with its new home:
132
+
133
+ ```
134
+ ⧉ /agent plan the next phase · luna · herdr pane w1:p3 · ↻2 · 5 tool uses · 42.1s
135
+ ```
136
+
137
+ Everything the agent said before it left is still readable in the overlay, and still squashes or rebases. The pane's Pi is the session's only writer from then on; `d` on the row clears it and leaves the usual `Detached session …` line, so the id stays findable.
138
+
139
+ `/agent -h <task>` skips the background entirely: the agent is born in a new pane, starts from the conversation snapshot (or from nothing with `-i`), and takes the task as its first prompt. Its row points at the pane from birth. Outside herdr, `h` explains why it did nothing and `-h` is an error.
140
+
127
141
  ## The mechanics
128
142
 
129
143
  ### 🎛️ Per-dispatch configuration
@@ -162,6 +176,7 @@ Everything goes through one command: `/agent [options] <task>`. Its one companio
162
176
  |---|---|
163
177
  | `-i`, `--isolate` | Start without the conversation snapshot |
164
178
  | `-s`, `--squash` | Deliver the result into the main context on completion |
179
+ | `-h`, `--herdr` | Start the agent in a new herdr pane instead of the background |
165
180
  | `-m MODEL` | Model for this agent (alias of Pi's `--model`) |
166
181
  | *pi CLI options* | Forwarded to the agent — `--thinking`, `--tools`, `--system-prompt`, … |
167
182
 
@@ -175,14 +190,16 @@ Everything goes through one command: `/agent [options] <task>`. Its one companio
175
190
  | Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
176
191
  | Widget / overlay | `s` | Squash the conversation into the main context |
177
192
  | Widget / overlay | `r` | Rebase the raw conversation into the main context (fast-forward only) |
193
+ | Widget / overlay | `h` | Open the agent's session in a new herdr pane |
178
194
  | Widget / overlay | `c` | Copy the latest response |
179
195
  | Widget / overlay | `i` | Copy the agent's full session ID |
180
196
  | Overlay | scroll · `End` | Pause tail-following · resume it |
181
197
 
182
198
  ## Good to know
183
199
 
184
- - 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.
200
+ - 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. A Pi in a herdr pane does not receive them.
185
201
  - `c` and `i` copy via `pbcopy`, so they are macOS-only for now.
202
+ - `-h` and `-s` conflict: an agent in a herdr pane never squashes into this context by itself. Squash its row later instead.
186
203
  - The overlay caps very large tool outputs and omits thinking entries.
187
204
 
188
205
  ## Roadmap
@@ -192,13 +209,14 @@ Everything goes through one command: `/agent [options] <task>`. Its one companio
192
209
  - [x] Rebase: the raw conversation grafted onto the main session — fast-forward only, compaction-faithful.
193
210
  - [x] Attach: a detached session returns to the widget, restored as dispatched.
194
211
  - [x] Live syntax coloring and eager autocomplete for the `/agent` line.
212
+ - [x] Herdr: hand an agent's session to a pane beside you, or dispatch straight into one.
195
213
  - [ ] Render an agent's compaction event in the overlay.
196
214
  - [ ] Wire the accepted-but-inert `pi` options.
197
215
  - [ ] Clipboard support beyond macOS.
198
216
 
199
217
  ## Under the hood
200
218
 
201
- Design notes — result delivery through the Pi SDK, the squashed-message format, the rebase and attach mechanisms, runtime sharing, model resolution, and editor internals — live in [INTERNALS.md](INTERNALS.md). The complete command grammar lives in [PARSER_SPEC.md](PARSER_SPEC.md).
219
+ Design notes — result delivery through the Pi SDK, the squashed-message format, the rebase, attach, and herdr mechanisms, runtime sharing, model resolution, and editor internals — live in [INTERNALS.md](INTERNALS.md). The complete command grammar lives in [PARSER_SPEC.md](PARSER_SPEC.md).
202
220
 
203
221
  ---
204
222
 
package/agent-viewer.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  formatMs,
21
21
  formatToolUses,
22
22
  formatTurns,
23
+ herdrPaneLabel,
23
24
  logSteering,
24
25
  mainContextLabel,
25
26
  truncatePlain,
@@ -36,6 +37,21 @@ import {
36
37
  export type ViewableAgent = RunningAgent | CompletedAgent;
37
38
  export type AgentViewerAction = "hide" | "detach";
38
39
 
40
+ /** What the overlay can do to its agent, provided by the widget that owns the agent. */
41
+ export type AgentViewerActions = {
42
+ canSquashMainContext(): boolean;
43
+ squashMainContext(): void;
44
+ canRebaseMainContext(): boolean;
45
+ rebaseMainContext(): void;
46
+ rebaseBlockReason(): string | undefined;
47
+ rebaseDetachCount(): number;
48
+ interrupt(): void;
49
+ /** Why h is withheld right now; undefined when the agent can open in a herdr pane. */
50
+ herdrBlockReason(): string | undefined;
51
+ /** Hand the session to a Pi in a new herdr pane; rejects with the reason on failure. */
52
+ openInHerdr(): Promise<void>;
53
+ };
54
+
39
55
  export type ActivityDescription = {
40
56
  text: string;
41
57
  truncation: "middle" | "tail";
@@ -162,8 +178,10 @@ export class AgentViewer implements Component {
162
178
  private lastWidth = 0;
163
179
  private copied: CopyTarget | undefined;
164
180
  private copiedTimer: ReturnType<typeof setTimeout> | undefined;
165
- private rebaseWarning: string | undefined;
166
- private rebaseWarningTimer: ReturnType<typeof setTimeout> | undefined;
181
+ /** A transient footer notice explaining why an action did nothing. */
182
+ private warning: string | undefined;
183
+ private warningTimer: ReturnType<typeof setTimeout> | undefined;
184
+ private herdrPending = false;
167
185
  /** One confirmation slot for both destructive overlay actions. */
168
186
  private readonly confirmation = new TimedConfirmation<"detach" | "rebase">(() =>
169
187
  this.tui.requestRender(),
@@ -177,19 +195,13 @@ export class AgentViewer implements Component {
177
195
  private readonly agent: ViewableAgent,
178
196
  private readonly theme: Theme,
179
197
  private readonly done: (result: AgentViewerAction) => void,
180
- private readonly canSquashMainContext: () => boolean,
181
- private readonly squashMainContext: () => void,
182
- private readonly canRebaseMainContext: () => boolean,
183
- private readonly rebaseMainContext: () => void,
184
- private readonly rebaseBlockReason: () => string | undefined,
185
- private readonly rebaseDetachCount: () => number,
186
- private readonly interrupt: () => void,
198
+ private readonly actions: AgentViewerActions,
187
199
  ) {}
188
200
 
189
201
  handleInput(data: string): void {
190
202
  if (matchesKey(data, "ctrl+x") && isLiveAgent(this.agent)) {
191
203
  this.confirmation.cancel();
192
- this.interrupt();
204
+ this.actions.interrupt();
193
205
  this.tui.requestRender();
194
206
  return;
195
207
  }
@@ -220,29 +232,33 @@ export class AgentViewer implements Component {
220
232
  this.copyToClipboard(this.agent.sessionId, "session-id");
221
233
  return;
222
234
  }
223
- if (matchesKey(data, "s") && this.canSquashMainContext()) {
224
- this.squashMainContext();
235
+ if (matchesKey(data, "s") && this.actions.canSquashMainContext()) {
236
+ this.actions.squashMainContext();
225
237
  this.tui.requestRender();
226
238
  return;
227
239
  }
228
240
  if (matchesKey(data, "r")) {
229
- if (this.canRebaseMainContext()) {
230
- if (this.rebaseDetachCount() > 0 && !this.confirmation.press("rebase")) {
241
+ if (this.actions.canRebaseMainContext()) {
242
+ if (this.actions.rebaseDetachCount() > 0 && !this.confirmation.press("rebase")) {
231
243
  this.tui.requestRender();
232
244
  return;
233
245
  }
234
246
  // Close the overlay: the payoff is the rebased conversation now sitting in the main transcript.
235
- this.rebaseMainContext();
247
+ this.actions.rebaseMainContext();
236
248
  this.done("hide");
237
249
  return;
238
250
  }
239
251
  this.confirmation.cancel();
240
- const reason = this.rebaseBlockReason();
252
+ const reason = this.actions.rebaseBlockReason();
241
253
  if (reason) {
242
- this.showRebaseWarning(`Can't rebase: ${reason}`);
254
+ this.showWarning(`Can't rebase: ${reason}`);
243
255
  return;
244
256
  }
245
257
  }
258
+ if (matchesKey(data, "h")) {
259
+ this.requestHerdr();
260
+ return;
261
+ }
246
262
  const viewport = this.viewportHeight();
247
263
  const maxScroll = Math.max(
248
264
  0,
@@ -291,10 +307,19 @@ export class AgentViewer implements Component {
291
307
  : this.agent.durationMs,
292
308
  ),
293
309
  );
294
- const status = live ? "running" : idle ? "idle" : failed ? "failed" : "completed";
310
+ const herdrPane = running ? undefined : this.agent.herdrPane;
311
+ const status = live
312
+ ? "running"
313
+ : idle
314
+ ? "idle"
315
+ : herdrPane
316
+ ? herdrPaneLabel(herdrPane)
317
+ : failed
318
+ ? "failed"
319
+ : "completed";
295
320
  const metadata = [
296
321
  th.fg("dim", this.agent.modelLabel),
297
- renderAgentContextMeter(this.agent, th),
322
+ renderAgentContextMeter(this.agent, th, { showPercentage: true }),
298
323
  th.fg("dim", status),
299
324
  ...stats.map((stat) => th.fg("dim", stat)),
300
325
  th.fg("dim", this.agent.sessionId),
@@ -335,7 +360,7 @@ export class AgentViewer implements Component {
335
360
  ),
336
361
  );
337
362
  } else if (this.confirmation.isArmedOn("rebase")) {
338
- const detachCount = this.rebaseDetachCount();
363
+ const detachCount = this.actions.rebaseDetachCount();
339
364
  lines.push(
340
365
  row(
341
366
  th.fg(
@@ -344,8 +369,10 @@ export class AgentViewer implements Component {
344
369
  ),
345
370
  ),
346
371
  );
347
- } else if (this.rebaseWarning) {
348
- lines.push(row(th.fg("warning", `⚠ ${this.rebaseWarning}`)));
372
+ } else if (this.herdrPending) {
373
+ lines.push(row(th.fg("accent", "⧉ Opening a herdr pane…")));
374
+ } else if (this.warning) {
375
+ lines.push(row(th.fg("warning", `⚠ ${this.warning}`)));
349
376
  } else {
350
377
  const scrollPct =
351
378
  content.length <= viewport
@@ -366,10 +393,12 @@ export class AgentViewer implements Component {
366
393
  : th.fg("dim", "c copy · i ID"),
367
394
  th.fg("dim", "↑↓ scroll"),
368
395
  th.fg("dim", "PgUp/PgDn"),
369
- this.canSquashMainContext() ? th.fg("dim", "s squash") : "",
370
- this.canRebaseMainContext() ? th.fg("dim", "r rebase") : "",
396
+ this.actions.canSquashMainContext() ? th.fg("dim", "s squash") : "",
397
+ this.actions.canRebaseMainContext() ? th.fg("dim", "r rebase") : "",
398
+ this.actions.herdrBlockReason() === undefined ? th.fg("dim", "h herdr") : "",
371
399
  isLiveAgent(this.agent) ? th.fg("dim", "Ctrl+x interrupt") : "",
372
400
  mainContext ? th.fg("dim", mainContext) : "",
401
+ herdrPane ? th.fg("dim", herdrPaneLabel(herdrPane)) : "",
373
402
  isLiveAgent(this.agent) ? th.fg("dim", "Esc hide") : "",
374
403
  th.fg(
375
404
  this.confirmation.isArmedOn("detach") ? "error" : "dim",
@@ -389,17 +418,36 @@ export class AgentViewer implements Component {
389
418
 
390
419
  dispose(): void {}
391
420
 
392
- /** Surface why r did nothing, in the footer, for a few seconds. Memory-only, like `✓ copied`. */
393
- private showRebaseWarning(warning: string): void {
394
- this.rebaseWarning = warning;
395
- if (this.rebaseWarningTimer) clearTimeout(this.rebaseWarningTimer);
396
- this.rebaseWarningTimer = setTimeout(() => {
397
- this.rebaseWarning = undefined;
421
+ /** Surface why a key did nothing, in the footer, for a few seconds. Memory-only, like `✓ copied`. */
422
+ private showWarning(warning: string): void {
423
+ this.warning = warning;
424
+ if (this.warningTimer) clearTimeout(this.warningTimer);
425
+ this.warningTimer = setTimeout(() => {
426
+ this.warning = undefined;
398
427
  this.tui.requestRender();
399
428
  }, CONFIRMATION_WINDOW_MS);
400
429
  this.tui.requestRender();
401
430
  }
402
431
 
432
+ private requestHerdr(): void {
433
+ if (this.herdrPending) return;
434
+ const reason = this.actions.herdrBlockReason();
435
+ if (reason) {
436
+ this.showWarning(`Can't open in herdr: ${reason}`);
437
+ return;
438
+ }
439
+ this.herdrPending = true;
440
+ this.tui.requestRender();
441
+ this.actions.openInHerdr().then(
442
+ // Close the overlay: the payoff is the pane now sitting beside the editor.
443
+ () => this.done("hide"),
444
+ (error) => {
445
+ this.herdrPending = false;
446
+ this.showWarning(`Can't open in herdr: ${errorMessage(error)}`);
447
+ },
448
+ );
449
+ }
450
+
403
451
  private requestDetach(): void {
404
452
  if (this.confirmation.press("detach")) {
405
453
  this.done("detach");
@@ -551,8 +599,11 @@ export class AgentViewer implements Component {
551
599
  if (lines.length === 0) return errorLines;
552
600
  return [...lines, this.theme.fg("dim", "───"), ...errorLines];
553
601
  }
554
- if (lines.length === 0) return [this.theme.fg("dim", "(empty response)")];
555
- return lines;
602
+ if (lines.length > 0) return lines;
603
+ const herdrPane = isRunningAgent(this.agent) ? undefined : this.agent.herdrPane;
604
+ return [
605
+ this.theme.fg("dim", herdrPane ? `runs in ${herdrPaneLabel(herdrPane)}` : "(empty response)"),
606
+ ];
556
607
  }
557
608
 
558
609
  private transcriptLines(width: number): string[] {
package/attach.ts CHANGED
@@ -1,12 +1,14 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { createAgentSessionFromServices, SessionManager } from "@earendil-works/pi-coding-agent";
4
+ import { forwardedArgsForPane } from "./command-line.js";
5
+ import { herdrAgentName, insideHerdr, splitPane, startPi } from "./herdr.js";
4
6
  import { conversationFingerprint } from "./rebase.js";
5
7
  import {
6
8
  assistantText,
7
9
  buildChildResourceLoaderOptions,
8
10
  createChildServices,
9
- DISPATCH_PREAMBLE,
11
+ DISPATCH_PREAMBLES,
10
12
  parseForwardedArgs,
11
13
  reportAgentFailure,
12
14
  resolveToolOptions,
@@ -21,6 +23,7 @@ import type {
21
23
  DispatchRecordData,
22
24
  ExtensionAPI,
23
25
  ExtensionCommandContext,
26
+ HerdrDelivery,
24
27
  Model,
25
28
  RunningAgent,
26
29
  } from "./shared.js";
@@ -49,7 +52,7 @@ export type DispatchBoundary = {
49
52
 
50
53
  /**
51
54
  * Split a child's resolved context at its dispatch boundary — the first user message carrying
52
- * the dispatch preamble. Undefined when no message carries it (a /fork child, or a child whose
55
+ * a dispatch preamble. Undefined when no message carries one (a /fork child, or a child whose
53
56
  * own compaction summarized the boundary away).
54
57
  */
55
58
  export function splitAtDispatchBoundary(
@@ -58,11 +61,12 @@ export function splitAtDispatchBoundary(
58
61
  for (const [index, message] of messages.entries()) {
59
62
  if (message.role !== "user") continue;
60
63
  const text = userMessageText(message);
61
- if (!text.startsWith(DISPATCH_PREAMBLE)) continue;
64
+ const preamble = DISPATCH_PREAMBLES.find((candidate) => text.startsWith(candidate));
65
+ if (preamble === undefined) continue;
62
66
  return {
63
67
  base: messages.slice(0, index),
64
68
  conversation: messages.slice(index),
65
- task: text.slice(DISPATCH_PREAMBLE.length),
69
+ task: text.slice(preamble.length),
66
70
  };
67
71
  }
68
72
  return undefined;
@@ -326,6 +330,33 @@ async function attachUserAgent(
326
330
  await confirmViewAgent(ctx, widget, runningAgent.sessionId, "Agent attached");
327
331
  }
328
332
 
333
+ /**
334
+ * The herdr mechanism: attach a child session to a Pi in a new herdr pane instead of to this
335
+ * process. The pane's Pi resumes the child's file with the dispatch record's options replayed as
336
+ * CLI flags (see `forwardedArgsForPane`); the model and thinking level come from the file, as on
337
+ * any resume. The file is resolved before the pane is split, so an agent without one costs no pane.
338
+ */
339
+ export function createHerdrDelivery(
340
+ getSessionContext: () => ExtensionCommandContext | undefined,
341
+ ): HerdrDelivery {
342
+ return {
343
+ available: insideHerdr,
344
+ split: async (agent) => {
345
+ const ctx = getSessionContext();
346
+ if (!ctx) throw new Error("no dispatching session context");
347
+ const childSessionDirectory = SessionManager.create(ctx.cwd).getSessionDir();
348
+ const file = matchChildSessionFiles(agent.sessionId, childSessionDirectory).find(
349
+ (match) => match.sessionId === agent.sessionId,
350
+ )?.file;
351
+ if (!file) throw new Error("the agent has no session file yet (it has not answered)");
352
+ const record = readDispatchRecord(SessionManager.open(file));
353
+ const piArgs = [...forwardedArgsForPane(record?.forwardedArgs ?? []), "--session", file];
354
+ const paneId = await splitPane(ctx.cwd);
355
+ return { paneId, start: () => startPi(paneId, herdrAgentName(agent.sessionId), piArgs) };
356
+ },
357
+ };
358
+ }
359
+
329
360
  /** The follow-up both success states share: offer to open the agent's overlay. */
330
361
  async function confirmViewAgent(
331
362
  ctx: ExtensionCommandContext,
package/command-line.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import type { AgentCommandName, ParsedAgentCommand } from "./shared.js";
2
2
 
3
3
  export type ValueCompletionDomain = "model" | "path" | "provider" | "thinking" | "tool";
4
- export type AgentOptionRole = "extension" | "forwarded" | "blocked";
4
+ /** `inert`: forwarded to a background run, where it has no effect, and dropped from a herdr pane's Pi. */
5
+ export type AgentOptionRole = "extension" | "forwarded" | "inert" | "blocked";
5
6
 
6
7
  export type AgentOptionDefinition = {
7
8
  semanticId: string;
@@ -58,6 +59,7 @@ export type AgentSemanticToken = {
58
59
  export type AgentArgumentScan = {
59
60
  isolate: boolean;
60
61
  squash: boolean;
62
+ herdr: boolean;
61
63
  forwardedArgs: string[];
62
64
  /** Ordered, non-overlapping semantic tokens over the argument text. Prose stays untokenized. */
63
65
  tokens: AgentSemanticToken[];
@@ -122,6 +124,14 @@ export const AGENT_OPTIONS: readonly AgentOptionDefinition[] = [
122
124
  autocomplete: true,
123
125
  description: "Squash the completed result into the main agent context",
124
126
  },
127
+ {
128
+ semanticId: "herdr",
129
+ names: ["-h", "--herdr"],
130
+ role: "extension",
131
+ arity: "boolean",
132
+ autocomplete: true,
133
+ description: "Open the agent in a new herdr pane",
134
+ },
125
135
  {
126
136
  semanticId: "provider",
127
137
  names: ["--provider"],
@@ -303,7 +313,7 @@ function blockedOptions(): AgentOptionDefinition[] {
303
313
  blockedOption("models", ["--models"]),
304
314
  blockedOption("export", ["--export"]),
305
315
  blockedOption("list-models", ["--list-models"]),
306
- blockedOption("help", ["-h", "--help"]),
316
+ blockedOption("help", ["--help"]),
307
317
  blockedOption("version", ["-v", "--version"]),
308
318
  ];
309
319
  }
@@ -312,7 +322,7 @@ function legacyValueOption(semanticId: string, names: readonly string[]): AgentO
312
322
  return {
313
323
  semanticId,
314
324
  names,
315
- role: "forwarded",
325
+ role: "inert",
316
326
  arity: "value",
317
327
  autocomplete: false,
318
328
  description: "Recognized legacy Pi option",
@@ -323,7 +333,7 @@ function legacyBooleanOption(semanticId: string, names: readonly string[]): Agen
323
333
  return {
324
334
  semanticId,
325
335
  names,
326
- role: "forwarded",
336
+ role: "inert",
327
337
  arity: "boolean",
328
338
  autocomplete: false,
329
339
  description: "Recognized legacy Pi option",
@@ -361,6 +371,7 @@ export function scanAgentArguments(
361
371
  const forwardedArgs: string[] = [];
362
372
  let isolate = false;
363
373
  let squash = false;
374
+ let herdr = false;
364
375
  let blocked: AgentSemanticToken | undefined;
365
376
  let consumedOption = false;
366
377
  let position = 0;
@@ -395,6 +406,8 @@ export function scanAgentArguments(
395
406
  isolate = true;
396
407
  } else if (option.semanticId === "squash") {
397
408
  squash = true;
409
+ } else if (option.semanticId === "herdr") {
410
+ herdr = true;
398
411
  } else if (option.arity === "value") {
399
412
  forwardedArgs.push(option.forwardName ?? token);
400
413
  const read = readValueSpan(args, position);
@@ -428,6 +441,7 @@ export function scanAgentArguments(
428
441
  const scan: AgentArgumentScan = {
429
442
  isolate,
430
443
  squash,
444
+ herdr,
431
445
  forwardedArgs,
432
446
  tokens,
433
447
  proseStart,
@@ -449,6 +463,29 @@ export function scanAgentArguments(
449
463
  };
450
464
  }
451
465
 
466
+ const PANE_RESOLVED_SEMANTIC_IDS: ReadonlySet<string> = new Set(["model", "provider", "thinking"]);
467
+
468
+ /**
469
+ * The forwarded pi tokens a Pi in a herdr pane receives: without the inert options, which a
470
+ * background run ignores but a real pi process would obey (`--no-session`, `--fork`, …), and
471
+ * without model, provider, and thinking, which the pane's Pi gets explicitly or from its file.
472
+ * Forwarded tokens alternate option and value by declared arity, so values are never mistaken
473
+ * for options.
474
+ *
475
+ * @example forwardedArgsForPane(["--model", "opus", "--no-session", "--tools", "read"]) // ["--tools", "read"]
476
+ */
477
+ export function forwardedArgsForPane(forwardedArgs: readonly string[]): string[] {
478
+ const kept: string[] = [];
479
+ for (let index = 0; index < forwardedArgs.length; index += 1) {
480
+ const option = OPTION_BY_NAME.get(forwardedArgs[index]!)!;
481
+ const end = option.arity === "value" ? index + 1 : index;
482
+ const dropped = option.role === "inert" || PANE_RESOLVED_SEMANTIC_IDS.has(option.semanticId);
483
+ if (!dropped) kept.push(...forwardedArgs.slice(index, end + 1));
484
+ index = end;
485
+ }
486
+ return kept;
487
+ }
488
+
452
489
  /**
453
490
  * Scan a complete editor line, shifting semantic tokens into line coordinates.
454
491
  *
@@ -487,11 +524,16 @@ export function parseAgentCommand(args: string, command: AgentCommandName): Pars
487
524
  }
488
525
  if (!scan.prose.trim())
489
526
  throw new Error(
490
- `Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-s|--squash] "<task>"`,
527
+ `Usage: /${command} [pi options] [-m MODELNAME] [-i|--isolate] [-s|--squash] [-h|--herdr] "<task>"`,
528
+ );
529
+ if (scan.herdr && scan.squash)
530
+ throw new Error(
531
+ `/${command} -h and -s conflict: an agent in a herdr pane never squashes into this context.`,
491
532
  );
492
533
  return {
493
534
  isolate: scan.isolate,
494
535
  squash: scan.squash,
536
+ herdr: scan.herdr,
495
537
  forwardedArgs: scan.forwardedArgs,
496
538
  task: scan.task,
497
539
  warnings: scan.warnings,
package/context-meter.ts CHANGED
@@ -33,9 +33,13 @@ export function renderContextMeter(
33
33
  export function renderAgentContextMeter(
34
34
  agent: ContextMeterAgent,
35
35
  theme: Pick<Theme, "fg">,
36
+ options: { showPercentage?: boolean } = {},
36
37
  ): string {
37
38
  const percent = "status" in agent
38
39
  ? agent.session?.getContextUsage()?.percent
39
40
  : agent.contextPercent;
40
- return renderContextMeter(percent, theme);
41
+ const meter = renderContextMeter(percent, theme);
42
+ if (!options.showPercentage || percent === undefined) return meter;
43
+ const boundedPercent = Math.max(0, Math.min(100, percent));
44
+ return `${meter}${theme.fg(contextMeterColor(boundedPercent), `${Math.floor(boundedPercent)}%`)}`;
41
45
  }