pi-squire 0.10.0 → 0.12.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/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,9 +55,11 @@ 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";
56
- import { createTmuxClient, type TmuxClient, WindowOpenError } from "./tmux.ts";
58
+ import { createResume, type ResumeRequest, resumableIds, startedLine } from "./resume.ts";
59
+ import { createTmuxClient, type OpenedWindow, requireTmuxVersion, 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";
62
+ import { type WindowPlacement, windowPlacement } from "./window-placement.ts";
59
63
  import {
60
64
  normalizeWorkspaceValue,
61
65
  prepareDirectory,
@@ -146,6 +150,9 @@ export const DelegateSchema = Type.Object({
146
150
  autoExit: Type.Boolean(),
147
151
  windowId: Type.String(),
148
152
  windowName: Type.String(),
153
+ windowSession: Type.Optional(
154
+ Type.String({ description: "The tmux session the window opened in, as tmux named it then." }),
155
+ ),
149
156
  cwd: Type.String({ description: "The directory the delegate's window opened in." }),
150
157
  sessionDir: Type.String({ description: "The session-storage directory the delegate was told to use." }),
151
158
  workspace: WorkspaceSchema,
@@ -156,9 +163,20 @@ const NOT_IN_TMUX = "this session is not inside tmux; start it in a tmux pane to
156
163
  const USAGE =
157
164
  "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
165
 
166
+ /** The flags `/delegate-resume` takes before the task. */
167
+ const RESUME_FLAGS = ["--model", "--thinking", "--auto-exit", "--no-auto-exit"] as const;
168
+ const RESUME_USAGE =
169
+ "usage: /delegate-resume <id> [--model <provider/id>] [--thinking <level>] [--auto-exit | --no-auto-exit] <task>";
170
+
159
171
  type SendPayload = { to: unknown; body: unknown; envelope?: { id?: unknown }; error?: unknown };
160
172
  /** `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 };
173
+ type LaunchPayload = {
174
+ args: string[];
175
+ env: Record<string, string>;
176
+ agent: string;
177
+ readonly cwd: string;
178
+ readonly toolCallId: string | undefined;
179
+ };
162
180
  type Parsed = {
163
181
  agent: string;
164
182
  model?: string;
@@ -194,12 +212,16 @@ type Start = {
194
212
  };
195
213
  /** A candidate model passed over: `why` finishes "<model> is …", `reason` is the long form for a refusal. */
196
214
  type Skipped = { model: string; why: string; reason: string };
215
+ /** What opening a delegate's window reads from a request: a `Start`, or the parts of one a resume decides. */
216
+ type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit">;
197
217
  /** A delegation that is open in a tmux window. */
198
218
  type Launched = {
199
219
  id: string;
200
220
  name: string;
201
221
  windowId: string;
202
222
  windowName: string;
223
+ /** The session the window opened in; undefined when tmux could not say. */
224
+ windowSession: string | undefined;
203
225
  requestId: string;
204
226
  cwd: string;
205
227
  sessionDir: string;
@@ -276,9 +298,14 @@ function word(text: string, from: number): { text: string; start: number; end: n
276
298
  * A flag's value at or after `from`: a plain word, or a double-quoted string
277
299
  * in which `\"` and `\\` stand for `"` and `\`. No shell expansion of any kind.
278
300
  */
279
- function flagValue(text: string, from: number, flag: string): { value: string; end: number } | { error: string } {
301
+ function flagValue(
302
+ text: string,
303
+ from: number,
304
+ flag: string,
305
+ usage: string = USAGE,
306
+ ): { value: string; end: number } | { error: string } {
280
307
  const next = word(text, from);
281
- if (next === undefined || next.text.startsWith("--")) return { error: `${flag} needs a value; ${USAGE}` };
308
+ if (next === undefined || next.text.startsWith("--")) return { error: `${flag} needs a value; ${usage}` };
282
309
  if (!next.text.startsWith('"')) return { value: next.text, end: next.end };
283
310
  let value = "";
284
311
  let i = next.start + 1;
@@ -287,9 +314,9 @@ function flagValue(text: string, from: number, flag: string): { value: string; e
287
314
  value += escaped ? text[i + 1] : text[i];
288
315
  i += escaped ? 2 : 1;
289
316
  }
290
- if (i >= text.length) return { error: `${flag}'s value has no closing quote; ${USAGE}` };
317
+ if (i >= text.length) return { error: `${flag}'s value has no closing quote; ${usage}` };
291
318
  if (i + 1 < text.length && !/\s/.test(text[i + 1])) {
292
- return { error: `${flag}'s quoted value must be followed by a space; ${USAGE}` };
319
+ return { error: `${flag}'s quoted value must be followed by a space; ${usage}` };
293
320
  }
294
321
  return { value, end: i + 1 };
295
322
  }
@@ -336,6 +363,32 @@ function parse(text: string): { parsed: Parsed } | { error: string } {
336
363
  return { parsed };
337
364
  }
338
365
 
366
+ /** `/delegate-resume`'s arguments: a delegation id, optional flags, then a verbatim task. */
367
+ function parseResume(text: string): { request: ResumeRequest } | { error: string } {
368
+ const id = word(text, 0);
369
+ if (id === undefined) return { error: RESUME_USAGE };
370
+ if (id.text.startsWith("-")) return { error: `the first argument is the delegation id; ${RESUME_USAGE}` };
371
+ const request: ResumeRequest = { id: id.text, task: "" };
372
+ let at = id.end;
373
+ for (let next = word(text, at); next?.text.startsWith("-"); next = word(text, at)) {
374
+ const flag = next.text;
375
+ if (flag === "--auto-exit" || flag === "--no-auto-exit") {
376
+ request.autoExit = flag === "--auto-exit";
377
+ at = next.end;
378
+ continue;
379
+ }
380
+ if (flag !== "--model" && flag !== "--thinking") return { error: `unknown flag ${flag}; ${RESUME_USAGE}` };
381
+ const read = flagValue(text, next.end, flag, RESUME_USAGE);
382
+ if ("error" in read) return read;
383
+ if (flag === "--model") request.model = read.value;
384
+ else request.thinking = read.value;
385
+ at = read.end;
386
+ }
387
+ request.task = text.slice(word(text, at)?.start ?? text.length).trim();
388
+ if (request.task === "") return { error: `a task is required after the id and flags; ${RESUME_USAGE}` };
389
+ return { request };
390
+ }
391
+
339
392
  /** Why `value` is not a usable model, or undefined when it is. */
340
393
  function modelProblem(value: string, registry: ModelRegistry): string | undefined {
341
394
  const slash = value.indexOf("/");
@@ -356,15 +409,17 @@ function modelProblem(value: string, registry: ModelRegistry): string | undefine
356
409
  * `modelProblem` and has no active mark wins; the rest are returned as skipped.
357
410
  * A paid candidate on a provider with a quota check is read first, and
358
411
  * skipped when its quota is used up. Refuses, listing every candidate, when
359
- * none is left.
412
+ * none is left. `preferred` replaces the agent's own list when there is no
413
+ * explicit model, as a resume does.
360
414
  */
361
415
  async function chooseModel(
362
416
  explicit: string | undefined,
363
417
  agent: Agent,
364
418
  models: ModelRegistry,
365
419
  proactive: Proactive,
420
+ preferred: readonly string[] = [agent.model, ...agent.fallback],
366
421
  ): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
367
- const candidates = explicit !== undefined ? [explicit] : [agent.model, ...agent.fallback];
422
+ const candidates = explicit !== undefined ? [explicit] : [...preferred];
368
423
  const skipped: Skipped[] = [];
369
424
  const notices: string[] = [];
370
425
  for (const model of candidates) {
@@ -475,7 +530,7 @@ function toolDescription(agents: readonly Agent[]): string {
475
530
  ),
476
531
  "",
477
532
  "`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.",
533
+ "`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
534
  "`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
535
  "",
481
536
  "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 +630,57 @@ export default function delegate(
575
630
  };
576
631
  }
577
632
 
578
- async function launchWindow(input: {
633
+ /** Write the task to the delegate's inbox and return its request id; the delegate finds it at session start. */
634
+ function sendTask(to: string, task: string): string {
635
+ const sent: SendPayload = { to, body: task };
636
+ pi.events.emit(SEND, sent);
637
+ if (typeof sent.error === "string") throw new Error(`could not send the task to ${to}: ${sent.error}`);
638
+ if (sent.envelope === undefined)
639
+ throw new Error(
640
+ "needs a provider of message:* to send the task; install a mailbox extension (for example pi-session-mail)",
641
+ );
642
+ const requestId = sent.envelope.id;
643
+ if (typeof requestId !== "string") throw new Error(`the message:send provider wrote no request id for ${to}`);
644
+ return requestId;
645
+ }
646
+
647
+ /**
648
+ * Build the delegate's command line and environment, emit `session:launch`
649
+ * and open its window; returns the window id and session. A launch and a resume share
650
+ * it and differ only in `session`, the arguments that choose the session:
651
+ * `--session-id <id> --name <name>` for a new one, `--session <path>` to reopen.
652
+ */
653
+ async function openChild(input: {
579
654
  id: string;
580
- requestId: string;
655
+ name: string;
656
+ session: string[];
581
657
  workspace: Workspace;
582
658
  trusted: boolean;
583
659
  cwd: string;
584
660
  sessionDir: string;
585
- start: Start;
661
+ start: ChildStart;
662
+ /** The tool call that started this launch or resume; undefined from a command. */
663
+ toolCallId: string | undefined;
586
664
  ctx: ExtensionContext;
587
- onWindowOpened: () => void;
588
- }): Promise<Launched> {
589
- const { id, requestId, workspace, trusted, cwd, sessionDir, start, ctx, onWindowOpened } = input;
665
+ /** Chosen once, when the launch or resume began, so no later settings change can refuse it after mail. */
666
+ placement: WindowPlacement;
667
+ onWindowOpened?: () => void;
668
+ }): Promise<OpenedWindow> {
669
+ const {
670
+ id,
671
+ name,
672
+ session,
673
+ workspace,
674
+ trusted,
675
+ cwd,
676
+ sessionDir,
677
+ start,
678
+ toolCallId,
679
+ ctx,
680
+ placement,
681
+ onWindowOpened,
682
+ } = input;
590
683
  const parent = ctx.sessionManager.getSessionId();
591
- const name = delegateName(start.agent.name, id, start.label);
592
684
  const managedContext =
593
685
  workspace.mode === "managed"
594
686
  ? `\n\nYour managed worktree is ${workspace.cwd}, branch ${workspace.createdBranch}, based on commit ${workspace.baseCommit}.`
@@ -598,10 +690,7 @@ export default function delegate(
598
690
  const prompt = writePromptFile(`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}${managedContext}`);
599
691
  const argv = [
600
692
  ...parentCommand(),
601
- "--session-id",
602
- id,
603
- "--name",
604
- name,
693
+ ...session,
605
694
  // Always explicit, so a permissive global default cannot trust what the parent does not.
606
695
  trusted ? "--approve" : "--no-approve",
607
696
  "--session-dir",
@@ -629,18 +718,51 @@ export default function delegate(
629
718
  const envSessionDir = process.env[SESSION_DIR_ENV];
630
719
  if (envSessionDir !== undefined && envSessionDir !== "") env[SESSION_DIR_ENV] = envSessionDir;
631
720
  // 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 };
721
+ const payload: LaunchPayload = { args: argv, env, agent: start.agent.name, cwd, toolCallId };
633
722
  pi.events.emit(LAUNCH, payload);
634
- let windowId: string;
723
+ let opened: OpenedWindow;
635
724
  try {
636
- windowId = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env });
637
- onWindowOpened();
725
+ opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement);
726
+ onWindowOpened?.();
638
727
  } catch (err) {
639
728
  if (err instanceof WindowOpenError) {
640
729
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${err.message}`, err.certainty);
641
730
  }
642
731
  throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
643
732
  }
733
+ return opened;
734
+ }
735
+
736
+ async function launchWindow(input: {
737
+ id: string;
738
+ requestId: string;
739
+ workspace: Workspace;
740
+ trusted: boolean;
741
+ cwd: string;
742
+ sessionDir: string;
743
+ start: Start;
744
+ toolCallId: string | undefined;
745
+ ctx: ExtensionContext;
746
+ placement: WindowPlacement;
747
+ onWindowOpened: () => void;
748
+ }): Promise<Launched> {
749
+ const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement, onWindowOpened } =
750
+ input;
751
+ const name = delegateName(start.agent.name, id, start.label);
752
+ const { id: windowId, session: windowSession } = await openChild({
753
+ id,
754
+ name,
755
+ session: ["--session-id", id, "--name", name],
756
+ workspace,
757
+ trusted,
758
+ cwd,
759
+ sessionDir,
760
+ start,
761
+ toolCallId,
762
+ ctx,
763
+ placement,
764
+ onWindowOpened,
765
+ });
644
766
  // No task text in the record: the request copy in `sent/` has it.
645
767
  results.recordStart(
646
768
  {
@@ -660,11 +782,19 @@ export default function delegate(
660
782
  ctx,
661
783
  );
662
784
  tracking.startPolling();
663
- return { id, name, windowId, windowName: name, requestId, cwd, sessionDir, workspace };
785
+ return { id, name, windowId, windowName: name, windowSession, requestId, cwd, sessionDir, workspace };
664
786
  }
665
787
 
666
- async function launch(start: Start, ctx: ExtensionContext, signal?: AbortSignal): Promise<Launched> {
788
+ async function launch(
789
+ start: Start,
790
+ ctx: ExtensionContext,
791
+ signal?: AbortSignal,
792
+ toolCallId?: string,
793
+ ): Promise<Launched> {
667
794
  if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
795
+ await requireTmuxVersion(tmux);
796
+ // Read once, before anything is created or mailed; the launch keeps this value.
797
+ const placement = windowPlacement(getAgentDir());
668
798
  const sessionDir = ctx.sessionManager.getSessionDir();
669
799
  if (!isAbsolute(sessionDir))
670
800
  throw new Error(
@@ -760,15 +890,7 @@ export default function delegate(
760
890
  // The task goes on disk before the window exists: the delegate finds it
761
891
  // at session start without a handshake, and a window that fails leaves
762
892
  // 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}`);
893
+ const requestId = sendTask(id, start.task);
772
894
  if (attempt !== undefined) {
773
895
  attempt = { ...attempt, requestId };
774
896
  results.recordAttempt(attempt, ctx);
@@ -781,7 +903,9 @@ export default function delegate(
781
903
  cwd: workspace.cwd,
782
904
  sessionDir,
783
905
  start,
906
+ toolCallId,
784
907
  ctx,
908
+ placement,
785
909
  onWindowOpened: () => {
786
910
  windowOpened = true;
787
911
  },
@@ -847,12 +971,43 @@ export default function delegate(
847
971
  return inQueue(plan.source.commonDir, combined, runWithRollback);
848
972
  }
849
973
 
974
+ const resumer = createResume({
975
+ results,
976
+ tmux,
977
+ delegateSchema: DelegateSchema,
978
+ notInTmux: NOT_IN_TMUX,
979
+ startPolling: tracking.startPolling,
980
+ placement: () => windowPlacement(getAgentDir()),
981
+ roster: rosterFor,
982
+ chooseModel: (explicit, agent, models, preferred) => chooseModel(explicit, agent, models, proactive, preferred),
983
+ thinkingProblem,
984
+ prepareDirectory: (cwd, ctx, signal) => prepareDirectory({ cwd, signal }, ctx, getAgentDir()),
985
+ sendTask,
986
+ open: ({ id, name, sessionPath, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement }) =>
987
+ openChild({
988
+ id,
989
+ name,
990
+ session: ["--session", sessionPath],
991
+ workspace,
992
+ trusted,
993
+ cwd,
994
+ sessionDir,
995
+ start,
996
+ toolCallId,
997
+ ctx,
998
+ placement,
999
+ }),
1000
+ });
1001
+
1002
+ /** The session a window opened in, for the start text. */
1003
+ const inSession = (session: string | undefined): string => (session === undefined ? "" : ` in session ${session}`);
1004
+
850
1005
  /** Where a selected directory put the delegate, for the start text; inherited starts stay compact. */
851
1006
  const placedIn = (launched: Launched): string =>
852
1007
  launched.workspace.mode !== "inherited" ? ` in ${launched.cwd}` : "";
853
1008
 
854
1009
  const started = (launched: Launched, start: Start): string =>
855
- `delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}${placedIn(launched)}${launched.workspace.mode === "managed" ? `. Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""}${start.skipped.length > 0 ? `. ${fallbackNote(start)}` : ""}${start.notices.map((line) => `. ${line}`).join("")}`;
1010
+ `delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}${inSession(launched.windowSession)}${placedIn(launched)}${launched.workspace.mode === "managed" ? `. Branch ${launched.workspace.createdBranch} starts at ${launched.workspace.baseCommit}. ${dirtyNotice(launched.workspace.sourceDirty)}` : ""}${start.skipped.length > 0 ? `. ${fallbackNote(start)}` : ""}${start.notices.map((line) => `. ${line}`).join("")}`;
856
1011
 
857
1012
  async function command(input: string, ctx: ExtensionContext): Promise<void> {
858
1013
  const fail = (message: string) => ctx.ui.notify(`delegate: ${message}`, "error");
@@ -880,6 +1035,19 @@ export default function delegate(
880
1035
  }
881
1036
  }
882
1037
 
1038
+ async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
1039
+ const outcome = parseResume(input);
1040
+ if ("error" in outcome) {
1041
+ ctx.ui.notify(`delegate: ${outcome.error}`, "error");
1042
+ return;
1043
+ }
1044
+ try {
1045
+ ctx.ui.notify(startedLine(await resumer.resume(outcome.request, ctx)), "info");
1046
+ } catch (err) {
1047
+ ctx.ui.notify(`delegate: ${(err as Error).message}`, "error");
1048
+ }
1049
+ }
1050
+
883
1051
  /** The roster description last registered, so unchanged metadata is not registered again. */
884
1052
  let registeredDescription: string | undefined;
885
1053
 
@@ -937,7 +1105,7 @@ export default function delegate(
937
1105
  // 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
1106
  annotations: { readOnlyHint: false, destructiveHint: true, idempotentHint: false, openWorldHint: true },
939
1107
  async execute(
940
- _toolCallId: string,
1108
+ toolCallId: string,
941
1109
  params: {
942
1110
  agent: string;
943
1111
  task: string;
@@ -977,10 +1145,10 @@ export default function delegate(
977
1145
  rosterFor(ctx),
978
1146
  ctx.modelRegistry,
979
1147
  );
980
- const launched = await launch(start, ctx, signal);
1148
+ const launched = await launch(start, ctx, signal, toolCallId);
981
1149
  ctx.ui.notify(started(launched, start), "info");
982
1150
  return toolResult(
983
- `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("")}`,
1151
+ `Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.windowName}${inSession(launched.windowSession)}${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("")}`,
984
1152
  {
985
1153
  outcome: "started",
986
1154
  id: launched.id,
@@ -992,6 +1160,7 @@ export default function delegate(
992
1160
  autoExit: start.autoExit,
993
1161
  windowId: launched.windowId,
994
1162
  windowName: launched.windowName,
1163
+ ...(launched.windowSession === undefined ? {} : { windowSession: launched.windowSession }),
995
1164
  cwd: launched.cwd,
996
1165
  sessionDir: launched.sessionDir,
997
1166
  workspace: launched.workspace,
@@ -1054,6 +1223,26 @@ export default function delegate(
1054
1223
  handler: command,
1055
1224
  });
1056
1225
 
1226
+ /** Delegation ids, flags, model ids and thinking levels, at the cursor's token. */
1227
+ function resumeCompletions(prefix: string): { value: string; label: string }[] | null {
1228
+ const partial = /(?:^|\s)(\S*)$/.exec(prefix)?.[1] ?? "";
1229
+ const head = prefix.slice(0, prefix.length - partial.length);
1230
+ const tokens = head.trim() === "" ? [] : head.trim().split(/\s+/);
1231
+ const previous = tokens[tokens.length - 1];
1232
+ let values: readonly string[] = RESUME_FLAGS;
1233
+ if (tokens.length === 0) values = resumableIds(results);
1234
+ else if (previous === "--model") values = modelIds(registry);
1235
+ else if (previous === "--thinking") values = THINKING_LEVELS;
1236
+ const hits = values.filter((value) => value.startsWith(partial));
1237
+ return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
1238
+ }
1239
+
1240
+ pi.registerCommand("delegate-resume", {
1241
+ description: `Reopen an exited delegate's session in a new window with a follow-up task: ${RESUME_USAGE}`,
1242
+ getArgumentCompletions: resumeCompletions,
1243
+ handler: resumeCommand,
1244
+ });
1245
+
1057
1246
  pi.registerCommand("delegate-clear", {
1058
1247
  description: "Clear usage-limit marks: all, a provider, or a provider/model",
1059
1248
  getArgumentCompletions: (prefix: string) => {
@@ -1084,4 +1273,5 @@ export default function delegate(
1084
1273
 
1085
1274
  registerDelegate(toolDescription(currentRoster().agents));
1086
1275
  tracking.registerTools();
1276
+ pi.registerTool(resumer.tool() as never);
1087
1277
  }
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
  }
@@ -281,7 +282,7 @@ export function renderDelegateResult(
281
282
  const lines = [
282
283
  `${theme.fg("accent", "●")} ${theme.fg("toolTitle", theme.bold(started.windowName))}${theme.fg(
283
284
  "dim",
284
- ` (${started.model}) — started`,
285
+ `${started.windowSession === undefined ? "" : ` in session ${started.windowSession}`} (${started.model}) — started`,
285
286
  )}`,
286
287
  ];
287
288
  const task = taskLine(context.args.task, theme);
@@ -292,7 +293,7 @@ export function renderDelegateResult(
292
293
  ...[
293
294
  ...started.notices,
294
295
  `delegation ${started.id}`,
295
- `window ${started.windowName} (${started.windowId})`,
296
+ `window ${started.windowName} (${started.windowId})${started.windowSession === undefined ? "" : ` in session ${started.windowSession}`}`,
296
297
  // Results saved before cwd was recorded have none.
297
298
  `cwd ${(started.cwd as string | undefined) === undefined ? cwdLine(undefined) : tilde(started.cwd)}`,
298
299
  // Results saved before workspaces were recorded have none.
@@ -0,0 +1,120 @@
1
+ // The text and details of the one message the parent receives for a settled delegation.
2
+ import { readMarks } from "./limits.ts";
3
+ import { cwdLine, modelLabel, snapshotLines, workspaceLine } from "./render.ts";
4
+ import { sessionIdOf } from "./result-sessions.ts";
5
+ import type { StartedRecorded } from "./result-types.ts";
6
+ import type { Inbound, MessageDetails, TaskQuote } from "./results.ts";
7
+ import type { WorkspaceSnapshot } from "./snapshot.ts";
8
+
9
+ /** A task quoted into the result header is cut at this many UTF-8 bytes. */
10
+ const TASK_CAP = 2 * 1024;
11
+ /** A result body is cut at this many UTF-8 bytes. */
12
+ const BODY_CAP = 32 * 1024;
13
+
14
+ type ResultDetails = Extract<MessageDetails, { kind: "result" }>;
15
+
16
+ /** Cut `text` to at most `max` UTF-8 bytes on a character boundary; undefined when it already fits. */
17
+ function cap(text: string, max: number): string | undefined {
18
+ const bytes = Buffer.from(text, "utf8");
19
+ if (bytes.length <= max) return undefined;
20
+ let end = max;
21
+ while (end > 0 && (bytes[end] & 0xc0) === 0x80) end--; // back off a split character
22
+ return bytes.subarray(0, end).toString("utf8");
23
+ }
24
+
25
+ /** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
26
+ function limitLine(sessionId: string, status: string): string | undefined {
27
+ const now = Date.now();
28
+ const mark = readMarks(now).find((item) => item.delegations.includes(sessionId));
29
+ if (mark === undefined) return undefined;
30
+ const scope = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
31
+ const until = new Date(mark.clearsAt).toISOString();
32
+ return `Usage limit: the delegate hit a usage limit on ${scope}; ${
33
+ mark.clearsAt > now ? `it is marked until ${until} and later delegations skip it.` : `it was marked until ${until}.`
34
+ }${status === "done" ? " It finished on a fallback model; its session names which." : ""}`;
35
+ }
36
+
37
+ /** The details that say a delegation reopened an earlier one's session. */
38
+ export function resumeDetails(record: StartedRecorded): { resumedFrom?: string; sessionId?: string } {
39
+ return {
40
+ ...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
41
+ ...(record.sessionId === undefined ? {} : { sessionId: record.sessionId }),
42
+ };
43
+ }
44
+
45
+ /** The details of a settled delegation's result message. */
46
+ export function resultDetails(
47
+ delegation: StartedRecorded,
48
+ name: string,
49
+ payload: Inbound,
50
+ session: string | undefined,
51
+ finished: string | undefined,
52
+ snapshot: WorkspaceSnapshot,
53
+ ): ResultDetails {
54
+ const reply = payload.envelope;
55
+ const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
56
+ let task: TaskQuote;
57
+ if (copy === undefined) {
58
+ task = { note: `[delegate] The task ${delegation.requestId} has no copy in sent/; only its id is known.` };
59
+ } else {
60
+ const cut = cap(copy.envelope.body, TASK_CAP);
61
+ const text =
62
+ cut === undefined ? copy.envelope.body : `${cut}\n[delegate] Task cut at 2 KiB; the full copy is ${copy.path}`;
63
+ task = { text, path: copy.path };
64
+ }
65
+ const cut = cap(reply.body, BODY_CAP);
66
+ const body =
67
+ cut === undefined ? reply.body : `${cut}\n[delegate] Body cut at 32 KiB; the full envelope is ${payload.path}`;
68
+ const limit = reply.status === "stopped" ? undefined : limitLine(sessionIdOf(delegation), reply.status);
69
+ return {
70
+ kind: "result",
71
+ status: reply.status,
72
+ name,
73
+ agent: delegation.agent,
74
+ model: delegation.model,
75
+ ...(finished === undefined ? {} : { finishedModel: finished }),
76
+ id: delegation.id,
77
+ ...resumeDetails(delegation),
78
+ requestId: delegation.requestId,
79
+ envelopePath: payload.path,
80
+ ...(session === undefined ? {} : { sessionPath: session }),
81
+ ...(delegation.cwd === undefined ? {} : { cwd: delegation.cwd }),
82
+ ...(delegation.sessionDir === undefined ? {} : { sessionDir: delegation.sessionDir }),
83
+ ...(delegation.workspace === undefined ? {} : { workspace: delegation.workspace }),
84
+ ...(limit === undefined ? {} : { limit }),
85
+ task,
86
+ body,
87
+ workspaceSnapshot: snapshot,
88
+ };
89
+ }
90
+
91
+ /** The snapshot lines of the result text: a heading line, then its path lists indented. */
92
+ function checkoutText(snapshot: WorkspaceSnapshot): string[] {
93
+ const [first, ...rest] = snapshotLines(snapshot);
94
+ return [`Checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`)];
95
+ }
96
+
97
+ /** The one message the parent model reads for a settled delegation. */
98
+ export function resultText(details: ResultDetails): string {
99
+ const { task } = details;
100
+ const quote =
101
+ "note" in task
102
+ ? task.note
103
+ : `Task, quoted from ${task.path}:\n${task.text
104
+ .split("\n")
105
+ .map((line) => `> ${line}`)
106
+ .join("\n")}`;
107
+ return [
108
+ `[delegate] Result from ${details.agent} (${modelLabel(details.model, details.finishedModel)}), delegation ${details.id}${details.resumedFrom === undefined ? "" : ` (resumed from ${details.resumedFrom})`}, request ${details.requestId}.`,
109
+ `Status: ${details.status}`,
110
+ ...(details.limit === undefined ? [] : [details.limit]),
111
+ quote,
112
+ `Working directory: ${cwdLine(details.cwd)}`,
113
+ ...(details.workspace === undefined ? [] : [`Workspace: ${workspaceLine(details.workspace)}`]),
114
+ ...(details.workspaceSnapshot === undefined ? [] : checkoutText(details.workspaceSnapshot)),
115
+ `Envelope: ${details.envelopePath}`,
116
+ `Delegate session: ${details.sessionPath ?? details.sessionId ?? details.id}`,
117
+ "",
118
+ details.body,
119
+ ].join("\n");
120
+ }