@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 +19 -2
- package/PARSER_SPEC.md +11 -13
- package/README.md +32 -12
- package/agent-viewer.ts +15 -14
- package/command-line.ts +1 -1
- package/editor-coloring.ts +2 -2
- package/index.ts +11 -4
- package/package.json +2 -1
- package/runner.ts +75 -2
- package/screenshots/below-editor-widget.png +0 -0
- package/screenshots/overlay.png +0 -0
- package/shared.ts +8 -0
- package/transcript.ts +7 -0
- package/widget.ts +27 -22
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.
|
|
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 `
|
|
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
|
|
49
|
-
`
|
|
50
|
-
|
|
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`,
|
|
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
|
|
243
|
-
|
|
244
|
-
|
|
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 `
|
|
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,
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
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
|
-
###
|
|
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 | `
|
|
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
|
-
-
|
|
117
|
-
-
|
|
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" | "
|
|
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 =
|
|
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
|
|
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.
|
|
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.
|
|
175
|
+
this.confirmingDetach = false;
|
|
176
176
|
this.composer.handleInput(data);
|
|
177
177
|
this.tui.requestRender();
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
180
|
-
if (matchesKey(data, "
|
|
181
|
-
this.
|
|
180
|
+
if (matchesKey(data, "d")) {
|
|
181
|
+
this.requestDetach();
|
|
182
182
|
return;
|
|
183
183
|
}
|
|
184
|
-
this.
|
|
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.
|
|
314
|
-
this.
|
|
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
|
|
329
|
-
if (this.
|
|
330
|
-
this.done("
|
|
329
|
+
private requestDetach(): void {
|
|
330
|
+
if (this.confirmingDetach) {
|
|
331
|
+
this.done("detach");
|
|
331
332
|
return;
|
|
332
333
|
}
|
|
333
|
-
this.
|
|
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"]),
|
package/editor-coloring.ts
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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 {
|
|
8
|
-
|
|
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(
|
|
15
|
-
|
|
19
|
+
const widget = new UserAgentWidget(
|
|
20
|
+
runningAgents,
|
|
21
|
+
(message) => pi.sendMessage(message, { triggerTurn: true }),
|
|
22
|
+
(sessionId) => pi.appendEntry<DetachedEntryData>(DETACHED_ENTRY_TYPE, { sessionId }),
|
|
16
23
|
);
|
|
17
24
|
let shuttingDown = false;
|
|
18
25
|
let nextAgentNumber = 0;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@giladbarnea/pi-user-agents",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"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
|
-
|
|
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:
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
235
|
+
this.detachConfirmationTarget = undefined;
|
|
234
236
|
if (selected) this.openSelected(selected.agent);
|
|
235
237
|
return { consume: true };
|
|
236
238
|
}
|
|
237
|
-
if (matchesKey(data, "
|
|
238
|
-
if (!this.
|
|
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
|
|
249
|
-
if (this.
|
|
250
|
-
this.
|
|
250
|
+
private confirmDetach(target: ViewableAgent): boolean {
|
|
251
|
+
if (this.detachConfirmationTarget === target) {
|
|
252
|
+
this.detachConfirmationTarget = undefined;
|
|
251
253
|
return true;
|
|
252
254
|
}
|
|
253
|
-
this.
|
|
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.
|
|
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: "
|
|
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 === "
|
|
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 === "
|
|
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
|
|
421
|
+
const confirmingDetach = this.detachConfirmationTarget === selected?.agent;
|
|
417
422
|
const segments = [theme.fg("accent", "↑↓ select"), theme.fg("accent", "Enter view")];
|
|
418
423
|
if (
|
|
419
|
-
!
|
|
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
|
-
|
|
427
|
-
|
|
431
|
+
confirmingDetach ? "error" : "accent",
|
|
432
|
+
confirmingDetach ? "d again to confirm" : "d detach",
|
|
428
433
|
),
|
|
429
434
|
theme.fg("accent", "Esc back"),
|
|
430
435
|
);
|