pi-squire 0.10.0 → 0.11.0
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/CHANGELOG.md +18 -1
- package/README.md +69 -10
- package/package.json +1 -1
- package/src/index.ts +191 -35
- package/src/render.ts +2 -1
- package/src/result-message.ts +120 -0
- package/src/result-sessions.ts +35 -3
- package/src/results.ts +37 -116
- package/src/resume.ts +448 -0
- package/src/tracking-text.ts +41 -0
- package/src/tracking.ts +26 -49
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,22 @@ follows [Semantic Versioning](https://semver.org/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.11.0] - 2026-10-26
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- The `session:launch` payload gains a read-only `toolCallId`, the ID of the
|
|
14
|
+
`delegate` tool call that started the launch, so a listener can tie a
|
|
15
|
+
delegate's window to that call. It is `undefined` for a launch from the
|
|
16
|
+
`/delegate` command.
|
|
17
|
+
- `delegation_resume` and `/delegate-resume <id> [--model …] [--thinking …] [--auto-exit|--no-auto-exit] <task>` reopen an exited delegate's own session (`pi --session <path>`) in a new window with a follow-up task. The resume is recorded as a new delegation with `resumedFrom`; mail, replies and usage-limit marks use the delegation's session id, so records from before resuming read as before. A delegate whose working directory is gone, one still running, one whose window is open and an older delegation of a resumed chain are refused. `delegation_status` shows `resumed from` and `resumed as`, results and closed-without-a-result messages name `resumedFrom`, and the latter point to `delegation_resume`. The `delegate` tool's description mentions it, and `--session` joins the launch invariants listed for `session:launch`. A resume emits `session:launch`
|
|
18
|
+
too, with `toolCallId` set to the `delegation_resume` call, or `undefined`
|
|
19
|
+
from `/delegate-resume`; unlike a launch's, it comes before the task is sent,
|
|
20
|
+
because a resume mails the task only once its window is open (mail for a
|
|
21
|
+
window that never opened would otherwise reach the next resume as a second
|
|
22
|
+
task; a failed send kills the new window). A resumed delegation is never
|
|
23
|
+
reported closed without a result.
|
|
24
|
+
|
|
9
25
|
## [0.10.0] - 2026-10-24
|
|
10
26
|
|
|
11
27
|
### Added
|
|
@@ -458,7 +474,8 @@ author's dotfiles; the changes below are against that copy.
|
|
|
458
474
|
|
|
459
475
|
- The published file list includes `child.ts`, which `index.ts` imports.
|
|
460
476
|
|
|
461
|
-
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.
|
|
477
|
+
[Unreleased]: https://github.com/gvanderclay/pi-squire/compare/v0.11.0...HEAD
|
|
478
|
+
[0.11.0]: https://github.com/gvanderclay/pi-squire/compare/v0.10.0...v0.11.0
|
|
462
479
|
[0.10.0]: https://github.com/gvanderclay/pi-squire/compare/v0.9.5...v0.10.0
|
|
463
480
|
[0.9.5]: https://github.com/gvanderclay/pi-squire/compare/v0.9.4...v0.9.5
|
|
464
481
|
[0.9.4]: https://github.com/gvanderclay/pi-squire/compare/v0.9.3...v0.9.4
|
package/README.md
CHANGED
|
@@ -428,7 +428,7 @@ agent's `auto-exit`.
|
|
|
428
428
|
|
|
429
429
|
## Tools
|
|
430
430
|
|
|
431
|
-
The model gets
|
|
431
|
+
The model gets four tools. They share the command's launch path and the
|
|
432
432
|
session's delegation records. None of them is registered inside a delegate.
|
|
433
433
|
|
|
434
434
|
| Tool | Parameters | What it does |
|
|
@@ -436,6 +436,7 @@ session's delegation records. None of them is registered inside a delegate.
|
|
|
436
436
|
| `delegate` | `agent`, `task`, optional `model`, `thinking`, `label`, `cwd` and `auto_exit` (boolean) | Validates the call the way the command does, starts the delegation without opening a dialog (except an unresolved [trust question](#project-trust) for `cwd`), and returns the delegation id at once without waiting for a result. |
|
|
437
437
|
| `delegation_status` | optional `id` | Reports each delegation as running, done, closed or closed without a result, with the window, the delegate's session and the result envelope's path. |
|
|
438
438
|
| `delegation_close` | `id` | Kills the window through tmux and records the close, so `delegation_status` reports it closed. |
|
|
439
|
+
| `delegation_resume` | `id`, `task`, optional `model`, `thinking` and `auto_exit` | Reopens an exited delegate's own session in a new window with a follow-up task, and returns the new delegation's id at once (see [Resuming a delegation](#resuming-a-delegation)). |
|
|
439
440
|
|
|
440
441
|
The `delegate` tool's description lists the current roster, one line per agent:
|
|
441
442
|
its name, description, default model and thinking, auto-exit when it is off,
|
|
@@ -778,6 +779,59 @@ session or offer to fork it rather than open it when the delegate ran in
|
|
|
778
779
|
another directory or its file lives in another storage directory. The
|
|
779
780
|
absolute path opens that file directly.
|
|
780
781
|
|
|
782
|
+
## Resuming a delegation
|
|
783
|
+
|
|
784
|
+
An exited delegate can continue its own session with a follow-up task:
|
|
785
|
+
|
|
786
|
+
```text
|
|
787
|
+
/delegate-resume <id> [--model <provider/id>] [--thinking <level>] [--auto-exit | --no-auto-exit] <task…>
|
|
788
|
+
```
|
|
789
|
+
|
|
790
|
+
The model does the same with `delegation_resume` (`id` and `task`, optionally
|
|
791
|
+
`model`, `thinking` and `auto_exit`), which returns the `delegate` tool's result
|
|
792
|
+
plus `resumedFrom`. The command completes the ids that can still be resumed.
|
|
793
|
+
|
|
794
|
+
- The delegate's own session file is reopened with `pi --session <absolute path>`
|
|
795
|
+
and the recorded `--session-dir`, in a new window named like the first. The
|
|
796
|
+
delegate keeps everything it read and decided; Pi appends to the same file.
|
|
797
|
+
Opening it yourself is `pi --session <path>`, outside this tool.
|
|
798
|
+
- The resume is recorded as a **new delegation** with its own id and a
|
|
799
|
+
`resumedFrom` naming the old one. Its task goes to the old session's mail
|
|
800
|
+
address, and its result arrives as a `[delegate]` message that names
|
|
801
|
+
`resumed from <id>`. Status shows `resumed from <id>` on the new delegation and
|
|
802
|
+
`resumed as <id>` on the old one. A late reply to the old request still
|
|
803
|
+
settles the old delegation only, and a resumed delegation is never reported
|
|
804
|
+
closed without a result.
|
|
805
|
+
- Unlike a launch, the task is mailed after the window opens: the session's
|
|
806
|
+
inbox outlives a failed attempt, so a task sent for a window that never
|
|
807
|
+
opened would reach the next resume as a second task. The reopened delegate
|
|
808
|
+
receives it once its mailbox starts. If the send fails, the new window is
|
|
809
|
+
killed and nothing is recorded.
|
|
810
|
+
- A chain resumes from its newest delegation; resuming an older one is refused,
|
|
811
|
+
naming the newest.
|
|
812
|
+
- It is refused, before anything is sent or opened, unless: the delegation is a
|
|
813
|
+
started one (not a workspace attempt); it is not `running` (mail a running
|
|
814
|
+
delegate instead) and tmux can be asked; no window with its id and name still
|
|
815
|
+
runs a program (an exited one is killed first; a window that merely shares the
|
|
816
|
+
name is ignored); its session file is found and the working directory in the
|
|
817
|
+
file's header still exists; its agent is still in the roster; and no other
|
|
818
|
+
resume of it is under way. A delegate whose window was killed mid-turn can be
|
|
819
|
+
resumed.
|
|
820
|
+
- A removed working directory, a removed managed worktree included, makes a
|
|
821
|
+
delegation not resumable: Pi runs a reopened session in its stored directory.
|
|
822
|
+
The window opens in that directory, with project trust decided again for it as
|
|
823
|
+
for `cwd` (see [Project trust](#project-trust)); there is no `cwd` or
|
|
824
|
+
`worktree` for a resume, and no worktree is created.
|
|
825
|
+
- Without `model` it continues on the model the delegate finished on, moving to
|
|
826
|
+
its agent's fallbacks when that one is unavailable or marked (see
|
|
827
|
+
[Usage limits](#usage-limits)). An explicit `model` is used alone and refused
|
|
828
|
+
rather than substituted. `thinking` and `auto_exit` default to the
|
|
829
|
+
delegation's own. The agent's prompt, tools and extensions are those of the
|
|
830
|
+
roster now.
|
|
831
|
+
- `session:launch` is emitted as for any launch, with the same payload.
|
|
832
|
+
- Not detected: a Pi process you opened yourself on the same session outside
|
|
833
|
+
pi-squire's window. Close it before resuming.
|
|
834
|
+
|
|
781
835
|
## Hooks
|
|
782
836
|
|
|
783
837
|
The `js` block below is the contract's worked example, and
|
|
@@ -788,8 +842,10 @@ that block.
|
|
|
788
842
|
|
|
789
843
|
### `session:launch`
|
|
790
844
|
|
|
791
|
-
`pi-squire` provides this hook. It emits `{ args, env, agent, cwd }`
|
|
792
|
-
|
|
845
|
+
`pi-squire` provides this hook. It emits `{ args, env, agent, cwd, toolCallId }`
|
|
846
|
+
just before it opens the delegate's window: for a launch, after the task has
|
|
847
|
+
been sent; for a resume, before it is sent, since a resume mails the task once
|
|
848
|
+
the window is open (see [Resuming a delegation](#resuming-a-delegation)).
|
|
793
849
|
|
|
794
850
|
| Field | Meaning |
|
|
795
851
|
| --- | --- |
|
|
@@ -797,15 +853,17 @@ task has been sent and just before it opens the delegate's window.
|
|
|
797
853
|
| `env` | the child's environment, as `KEY`/`value` pairs |
|
|
798
854
|
| `agent` | the agent's name, as in `<agent dir>/squire/agents/<name>/AGENT.md`; read it, do not change it |
|
|
799
855
|
| `cwd` | the directory the window opens in, the parent's or the resolved `--cwd`; read it, do not change it |
|
|
856
|
+
| `toolCallId` | the ID of the `delegate` or `delegation_resume` tool call that started the launch; absent when started from the `/delegate` or `/delegate-resume` command; read it, do not change it |
|
|
800
857
|
|
|
801
858
|
Listeners may only append to `args` and add keys to `env`; there is no veto,
|
|
802
859
|
and `pi-squire` passes both to tmux unchanged. Do all work synchronously: the
|
|
803
860
|
emitter reads the payload the moment `emit` returns.
|
|
804
861
|
|
|
805
|
-
`cwd`, the trust flag (`--approve` or `--no-approve`)
|
|
806
|
-
|
|
862
|
+
`cwd`, the trust flag (`--approve` or `--no-approve`), `--session-dir` and the
|
|
863
|
+
session choice (`--session-id` for a new delegate, `--session` for a [resumed](#resuming-a-delegation)
|
|
864
|
+
one) are launch invariants that `pi-squire` chooses. The window opens in its own `cwd`,
|
|
807
865
|
whatever a listener assigns to the payload. A listener must not add another
|
|
808
|
-
trust flag, `--session-dir` or a working-directory change to `args`, since
|
|
866
|
+
trust flag, `--session-dir`, `--session` or `--session-id`, or a working-directory change to `args`, since
|
|
809
867
|
that would launch a child whose trust or session storage the parent did not
|
|
810
868
|
choose and cannot find; append only unrelated settings.
|
|
811
869
|
|
|
@@ -890,16 +948,17 @@ version and is listed under "Breaking" in [CHANGELOG.md](CHANGELOG.md).
|
|
|
890
948
|
|
|
891
949
|
Other extensions may rely on:
|
|
892
950
|
|
|
893
|
-
- the `session:launch` payload `{ args, env, agent, cwd }`, emitted
|
|
894
|
-
synchronously after the task is sent
|
|
951
|
+
- the `session:launch` payload `{ args, env, agent, cwd, toolCallId }`, emitted
|
|
952
|
+
synchronously before the window opens (after the task is sent for a launch,
|
|
953
|
+
before it is sent for a resume), with
|
|
895
954
|
`cwd`, the trust flag and `--session-dir` chosen by `pi-squire` (see
|
|
896
955
|
[Hooks](#hooks));
|
|
897
956
|
- the `message:send`, `message:inbound` and `message:scan` hooks, consumed as
|
|
898
957
|
their sections document;
|
|
899
|
-
- the `delegate`, `delegation_status` and `
|
|
958
|
+
- the `delegate`, `delegation_status`, `delegation_close` and `delegation_resume` tool names,
|
|
900
959
|
parameters, the recorded `workspace` shape and the `workspaceSnapshot`
|
|
901
960
|
shape in results and status;
|
|
902
|
-
- the `/delegate` syntax and flags;
|
|
961
|
+
- the `/delegate` and `/delegate-resume` syntax and flags;
|
|
903
962
|
- the `[delegate]` result text and the closed-without-a-result text;
|
|
904
963
|
- `AGENT.md`'s frontmatter keys and its location,
|
|
905
964
|
`<agent dir>/squire/agents/<name>/AGENT.md`;
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// session has no tool for leaves the agent out of the roster. Its `tools` goes
|
|
22
22
|
// as `--tools` and each of its `extensions` as `-e`, both for the child alone.
|
|
23
23
|
//
|
|
24
|
-
// The model gets
|
|
24
|
+
// The model gets four tools. `delegate` takes agent, task and optional model
|
|
25
25
|
// and thinking, validates them the way the command does, and starts the
|
|
26
26
|
// delegation at once, opening no dialog, so parallel calls cannot block one
|
|
27
27
|
// another; the one exception is a `cwd` whose project trust is unresolved,
|
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
// reports each delegation as running (window open, no result yet), done (with
|
|
30
30
|
// its result status) or closed, with the session name, window, session and
|
|
31
31
|
// envelope paths.
|
|
32
|
-
// `delegation_close` kills the window and records the close.
|
|
32
|
+
// `delegation_close` kills the window and records the close. `delegation_resume`
|
|
33
|
+
// (`resume.ts`) reopens an exited delegate's session in a new window with a
|
|
34
|
+
// follow-up task, recorded as a new delegation. Inside a delegate
|
|
33
35
|
// (`PI_DELEGATE_PARENT` set) the extension registers only `child.ts`'s
|
|
34
36
|
// `/auto-exit` and handlers, so a delegate cannot delegate.
|
|
35
37
|
//
|
|
@@ -53,6 +55,7 @@ import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.t
|
|
|
53
55
|
import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
|
|
54
56
|
import { dirtyNotice, renderDelegateCall, renderDelegateResult, resourceLine } from "./render.ts";
|
|
55
57
|
import { createResults, delegateName, type WorkspaceAttempt } from "./results.ts";
|
|
58
|
+
import { createResume, type ResumeRequest, resumableIds, startedLine } from "./resume.ts";
|
|
56
59
|
import { createTmuxClient, type TmuxClient, WindowOpenError } from "./tmux.ts";
|
|
57
60
|
import { createTracking, toolResult } from "./tracking.ts";
|
|
58
61
|
import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
|
|
@@ -156,9 +159,20 @@ const NOT_IN_TMUX = "this session is not inside tmux; start it in a tmux pane to
|
|
|
156
159
|
const USAGE =
|
|
157
160
|
"usage: /delegate <agent> [--model <provider/id>] [--thinking <level>] [--label <name>] [--cwd <dir> | --worktree <branch> [--worktree-source <dir>] [--worktree-base <commit-ish>] ] [--auto-exit | --no-auto-exit] <task>";
|
|
158
161
|
|
|
162
|
+
/** The flags `/delegate-resume` takes before the task. */
|
|
163
|
+
const RESUME_FLAGS = ["--model", "--thinking", "--auto-exit", "--no-auto-exit"] as const;
|
|
164
|
+
const RESUME_USAGE =
|
|
165
|
+
"usage: /delegate-resume <id> [--model <provider/id>] [--thinking <level>] [--auto-exit | --no-auto-exit] <task>";
|
|
166
|
+
|
|
159
167
|
type SendPayload = { to: unknown; body: unknown; envelope?: { id?: unknown }; error?: unknown };
|
|
160
168
|
/** `cwd` is informational: the window opens in the launch's own value, whatever a listener assigns. */
|
|
161
|
-
type LaunchPayload = {
|
|
169
|
+
type LaunchPayload = {
|
|
170
|
+
args: string[];
|
|
171
|
+
env: Record<string, string>;
|
|
172
|
+
agent: string;
|
|
173
|
+
readonly cwd: string;
|
|
174
|
+
readonly toolCallId: string | undefined;
|
|
175
|
+
};
|
|
162
176
|
type Parsed = {
|
|
163
177
|
agent: string;
|
|
164
178
|
model?: string;
|
|
@@ -194,6 +208,8 @@ type Start = {
|
|
|
194
208
|
};
|
|
195
209
|
/** A candidate model passed over: `why` finishes "<model> is …", `reason` is the long form for a refusal. */
|
|
196
210
|
type Skipped = { model: string; why: string; reason: string };
|
|
211
|
+
/** What opening a delegate's window reads from a request: a `Start`, or the parts of one a resume decides. */
|
|
212
|
+
type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit">;
|
|
197
213
|
/** A delegation that is open in a tmux window. */
|
|
198
214
|
type Launched = {
|
|
199
215
|
id: string;
|
|
@@ -276,9 +292,14 @@ function word(text: string, from: number): { text: string; start: number; end: n
|
|
|
276
292
|
* A flag's value at or after `from`: a plain word, or a double-quoted string
|
|
277
293
|
* in which `\"` and `\\` stand for `"` and `\`. No shell expansion of any kind.
|
|
278
294
|
*/
|
|
279
|
-
function flagValue(
|
|
295
|
+
function flagValue(
|
|
296
|
+
text: string,
|
|
297
|
+
from: number,
|
|
298
|
+
flag: string,
|
|
299
|
+
usage: string = USAGE,
|
|
300
|
+
): { value: string; end: number } | { error: string } {
|
|
280
301
|
const next = word(text, from);
|
|
281
|
-
if (next === undefined || next.text.startsWith("--")) return { error: `${flag} needs a value; ${
|
|
302
|
+
if (next === undefined || next.text.startsWith("--")) return { error: `${flag} needs a value; ${usage}` };
|
|
282
303
|
if (!next.text.startsWith('"')) return { value: next.text, end: next.end };
|
|
283
304
|
let value = "";
|
|
284
305
|
let i = next.start + 1;
|
|
@@ -287,9 +308,9 @@ function flagValue(text: string, from: number, flag: string): { value: string; e
|
|
|
287
308
|
value += escaped ? text[i + 1] : text[i];
|
|
288
309
|
i += escaped ? 2 : 1;
|
|
289
310
|
}
|
|
290
|
-
if (i >= text.length) return { error: `${flag}'s value has no closing quote; ${
|
|
311
|
+
if (i >= text.length) return { error: `${flag}'s value has no closing quote; ${usage}` };
|
|
291
312
|
if (i + 1 < text.length && !/\s/.test(text[i + 1])) {
|
|
292
|
-
return { error: `${flag}'s quoted value must be followed by a space; ${
|
|
313
|
+
return { error: `${flag}'s quoted value must be followed by a space; ${usage}` };
|
|
293
314
|
}
|
|
294
315
|
return { value, end: i + 1 };
|
|
295
316
|
}
|
|
@@ -336,6 +357,32 @@ function parse(text: string): { parsed: Parsed } | { error: string } {
|
|
|
336
357
|
return { parsed };
|
|
337
358
|
}
|
|
338
359
|
|
|
360
|
+
/** `/delegate-resume`'s arguments: a delegation id, optional flags, then a verbatim task. */
|
|
361
|
+
function parseResume(text: string): { request: ResumeRequest } | { error: string } {
|
|
362
|
+
const id = word(text, 0);
|
|
363
|
+
if (id === undefined) return { error: RESUME_USAGE };
|
|
364
|
+
if (id.text.startsWith("-")) return { error: `the first argument is the delegation id; ${RESUME_USAGE}` };
|
|
365
|
+
const request: ResumeRequest = { id: id.text, task: "" };
|
|
366
|
+
let at = id.end;
|
|
367
|
+
for (let next = word(text, at); next?.text.startsWith("-"); next = word(text, at)) {
|
|
368
|
+
const flag = next.text;
|
|
369
|
+
if (flag === "--auto-exit" || flag === "--no-auto-exit") {
|
|
370
|
+
request.autoExit = flag === "--auto-exit";
|
|
371
|
+
at = next.end;
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
if (flag !== "--model" && flag !== "--thinking") return { error: `unknown flag ${flag}; ${RESUME_USAGE}` };
|
|
375
|
+
const read = flagValue(text, next.end, flag, RESUME_USAGE);
|
|
376
|
+
if ("error" in read) return read;
|
|
377
|
+
if (flag === "--model") request.model = read.value;
|
|
378
|
+
else request.thinking = read.value;
|
|
379
|
+
at = read.end;
|
|
380
|
+
}
|
|
381
|
+
request.task = text.slice(word(text, at)?.start ?? text.length).trim();
|
|
382
|
+
if (request.task === "") return { error: `a task is required after the id and flags; ${RESUME_USAGE}` };
|
|
383
|
+
return { request };
|
|
384
|
+
}
|
|
385
|
+
|
|
339
386
|
/** Why `value` is not a usable model, or undefined when it is. */
|
|
340
387
|
function modelProblem(value: string, registry: ModelRegistry): string | undefined {
|
|
341
388
|
const slash = value.indexOf("/");
|
|
@@ -356,15 +403,17 @@ function modelProblem(value: string, registry: ModelRegistry): string | undefine
|
|
|
356
403
|
* `modelProblem` and has no active mark wins; the rest are returned as skipped.
|
|
357
404
|
* A paid candidate on a provider with a quota check is read first, and
|
|
358
405
|
* skipped when its quota is used up. Refuses, listing every candidate, when
|
|
359
|
-
* none is left.
|
|
406
|
+
* none is left. `preferred` replaces the agent's own list when there is no
|
|
407
|
+
* explicit model, as a resume does.
|
|
360
408
|
*/
|
|
361
409
|
async function chooseModel(
|
|
362
410
|
explicit: string | undefined,
|
|
363
411
|
agent: Agent,
|
|
364
412
|
models: ModelRegistry,
|
|
365
413
|
proactive: Proactive,
|
|
414
|
+
preferred: readonly string[] = [agent.model, ...agent.fallback],
|
|
366
415
|
): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
|
|
367
|
-
const candidates = explicit !== undefined ? [explicit] : [
|
|
416
|
+
const candidates = explicit !== undefined ? [explicit] : [...preferred];
|
|
368
417
|
const skipped: Skipped[] = [];
|
|
369
418
|
const notices: string[] = [];
|
|
370
419
|
for (const model of candidates) {
|
|
@@ -475,7 +524,7 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
475
524
|
),
|
|
476
525
|
"",
|
|
477
526
|
"`cwd` runs the delegate in another existing directory. Alternatively, `worktree` creates a new local branch and checkout from this repository's committed HEAD, or `worktree_source` selects another existing Git checkout; `worktree_base` selects a commit in that source. Source dirt is reported but not copied. `cwd` and `worktree` are mutually exclusive, and source/base require worktree.",
|
|
478
|
-
"`model` and `thinking` override the agent's defaults for this call; an unknown value is refused with close matches. `label` names the delegate's window and session; give one that says what the task is. `delegation_status` reports a delegation, and `delegation_close` ends one.",
|
|
527
|
+
"`model` and `thinking` override the agent's defaults for this call; an unknown value is refused with close matches. `label` names the delegate's window and session; give one that says what the task is. `delegation_status` reports a delegation, and `delegation_close` ends one. `delegation_resume` continues an exited delegate in its own session with a follow-up task, for work that needs the context it already built.",
|
|
479
528
|
"`auto_exit` (default the agent's, normally true) closes the delegate's window once it finishes. Set it false when you mean to keep talking to the delegate by mail after its result; the user can also keep a window open by typing in it.",
|
|
480
529
|
"",
|
|
481
530
|
"Writing the task: the delegate sees nothing of this session, so the task must stand alone. State the question, the decision its answer feeds, what is already known or ruled out, and the files or paths to start from. When the work produces a report, name the path to write it to and ask for the conclusion back, not the contents.",
|
|
@@ -575,20 +624,42 @@ export default function delegate(
|
|
|
575
624
|
};
|
|
576
625
|
}
|
|
577
626
|
|
|
578
|
-
|
|
627
|
+
/** Write the task to the delegate's inbox and return its request id; the delegate finds it at session start. */
|
|
628
|
+
function sendTask(to: string, task: string): string {
|
|
629
|
+
const sent: SendPayload = { to, body: task };
|
|
630
|
+
pi.events.emit(SEND, sent);
|
|
631
|
+
if (typeof sent.error === "string") throw new Error(`could not send the task to ${to}: ${sent.error}`);
|
|
632
|
+
if (sent.envelope === undefined)
|
|
633
|
+
throw new Error(
|
|
634
|
+
"needs a provider of message:* to send the task; install a mailbox extension (for example pi-session-mail)",
|
|
635
|
+
);
|
|
636
|
+
const requestId = sent.envelope.id;
|
|
637
|
+
if (typeof requestId !== "string") throw new Error(`the message:send provider wrote no request id for ${to}`);
|
|
638
|
+
return requestId;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* Build the delegate's command line and environment, emit `session:launch`
|
|
643
|
+
* and open its window; returns the window id. A launch and a resume share
|
|
644
|
+
* it and differ only in `session`, the arguments that choose the session:
|
|
645
|
+
* `--session-id <id> --name <name>` for a new one, `--session <path>` to reopen.
|
|
646
|
+
*/
|
|
647
|
+
async function openChild(input: {
|
|
579
648
|
id: string;
|
|
580
|
-
|
|
649
|
+
name: string;
|
|
650
|
+
session: string[];
|
|
581
651
|
workspace: Workspace;
|
|
582
652
|
trusted: boolean;
|
|
583
653
|
cwd: string;
|
|
584
654
|
sessionDir: string;
|
|
585
|
-
start:
|
|
655
|
+
start: ChildStart;
|
|
656
|
+
/** The tool call that started this launch or resume; undefined from a command. */
|
|
657
|
+
toolCallId: string | undefined;
|
|
586
658
|
ctx: ExtensionContext;
|
|
587
|
-
onWindowOpened
|
|
588
|
-
}): Promise<
|
|
589
|
-
const { id,
|
|
659
|
+
onWindowOpened?: () => void;
|
|
660
|
+
}): Promise<string> {
|
|
661
|
+
const { id, name, session, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, onWindowOpened } = input;
|
|
590
662
|
const parent = ctx.sessionManager.getSessionId();
|
|
591
|
-
const name = delegateName(start.agent.name, id, start.label);
|
|
592
663
|
const managedContext =
|
|
593
664
|
workspace.mode === "managed"
|
|
594
665
|
? `\n\nYour managed worktree is ${workspace.cwd}, branch ${workspace.createdBranch}, based on commit ${workspace.baseCommit}.`
|
|
@@ -598,10 +669,7 @@ export default function delegate(
|
|
|
598
669
|
const prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}${managedContext}`);
|
|
599
670
|
const argv = [
|
|
600
671
|
...parentCommand(),
|
|
601
|
-
|
|
602
|
-
id,
|
|
603
|
-
"--name",
|
|
604
|
-
name,
|
|
672
|
+
...session,
|
|
605
673
|
// Always explicit, so a permissive global default cannot trust what the parent does not.
|
|
606
674
|
trusted ? "--approve" : "--no-approve",
|
|
607
675
|
"--session-dir",
|
|
@@ -629,18 +697,48 @@ export default function delegate(
|
|
|
629
697
|
const envSessionDir = process.env[SESSION_DIR_ENV];
|
|
630
698
|
if (envSessionDir !== undefined && envSessionDir !== "") env[SESSION_DIR_ENV] = envSessionDir;
|
|
631
699
|
// Listeners may only append to `args` and add to `env`; there is no veto.
|
|
632
|
-
const payload: LaunchPayload = { args: argv, env, agent: start.agent.name, cwd };
|
|
700
|
+
const payload: LaunchPayload = { args: argv, env, agent: start.agent.name, cwd, toolCallId };
|
|
633
701
|
pi.events.emit(LAUNCH, payload);
|
|
634
702
|
let windowId: string;
|
|
635
703
|
try {
|
|
636
704
|
windowId = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env });
|
|
637
|
-
onWindowOpened();
|
|
705
|
+
onWindowOpened?.();
|
|
638
706
|
} catch (err) {
|
|
639
707
|
if (err instanceof WindowOpenError) {
|
|
640
708
|
throw new WindowOpenError(`could not open window ${name} for ${id}: ${err.message}`, err.certainty);
|
|
641
709
|
}
|
|
642
710
|
throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
|
|
643
711
|
}
|
|
712
|
+
return windowId;
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
async function launchWindow(input: {
|
|
716
|
+
id: string;
|
|
717
|
+
requestId: string;
|
|
718
|
+
workspace: Workspace;
|
|
719
|
+
trusted: boolean;
|
|
720
|
+
cwd: string;
|
|
721
|
+
sessionDir: string;
|
|
722
|
+
start: Start;
|
|
723
|
+
toolCallId: string | undefined;
|
|
724
|
+
ctx: ExtensionContext;
|
|
725
|
+
onWindowOpened: () => void;
|
|
726
|
+
}): Promise<Launched> {
|
|
727
|
+
const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, onWindowOpened } = input;
|
|
728
|
+
const name = delegateName(start.agent.name, id, start.label);
|
|
729
|
+
const windowId = await openChild({
|
|
730
|
+
id,
|
|
731
|
+
name,
|
|
732
|
+
session: ["--session-id", id, "--name", name],
|
|
733
|
+
workspace,
|
|
734
|
+
trusted,
|
|
735
|
+
cwd,
|
|
736
|
+
sessionDir,
|
|
737
|
+
start,
|
|
738
|
+
toolCallId,
|
|
739
|
+
ctx,
|
|
740
|
+
onWindowOpened,
|
|
741
|
+
});
|
|
644
742
|
// No task text in the record: the request copy in `sent/` has it.
|
|
645
743
|
results.recordStart(
|
|
646
744
|
{
|
|
@@ -663,7 +761,12 @@ export default function delegate(
|
|
|
663
761
|
return { id, name, windowId, windowName: name, requestId, cwd, sessionDir, workspace };
|
|
664
762
|
}
|
|
665
763
|
|
|
666
|
-
async function launch(
|
|
764
|
+
async function launch(
|
|
765
|
+
start: Start,
|
|
766
|
+
ctx: ExtensionContext,
|
|
767
|
+
signal?: AbortSignal,
|
|
768
|
+
toolCallId?: string,
|
|
769
|
+
): Promise<Launched> {
|
|
667
770
|
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
668
771
|
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
669
772
|
if (!isAbsolute(sessionDir))
|
|
@@ -760,15 +863,7 @@ export default function delegate(
|
|
|
760
863
|
// The task goes on disk before the window exists: the delegate finds it
|
|
761
864
|
// at session start without a handshake, and a window that fails leaves
|
|
762
865
|
// mail that is reported rather than cleaned up.
|
|
763
|
-
const
|
|
764
|
-
pi.events.emit(SEND, sent);
|
|
765
|
-
if (typeof sent.error === "string") throw new Error(`could not send the task to ${id}: ${sent.error}`);
|
|
766
|
-
if (sent.envelope === undefined)
|
|
767
|
-
throw new Error(
|
|
768
|
-
"needs a provider of message:* to send the task; install a mailbox extension (for example pi-session-mail)",
|
|
769
|
-
);
|
|
770
|
-
const requestId = sent.envelope.id;
|
|
771
|
-
if (typeof requestId !== "string") throw new Error(`the message:send provider wrote no request id for ${id}`);
|
|
866
|
+
const requestId = sendTask(id, start.task);
|
|
772
867
|
if (attempt !== undefined) {
|
|
773
868
|
attempt = { ...attempt, requestId };
|
|
774
869
|
results.recordAttempt(attempt, ctx);
|
|
@@ -781,6 +876,7 @@ export default function delegate(
|
|
|
781
876
|
cwd: workspace.cwd,
|
|
782
877
|
sessionDir,
|
|
783
878
|
start,
|
|
879
|
+
toolCallId,
|
|
784
880
|
ctx,
|
|
785
881
|
onWindowOpened: () => {
|
|
786
882
|
windowOpened = true;
|
|
@@ -847,6 +943,32 @@ export default function delegate(
|
|
|
847
943
|
return inQueue(plan.source.commonDir, combined, runWithRollback);
|
|
848
944
|
}
|
|
849
945
|
|
|
946
|
+
const resumer = createResume({
|
|
947
|
+
results,
|
|
948
|
+
tmux,
|
|
949
|
+
delegateSchema: DelegateSchema,
|
|
950
|
+
notInTmux: NOT_IN_TMUX,
|
|
951
|
+
startPolling: tracking.startPolling,
|
|
952
|
+
roster: rosterFor,
|
|
953
|
+
chooseModel: (explicit, agent, models, preferred) => chooseModel(explicit, agent, models, proactive, preferred),
|
|
954
|
+
thinkingProblem,
|
|
955
|
+
prepareDirectory: (cwd, ctx, signal) => prepareDirectory({ cwd, signal }, ctx, getAgentDir()),
|
|
956
|
+
sendTask,
|
|
957
|
+
open: ({ id, name, sessionPath, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx }) =>
|
|
958
|
+
openChild({
|
|
959
|
+
id,
|
|
960
|
+
name,
|
|
961
|
+
session: ["--session", sessionPath],
|
|
962
|
+
workspace,
|
|
963
|
+
trusted,
|
|
964
|
+
cwd,
|
|
965
|
+
sessionDir,
|
|
966
|
+
start,
|
|
967
|
+
toolCallId,
|
|
968
|
+
ctx,
|
|
969
|
+
}),
|
|
970
|
+
});
|
|
971
|
+
|
|
850
972
|
/** Where a selected directory put the delegate, for the start text; inherited starts stay compact. */
|
|
851
973
|
const placedIn = (launched: Launched): string =>
|
|
852
974
|
launched.workspace.mode !== "inherited" ? ` in ${launched.cwd}` : "";
|
|
@@ -880,6 +1002,19 @@ export default function delegate(
|
|
|
880
1002
|
}
|
|
881
1003
|
}
|
|
882
1004
|
|
|
1005
|
+
async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
|
|
1006
|
+
const outcome = parseResume(input);
|
|
1007
|
+
if ("error" in outcome) {
|
|
1008
|
+
ctx.ui.notify(`delegate: ${outcome.error}`, "error");
|
|
1009
|
+
return;
|
|
1010
|
+
}
|
|
1011
|
+
try {
|
|
1012
|
+
ctx.ui.notify(startedLine(await resumer.resume(outcome.request, ctx)), "info");
|
|
1013
|
+
} catch (err) {
|
|
1014
|
+
ctx.ui.notify(`delegate: ${(err as Error).message}`, "error");
|
|
1015
|
+
}
|
|
1016
|
+
}
|
|
1017
|
+
|
|
883
1018
|
/** The roster description last registered, so unchanged metadata is not registered again. */
|
|
884
1019
|
let registeredDescription: string | undefined;
|
|
885
1020
|
|
|
@@ -937,7 +1072,7 @@ export default function delegate(
|
|
|
937
1072
|
// Opens a new window each call and removes nothing. Open world: the delegate calls a remote model and runs its agent's tools unattended.
|
|
938
1073
|
annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
|
|
939
1074
|
async execute(
|
|
940
|
-
|
|
1075
|
+
toolCallId: string,
|
|
941
1076
|
params: {
|
|
942
1077
|
agent: string;
|
|
943
1078
|
task: string;
|
|
@@ -977,7 +1112,7 @@ export default function delegate(
|
|
|
977
1112
|
rosterFor(ctx),
|
|
978
1113
|
ctx.modelRegistry,
|
|
979
1114
|
);
|
|
980
|
-
const launched = await launch(start, ctx, signal);
|
|
1115
|
+
const launched = await launch(start, ctx, signal, toolCallId);
|
|
981
1116
|
ctx.ui.notify(started(launched, start), "info");
|
|
982
1117
|
return toolResult(
|
|
983
1118
|
`Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.windowName}${placedIn(launched)}.${launched.workspace.mode === "managed" ? ` Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""} It runs in the background, and its result arrives as a message that starts your next turn on its own; delegation_close ends it.${start.skipped.length > 0 ? ` ${fallbackNote(start)}` : ""}${start.notices.map((line) => ` ${line}`).join("")}`,
|
|
@@ -1054,6 +1189,26 @@ export default function delegate(
|
|
|
1054
1189
|
handler: command,
|
|
1055
1190
|
});
|
|
1056
1191
|
|
|
1192
|
+
/** Delegation ids, flags, model ids and thinking levels, at the cursor's token. */
|
|
1193
|
+
function resumeCompletions(prefix: string): { value: string; label: string }[] | null {
|
|
1194
|
+
const partial = /(?:^|\s)(\S*)$/.exec(prefix)?.[1] ?? "";
|
|
1195
|
+
const head = prefix.slice(0, prefix.length - partial.length);
|
|
1196
|
+
const tokens = head.trim() === "" ? [] : head.trim().split(/\s+/);
|
|
1197
|
+
const previous = tokens[tokens.length - 1];
|
|
1198
|
+
let values: readonly string[] = RESUME_FLAGS;
|
|
1199
|
+
if (tokens.length === 0) values = resumableIds(results);
|
|
1200
|
+
else if (previous === "--model") values = modelIds(registry);
|
|
1201
|
+
else if (previous === "--thinking") values = THINKING_LEVELS;
|
|
1202
|
+
const hits = values.filter((value) => value.startsWith(partial));
|
|
1203
|
+
return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
|
|
1204
|
+
}
|
|
1205
|
+
|
|
1206
|
+
pi.registerCommand("delegate-resume", {
|
|
1207
|
+
description: `Reopen an exited delegate's session in a new window with a follow-up task: ${RESUME_USAGE}`,
|
|
1208
|
+
getArgumentCompletions: resumeCompletions,
|
|
1209
|
+
handler: resumeCommand,
|
|
1210
|
+
});
|
|
1211
|
+
|
|
1057
1212
|
pi.registerCommand("delegate-clear", {
|
|
1058
1213
|
description: "Clear usage-limit marks: all, a provider, or a provider/model",
|
|
1059
1214
|
getArgumentCompletions: (prefix: string) => {
|
|
@@ -1084,4 +1239,5 @@ export default function delegate(
|
|
|
1084
1239
|
|
|
1085
1240
|
registerDelegate(toolDescription(currentRoster().agents));
|
|
1086
1241
|
tracking.registerTools();
|
|
1242
|
+
pi.registerTool(resumer.tool() as never);
|
|
1087
1243
|
}
|
package/src/render.ts
CHANGED
|
@@ -154,6 +154,7 @@ function plainLines(text: string): string[] {
|
|
|
154
154
|
|
|
155
155
|
function idLines(details: MessageDetails): string[] {
|
|
156
156
|
const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
|
|
157
|
+
if (details.resumedFrom !== undefined) lines.splice(1, 0, `resumed from ${details.resumedFrom}`);
|
|
157
158
|
if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
|
|
158
159
|
else lines.push(`window ${details.windowName}`);
|
|
159
160
|
lines.push(`cwd ${details.cwd === undefined ? cwdLine(undefined) : tilde(details.cwd)}`);
|
|
@@ -163,7 +164,7 @@ function idLines(details: MessageDetails): string[] {
|
|
|
163
164
|
lines.push(`checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`));
|
|
164
165
|
}
|
|
165
166
|
lines.push(
|
|
166
|
-
`session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
|
|
167
|
+
`session ${details.sessionPath === undefined ? `${details.sessionId ?? details.id} (no session file found)` : tilde(details.sessionPath)}`,
|
|
167
168
|
);
|
|
168
169
|
return lines;
|
|
169
170
|
}
|