@bermudi/pi-delegate 0.1.18 → 0.1.20

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/README.md CHANGED
@@ -35,17 +35,16 @@ delegate({
35
35
  ```
36
36
 
37
37
  Parent extension/MCP tools are not copied, and project instructions are rebuilt
38
- for the task's `cwd`. Omit `agent` when you want an ad-hoc task using delegate's
39
- normal inline defaults instead.
38
+ for the task's `cwd`. Omit `agent` when you want an inline task using delegate's
39
+ normal defaults instead.
40
40
 
41
41
  The other built-ins are:
42
42
 
43
43
  - `scout` — read-only investigation with `read`, `grep`, `find`, and `ls`.
44
44
  - `coder` — implementation and verification with `read`, `write`, `edit`, and
45
45
  `bash` in the shared workspace.
46
- - `reviewer` — review with `read` and `bash`, using a disposable scratch copy by
47
- default. Set `workspace: "shared"` when a reviewer needs a persistent
48
- `sessionId`.
46
+ - `reviewer` — review with `read` and `bash` in the shared workspace by default.
47
+ Set `workspace: "scratch"` when its project changes should be discarded.
49
48
 
50
49
  ### Shared-write safety
51
50
 
@@ -64,7 +63,7 @@ visible batch-level warning while it is active.
64
63
  A same-named Markdown file can override any built-in (first definition wins
65
64
  across `.pi/agents/`, `~/.pi/agent/agents/`, `~/.agents/`, `.claude/agents/`,
66
65
  `~/.claude/agents/`). A prompt-only override keeps the built-in's tools and
67
- workspace — `scout` stays read-only and `reviewer` stays scratch unless the
66
+ workspace — `scout` stays read-only and `reviewer` stays shared unless the
68
67
  file explicitly sets `tools` or `workspace`. Fresh built-ins inherit the
69
68
  parent's exact model object and thinking level; an explicit `model`/`thinking`
70
69
  in the Markdown file replaces that inheritance. Task fields always win. For
@@ -106,7 +105,7 @@ host filesystem.
106
105
 
107
106
  ### Git-native isolated writers
108
107
 
109
- Use `workspace: "isolated"` for synchronous, one-shot coding tasks that should
108
+ Use `workspace: "isolated"` for sync or async one-shot coding tasks that should
110
109
  run in parallel without sharing a working tree:
111
110
 
112
111
  ```ts
@@ -128,10 +127,12 @@ still matches the baseline.
128
127
 
129
128
  A clean reconciliation reports `applied_unverified`: textual merging does not
130
129
  prove the code builds or tests pass, so the result includes a suggested
131
- verification call. Isolated mode currently requires Git, rejects async and
132
- session reuse, disables whole-task retries, and rejects repositories with
133
- submodules. It is separation, not confinement: absolute-path writes can still
134
- reach the host.
130
+ verification call. Async isolated tasks prepare after returning their ticket and
131
+ settle only after reconciliation. Cancellation never applies unfinished work; a
132
+ completed proposal cancelled before source application is retained as a private
133
+ ref and full patch. Isolated mode requires Git, rejects session reuse, disables
134
+ whole-task retries, and rejects repositories with submodules. It is separation,
135
+ not confinement: absolute-path writes can still reach the host.
135
136
 
136
137
  ### Token accounting
137
138
 
@@ -150,7 +151,7 @@ Async tickets keep running after the parent's turn settles, and pi renders an
150
151
  idle session — so delegate adds three signals:
151
152
 
152
153
  - **Footer status** — while any ticket is active, the footer shows
153
- `⏳ 2 subagents · t5042v19`, updated live as subagents start and finish.
154
+ `⏳ 2 subagents · t5042v19 · /subagents`, updated live as subagents start and finish.
154
155
  - **Settle warning** — the first time a turn settles with a ticket still
155
156
  active, a warning notification names the ticket and reminds you that
156
157
  quitting aborts it. Once per ticket; the footer carries it from there.
@@ -163,6 +164,54 @@ the mitigation there; on quit, delegate also prints a trace line to the
163
164
  terminal naming the aborted tickets and agents, and on `/reload` it shows a
164
165
  warning notification.
165
166
 
167
+ ### Live subagent browser
168
+
169
+ Press **Ctrl+Shift+B** from the editor, or run **`/subagents`**, to open a live
170
+ browser without sending a model request. It shows both sync tasks and async
171
+ tickets, including retained completed work. Your draft is left untouched.
172
+
173
+ | Key | Action |
174
+ | --- | --- |
175
+ | ↑ / ↓ | Select an agent |
176
+ | Tab / ← / → | Switch between tool activity and assistant responses |
177
+ | PgUp / PgDn | Scroll the selected view |
178
+ | Home / End | Jump to oldest retained text / follow live output |
179
+ | p | Pause or resume the selected agent's **whole async ticket** |
180
+ | Esc | Close and return to your draft |
181
+
182
+ The view shows running commands, tool results, edited file paths, queue
183
+ dependencies, errors, token counts and activity age. Pause controls use the
184
+ same cooperative boundary described below; they do not freeze subprocesses.
185
+ Down in the editor keeps its normal behavior rather than intercepting menus
186
+ or replacing a custom editor.
187
+
188
+ This is a bounded live preview, not a transcript archive: assistant text keeps
189
+ the current attempt's last 32K characters (no thinking blocks); tool activity
190
+ shows up to 100 recent calls with output tails. The browser retains the last
191
+ 20 completed sync calls. Async results remain available until the existing
192
+ ticket cleanup removes them (eligible after 30 minutes). Nothing is recovered
193
+ from transcripts after a restart. A refresh timer runs only while the browser
194
+ is open and is cleared on close or shutdown. This feature requires Pi's TUI,
195
+ not print/JSON/RPC mode.
196
+
197
+ ### Pause and resume
198
+
199
+ Pause an async ticket with `delegate({ ticketAction: "pause", ticket: "<id>" })`;
200
+ continue it with `ticketAction: "resume"`. Both return immediately.
201
+
202
+ Each current model response and its tool calls finish, then the agent waits
203
+ before its next model request. Queued tasks do not start. **Pausing** means
204
+ work is reaching that boundary; **paused** means it has reached it. A task
205
+ that finishes naturally may complete instead. Resume keeps the same live
206
+ conversations, including scratch workspaces.
207
+
208
+ Pause is not cancellation, rollback, or a process freeze. Background commands
209
+ can keep running, and files may be unfinished. In-progress isolated preparation
210
+ or application finishes before pausing. Sessions, concurrency slots and
211
+ workspace reservations remain held. Inactivity checks stop while parked, but
212
+ explicit wall-clock deadlines keep counting. Wait does not resume work; cancel
213
+ still works. Pauses do not survive Pi exit or reload.
214
+
166
215
  ### Stall detection and cancellation
167
216
 
168
217
  `stallTimeoutMs` is an inactivity watchdog, not a hard execution deadline. When
@@ -216,8 +265,8 @@ over an installed extension.
216
265
  and workspace, and an explicit `model`/`thinking` replaces parent inheritance.
217
266
  For `default`, the `delegate.json` override maps are ignored (task fields still
218
267
  win); for the other built-ins, those maps win field-by-field over frontmatter.
219
- - **Ad-hoc subagent** — A subagent created from inline task fields instead of a
220
- named Markdown agent profile. In current output this is labeled `ad-hoc`.
268
+ - **Inline subagent** — A subagent created from inline task fields instead of a
269
+ named Markdown agent profile. In current output this is labeled `inline`.
221
270
  - **Inline task** — The task object itself when its configuration is supplied
222
271
  directly in the delegate call. Prefer this term over “inline agent” when
223
272
  talking about the API shape.
@@ -230,7 +279,7 @@ over an installed extension.
230
279
  - **Resumed subagent** — A subagent rehydrated from a previous session `.jsonl`
231
280
  via `resumeFrom`. It can also be pooled by providing a `sessionId`.
232
281
  - **Async ticket** — A background execution handle returned when top-level
233
- `async: true` is used. Poll, wait, or cancel tickets with top-level
282
+ `async: true` is used. Poll, wait, pause, resume, or cancel tickets with top-level
234
283
  `ticketAction: "poll"`, `ticketAction: "wait"` (blocks until the ticket settles;
235
284
  optional `timeoutMs`), or `ticketAction: "cancel"`.
236
285
  - **Skill** — A `SKILL.md` instruction bundle injected into the subagent system
package/agents.ts CHANGED
@@ -120,7 +120,7 @@ export const BUILTIN_AGENT_CONFIGS: Readonly<Record<string, AgentConfig>> = {
120
120
  [DEFAULT_AGENT_NAME]: {
121
121
  name: DEFAULT_AGENT_NAME,
122
122
  description:
123
- "General-purpose subagent mirroring the live parent model, thinking level, native tools, and base prompt. Prefer it for general work; pick a specialist only when its role, tool limits, or scratch workspace genuinely fit.",
123
+ "General-purpose subagent mirroring the live parent model, thinking level, native tools, and base prompt. Prefer it for general work; pick a specialist when its role fits.",
124
124
  tools: DEFAULT_TOOLS,
125
125
  systemPrompt: "",
126
126
  builtin: true,
@@ -0,0 +1,31 @@
1
+ /** Human-facing, bounded text preview. Never includes thinking blocks or reads
2
+ * transcripts from disk. Updates replace the current response, not append
3
+ * streaming snapshots; completed responses remain available for scrollback. */
4
+ export class AssistantPreview {
5
+ private completed = "";
6
+ private current = "";
7
+ static readonly limit = 32_768;
8
+
9
+ update(text: string): void {
10
+ // Empty synthetic provider failures must not erase a streamed response.
11
+ if (text) this.current = text.slice(-AssistantPreview.limit);
12
+ }
13
+
14
+ finish(text: string): void {
15
+ this.update(text);
16
+ if (this.current) {
17
+ this.completed = [this.completed, this.current]
18
+ .filter(Boolean)
19
+ .join("\n\n")
20
+ .slice(-AssistantPreview.limit);
21
+ this.current = "";
22
+ }
23
+ }
24
+
25
+ get text(): string {
26
+ return [this.completed, this.current]
27
+ .filter(Boolean)
28
+ .join("\n\n")
29
+ .slice(-AssistantPreview.limit);
30
+ }
31
+ }
@@ -0,0 +1,250 @@
1
+ import type { DelegateRuntime } from "./runtime.ts";
2
+ import type {
3
+ AsyncTicket,
4
+ DelegateDetails,
5
+ TaskProgress,
6
+ ToolActivity,
7
+ } from "./types.ts";
8
+
9
+ interface SyncDetails {
10
+ tasks: { prompt?: string }[];
11
+ progress: TaskProgress[];
12
+ results: { output?: string; error?: string }[];
13
+ serializedNotice?: string;
14
+ dispatchWarning?: string;
15
+ }
16
+
17
+ /** Finished sync calls must not pin entire tool arguments/results after the
18
+ * parent compacts. Keep only primitive display arguments and bounded text. */
19
+ function compactProgress(p: TaskProgress): TaskProgress {
20
+ let budget = 65_536;
21
+ const activities: ToolActivity[] = [];
22
+ const take = (text: string): string => {
23
+ const limit = Math.min(4096, budget);
24
+ if (limit <= 0) return "";
25
+ const value = text.length > limit ? `…${text.slice(-limit)}` : text;
26
+ budget -= value.length;
27
+ return value;
28
+ };
29
+ for (const tool of p.activities.slice(-100).reverse()) {
30
+ if (budget <= 0) break;
31
+ const args: Record<string, unknown> = {};
32
+ for (const key of [
33
+ "path",
34
+ "file_path",
35
+ "command",
36
+ "pattern",
37
+ "query",
38
+ "url",
39
+ "task",
40
+ "prompt",
41
+ "offset",
42
+ "limit",
43
+ ]) {
44
+ const value = tool.args[key];
45
+ if (typeof value === "string" && budget > 0) args[key] = take(value);
46
+ else if (typeof value === "number") args[key] = value;
47
+ }
48
+ const output = tool.result
49
+ ? tool.result.content
50
+ .filter((part) => part.type === "text")
51
+ .slice(-20)
52
+ .map((part) => part.text?.slice(-4096) ?? "")
53
+ .join("\n")
54
+ : (tool.liveOutput ?? "");
55
+ const text = budget > 0 ? take(output) : "";
56
+ activities.push({
57
+ id: tool.id,
58
+ name: tool.name,
59
+ args,
60
+ startTime: tool.startTime,
61
+ endTime: tool.endTime,
62
+ result: tool.result
63
+ ? { isError: tool.result.isError, content: [{ type: "text", text }] }
64
+ : undefined,
65
+ liveOutput: tool.result ? undefined : text,
66
+ });
67
+ }
68
+ return {
69
+ ...p,
70
+ assistantPreview: p.assistantPreview?.slice(-32_768),
71
+ error: p.error?.slice(0, 4096),
72
+ activities: activities.reverse(),
73
+ warnings: [
74
+ ...(p.warnings ?? []).slice(0, 10).map((s) => s.slice(0, 4096)),
75
+ ...(activities.length < p.activities.length
76
+ ? ["Earlier tool activity omitted from retained sync preview."]
77
+ : []),
78
+ ],
79
+ };
80
+ }
81
+
82
+ function compactDetails(details: SyncDetails): SyncDetails {
83
+ return {
84
+ tasks: details.tasks.map((task) => ({
85
+ prompt: task.prompt?.slice(0, 4096),
86
+ })),
87
+ progress: details.progress.map(compactProgress),
88
+ results: details.results.map((result) => ({
89
+ output: result.output?.slice(-32_768),
90
+ error: result.error?.slice(0, 4096),
91
+ })),
92
+ serializedNotice: details.serializedNotice?.slice(0, 4096),
93
+ dispatchWarning: details.dispatchWarning?.slice(0, 4096),
94
+ };
95
+ }
96
+
97
+ interface SyncRun {
98
+ details: SyncDetails;
99
+ finished: boolean;
100
+ created: number;
101
+ error?: string;
102
+ }
103
+
104
+ /** UI-only history. Active sync runs are never evicted; retain the last twenty
105
+ * finished calls. Async history stays owned by the existing ticket registry. */
106
+ export class BrowserHistory {
107
+ readonly runs = new Map<string, SyncRun>();
108
+ generation = 0;
109
+
110
+ reset(): void {
111
+ this.generation++;
112
+ this.runs.clear();
113
+ }
114
+
115
+ update(
116
+ id: string,
117
+ details: DelegateDetails | undefined,
118
+ finished: boolean,
119
+ generation = this.generation,
120
+ ): void {
121
+ if (generation !== this.generation) return;
122
+ if (!details?.progress.length) {
123
+ if (finished)
124
+ this.fail(
125
+ id,
126
+ "Dispatch ended before tasks could start; see the delegate result.",
127
+ );
128
+ return;
129
+ }
130
+ const created = this.runs.get(id)?.created ?? Date.now();
131
+ this.runs.delete(id);
132
+ this.runs.set(id, {
133
+ details: finished ? compactDetails(details) : details,
134
+ finished,
135
+ created,
136
+ });
137
+ this.prune();
138
+ }
139
+
140
+ fail(id: string, error: unknown, generation = this.generation): void {
141
+ if (generation !== this.generation) return;
142
+ const run = this.runs.get(id);
143
+ if (!run) return;
144
+ run.finished = true;
145
+ run.details = compactDetails(run.details);
146
+ run.error = (error instanceof Error ? error.message : String(error)).slice(
147
+ 0,
148
+ 4096,
149
+ );
150
+ this.runs.delete(id);
151
+ this.runs.set(id, run);
152
+ this.prune();
153
+ }
154
+
155
+ private prune(): void {
156
+ const finished = [...this.runs].filter(([, run]) => run.finished);
157
+ for (const [id] of finished.slice(0, -20)) this.runs.delete(id);
158
+ }
159
+ }
160
+
161
+ export interface BrowserRow {
162
+ key: string;
163
+ batch: string;
164
+ created: number;
165
+ progress: TaskProgress;
166
+ siblings: TaskProgress[];
167
+ prompt: string;
168
+ output?: string;
169
+ error?: string;
170
+ ticket?: AsyncTicket;
171
+ finished: boolean;
172
+ notice?: string;
173
+ }
174
+
175
+ export function browserRows(
176
+ runtime: DelegateRuntime,
177
+ history: BrowserHistory,
178
+ ): BrowserRow[] {
179
+ const rows: BrowserRow[] = [];
180
+ for (const ticket of runtime.tickets.values()) {
181
+ for (const progress of ticket.progress) {
182
+ const result = ticket.results[progress.index];
183
+ rows.push({
184
+ key: `${ticket.id}:${progress.index}`,
185
+ batch: ticket.id,
186
+ created: ticket.created,
187
+ progress,
188
+ siblings: ticket.progress,
189
+ prompt: ticket.tasks[progress.index]?.prompt ?? progress.task,
190
+ output: result?.output,
191
+ error: result?.error ?? ticket.error,
192
+ ticket,
193
+ finished: ticket.status !== "running" && ticket.status !== "cancelling",
194
+ notice: [ticket.serializedNotice, ticket.dispatchWarning]
195
+ .filter(Boolean)
196
+ .join("\n"),
197
+ });
198
+ }
199
+ }
200
+ for (const [id, run] of history.runs) {
201
+ for (const progress of run.details.progress) {
202
+ const result = run.details.results[progress.index];
203
+ rows.push({
204
+ key: `sync:${id}:${progress.index}`,
205
+ batch: `sync ${id.slice(-8)}`,
206
+ created: run.created,
207
+ progress,
208
+ siblings: run.details.progress,
209
+ prompt: run.details.tasks[progress.index]?.prompt ?? progress.task,
210
+ output: result?.output,
211
+ error: result?.error ?? run.error,
212
+ finished: run.finished,
213
+ notice: [run.details.serializedNotice, run.details.dispatchWarning]
214
+ .filter(Boolean)
215
+ .join("\n"),
216
+ });
217
+ }
218
+ }
219
+ return rows.sort(
220
+ (a, b) =>
221
+ Number(a.finished) - Number(b.finished) ||
222
+ b.created - a.created ||
223
+ a.batch.localeCompare(b.batch) ||
224
+ a.progress.index - b.progress.index,
225
+ );
226
+ }
227
+
228
+ export function browserRowStatus(row: BrowserRow): string {
229
+ const p = row.progress;
230
+ if (p.incomplete) return "incomplete — worker may still be active";
231
+ if (p.status === "done") return "done";
232
+ if (p.status === "failed") return "failed";
233
+ if (row.ticket?.status === "cancelling") return "cancelling";
234
+ if (row.finished)
235
+ return row.error ? "failed" : (row.ticket?.status ?? "finished");
236
+ if (p.paused) return "paused";
237
+ if (p.status === "pending") {
238
+ if (row.ticket?.pause && row.ticket.pause.state !== "running")
239
+ return "queued — ticket paused";
240
+ const before =
241
+ p.waitingFor === undefined ? undefined : row.siblings[p.waitingFor];
242
+ if (before && before.status !== "done" && before.status !== "failed")
243
+ return `queued — waiting for ${before.id ?? `task ${before.index + 1}`}`;
244
+ return "queued — waiting for preparation or capacity";
245
+ }
246
+ const phase = p.activity ?? "starting";
247
+ return row.ticket?.pause?.state === "pausing"
248
+ ? `${phase} — pausing after turn`
249
+ : phase;
250
+ }