@giladbarnea/pi-user-agents 0.0.7 → 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 +11 -1
- package/PARSER_SPEC.md +10 -2
- package/README.md +26 -7
- package/agent-viewer.ts +107 -38
- package/attach.ts +35 -4
- package/command-line.ts +47 -5
- package/context-meter.ts +5 -1
- package/herdr.ts +92 -0
- package/index.ts +2 -1
- package/package.json +3 -3
- package/runner.ts +109 -15
- package/screenshots/below-editor-widget-2.png +0 -0
- package/screenshots/editor-with-autocomplete.png +0 -0
- package/screenshots/overlay-2.png +0 -0
- package/shared.ts +26 -0
- package/widget.ts +298 -63
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
|
|
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
|
-
|
|
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
|
@@ -69,7 +69,7 @@ Every agent gets a row in a widget under the editor: status, task, model, turn c
|
|
|
69
69
|
|
|
70
70
|
<br>
|
|
71
71
|
|
|
72
|
-
Press `←` or `↓` from the editor to focus the widget
|
|
72
|
+
Press `←` or `↓` from the editor to focus the widget. The selected row shows a short session ID and offers the same agent actions as the overlay. Press `Enter` to open the full conversation, streaming live and following the tail as it grows. 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.
|
|
73
73
|
|
|
74
74
|
<a href="screenshots/overlay.png">
|
|
75
75
|
<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">
|
|
@@ -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
|
|
|
@@ -173,15 +188,18 @@ Everything goes through one command: `/agent [options] <task>`. Its one companio
|
|
|
173
188
|
| Widget / overlay | `d` `d` | Detach the agent, keeping its session (twice to confirm) |
|
|
174
189
|
| Widget / overlay | `Esc` | Back |
|
|
175
190
|
| Overlay | `Enter` | Steer mid-turn, or start another turn when idle |
|
|
176
|
-
|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
191
|
+
| Widget / overlay | `s` | Squash the conversation into the main context |
|
|
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 |
|
|
194
|
+
| Widget / overlay | `c` | Copy the latest response |
|
|
195
|
+
| Widget / overlay | `i` | Copy the agent's full session ID |
|
|
179
196
|
| Overlay | scroll · `End` | Pause tail-following · resume it |
|
|
180
197
|
|
|
181
198
|
## Good to know
|
|
182
199
|
|
|
183
|
-
- 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.
|
|
184
|
-
- `c`
|
|
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.
|
|
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.
|
|
185
203
|
- The overlay caps very large tool outputs and omits thinking entries.
|
|
186
204
|
|
|
187
205
|
## Roadmap
|
|
@@ -191,13 +209,14 @@ Everything goes through one command: `/agent [options] <task>`. Its one companio
|
|
|
191
209
|
- [x] Rebase: the raw conversation grafted onto the main session — fast-forward only, compaction-faithful.
|
|
192
210
|
- [x] Attach: a detached session returns to the widget, restored as dispatched.
|
|
193
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.
|
|
194
213
|
- [ ] Render an agent's compaction event in the overlay.
|
|
195
214
|
- [ ] Wire the accepted-but-inert `pi` options.
|
|
196
215
|
- [ ] Clipboard support beyond macOS.
|
|
197
216
|
|
|
198
217
|
## Under the hood
|
|
199
218
|
|
|
200
|
-
Design notes — result delivery through the Pi SDK, the squashed-message format, the rebase and
|
|
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).
|
|
201
220
|
|
|
202
221
|
---
|
|
203
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";
|
|
@@ -151,15 +167,21 @@ function truncateMiddle(
|
|
|
151
167
|
const VIEWER_CHROME_LINES = 6;
|
|
152
168
|
const VIEWER_MIN_VIEWPORT = 3;
|
|
153
169
|
const VIEWER_HEIGHT_PCT = 85;
|
|
170
|
+
const COPY_FEEDBACK_MS = 1500;
|
|
171
|
+
|
|
172
|
+
type CopyTarget = "response" | "session-id";
|
|
154
173
|
|
|
155
174
|
/** Focus-capturing overlay that shows an agent's live or finished response. */
|
|
156
175
|
export class AgentViewer implements Component {
|
|
157
176
|
private scrollOffset = 0;
|
|
158
177
|
private autoScroll = true;
|
|
159
178
|
private lastWidth = 0;
|
|
160
|
-
private
|
|
161
|
-
private
|
|
162
|
-
|
|
179
|
+
private copied: CopyTarget | undefined;
|
|
180
|
+
private copiedTimer: 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;
|
|
163
185
|
/** One confirmation slot for both destructive overlay actions. */
|
|
164
186
|
private readonly confirmation = new TimedConfirmation<"detach" | "rebase">(() =>
|
|
165
187
|
this.tui.requestRender(),
|
|
@@ -173,19 +195,13 @@ export class AgentViewer implements Component {
|
|
|
173
195
|
private readonly agent: ViewableAgent,
|
|
174
196
|
private readonly theme: Theme,
|
|
175
197
|
private readonly done: (result: AgentViewerAction) => void,
|
|
176
|
-
private readonly
|
|
177
|
-
private readonly squashMainContext: () => void,
|
|
178
|
-
private readonly canRebaseMainContext: () => boolean,
|
|
179
|
-
private readonly rebaseMainContext: () => void,
|
|
180
|
-
private readonly rebaseBlockReason: () => string | undefined,
|
|
181
|
-
private readonly rebaseDetachCount: () => number,
|
|
182
|
-
private readonly interrupt: () => void,
|
|
198
|
+
private readonly actions: AgentViewerActions,
|
|
183
199
|
) {}
|
|
184
200
|
|
|
185
201
|
handleInput(data: string): void {
|
|
186
202
|
if (matchesKey(data, "ctrl+x") && isLiveAgent(this.agent)) {
|
|
187
203
|
this.confirmation.cancel();
|
|
188
|
-
this.interrupt();
|
|
204
|
+
this.actions.interrupt();
|
|
189
205
|
this.tui.requestRender();
|
|
190
206
|
return;
|
|
191
207
|
}
|
|
@@ -212,29 +228,37 @@ export class AgentViewer implements Component {
|
|
|
212
228
|
this.copyResponse();
|
|
213
229
|
return;
|
|
214
230
|
}
|
|
215
|
-
if (matchesKey(data, "
|
|
216
|
-
this.
|
|
231
|
+
if (matchesKey(data, "i")) {
|
|
232
|
+
this.copyToClipboard(this.agent.sessionId, "session-id");
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (matchesKey(data, "s") && this.actions.canSquashMainContext()) {
|
|
236
|
+
this.actions.squashMainContext();
|
|
217
237
|
this.tui.requestRender();
|
|
218
238
|
return;
|
|
219
239
|
}
|
|
220
240
|
if (matchesKey(data, "r")) {
|
|
221
|
-
if (this.canRebaseMainContext()) {
|
|
222
|
-
if (this.rebaseDetachCount() > 0 && !this.confirmation.press("rebase")) {
|
|
241
|
+
if (this.actions.canRebaseMainContext()) {
|
|
242
|
+
if (this.actions.rebaseDetachCount() > 0 && !this.confirmation.press("rebase")) {
|
|
223
243
|
this.tui.requestRender();
|
|
224
244
|
return;
|
|
225
245
|
}
|
|
226
246
|
// Close the overlay: the payoff is the rebased conversation now sitting in the main transcript.
|
|
227
|
-
this.rebaseMainContext();
|
|
247
|
+
this.actions.rebaseMainContext();
|
|
228
248
|
this.done("hide");
|
|
229
249
|
return;
|
|
230
250
|
}
|
|
231
251
|
this.confirmation.cancel();
|
|
232
|
-
const reason = this.rebaseBlockReason();
|
|
252
|
+
const reason = this.actions.rebaseBlockReason();
|
|
233
253
|
if (reason) {
|
|
234
|
-
this.
|
|
254
|
+
this.showWarning(`Can't rebase: ${reason}`);
|
|
235
255
|
return;
|
|
236
256
|
}
|
|
237
257
|
}
|
|
258
|
+
if (matchesKey(data, "h")) {
|
|
259
|
+
this.requestHerdr();
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
238
262
|
const viewport = this.viewportHeight();
|
|
239
263
|
const maxScroll = Math.max(
|
|
240
264
|
0,
|
|
@@ -283,10 +307,19 @@ export class AgentViewer implements Component {
|
|
|
283
307
|
: this.agent.durationMs,
|
|
284
308
|
),
|
|
285
309
|
);
|
|
286
|
-
const
|
|
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";
|
|
287
320
|
const metadata = [
|
|
288
321
|
th.fg("dim", this.agent.modelLabel),
|
|
289
|
-
renderAgentContextMeter(this.agent, th),
|
|
322
|
+
renderAgentContextMeter(this.agent, th, { showPercentage: true }),
|
|
290
323
|
th.fg("dim", status),
|
|
291
324
|
...stats.map((stat) => th.fg("dim", stat)),
|
|
292
325
|
th.fg("dim", this.agent.sessionId),
|
|
@@ -327,7 +360,7 @@ export class AgentViewer implements Component {
|
|
|
327
360
|
),
|
|
328
361
|
);
|
|
329
362
|
} else if (this.confirmation.isArmedOn("rebase")) {
|
|
330
|
-
const detachCount = this.rebaseDetachCount();
|
|
363
|
+
const detachCount = this.actions.rebaseDetachCount();
|
|
331
364
|
lines.push(
|
|
332
365
|
row(
|
|
333
366
|
th.fg(
|
|
@@ -336,8 +369,10 @@ export class AgentViewer implements Component {
|
|
|
336
369
|
),
|
|
337
370
|
),
|
|
338
371
|
);
|
|
339
|
-
} else if (this.
|
|
340
|
-
lines.push(row(th.fg("
|
|
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}`)));
|
|
341
376
|
} else {
|
|
342
377
|
const scrollPct =
|
|
343
378
|
content.length <= viewport
|
|
@@ -351,13 +386,19 @@ export class AgentViewer implements Component {
|
|
|
351
386
|
th.fg("dim", `${content.length} lines`),
|
|
352
387
|
th.fg("dim", scrollPct),
|
|
353
388
|
this.canSteer() ? th.fg("dim", "Enter steer") : "",
|
|
354
|
-
this.
|
|
389
|
+
this.copied === "response"
|
|
390
|
+
? th.fg("success", "✓ copied")
|
|
391
|
+
: this.copied === "session-id"
|
|
392
|
+
? th.fg("success", "✓ copied ID")
|
|
393
|
+
: th.fg("dim", "c copy · i ID"),
|
|
355
394
|
th.fg("dim", "↑↓ scroll"),
|
|
356
395
|
th.fg("dim", "PgUp/PgDn"),
|
|
357
|
-
this.canSquashMainContext() ? th.fg("dim", "s squash") : "",
|
|
358
|
-
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") : "",
|
|
359
399
|
isLiveAgent(this.agent) ? th.fg("dim", "Ctrl+x interrupt") : "",
|
|
360
400
|
mainContext ? th.fg("dim", mainContext) : "",
|
|
401
|
+
herdrPane ? th.fg("dim", herdrPaneLabel(herdrPane)) : "",
|
|
361
402
|
isLiveAgent(this.agent) ? th.fg("dim", "Esc hide") : "",
|
|
362
403
|
th.fg(
|
|
363
404
|
this.confirmation.isArmedOn("detach") ? "error" : "dim",
|
|
@@ -377,17 +418,36 @@ export class AgentViewer implements Component {
|
|
|
377
418
|
|
|
378
419
|
dispose(): void {}
|
|
379
420
|
|
|
380
|
-
/** Surface why
|
|
381
|
-
private
|
|
382
|
-
this.
|
|
383
|
-
if (this.
|
|
384
|
-
this.
|
|
385
|
-
this.
|
|
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;
|
|
386
427
|
this.tui.requestRender();
|
|
387
428
|
}, CONFIRMATION_WINDOW_MS);
|
|
388
429
|
this.tui.requestRender();
|
|
389
430
|
}
|
|
390
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
|
+
|
|
391
451
|
private requestDetach(): void {
|
|
392
452
|
if (this.confirmation.press("detach")) {
|
|
393
453
|
this.done("detach");
|
|
@@ -401,14 +461,20 @@ export class AgentViewer implements Component {
|
|
|
401
461
|
isRunningAgent(this.agent) || this.agent.ok
|
|
402
462
|
? this.agent.responseText
|
|
403
463
|
: (this.agent.error ?? "");
|
|
464
|
+
this.copyToClipboard(text, "response");
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
private copyToClipboard(text: string, target: CopyTarget): void {
|
|
404
468
|
const child = childProcess.execFile("pbcopy", (error) => {
|
|
405
469
|
if (error) return;
|
|
406
|
-
this.
|
|
470
|
+
this.copied = target;
|
|
471
|
+
if (this.copiedTimer) clearTimeout(this.copiedTimer);
|
|
407
472
|
this.tui.requestRender();
|
|
408
|
-
setTimeout(() => {
|
|
409
|
-
this.
|
|
473
|
+
this.copiedTimer = setTimeout(() => {
|
|
474
|
+
this.copied = undefined;
|
|
475
|
+
this.copiedTimer = undefined;
|
|
410
476
|
this.tui.requestRender();
|
|
411
|
-
},
|
|
477
|
+
}, COPY_FEEDBACK_MS);
|
|
412
478
|
});
|
|
413
479
|
child.stdin?.end(text);
|
|
414
480
|
}
|
|
@@ -533,8 +599,11 @@ export class AgentViewer implements Component {
|
|
|
533
599
|
if (lines.length === 0) return errorLines;
|
|
534
600
|
return [...lines, this.theme.fg("dim", "───"), ...errorLines];
|
|
535
601
|
}
|
|
536
|
-
if (lines.length
|
|
537
|
-
|
|
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
|
+
];
|
|
538
607
|
}
|
|
539
608
|
|
|
540
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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", ["
|
|
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: "
|
|
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: "
|
|
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
|
-
|
|
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
|
}
|