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 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.10.0...HEAD
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 three tools. They share the command's launch path and the
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 }` after the
792
- task has been sent and just before it opens the delegate's window.
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`) and `--session-dir` are
806
- launch invariants that `pi-squire` chooses. The window opens in its own `cwd`,
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 and before the window opens, with
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 `delegation_close` tool names,
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-squire",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
5
5
  "keywords": [
6
6
  "pi-package",
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 three tools. `delegate` takes agent, task and optional model
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. Inside a delegate
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 = { args: string[]; env: Record<string, string>; agent: string; readonly cwd: string };
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(text: string, from: number, flag: string): { value: string; end: number } | { error: string } {
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; ${USAGE}` };
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; ${USAGE}` };
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; ${USAGE}` };
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] : [agent.model, ...agent.fallback];
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
- async function launchWindow(input: {
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
- requestId: string;
649
+ name: string;
650
+ session: string[];
581
651
  workspace: Workspace;
582
652
  trusted: boolean;
583
653
  cwd: string;
584
654
  sessionDir: string;
585
- start: 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: () => void;
588
- }): Promise<Launched> {
589
- const { id, requestId, workspace, trusted, cwd, sessionDir, start, ctx, onWindowOpened } = input;
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
- "--session-id",
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(start: Start, ctx: ExtensionContext, signal?: AbortSignal): Promise<Launched> {
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 sent: SendPayload = { to: id, body: start.task };
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
- _toolCallId: string,
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
  }