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/CHANGELOG.md +36 -1
- package/README.md +107 -13
- package/package.json +1 -1
- package/src/index.ts +231 -41
- package/src/render.ts +4 -3
- package/src/result-message.ts +120 -0
- package/src/result-sessions.ts +35 -3
- package/src/results.ts +37 -116
- package/src/resume.ts +472 -0
- package/src/tmux.ts +161 -37
- package/src/tracking-text.ts +41 -0
- package/src/tracking.ts +43 -51
- package/src/window-placement.ts +31 -0
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,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 {
|
|
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 = {
|
|
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(
|
|
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; ${
|
|
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; ${
|
|
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; ${
|
|
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] : [
|
|
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
|
-
|
|
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
|
-
|
|
655
|
+
name: string;
|
|
656
|
+
session: string[];
|
|
581
657
|
workspace: Workspace;
|
|
582
658
|
trusted: boolean;
|
|
583
659
|
cwd: string;
|
|
584
660
|
sessionDir: string;
|
|
585
|
-
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
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
|
-
|
|
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
|
|
723
|
+
let opened: OpenedWindow;
|
|
635
724
|
try {
|
|
636
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|