pi-squire 0.14.0 → 0.15.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 +116 -1
- package/README.md +179 -70
- package/package.json +3 -3
- package/src/agents.ts +361 -111
- package/src/child.ts +72 -21
- package/src/index.ts +242 -66
- package/src/navigator.ts +1 -1
- package/src/new-delegate.ts +17 -8
- package/src/result-sessions.ts +39 -0
- package/src/results.ts +2 -0
- package/src/resume-checks.ts +45 -12
- package/src/resume.ts +7 -2
package/src/index.ts
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
//
|
|
4
4
|
// `/delegate <agent> [--model <provider/id>] [--thinking <level>]
|
|
5
5
|
// [--label <name>] [--cwd <dir> | --worktree <branch> [--worktree-source <dir>] [--worktree-base <commit-ish>] ] [--auto-exit | --no-auto-exit] <task…>`
|
|
6
|
-
// reads the agent from `<agent dir>/
|
|
6
|
+
// reads the agent from `<agent dir>/agents/<name>.md` or, in a trusted project,
|
|
7
|
+
// `<cwd>/.pi/agents/<name>.md` (`agents.ts`), writes the task
|
|
7
8
|
// to the delegate's inbox through `message:send`, emits `session:launch` so
|
|
8
9
|
// listeners can add arguments and environment, then opens a window in the
|
|
9
10
|
// parent's working directory, or the existing directory `--cwd` selects
|
|
@@ -17,9 +18,13 @@
|
|
|
17
18
|
// With auto-exit on (the agent's `auto-exit`, default true, overridden per
|
|
18
19
|
// call) the delegate closes its own window after a normal completion; the
|
|
19
20
|
// delegate's side lives in `child.ts`. With it off the window stays open.
|
|
20
|
-
// An agent's `
|
|
21
|
-
//
|
|
22
|
-
//
|
|
21
|
+
// An agent's `tools`, or the parent's active tools when it has no `tools`
|
|
22
|
+
// field (less any `-name` entries), goes to the child as `--tools` with the
|
|
23
|
+
// `delegateTools` setting added (`--no-tools` when that leaves nothing), and
|
|
24
|
+
// each of its `extensions` as `-e`, both for the child alone. A tool only the
|
|
25
|
+
// agent's own extensions register is passed only when `tools` or
|
|
26
|
+
// `delegateTools` names it. An agent with no `model` or `thinking` runs
|
|
27
|
+
// on the parent's.
|
|
23
28
|
//
|
|
24
29
|
// The model gets four tools. `delegate` takes agent, task and optional model
|
|
25
30
|
// and thinking, validates them the way the command does, and starts the
|
|
@@ -50,7 +55,18 @@ import {
|
|
|
50
55
|
} from "@earendil-works/pi-coding-agent";
|
|
51
56
|
import { Type } from "typebox";
|
|
52
57
|
|
|
53
|
-
import {
|
|
58
|
+
import {
|
|
59
|
+
type Agent,
|
|
60
|
+
delegateTools,
|
|
61
|
+
disabledProblem,
|
|
62
|
+
globalAgentsDir,
|
|
63
|
+
isThinking,
|
|
64
|
+
projectAgentsDir,
|
|
65
|
+
type Roster,
|
|
66
|
+
readRoster,
|
|
67
|
+
THINKING_LEVELS,
|
|
68
|
+
withoutLocked,
|
|
69
|
+
} from "./agents.ts";
|
|
54
70
|
import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
|
|
55
71
|
import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
|
|
56
72
|
import { createNavigator, OPEN_USAGE, openCompletions, parseOpen } from "./navigator.ts";
|
|
@@ -92,6 +108,13 @@ const LAUNCH = "session:launch";
|
|
|
92
108
|
const PARENT_ENV = "PI_DELEGATE_PARENT";
|
|
93
109
|
/** Forwarded to the child when the parent has it set. */
|
|
94
110
|
const SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
|
111
|
+
/** The tools this extension registers in a parent; a delegate never gets them. */
|
|
112
|
+
const DELEGATION_TOOLS: ReadonlySet<string> = new Set([
|
|
113
|
+
"delegate",
|
|
114
|
+
"delegation_status",
|
|
115
|
+
"delegation_close",
|
|
116
|
+
"delegation_resume",
|
|
117
|
+
]);
|
|
95
118
|
/** Asked of every delegate, so the parent gets an answer it can use alone. */
|
|
96
119
|
const FINAL_LINE =
|
|
97
120
|
"End with one self-contained final message: the parent session sees only that message, never this conversation.";
|
|
@@ -406,26 +429,49 @@ function modelProblem(value: string, registry: ModelRegistry): string | undefine
|
|
|
406
429
|
return `unknown model ${JSON.stringify(value)}${close.length > 0 ? `; close matches: ${close.join(", ")}` : ""}`;
|
|
407
430
|
}
|
|
408
431
|
|
|
432
|
+
/** The registry's model for an exact `provider/id`; undefined for anything else, such as a pattern. */
|
|
433
|
+
function registered(value: string, registry: ModelRegistry): { provider: string; id: string } | undefined {
|
|
434
|
+
const slash = value.indexOf("/");
|
|
435
|
+
if (slash <= 0 || slash === value.length - 1) return undefined;
|
|
436
|
+
return registry.find(value.slice(0, slash), value.slice(slash + 1));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** An agent's own models in order: its `model`, or the parent's when it has none, then its fallbacks. */
|
|
440
|
+
function agentModels(agent: Agent, parentModel: string | undefined): string[] {
|
|
441
|
+
const first = agent.model ?? parentModel;
|
|
442
|
+
return [...(first === undefined ? [] : [first]), ...agent.fallback];
|
|
443
|
+
}
|
|
444
|
+
|
|
409
445
|
/**
|
|
410
446
|
* The model for a launch. An explicit model is the only candidate; otherwise
|
|
411
|
-
* the agent's model then its fallbacks. The
|
|
412
|
-
* `modelProblem` and has no active mark wins; the
|
|
413
|
-
* A paid candidate on a provider with a quota
|
|
414
|
-
* skipped when its quota is used up.
|
|
415
|
-
*
|
|
416
|
-
*
|
|
447
|
+
* `preferred`, by default `own`: the agent's model then its fallbacks. The
|
|
448
|
+
* first candidate that passes `modelProblem` and has no active mark wins; the
|
|
449
|
+
* rest are returned as skipped. A paid candidate on a provider with a quota
|
|
450
|
+
* check is read first, and skipped when its quota is used up. A candidate from
|
|
451
|
+
* the agent file that is not an exact `provider/id` the registry knows, such
|
|
452
|
+
* as a pattern, is passed to the child unchanged for Pi to resolve. Refuses,
|
|
453
|
+
* listing every candidate, when none is left. A resume passes its own
|
|
454
|
+
* `preferred`.
|
|
417
455
|
*/
|
|
418
456
|
async function chooseModel(
|
|
419
457
|
explicit: string | undefined,
|
|
420
458
|
agent: Agent,
|
|
421
459
|
models: ModelRegistry,
|
|
422
460
|
proactive: Proactive,
|
|
423
|
-
|
|
461
|
+
own: readonly string[],
|
|
462
|
+
preferred: readonly string[] = own,
|
|
424
463
|
): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
|
|
425
464
|
const candidates = explicit !== undefined ? [explicit] : [...preferred];
|
|
465
|
+
if (candidates.length === 0)
|
|
466
|
+
throw new Error(
|
|
467
|
+
`agent ${agent.name} names no model and this session has none; add model to ${agent.path}, or pass one`,
|
|
468
|
+
);
|
|
426
469
|
const skipped: Skipped[] = [];
|
|
427
470
|
const notices: string[] = [];
|
|
428
471
|
for (const model of candidates) {
|
|
472
|
+
const rest = () => candidates.slice(candidates.indexOf(model) + 1);
|
|
473
|
+
if (explicit === undefined && registered(model, models) === undefined)
|
|
474
|
+
return { model, skipped, notices, rest: rest() };
|
|
429
475
|
const problem = modelProblem(model, models);
|
|
430
476
|
if (problem !== undefined) {
|
|
431
477
|
skipped.push({ model, why: `unavailable (${problem})`, reason: problem });
|
|
@@ -445,7 +491,7 @@ async function chooseModel(
|
|
|
445
491
|
if (checked.notice !== undefined && !notices.includes(checked.notice)) notices.push(checked.notice);
|
|
446
492
|
mark = activeMark(models, provider, id) ?? checked.mark;
|
|
447
493
|
}
|
|
448
|
-
if (mark === undefined) return { model, skipped, notices, rest:
|
|
494
|
+
if (mark === undefined) return { model, skipped, notices, rest: rest() };
|
|
449
495
|
const what = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
|
|
450
496
|
const until = new Date(mark.clearsAt).toISOString();
|
|
451
497
|
skipped.push({
|
|
@@ -458,8 +504,10 @@ async function chooseModel(
|
|
|
458
504
|
const [only] = skipped;
|
|
459
505
|
if (!only.why.startsWith("usage-limited")) throw new Error(only.reason);
|
|
460
506
|
// An explicit model is never substituted, but the refusal names the agent's models still open.
|
|
461
|
-
const open =
|
|
462
|
-
if (model === only.model
|
|
507
|
+
const open = own.filter((model) => {
|
|
508
|
+
if (model === only.model) return false;
|
|
509
|
+
if (registered(model, models) === undefined) return true;
|
|
510
|
+
if (modelProblem(model, models) !== undefined) return false;
|
|
463
511
|
const slash = model.indexOf("/");
|
|
464
512
|
return activeMark(models, model.slice(0, slash), model.slice(slash + 1)) === undefined;
|
|
465
513
|
});
|
|
@@ -508,32 +556,97 @@ function delegateVisible(options: { selectedTools: string[]; hiddenTools: string
|
|
|
508
556
|
return options.selectedTools.includes("delegate") && !options.hiddenTools.includes("delegate");
|
|
509
557
|
}
|
|
510
558
|
|
|
559
|
+
/** Where agent files go, for the messages that say how to add one. */
|
|
560
|
+
function whereAgentsGo(): string {
|
|
561
|
+
return `${join(globalAgentsDir(getAgentDir()), "<name>.md")} (or .pi/agents/<name>.md in a trusted project), with the delegate's instructions as its body and optional frontmatter: description, model, thinking, tools, extensions, fallback, auto-exit, prompt_mode, display_name, locked and enabled`;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** One roster line for the `delegate` tool's description. */
|
|
565
|
+
function rosterLine(agent: Agent): string {
|
|
566
|
+
const models = [agent.model ?? "this session's model", ...agent.fallback.map((model) => `fallback ${model}`)];
|
|
567
|
+
const details = [
|
|
568
|
+
`default ${models.join(", ")}`,
|
|
569
|
+
`thinking ${agent.thinking ?? "this session's"}`,
|
|
570
|
+
...(agent.autoExit ? [] : ["auto-exit off"]),
|
|
571
|
+
...toolsDetail(agent),
|
|
572
|
+
...(agent.locked.length > 0 ? [`locked: ${agent.locked.join("/")}`] : []),
|
|
573
|
+
];
|
|
574
|
+
const shown = agent.displayName === undefined ? "" : ` "${agent.displayName}"`;
|
|
575
|
+
return `- ${agent.name}${shown} — ${agent.description} (${details.join(", ")})`;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* The entry that makes Pi filter MCP tools by `--tools`. Pi keeps every MCP
|
|
580
|
+
* tool registered (callable from codemode, loadable by tool_search) unless an
|
|
581
|
+
* entry starts with `mcp__`; this one does and names no tool, since an MCP
|
|
582
|
+
* tool is `mcp__<server>__<tool>`.
|
|
583
|
+
*/
|
|
584
|
+
const MCP_FILTER = "mcp__";
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* The child's tool arguments for a selection: `--tools` with exactly those
|
|
588
|
+
* tools, MCP tools included, or `--no-tools` (which Pi applies to MCP tools
|
|
589
|
+
* too) when it is empty. Always explicit: no flag would give the child the
|
|
590
|
+
* parent's tools rather than Pi's defaults.
|
|
591
|
+
*/
|
|
592
|
+
function toolArgs(tools: readonly string[]): string[] {
|
|
593
|
+
if (tools.length === 0) return ["--no-tools"];
|
|
594
|
+
const filtered = tools.some((name) => name.startsWith(MCP_FILTER)) ? tools : [...tools, MCP_FILTER];
|
|
595
|
+
return ["--tools", filtered.join(",")];
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
/** The roster line's word on an agent's tools; nothing for the parent's tools unchanged. */
|
|
599
|
+
function toolsDetail(agent: Agent): string[] {
|
|
600
|
+
if (agent.tools !== undefined) return [agent.tools.length > 0 ? `tools: ${agent.tools.join("/")}` : "no tools"];
|
|
601
|
+
return agent.toolsExcept.length > 0 ? [`tools: this session's except ${agent.toolsExcept.join("/")}`] : [];
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Whether tool `name` is `pattern`, where `*` matches any characters, as in Pi's tool lists. */
|
|
605
|
+
function toolMatches(pattern: string, name: string): boolean {
|
|
606
|
+
const source = pattern
|
|
607
|
+
.split("*")
|
|
608
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
609
|
+
.join(".*");
|
|
610
|
+
return new RegExp(`^${source}$`).test(name);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* The delegate's tool selection, always a plain list: the agent's `tools`
|
|
615
|
+
* names, or `parentTools` less its `-name` entries when it has none, then
|
|
616
|
+
* `extra` (the `delegateTools` setting), with the delegation tools left out.
|
|
617
|
+
* Empty means the delegate gets no tools.
|
|
618
|
+
*/
|
|
619
|
+
function toolSelection(agent: Agent, parentTools: readonly string[], extra: readonly string[]): string[] {
|
|
620
|
+
const base =
|
|
621
|
+
agent.tools ?? parentTools.filter((name) => !agent.toolsExcept.some((pattern) => toolMatches(pattern, name)));
|
|
622
|
+
return [...new Set([...base, ...extra])].filter((name) => !DELEGATION_TOOLS.has(name));
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/** The delegate's appended prompt: the agent's body, then the lines every delegate gets. */
|
|
626
|
+
function delegatePrompt(agent: Agent, parent: string, managedContext: string): string {
|
|
627
|
+
const tail = `${FINAL_LINE}\n\n${parentTrust(parent)}${managedContext}`;
|
|
628
|
+
if (agent.prompt === "") return tail;
|
|
629
|
+
// `replace` is how every delegate was built before `prompt_mode`, byte for byte.
|
|
630
|
+
const body =
|
|
631
|
+
agent.promptMode === "replace" ? agent.prompt : `<agent_instructions>\n${agent.prompt}\n</agent_instructions>`;
|
|
632
|
+
return `${body}\n\n${tail}`;
|
|
633
|
+
}
|
|
634
|
+
|
|
511
635
|
/** The `delegate` tool's description: what delegation does, and the roster as it stands. */
|
|
512
636
|
function toolDescription(agents: readonly Agent[]): string {
|
|
513
637
|
const intro =
|
|
514
638
|
"Hand a self-contained task to a delegate Pi session in a background tmux window, so it works while you do not. Each call starts the delegation at once; the delegate runs in this session's working directory, or the cwd given, with this session's agent root, and reports back later as one message. While it runs, keep working or end your turn: its result starts your next turn on its own. Do not poll delegation_status or send a blocking mail request merely to wait for the result; send a substantive follow-up only when you have something to add or ask. Use it when the work is self-contained and you would rather keep your own context for it.";
|
|
515
639
|
if (agents.length === 0) {
|
|
516
|
-
return `${intro}\n\nNo agents are defined yet. Add ${
|
|
517
|
-
getAgentDir(),
|
|
518
|
-
"squire",
|
|
519
|
-
"agents",
|
|
520
|
-
"<name>",
|
|
521
|
-
"AGENT.md",
|
|
522
|
-
)} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback) and the prompt as its body; /delegate explains the format too.`;
|
|
640
|
+
return `${intro}\n\nNo agents are defined yet. Add ${whereAgentsGo()}.`;
|
|
523
641
|
}
|
|
524
642
|
return [
|
|
525
643
|
intro,
|
|
526
644
|
"",
|
|
527
645
|
"Agents:",
|
|
528
|
-
...agents.map(
|
|
529
|
-
(agent) =>
|
|
530
|
-
`- ${agent.name} — ${agent.description} (default ${[agent.model, ...agent.fallback.map((model) => `fallback ${model}`)].join(", ")}, thinking ${agent.thinking}${agent.autoExit ? "" : ", auto-exit off"}${
|
|
531
|
-
agent.excludeTools.length > 0 ? `, no ${agent.excludeTools.join("/")}` : ""
|
|
532
|
-
}${agent.tools.length > 0 ? `, tools: ${agent.tools.join("/")}` : ""})`,
|
|
533
|
-
),
|
|
646
|
+
...agents.map(rosterLine),
|
|
534
647
|
"",
|
|
535
648
|
"`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.",
|
|
536
|
-
"`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.",
|
|
649
|
+
"`model` and `thinking` override the agent's defaults for this call, except a field the agent locks; 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.",
|
|
537
650
|
"`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.",
|
|
538
651
|
"",
|
|
539
652
|
"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.",
|
|
@@ -564,44 +677,75 @@ export default function delegate(
|
|
|
564
677
|
|
|
565
678
|
/** Captured because `getArgumentCompletions` is called without a context. */
|
|
566
679
|
let registry: ModelRegistry | undefined;
|
|
680
|
+
/** The latest context, for the roster a completion reads; undefined before the session starts. */
|
|
681
|
+
let latest: ExtensionContext | undefined;
|
|
567
682
|
|
|
568
683
|
const modelIds = (source: ModelRegistry | undefined): string[] =>
|
|
569
684
|
(source?.getAll() ?? []).map((model) => `${model.provider}/${model.id}`);
|
|
570
685
|
|
|
571
|
-
/**
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
686
|
+
/** The project's agents folder when `ctx`'s project is trusted; undefined otherwise or without a context. */
|
|
687
|
+
function trustedProject(ctx: ExtensionContext | undefined): string | undefined {
|
|
688
|
+
if (ctx === undefined) return undefined;
|
|
689
|
+
try {
|
|
690
|
+
return ctx.isProjectTrusted() ? projectAgentsDir(ctx.cwd) : undefined;
|
|
691
|
+
} catch {
|
|
692
|
+
return undefined; // a stale context after a session replacement
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
/** The roster as it stands: the global agents, and the project's when it is trusted. */
|
|
697
|
+
const currentRoster = (ctx: ExtensionContext | undefined = latest): Roster =>
|
|
698
|
+
readRoster(getAgentDir(), trustedProject(ctx));
|
|
699
|
+
|
|
700
|
+
/** What an agent with no `model` or `thinking` runs on: the parent's model and level now. */
|
|
701
|
+
function parentDefaults(ctx: ExtensionContext): { model?: string; thinking: string } {
|
|
702
|
+
let thinking = "medium";
|
|
576
703
|
try {
|
|
577
|
-
|
|
704
|
+
thinking = pi.getThinkingLevel();
|
|
578
705
|
} catch {
|
|
579
|
-
|
|
706
|
+
// Pi binds this before any call can reach here; the default only covers a host without it.
|
|
580
707
|
}
|
|
708
|
+
return { ...(ctx.model === undefined ? {} : { model: `${ctx.model.provider}/${ctx.model.id}` }), thinking };
|
|
581
709
|
}
|
|
582
710
|
|
|
583
|
-
/**
|
|
584
|
-
|
|
711
|
+
/**
|
|
712
|
+
* The delegate's tools for a launch or resume starting now: the agent's
|
|
713
|
+
* named tools, or the parent's active tools at this moment (less any
|
|
714
|
+
* `-name` entries), then the `delegateTools` setting. The agent's own
|
|
715
|
+
* extensions are never loaded here: a tool only they register is passed
|
|
716
|
+
* only when `tools` or `delegateTools` names it. Throws, naming the file,
|
|
717
|
+
* on an invalid setting.
|
|
718
|
+
*/
|
|
719
|
+
function childTools(agent: Agent): string[] {
|
|
720
|
+
return toolSelection(agent, agent.tools === undefined ? pi.getActiveTools() : [], delegateTools(getAgentDir()));
|
|
721
|
+
}
|
|
585
722
|
|
|
586
723
|
/** Read the roster now, warning about every file left out. */
|
|
587
724
|
function rosterFor(ctx: ExtensionContext): Roster {
|
|
588
|
-
const roster = currentRoster();
|
|
725
|
+
const roster = currentRoster(ctx);
|
|
589
726
|
for (const warning of roster.warnings) ctx.ui.notify(`delegate: ${warning}`, "warning");
|
|
590
727
|
return roster;
|
|
591
728
|
}
|
|
592
729
|
|
|
730
|
+
/** The roster warnings already shown this session, so a run shows only new ones. */
|
|
731
|
+
const announced = new Set<string>();
|
|
732
|
+
|
|
733
|
+
/** Show each roster warning not yet shown this session, such as a file that is disabled. */
|
|
734
|
+
function announce(roster: Roster, ctx: ExtensionContext): void {
|
|
735
|
+
for (const warning of roster.warnings) {
|
|
736
|
+
if (announced.has(warning)) continue;
|
|
737
|
+
announced.add(warning);
|
|
738
|
+
ctx.ui.notify(`delegate: ${warning}`, "warning");
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
|
|
593
742
|
/** The refusal for an empty roster. */
|
|
594
|
-
const noAgents = (): string =>
|
|
595
|
-
`no agents are defined; add ${join(
|
|
596
|
-
getAgentDir(),
|
|
597
|
-
"squire",
|
|
598
|
-
"agents",
|
|
599
|
-
"<name>",
|
|
600
|
-
"AGENT.md",
|
|
601
|
-
)} with frontmatter description, model and thinking (optionally auto-exit, exclude-tools, tools, extensions and fallback), and the prompt as its body`;
|
|
743
|
+
const noAgents = (): string => `no agents are defined; add ${whereAgentsGo()}`;
|
|
602
744
|
|
|
603
745
|
/** The request as a start, or an error naming what to fix. */
|
|
604
|
-
async function validate(request: Parsed, roster: Roster,
|
|
746
|
+
async function validate(request: Parsed, roster: Roster, ctx: ExtensionContext): Promise<Start> {
|
|
747
|
+
const disabled = disabledProblem(roster, request.agent);
|
|
748
|
+
if (disabled !== undefined) throw new Error(disabled);
|
|
605
749
|
if (roster.agents.length === 0) throw new Error(noAgents());
|
|
606
750
|
const agent = roster.agents.find((candidate) => candidate.name === request.agent);
|
|
607
751
|
if (agent === undefined) {
|
|
@@ -610,8 +754,14 @@ export default function delegate(
|
|
|
610
754
|
);
|
|
611
755
|
}
|
|
612
756
|
if (request.task.trim() === "") throw new Error("a task is required");
|
|
613
|
-
const
|
|
614
|
-
const
|
|
757
|
+
const parent = parentDefaults(ctx);
|
|
758
|
+
const own = agentModels(agent, parent.model);
|
|
759
|
+
const defaultThinking = agent.thinking ?? parent.thinking;
|
|
760
|
+
const unlocked = withoutLocked(agent, request, { model: own[0], thinking: defaultThinking });
|
|
761
|
+
const thinking = unlocked.request.thinking ?? defaultThinking;
|
|
762
|
+
const chosen = await chooseModel(unlocked.request.model, agent, ctx.modelRegistry, proactive, own);
|
|
763
|
+
const { model, skipped, rest } = chosen;
|
|
764
|
+
const notices = unlocked.notice === undefined ? chosen.notices : [unlocked.notice, ...chosen.notices];
|
|
615
765
|
const badThinking = thinkingProblem(thinking);
|
|
616
766
|
if (badThinking !== undefined) throw new Error(badThinking);
|
|
617
767
|
const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
|
|
@@ -667,6 +817,8 @@ export default function delegate(
|
|
|
667
817
|
ctx: ExtensionContext;
|
|
668
818
|
/** Chosen once, when the launch or resume began, so no later settings change can refuse it after mail. */
|
|
669
819
|
placement: WindowPlacement;
|
|
820
|
+
/** The delegate's tools, from `toolSelection`, read when the launch began; empty means none. */
|
|
821
|
+
tools: readonly string[];
|
|
670
822
|
onWindowOpened?: () => void;
|
|
671
823
|
}): Promise<OpenedWindow> {
|
|
672
824
|
const {
|
|
@@ -681,6 +833,7 @@ export default function delegate(
|
|
|
681
833
|
toolCallId,
|
|
682
834
|
ctx,
|
|
683
835
|
placement,
|
|
836
|
+
tools,
|
|
684
837
|
onWindowOpened,
|
|
685
838
|
} = input;
|
|
686
839
|
const parent = ctx.sessionManager.getSessionId();
|
|
@@ -690,7 +843,7 @@ export default function delegate(
|
|
|
690
843
|
: workspace.mode === "existing"
|
|
691
844
|
? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
|
|
692
845
|
: "";
|
|
693
|
-
const prompt = writePromptFile(
|
|
846
|
+
const prompt = writePromptFile(delegatePrompt(start.agent, parent, managedContext));
|
|
694
847
|
const argv = [
|
|
695
848
|
...parentCommand(),
|
|
696
849
|
...session,
|
|
@@ -703,8 +856,7 @@ export default function delegate(
|
|
|
703
856
|
"--thinking",
|
|
704
857
|
start.thinking,
|
|
705
858
|
...start.agent.extensions.flatMap((extension) => ["-e", extension]),
|
|
706
|
-
...(
|
|
707
|
-
...(start.agent.excludeTools.length > 0 ? ["--exclude-tools", start.agent.excludeTools.join(",")] : []),
|
|
859
|
+
...toolArgs(tools),
|
|
708
860
|
"--append-system-prompt",
|
|
709
861
|
prompt,
|
|
710
862
|
];
|
|
@@ -747,11 +899,12 @@ export default function delegate(
|
|
|
747
899
|
toolCallId: string | undefined;
|
|
748
900
|
ctx: ExtensionContext;
|
|
749
901
|
placement: WindowPlacement;
|
|
902
|
+
tools: readonly string[];
|
|
750
903
|
onWindowOpened: () => void;
|
|
751
904
|
}): Promise<Launched> {
|
|
752
905
|
const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement, onWindowOpened } =
|
|
753
906
|
input;
|
|
754
|
-
const name = delegateName(start.agent.name, id, start.label);
|
|
907
|
+
const name = delegateName(start.agent.displayName ?? start.agent.name, id, start.label);
|
|
755
908
|
const { id: windowId, session: windowSession } = await openChild({
|
|
756
909
|
id,
|
|
757
910
|
name,
|
|
@@ -764,6 +917,7 @@ export default function delegate(
|
|
|
764
917
|
toolCallId,
|
|
765
918
|
ctx,
|
|
766
919
|
placement,
|
|
920
|
+
tools: input.tools,
|
|
767
921
|
onWindowOpened,
|
|
768
922
|
});
|
|
769
923
|
// No task text in the record: the request copy in `sent/` has it.
|
|
@@ -771,6 +925,7 @@ export default function delegate(
|
|
|
771
925
|
{
|
|
772
926
|
id,
|
|
773
927
|
agent: start.agent.name,
|
|
928
|
+
...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
|
|
774
929
|
model: start.model,
|
|
775
930
|
thinking: start.thinking,
|
|
776
931
|
name,
|
|
@@ -796,8 +951,9 @@ export default function delegate(
|
|
|
796
951
|
): Promise<Launched> {
|
|
797
952
|
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
798
953
|
await requireTmuxVersion(tmux);
|
|
799
|
-
// Read once, before anything is created or mailed; the launch keeps
|
|
954
|
+
// Read once, before anything is created or mailed; the launch keeps these values.
|
|
800
955
|
const placement = windowPlacement(getAgentDir());
|
|
956
|
+
const tools = childTools(start.agent);
|
|
801
957
|
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
802
958
|
if (!isAbsolute(sessionDir))
|
|
803
959
|
throw new Error(
|
|
@@ -909,6 +1065,7 @@ export default function delegate(
|
|
|
909
1065
|
toolCallId,
|
|
910
1066
|
ctx,
|
|
911
1067
|
placement,
|
|
1068
|
+
tools,
|
|
912
1069
|
onWindowOpened: () => {
|
|
913
1070
|
windowOpened = true;
|
|
914
1071
|
},
|
|
@@ -982,7 +1139,9 @@ export default function delegate(
|
|
|
982
1139
|
startPolling: tracking.startPolling,
|
|
983
1140
|
placement: () => windowPlacement(getAgentDir()),
|
|
984
1141
|
roster: rosterFor,
|
|
985
|
-
|
|
1142
|
+
parentDefaults,
|
|
1143
|
+
chooseModel: (explicit, agent, models, preferred) =>
|
|
1144
|
+
chooseModel(explicit, agent, models, proactive, agentModels(agent, undefined), preferred),
|
|
986
1145
|
thinkingProblem,
|
|
987
1146
|
prepareDirectory: (cwd, ctx, signal) => prepareDirectory({ cwd, signal }, ctx, getAgentDir()),
|
|
988
1147
|
sendTask,
|
|
@@ -999,6 +1158,8 @@ export default function delegate(
|
|
|
999
1158
|
toolCallId,
|
|
1000
1159
|
ctx,
|
|
1001
1160
|
placement,
|
|
1161
|
+
// Read before the window opens, and a resume mails only after it opens.
|
|
1162
|
+
tools: childTools(start.agent),
|
|
1002
1163
|
}),
|
|
1003
1164
|
});
|
|
1004
1165
|
|
|
@@ -1022,11 +1183,7 @@ export default function delegate(
|
|
|
1022
1183
|
const worktree = normalizeWorkspaceValue(parsed.worktree, "--worktree");
|
|
1023
1184
|
if (worktree_source !== undefined && worktree === undefined)
|
|
1024
1185
|
throw new Error("--worktree-source requires --worktree");
|
|
1025
|
-
const start = await validate(
|
|
1026
|
-
{ ...parsed, cwd, worktree, worktree_base, worktree_source },
|
|
1027
|
-
rosterFor(ctx),
|
|
1028
|
-
ctx.modelRegistry,
|
|
1029
|
-
);
|
|
1186
|
+
const start = await validate({ ...parsed, cwd, worktree, worktree_base, worktree_source }, rosterFor(ctx), ctx);
|
|
1030
1187
|
const launched = await launch(start, ctx);
|
|
1031
1188
|
ctx.ui.notify(started(launched, start), "info");
|
|
1032
1189
|
} catch (err) {
|
|
@@ -1039,6 +1196,11 @@ export default function delegate(
|
|
|
1039
1196
|
unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
|
|
1040
1197
|
roster: rosterFor,
|
|
1041
1198
|
noAgents,
|
|
1199
|
+
defaults: (agent, ctx) => {
|
|
1200
|
+
const parent = parentDefaults(ctx);
|
|
1201
|
+
const model = agentModels(agent, parent.model)[0];
|
|
1202
|
+
return { ...(model === undefined ? {} : { model }), thinking: agent.thinking ?? parent.thinking };
|
|
1203
|
+
},
|
|
1042
1204
|
labelProblem,
|
|
1043
1205
|
start: (request: NewRequest, context) => runDelegate(request, context),
|
|
1044
1206
|
};
|
|
@@ -1096,9 +1258,16 @@ export default function delegate(
|
|
|
1096
1258
|
task: Type.String({
|
|
1097
1259
|
description: "The task for the delegate. It sees this and its agent prompt, nothing else of this session.",
|
|
1098
1260
|
}),
|
|
1099
|
-
model: Type.Optional(
|
|
1261
|
+
model: Type.Optional(
|
|
1262
|
+
Type.String({
|
|
1263
|
+
description:
|
|
1264
|
+
"provider/id model for this call; defaults to the agent's, or this session's when it names none. Not used when the agent locks model.",
|
|
1265
|
+
}),
|
|
1266
|
+
),
|
|
1100
1267
|
thinking: Type.Optional(
|
|
1101
|
-
Type.String({
|
|
1268
|
+
Type.String({
|
|
1269
|
+
description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the agent's, or this session's when it names none. Not used when the agent locks thinking.`,
|
|
1270
|
+
}),
|
|
1102
1271
|
),
|
|
1103
1272
|
label: Type.Optional(
|
|
1104
1273
|
Type.String({
|
|
@@ -1179,7 +1348,7 @@ export default function delegate(
|
|
|
1179
1348
|
task: params.task ?? "",
|
|
1180
1349
|
},
|
|
1181
1350
|
rosterFor(ctx),
|
|
1182
|
-
ctx
|
|
1351
|
+
ctx,
|
|
1183
1352
|
);
|
|
1184
1353
|
const launched = await launch(start, ctx, signal, toolCallId);
|
|
1185
1354
|
ctx.ui.notify(started(launched, start), "info");
|
|
@@ -1212,15 +1381,22 @@ export default function delegate(
|
|
|
1212
1381
|
|
|
1213
1382
|
pi.on("session_start", (_event, ctx) => {
|
|
1214
1383
|
registry = ctx.modelRegistry;
|
|
1384
|
+
latest = ctx;
|
|
1215
1385
|
tracking.restore(ctx);
|
|
1216
1386
|
// The roster is read at call time; the description is rebuilt here and before each run.
|
|
1217
|
-
|
|
1387
|
+
const roster = currentRoster(ctx);
|
|
1388
|
+
announced.clear();
|
|
1389
|
+
announce(roster, ctx);
|
|
1390
|
+
registerDelegate(toolDescription(roster.agents));
|
|
1218
1391
|
});
|
|
1219
1392
|
|
|
1220
1393
|
// One roster/prompt snapshot prepares this run: refresh the description only when
|
|
1221
1394
|
// it changed, and replace only this extension's prompt section from the same snapshot.
|
|
1222
1395
|
pi.on("before_agent_start", (event, ctx) => {
|
|
1223
|
-
|
|
1396
|
+
latest = ctx;
|
|
1397
|
+
const roster = currentRoster(ctx);
|
|
1398
|
+
announce(roster, ctx);
|
|
1399
|
+
const description = toolDescription(roster.agents);
|
|
1224
1400
|
const prompt = readParentPrompt();
|
|
1225
1401
|
const visible = delegateVisible(event.systemPromptOptions);
|
|
1226
1402
|
// Report a read failure even when guidance cannot be used, so it does not go unseen.
|
package/src/navigator.ts
CHANGED
|
@@ -205,7 +205,7 @@ async function openDelegates({ results, tmux }: Env): Promise<OpenDelegate[] | u
|
|
|
205
205
|
if (window === undefined || window.exited) continue;
|
|
206
206
|
const state = deriveState(record, "open");
|
|
207
207
|
if (state !== "running" && state !== "done") continue;
|
|
208
|
-
found.push({ id: record.id, name: recordedName(record), agent: record.agent, state, window });
|
|
208
|
+
found.push({ id: record.id, name: recordedName(record), agent: record.displayName ?? record.agent, state, window });
|
|
209
209
|
}
|
|
210
210
|
return found.reverse();
|
|
211
211
|
}
|
package/src/new-delegate.ts
CHANGED
|
@@ -47,6 +47,8 @@ export type NewDelegateDeps = {
|
|
|
47
47
|
roster(ctx: ExtensionContext): Roster;
|
|
48
48
|
/** The refusal text for an empty roster. */
|
|
49
49
|
noAgents(): string;
|
|
50
|
+
/** The model and thinking `agent` runs on when the request names none: its own, else the parent's. */
|
|
51
|
+
defaults(agent: Agent, ctx: ExtensionContext): { model?: string; thinking: string };
|
|
50
52
|
/** Why `value` is not a usable label, or undefined; the check `--label` gets. */
|
|
51
53
|
labelProblem(value: string): string | undefined;
|
|
52
54
|
/** Start the delegate as `/delegate` would, notifying the start or the refusal. */
|
|
@@ -207,12 +209,12 @@ export class SettingsForm implements Component {
|
|
|
207
209
|
this.options = options;
|
|
208
210
|
const { agent, models, style } = options;
|
|
209
211
|
for (const model of models) this.byId.set(`${model.provider}/${model.id}`, model);
|
|
210
|
-
this.model = agent.model;
|
|
211
|
-
this.thinking = agent.thinking;
|
|
212
|
+
this.model = agent.model ?? "";
|
|
213
|
+
this.thinking = agent.thinking ?? "";
|
|
212
214
|
this.autoExit = agent.autoExit;
|
|
213
215
|
this.cwd = options.cwd;
|
|
214
216
|
this.clampInitialThinking();
|
|
215
|
-
const modelItems: SelectItem[] = [...new Set([
|
|
217
|
+
const modelItems: SelectItem[] = [...new Set([this.model, ...this.byId.keys()])].map((value) => ({
|
|
216
218
|
value,
|
|
217
219
|
label: value,
|
|
218
220
|
}));
|
|
@@ -360,8 +362,8 @@ export class SettingsForm implements Component {
|
|
|
360
362
|
return;
|
|
361
363
|
}
|
|
362
364
|
this.options.done({
|
|
363
|
-
...(this.model === this.options.agent.model ? {} : { model: this.model }),
|
|
364
|
-
...(this.thinking === this.options.agent.thinking ? {} : { thinking: this.thinking }),
|
|
365
|
+
...(this.model === (this.options.agent.model ?? "") ? {} : { model: this.model }),
|
|
366
|
+
...(this.thinking === (this.options.agent.thinking ?? "") ? {} : { thinking: this.thinking }),
|
|
365
367
|
...(this.autoExit === this.options.agent.autoExit ? {} : { autoExit: this.autoExit }),
|
|
366
368
|
...(this.cwd === this.options.cwd ? {} : { cwd: this.cwd }),
|
|
367
369
|
...(this.worktree === "" ? {} : { worktree: this.worktree }),
|
|
@@ -410,14 +412,20 @@ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateD
|
|
|
410
412
|
if (unavailable !== undefined) return fail(unavailable);
|
|
411
413
|
const roster = deps.roster(ctx);
|
|
412
414
|
if (roster.agents.length === 0) return fail(deps.noAgents());
|
|
413
|
-
const options = roster.agents.map(
|
|
415
|
+
const options = roster.agents.map(
|
|
416
|
+
(agent) =>
|
|
417
|
+
`${agent.name}${agent.displayName === undefined ? "" : ` (${agent.displayName})`} — ${agent.description}`,
|
|
418
|
+
);
|
|
414
419
|
const picked = await ctx.ui.select("New delegate: agent", options);
|
|
415
420
|
const agent = roster.agents[options.indexOf(picked ?? "")];
|
|
416
421
|
if (agent === undefined) return;
|
|
417
422
|
const task = await ctx.ui.editor(`Task for ${agent.name}`);
|
|
418
423
|
if (task === undefined) return;
|
|
419
424
|
if (task.trim() === "") return fail("a task is required");
|
|
420
|
-
const typed = await ctx.ui.input(
|
|
425
|
+
const typed = await ctx.ui.input(
|
|
426
|
+
`Label (optional; empty for ${agent.displayName ?? agent.name}-<id>)`,
|
|
427
|
+
"letters, digits, _ or -",
|
|
428
|
+
);
|
|
421
429
|
if (typed === undefined) return;
|
|
422
430
|
const label = typed.trim() === "" ? undefined : typed.trim();
|
|
423
431
|
const badLabel = label === undefined ? undefined : deps.labelProblem(label);
|
|
@@ -425,7 +433,8 @@ export async function startNewDelegate(ctx: ExtensionContext, deps: NewDelegateD
|
|
|
425
433
|
const chosen = await ctx.ui.custom<FormResult | undefined>(
|
|
426
434
|
(_tui, theme, _keys, done): Component =>
|
|
427
435
|
new SettingsForm({
|
|
428
|
-
|
|
436
|
+
// The rows start at what a request naming nothing runs on.
|
|
437
|
+
agent: { ...agent, ...deps.defaults(agent, ctx) } as Agent,
|
|
429
438
|
models: ctx.modelRegistry.getAvailable() as Model<never>[],
|
|
430
439
|
cwd: ctx.cwd,
|
|
431
440
|
style: theme,
|
package/src/result-sessions.ts
CHANGED
|
@@ -77,3 +77,42 @@ export function finishedModel(sessionPath: string | undefined): string | undefin
|
|
|
77
77
|
}
|
|
78
78
|
return undefined;
|
|
79
79
|
}
|
|
80
|
+
|
|
81
|
+
/** One session-file line, as much of it as the branch walk reads. */
|
|
82
|
+
type BranchEntry = { type?: unknown; id?: unknown; parentId?: unknown; thinkingLevel?: unknown };
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The thinking level a delegate's session last changed to on its active
|
|
86
|
+
* branch: Pi records one `thinking_level_change` when a session starts and
|
|
87
|
+
* one per later change, the clamp to a new model's levels included. The
|
|
88
|
+
* branch is the leaf, Pi's last entry, back through each `parentId`.
|
|
89
|
+
* Undefined when the file cannot be read or holds no change on that branch.
|
|
90
|
+
*/
|
|
91
|
+
export function sessionThinking(sessionPath: string): string | undefined {
|
|
92
|
+
let lines: string[];
|
|
93
|
+
try {
|
|
94
|
+
lines = readFileSync(sessionPath, "utf8").split("\n");
|
|
95
|
+
} catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
const byId = new Map<string, BranchEntry>();
|
|
99
|
+
let leaf: BranchEntry | undefined;
|
|
100
|
+
for (const line of lines) {
|
|
101
|
+
let entry: BranchEntry;
|
|
102
|
+
try {
|
|
103
|
+
entry = JSON.parse(line);
|
|
104
|
+
} catch {
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (entry?.type === "session" || typeof entry?.id !== "string") continue;
|
|
108
|
+
byId.set(entry.id, entry);
|
|
109
|
+
leaf = entry;
|
|
110
|
+
}
|
|
111
|
+
const seen = new Set<BranchEntry>();
|
|
112
|
+
for (let entry = leaf; entry !== undefined && !seen.has(entry); ) {
|
|
113
|
+
seen.add(entry);
|
|
114
|
+
if (entry.type === "thinking_level_change" && typeof entry.thinkingLevel === "string") return entry.thinkingLevel;
|
|
115
|
+
entry = typeof entry.parentId === "string" ? byId.get(entry.parentId) : undefined;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|