pi-squire 0.14.0 → 0.16.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 +438 -1
- package/README.md +1062 -152
- package/package.json +4 -3
- package/src/agents.ts +395 -124
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +107 -62
- package/src/completion-holds.ts +104 -0
- package/src/dashboard.ts +144 -0
- package/src/git.ts +6 -1
- package/src/identity.ts +152 -0
- package/src/index.ts +585 -107
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +1 -0
- package/src/render.ts +4 -1
- package/src/result-close.ts +37 -0
- package/src/result-restore.ts +95 -0
- package/src/result-sessions.ts +39 -0
- package/src/result-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +176 -108
- package/src/resume-checks.ts +59 -14
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +69 -15
- package/src/session-owner.ts +279 -0
- package/src/task-replies.ts +247 -0
- package/src/task-settlement.ts +81 -0
- package/src/task-store.ts +26 -0
- package/src/tmux.ts +134 -54
- package/src/tool-ceiling.ts +125 -0
- package/src/tracking.ts +72 -65
- package/src/tree-close.ts +236 -0
- package/src/tree-fence.ts +51 -0
- package/src/tree-lock.ts +77 -0
- package/src/tree-notice.ts +124 -0
- package/src/tree-registry.ts +365 -0
- package/src/tree-settings.ts +31 -0
- package/src/tree-types.ts +32 -0
- package/src/tree-view.ts +246 -0
- package/src/trust-control.ts +214 -0
- package/src/trust-prompt.ts +163 -0
- package/src/trust-wire.ts +203 -0
- package/src/widget-activity-join.ts +24 -0
- package/src/widget-activity-runtime.ts +104 -0
- package/src/widget-activity.ts +104 -0
- package/src/widget-ancestors.ts +142 -0
- package/src/widget-current.ts +41 -0
- package/src/widget-data.ts +147 -0
- package/src/widget-focus.ts +133 -0
- package/src/widget-live-process.ts +31 -0
- package/src/widget-live.ts +356 -0
- package/src/widget-render.ts +181 -0
- package/src/widget-selection.ts +58 -0
- package/src/widget-settings.ts +29 -0
- package/src/widget-summary.ts +51 -0
- package/src/widget-title-file.ts +127 -0
- package/src/widget-title.ts +118 -0
- package/src/widget.ts +272 -0
- package/src/window-placement.ts +30 -5
- package/src/workspace.ts +38 -90
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
|
|
@@ -14,12 +15,14 @@
|
|
|
14
15
|
// in the parent session; its answer is taken over on `message:inbound` and
|
|
15
16
|
// shown as one result message, so the parent hears back on its own.
|
|
16
17
|
//
|
|
17
|
-
// With auto-exit on (the agent's `
|
|
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).
|
|
24
|
+
// An agent with no `model` or `thinking` runs
|
|
25
|
+
// on the parent's.
|
|
23
26
|
//
|
|
24
27
|
// The model gets four tools. `delegate` takes agent, task and optional model
|
|
25
28
|
// and thinking, validates them the way the command does, and starts the
|
|
@@ -31,9 +34,9 @@
|
|
|
31
34
|
// envelope paths.
|
|
32
35
|
// `delegation_close` kills the window and records the close. `delegation_resume`
|
|
33
36
|
// (`resume.ts`) reopens an exited delegate's session in a new window with a
|
|
34
|
-
// follow-up task, recorded as a new delegation.
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
+
// follow-up task, recorded as a new delegation. A delegate can launch children
|
|
38
|
+
// only below maxDepth and with its current agent allow_nested_delegation grant.
|
|
39
|
+
// Durable identity restores that boundary even on a manual session reopening.
|
|
37
40
|
//
|
|
38
41
|
// The `session:launch` contract this package provides, and the `message:*`
|
|
39
42
|
// hooks it consumes, live in this package's README.
|
|
@@ -50,19 +53,54 @@ import {
|
|
|
50
53
|
} from "@earendil-works/pi-coding-agent";
|
|
51
54
|
import { Type } from "typebox";
|
|
52
55
|
|
|
53
|
-
import {
|
|
56
|
+
import {
|
|
57
|
+
type Agent,
|
|
58
|
+
delegateTools,
|
|
59
|
+
disabledProblem,
|
|
60
|
+
globalAgentsDir,
|
|
61
|
+
isThinking,
|
|
62
|
+
projectAgentsDir,
|
|
63
|
+
type Roster,
|
|
64
|
+
readRoster,
|
|
65
|
+
THINKING_LEVELS,
|
|
66
|
+
withoutLocked,
|
|
67
|
+
} from "./agents.ts";
|
|
54
68
|
import { AGENT_ENV, AUTO_EXIT_ENV, FALLBACK_ENV, registerChild } from "./child.ts";
|
|
69
|
+
import { registerCompletionHost } from "./completion-holds.ts";
|
|
70
|
+
import { createIdentity, grantsNesting, type Identity, readIdentity, writeIdentity } from "./identity.ts";
|
|
55
71
|
import { activeMark, clearMarks, isFree, readMarks } from "./limits.ts";
|
|
56
72
|
import { createNavigator, OPEN_USAGE, openCompletions, parseOpen } from "./navigator.ts";
|
|
57
73
|
import { type NewDelegateDeps, type NewRequest, startNewDelegate } from "./new-delegate.ts";
|
|
58
74
|
import { dirtyNotice, renderDelegateCall, renderDelegateResult, resourceLine } from "./render.ts";
|
|
59
|
-
import { createResults, delegateName, type WorkspaceAttempt } from "./results.ts";
|
|
75
|
+
import { createResults, delegateName, isStarted, sessionIdOf, type WorkspaceAttempt } from "./results.ts";
|
|
60
76
|
import { createResume, type ResumeRequest, resumableIds, startedLine } from "./resume.ts";
|
|
61
77
|
import { pickResume } from "./resume-picker.ts";
|
|
78
|
+
import { ownedAPI, SessionOwner, sessionIsOwned } from "./session-owner.ts";
|
|
79
|
+
import { registerTaskReplies } from "./task-replies.ts";
|
|
62
80
|
import { createTmuxClient, type OpenedWindow, requireTmuxVersion, type TmuxClient, WindowOpenError } from "./tmux.ts";
|
|
81
|
+
import { availableTools, enforceToolCeiling } from "./tool-ceiling.ts";
|
|
63
82
|
import { createTracking, toolResult } from "./tracking.ts";
|
|
83
|
+
import { treeNotice } from "./tree-notice.ts";
|
|
84
|
+
import {
|
|
85
|
+
assertParentPlacement,
|
|
86
|
+
bindTree,
|
|
87
|
+
claimTree,
|
|
88
|
+
dispatchTree,
|
|
89
|
+
failedTree,
|
|
90
|
+
finishTree,
|
|
91
|
+
openedTree,
|
|
92
|
+
publishTreeRecords,
|
|
93
|
+
type Reservation,
|
|
94
|
+
readTree,
|
|
95
|
+
reconcileTree,
|
|
96
|
+
reserveTree,
|
|
97
|
+
} from "./tree-registry.ts";
|
|
98
|
+
import { assertResumeAuthority, historicalRecord, visibleRecords } from "./tree-view.ts";
|
|
99
|
+
import { registerTrustControl } from "./trust-control.ts";
|
|
64
100
|
import { createProactive, createUsageClient, type Proactive, type UsageClient } from "./usage.ts";
|
|
65
|
-
import {
|
|
101
|
+
import { registerDelegateWidget } from "./widget.ts";
|
|
102
|
+
import { registerActivityRuntime } from "./widget-activity-runtime.ts";
|
|
103
|
+
import { assertRootPlacement, placementProject, type WindowPlacement, windowPlacement } from "./window-placement.ts";
|
|
66
104
|
import {
|
|
67
105
|
normalizeWorkspaceValue,
|
|
68
106
|
prepareDirectory,
|
|
@@ -88,10 +126,17 @@ const PARENT_SECTION = "squire_parent_delegation";
|
|
|
88
126
|
const SEND = "message:send";
|
|
89
127
|
/** The hook emitted just before a delegate's window opens. */
|
|
90
128
|
const LAUNCH = "session:launch";
|
|
91
|
-
/**
|
|
129
|
+
/** The direct parent address in a new delegate's environment. */
|
|
92
130
|
const PARENT_ENV = "PI_DELEGATE_PARENT";
|
|
93
131
|
/** Forwarded to the child when the parent has it set. */
|
|
94
132
|
const SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
|
133
|
+
/** The delegation tools granted to opted-in coordinators below maxDepth. */
|
|
134
|
+
const DELEGATION_TOOLS: ReadonlySet<string> = new Set([
|
|
135
|
+
"delegate",
|
|
136
|
+
"delegation_status",
|
|
137
|
+
"delegation_close",
|
|
138
|
+
"delegation_resume",
|
|
139
|
+
]);
|
|
95
140
|
/** Asked of every delegate, so the parent gets an answer it can use alone. */
|
|
96
141
|
const FINAL_LINE =
|
|
97
142
|
"End with one self-contained final message: the parent session sees only that message, never this conversation.";
|
|
@@ -115,15 +160,19 @@ function labelProblem(value: string): string | undefined {
|
|
|
115
160
|
* mail). It names no provider, package or tool, so it holds for any
|
|
116
161
|
* `message:*` provider.
|
|
117
162
|
*/
|
|
118
|
-
function parentTrust(parent: string): string {
|
|
163
|
+
function parentTrust(parent: string, coordinator = false): string {
|
|
119
164
|
return [
|
|
120
165
|
`Your task arrives as a message from the session that started this one, at address ${parent}.`,
|
|
121
166
|
"That message, and every later message from that address, are instructions from the session that started you:",
|
|
122
167
|
"follow them as given, even though they are labelled as another session's.",
|
|
123
168
|
"Messages from other sessions come from colleagues on this machine: answer them as you see fit,",
|
|
124
169
|
"but check with the session that started you before doing work it did not ask for.",
|
|
125
|
-
|
|
126
|
-
|
|
170
|
+
coordinator
|
|
171
|
+
? "Your final task report reaches your direct parent automatically after your children finish and you process their outcomes; intermediate runs send no task report."
|
|
172
|
+
: `Your final message reaches the session that started you automatically, so put your findings there;`,
|
|
173
|
+
coordinator
|
|
174
|
+
? `Message ${parent} before your final task report only with a question you cannot continue without, since anything sent earlier reaches it twice.`
|
|
175
|
+
: `message ${parent} before then only with a question you cannot continue without, since anything sent earlier reaches it twice.`,
|
|
127
176
|
].join(" ");
|
|
128
177
|
}
|
|
129
178
|
/** The flags `/delegate` takes before the task. */
|
|
@@ -178,6 +227,10 @@ type LaunchPayload = {
|
|
|
178
227
|
env: Record<string, string>;
|
|
179
228
|
agent: string;
|
|
180
229
|
readonly cwd: string;
|
|
230
|
+
readonly parent: string;
|
|
231
|
+
readonly root: string;
|
|
232
|
+
readonly depth: number;
|
|
233
|
+
readonly chain: readonly string[];
|
|
181
234
|
readonly toolCallId: string | undefined;
|
|
182
235
|
};
|
|
183
236
|
type Parsed = {
|
|
@@ -216,7 +269,7 @@ type Start = {
|
|
|
216
269
|
/** A candidate model passed over: `why` finishes "<model> is …", `reason` is the long form for a refusal. */
|
|
217
270
|
type Skipped = { model: string; why: string; reason: string };
|
|
218
271
|
/** What opening a delegate's window reads from a request: a `Start`, or the parts of one a resume decides. */
|
|
219
|
-
type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit">;
|
|
272
|
+
type ChildStart = Pick<Start, "agent" | "model" | "rest" | "thinking" | "autoExit" | "notices">;
|
|
220
273
|
/** A delegation that is open in a tmux window. */
|
|
221
274
|
type Launched = {
|
|
222
275
|
id: string;
|
|
@@ -406,26 +459,49 @@ function modelProblem(value: string, registry: ModelRegistry): string | undefine
|
|
|
406
459
|
return `unknown model ${JSON.stringify(value)}${close.length > 0 ? `; close matches: ${close.join(", ")}` : ""}`;
|
|
407
460
|
}
|
|
408
461
|
|
|
462
|
+
/** The registry's model for an exact `provider/id`; undefined for anything else, such as a pattern. */
|
|
463
|
+
function registered(value: string, registry: ModelRegistry): { provider: string; id: string } | undefined {
|
|
464
|
+
const slash = value.indexOf("/");
|
|
465
|
+
if (slash <= 0 || slash === value.length - 1) return undefined;
|
|
466
|
+
return registry.find(value.slice(0, slash), value.slice(slash + 1));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** An agent's own models in order: its `model`, or the parent's when it has none, then its fallbacks. */
|
|
470
|
+
function agentModels(agent: Agent, parentModel: string | undefined): string[] {
|
|
471
|
+
const first = agent.model ?? parentModel;
|
|
472
|
+
return [...(first === undefined ? [] : [first]), ...agent.fallback];
|
|
473
|
+
}
|
|
474
|
+
|
|
409
475
|
/**
|
|
410
476
|
* 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
|
-
*
|
|
477
|
+
* `preferred`, by default `own`: the agent's model then its fallbacks. The
|
|
478
|
+
* first candidate that passes `modelProblem` and has no active mark wins; the
|
|
479
|
+
* rest are returned as skipped. A paid candidate on a provider with a quota
|
|
480
|
+
* check is read first, and skipped when its quota is used up. A candidate from
|
|
481
|
+
* the agent file that is not an exact `provider/id` the registry knows, such
|
|
482
|
+
* as a pattern, is passed to the child unchanged for Pi to resolve. Refuses,
|
|
483
|
+
* listing every candidate, when none is left. A resume passes its own
|
|
484
|
+
* `preferred`.
|
|
417
485
|
*/
|
|
418
486
|
async function chooseModel(
|
|
419
487
|
explicit: string | undefined,
|
|
420
488
|
agent: Agent,
|
|
421
489
|
models: ModelRegistry,
|
|
422
490
|
proactive: Proactive,
|
|
423
|
-
|
|
491
|
+
own: readonly string[],
|
|
492
|
+
preferred: readonly string[] = own,
|
|
424
493
|
): Promise<{ model: string; skipped: Skipped[]; notices: string[]; rest: string[] }> {
|
|
425
494
|
const candidates = explicit !== undefined ? [explicit] : [...preferred];
|
|
495
|
+
if (candidates.length === 0)
|
|
496
|
+
throw new Error(
|
|
497
|
+
`agent ${agent.name} names no model and this session has none; add model to ${agent.path}, or pass one`,
|
|
498
|
+
);
|
|
426
499
|
const skipped: Skipped[] = [];
|
|
427
500
|
const notices: string[] = [];
|
|
428
501
|
for (const model of candidates) {
|
|
502
|
+
const rest = () => candidates.slice(candidates.indexOf(model) + 1);
|
|
503
|
+
if (explicit === undefined && registered(model, models) === undefined)
|
|
504
|
+
return { model, skipped, notices, rest: rest() };
|
|
429
505
|
const problem = modelProblem(model, models);
|
|
430
506
|
if (problem !== undefined) {
|
|
431
507
|
skipped.push({ model, why: `unavailable (${problem})`, reason: problem });
|
|
@@ -445,7 +521,7 @@ async function chooseModel(
|
|
|
445
521
|
if (checked.notice !== undefined && !notices.includes(checked.notice)) notices.push(checked.notice);
|
|
446
522
|
mark = activeMark(models, provider, id) ?? checked.mark;
|
|
447
523
|
}
|
|
448
|
-
if (mark === undefined) return { model, skipped, notices, rest:
|
|
524
|
+
if (mark === undefined) return { model, skipped, notices, rest: rest() };
|
|
449
525
|
const what = mark.scope.includes("/") ? `model ${mark.scope}` : `provider ${mark.scope}`;
|
|
450
526
|
const until = new Date(mark.clearsAt).toISOString();
|
|
451
527
|
skipped.push({
|
|
@@ -458,8 +534,10 @@ async function chooseModel(
|
|
|
458
534
|
const [only] = skipped;
|
|
459
535
|
if (!only.why.startsWith("usage-limited")) throw new Error(only.reason);
|
|
460
536
|
// An explicit model is never substituted, but the refusal names the agent's models still open.
|
|
461
|
-
const open =
|
|
462
|
-
if (model === only.model
|
|
537
|
+
const open = own.filter((model) => {
|
|
538
|
+
if (model === only.model) return false;
|
|
539
|
+
if (registered(model, models) === undefined) return true;
|
|
540
|
+
if (modelProblem(model, models) !== undefined) return false;
|
|
463
541
|
const slash = model.indexOf("/");
|
|
464
542
|
return activeMark(models, model.slice(0, slash), model.slice(slash + 1)) === undefined;
|
|
465
543
|
});
|
|
@@ -508,32 +586,100 @@ function delegateVisible(options: { selectedTools: string[]; hiddenTools: string
|
|
|
508
586
|
return options.selectedTools.includes("delegate") && !options.hiddenTools.includes("delegate");
|
|
509
587
|
}
|
|
510
588
|
|
|
589
|
+
/** Where agent files go, for the messages that say how to add one. */
|
|
590
|
+
function whereAgentsGo(): string {
|
|
591
|
+
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, fallback, auto_exit, prompt_mode, display_name, locked and enabled`;
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** One roster line for the `delegate` tool's description. */
|
|
595
|
+
function rosterLine(agent: Agent): string {
|
|
596
|
+
const models = [agent.model ?? "this session's model", ...agent.fallback.map((model) => `fallback ${model}`)];
|
|
597
|
+
const details = [
|
|
598
|
+
`default ${models.join(", ")}`,
|
|
599
|
+
`thinking ${agent.thinking ?? "this session's"}`,
|
|
600
|
+
...(agent.autoExit ? [] : ["auto-exit off"]),
|
|
601
|
+
...toolsDetail(agent),
|
|
602
|
+
...(agent.locked.length > 0 ? [`locked: ${agent.locked.join("/")}`] : []),
|
|
603
|
+
];
|
|
604
|
+
const shown = agent.displayName === undefined ? "" : ` "${agent.displayName}"`;
|
|
605
|
+
return `- ${agent.name}${shown} — ${agent.description} (${details.join(", ")})`;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The entry that makes Pi filter MCP tools by `--tools`. Pi keeps every MCP
|
|
610
|
+
* tool registered (callable from codemode, loadable by tool_search) unless an
|
|
611
|
+
* entry starts with `mcp__`; this one does and names no tool, since an MCP
|
|
612
|
+
* tool is `mcp__<server>__<tool>`.
|
|
613
|
+
*/
|
|
614
|
+
const MCP_FILTER = "mcp__";
|
|
615
|
+
|
|
616
|
+
/**
|
|
617
|
+
* The child's tool arguments for a selection: `--tools` with exactly those
|
|
618
|
+
* tools, MCP tools included, or `--no-tools` (which Pi applies to MCP tools
|
|
619
|
+
* too) when it is empty. Always explicit: no flag would give the child the
|
|
620
|
+
* parent's tools rather than Pi's defaults.
|
|
621
|
+
*/
|
|
622
|
+
function toolArgs(tools: readonly string[]): string[] {
|
|
623
|
+
if (tools.length === 0) return ["--no-tools"];
|
|
624
|
+
const filtered = tools.some((name) => name.startsWith(MCP_FILTER)) ? tools : [...tools, MCP_FILTER];
|
|
625
|
+
return ["--tools", filtered.join(",")];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** The roster line's word on an agent's tools; nothing for the parent's tools unchanged. */
|
|
629
|
+
function toolsDetail(agent: Agent): string[] {
|
|
630
|
+
if (agent.tools !== undefined) return [agent.tools.length > 0 ? `tools: ${agent.tools.join("/")}` : "no tools"];
|
|
631
|
+
return agent.toolsExcept.length > 0 ? [`tools: this session's except ${agent.toolsExcept.join("/")}`] : [];
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/** Whether tool `name` is `pattern`, where `*` matches any characters, as in Pi's tool lists. */
|
|
635
|
+
function toolMatches(pattern: string, name: string): boolean {
|
|
636
|
+
const source = pattern
|
|
637
|
+
.split("*")
|
|
638
|
+
.map((part) => part.replace(/[.+?^${}()|[\]\\]/g, "\\$&"))
|
|
639
|
+
.join(".*");
|
|
640
|
+
return new RegExp(`^${source}$`).test(name);
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/**
|
|
644
|
+
* The delegate's tool selection, always a plain list: the agent's `tools`
|
|
645
|
+
* names, or `parentTools` less its `-name` entries when it has none, then
|
|
646
|
+
* `extra` (the `delegateTools` setting), with the delegation tools left out.
|
|
647
|
+
* Empty means the delegate gets no tools.
|
|
648
|
+
*/
|
|
649
|
+
function toolSelection(agent: Agent, parentTools: readonly string[], extra: readonly string[]): string[] {
|
|
650
|
+
const base =
|
|
651
|
+
agent.tools ?? parentTools.filter((name) => !agent.toolsExcept.some((pattern) => toolMatches(pattern, name)));
|
|
652
|
+
return [...new Set([...base, ...extra])].filter((name) => !DELEGATION_TOOLS.has(name));
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
/** The delegate's appended prompt: the agent's body, then the lines every delegate gets. */
|
|
656
|
+
function delegatePrompt(agent: Agent, parent: string, managedContext: string, coordinator: boolean): string {
|
|
657
|
+
const final = coordinator
|
|
658
|
+
? "End the task with one self-contained final report to your direct parent after your child tasks finish and you process their outcomes."
|
|
659
|
+
: FINAL_LINE;
|
|
660
|
+
const tail = `${final}\n\n${parentTrust(parent, coordinator)}${managedContext}`;
|
|
661
|
+
if (agent.prompt === "") return tail;
|
|
662
|
+
// `replace` is how every delegate was built before `prompt_mode`, byte for byte.
|
|
663
|
+
const body =
|
|
664
|
+
agent.promptMode === "replace" ? agent.prompt : `<agent_instructions>\n${agent.prompt}\n</agent_instructions>`;
|
|
665
|
+
return `${body}\n\n${tail}`;
|
|
666
|
+
}
|
|
667
|
+
|
|
511
668
|
/** The `delegate` tool's description: what delegation does, and the roster as it stands. */
|
|
512
669
|
function toolDescription(agents: readonly Agent[]): string {
|
|
513
670
|
const intro =
|
|
514
671
|
"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
672
|
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.`;
|
|
673
|
+
return `${intro}\n\nNo agents are defined yet. Add ${whereAgentsGo()}.`;
|
|
523
674
|
}
|
|
524
675
|
return [
|
|
525
676
|
intro,
|
|
526
677
|
"",
|
|
527
678
|
"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
|
-
),
|
|
679
|
+
...agents.map(rosterLine),
|
|
534
680
|
"",
|
|
535
681
|
"`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.",
|
|
682
|
+
"`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
683
|
"`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
684
|
"",
|
|
539
685
|
"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.",
|
|
@@ -546,62 +692,201 @@ function toolDescription(agents: readonly Agent[]): string {
|
|
|
546
692
|
* passes only `pi`, so they are not part of the package's documented contract.
|
|
547
693
|
*/
|
|
548
694
|
export default function delegate(
|
|
549
|
-
|
|
695
|
+
api: ExtensionAPI,
|
|
550
696
|
tmux: TmuxClient = createTmuxClient(),
|
|
551
697
|
usage: UsageClient = createUsageClient(),
|
|
552
698
|
): void {
|
|
553
|
-
|
|
554
|
-
const
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
const results = createResults(
|
|
699
|
+
const owner = new SessionOwner();
|
|
700
|
+
const host = api;
|
|
701
|
+
host.on("session_start", (_event, ctx) => owner.start(ctx));
|
|
702
|
+
host.events.on("message:inbound", (payload) => owner.receive(payload as import("./results.ts").Inbound));
|
|
703
|
+
const pi = ownedAPI(host, owner);
|
|
704
|
+
const externalReady = registerCompletionHost(pi);
|
|
705
|
+
const identity = createIdentity(pi);
|
|
706
|
+
const results = createResults(
|
|
707
|
+
pi,
|
|
708
|
+
() => identity.current() !== undefined,
|
|
709
|
+
(ctx, records) =>
|
|
710
|
+
publishTreeRecords(
|
|
711
|
+
identity.current()?.root ?? ctx.sessionManager.getSessionId(),
|
|
712
|
+
ctx.sessionManager.getSessionId(),
|
|
713
|
+
records,
|
|
714
|
+
),
|
|
715
|
+
() => owner.active(),
|
|
716
|
+
(ctx) =>
|
|
717
|
+
readTree(identity.current()?.root ?? ctx.sessionManager.getSessionId())
|
|
718
|
+
.entries.filter((entry) => entry.identity.parent === ctx.sessionManager.getSessionId() && entry.closed)
|
|
719
|
+
.map((entry) => entry.id),
|
|
720
|
+
);
|
|
721
|
+
pi.on("session_start", (_event, ctx) => tracking.restore(ctx));
|
|
722
|
+
const replies = registerTaskReplies(
|
|
723
|
+
pi,
|
|
724
|
+
() => identity.current()?.parent,
|
|
725
|
+
() => {
|
|
726
|
+
try {
|
|
727
|
+
owner.assert();
|
|
728
|
+
identity.current();
|
|
729
|
+
return results.completionReady() && externalReady();
|
|
730
|
+
} catch {
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
(ctx) => coordinates(currentRoster(ctx)),
|
|
735
|
+
(ctx, ids) => {
|
|
736
|
+
owner.assert();
|
|
737
|
+
const own = identity.current();
|
|
738
|
+
if (own) finishTree(own.root, ctx.sessionManager.getSessionId(), ids);
|
|
739
|
+
},
|
|
740
|
+
);
|
|
741
|
+
registerChild(pi, replies, identity.current, () => owner.active());
|
|
742
|
+
host.on("session_start", () => owner.arm());
|
|
743
|
+
// Restore answers synchronously before this potentially slow tmux read.
|
|
744
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
745
|
+
try {
|
|
746
|
+
const own = identity.current();
|
|
747
|
+
if (!own) return;
|
|
748
|
+
const window = await tmux.currentWindow?.();
|
|
749
|
+
owner.assert();
|
|
750
|
+
claimTree(own.root, ctx.sessionManager.getSessionId(), window);
|
|
751
|
+
} catch (error) {
|
|
752
|
+
ctx.ui.notify(
|
|
753
|
+
`delegate: could not publish startup accounting: ${String(error)}; uncertain reservations remain charged.`,
|
|
754
|
+
"error",
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
});
|
|
561
758
|
|
|
562
759
|
const proactive = createProactive(usage);
|
|
563
|
-
|
|
760
|
+
let viewContext: ExtensionContext | undefined;
|
|
761
|
+
pi.on("session_start", (_event, ctx) => {
|
|
762
|
+
viewContext = ctx;
|
|
763
|
+
});
|
|
764
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
765
|
+
if (!identity.current() && results.list().length === 0) {
|
|
766
|
+
try {
|
|
767
|
+
await treeNotice(ctx, tmux, () => owner.active());
|
|
768
|
+
} catch (error) {
|
|
769
|
+
ctx.ui.notify(`delegate: owner discovery unavailable: ${String(error)}`, "warning");
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
const viewResults = {
|
|
774
|
+
...results,
|
|
775
|
+
list: () => (viewContext ? visibleRecords(results, viewContext) : results.list()),
|
|
776
|
+
find: (id: string) =>
|
|
777
|
+
viewContext ? visibleRecords(results, viewContext).find((record) => record.id === id) : results.find(id),
|
|
778
|
+
};
|
|
779
|
+
const tracking = createTracking(pi, results, tmux, proactive.errors, viewResults, () => owner.assert());
|
|
780
|
+
const trustControl = registerTrustControl(pi, identity.current);
|
|
781
|
+
// Factual lifecycle observation survives failed action processing; only creation needs ownership.
|
|
782
|
+
registerActivityRuntime(host, () => owner.active(), tmux, owner.token);
|
|
783
|
+
registerDelegateWidget(
|
|
784
|
+
pi,
|
|
785
|
+
results,
|
|
786
|
+
tmux,
|
|
787
|
+
async (ctx, sessionId) => {
|
|
788
|
+
owner.assert();
|
|
789
|
+
await navigator.panel(ctx, sessionId);
|
|
790
|
+
},
|
|
791
|
+
() => owner.assert(),
|
|
792
|
+
);
|
|
564
793
|
|
|
565
794
|
/** Captured because `getArgumentCompletions` is called without a context. */
|
|
566
795
|
let registry: ModelRegistry | undefined;
|
|
796
|
+
/** The latest context, for the roster a completion reads; undefined before the session starts. */
|
|
797
|
+
let latest: ExtensionContext | undefined;
|
|
567
798
|
|
|
568
799
|
const modelIds = (source: ModelRegistry | undefined): string[] =>
|
|
569
800
|
(source?.getAll() ?? []).map((model) => `${model.provider}/${model.id}`);
|
|
570
801
|
|
|
571
|
-
/**
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
802
|
+
/** The project's agents folder when `ctx`'s project is trusted; undefined otherwise or without a context. */
|
|
803
|
+
function trustedProject(ctx: ExtensionContext | undefined): string | undefined {
|
|
804
|
+
if (ctx === undefined) return undefined;
|
|
805
|
+
try {
|
|
806
|
+
return ctx.isProjectTrusted() ? projectAgentsDir(ctx.cwd) : undefined;
|
|
807
|
+
} catch {
|
|
808
|
+
return undefined; // a stale context after a session replacement
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/** The roster as it stands: the global agents, and the project's when it is trusted. */
|
|
813
|
+
const currentRoster = (ctx: ExtensionContext | undefined = latest): Roster =>
|
|
814
|
+
readRoster(getAgentDir(), trustedProject(ctx));
|
|
815
|
+
|
|
816
|
+
/** What an agent with no `model` or `thinking` runs on: the parent's model and level now. */
|
|
817
|
+
function parentDefaults(ctx: ExtensionContext): { model?: string; thinking: string } {
|
|
818
|
+
let thinking = "medium";
|
|
576
819
|
try {
|
|
577
|
-
|
|
820
|
+
thinking = pi.getThinkingLevel();
|
|
578
821
|
} catch {
|
|
579
|
-
|
|
822
|
+
// Pi binds this before any call can reach here; the default only covers a host without it.
|
|
580
823
|
}
|
|
824
|
+
return { ...(ctx.model === undefined ? {} : { model: `${ctx.model.provider}/${ctx.model.id}` }), thinking };
|
|
581
825
|
}
|
|
582
826
|
|
|
583
|
-
/**
|
|
584
|
-
|
|
827
|
+
/**
|
|
828
|
+
* The delegate's tools for a launch or resume starting now: the agent's
|
|
829
|
+
* named tools, or the parent's current tools (including indirect callable
|
|
830
|
+
* tools when nested, less any `-name` entries), then `delegateTools`.
|
|
831
|
+
* Throws, naming the file, on an invalid setting.
|
|
832
|
+
*/
|
|
833
|
+
function childTools(agent: Agent): string[] {
|
|
834
|
+
const selected = toolSelection(
|
|
835
|
+
agent,
|
|
836
|
+
agent.tools === undefined ? (identity.current() ? availableTools(pi) : pi.getActiveTools()) : [],
|
|
837
|
+
delegateTools(getAgentDir()),
|
|
838
|
+
);
|
|
839
|
+
if (grantsNesting(agent, (identity.current()?.depth ?? 0) + 1)) selected.push(...DELEGATION_TOOLS);
|
|
840
|
+
return [...new Set(selected)];
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
function coordinates(roster: Roster): boolean {
|
|
844
|
+
const own = identity.current();
|
|
845
|
+
const agent = roster.agents.find((item) => item.name === own?.agent);
|
|
846
|
+
return own !== undefined && agent !== undefined && grantsNesting(agent, own.depth);
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
function admit(agent: string, roster: Roster): void {
|
|
850
|
+
owner.assert();
|
|
851
|
+
results.checkStorage();
|
|
852
|
+
identity.admit(agent, roster);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function exposeDelegation(roster: Roster): void {
|
|
856
|
+
if (!identity.current()) return;
|
|
857
|
+
const enabled = coordinates(roster);
|
|
858
|
+
const base = pi.getActiveTools().filter((name) => !DELEGATION_TOOLS.has(name));
|
|
859
|
+
pi.setActiveTools(enabled ? [...base, ...DELEGATION_TOOLS] : base);
|
|
860
|
+
}
|
|
585
861
|
|
|
586
862
|
/** Read the roster now, warning about every file left out. */
|
|
587
863
|
function rosterFor(ctx: ExtensionContext): Roster {
|
|
588
|
-
const roster = currentRoster();
|
|
864
|
+
const roster = currentRoster(ctx);
|
|
589
865
|
for (const warning of roster.warnings) ctx.ui.notify(`delegate: ${warning}`, "warning");
|
|
590
866
|
return roster;
|
|
591
867
|
}
|
|
592
868
|
|
|
869
|
+
/** The roster warnings already shown this session, so a run shows only new ones. */
|
|
870
|
+
const announced = new Set<string>();
|
|
871
|
+
|
|
872
|
+
/** Show each roster warning not yet shown this session, such as a file that is disabled. */
|
|
873
|
+
function announce(roster: Roster, ctx: ExtensionContext): void {
|
|
874
|
+
for (const warning of roster.warnings) {
|
|
875
|
+
if (announced.has(warning)) continue;
|
|
876
|
+
announced.add(warning);
|
|
877
|
+
ctx.ui.notify(`delegate: ${warning}`, "warning");
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
593
881
|
/** 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`;
|
|
882
|
+
const noAgents = (): string => `no agents are defined; add ${whereAgentsGo()}`;
|
|
602
883
|
|
|
603
884
|
/** The request as a start, or an error naming what to fix. */
|
|
604
|
-
async function validate(request: Parsed, roster: Roster,
|
|
885
|
+
async function validate(request: Parsed, roster: Roster, ctx: ExtensionContext): Promise<Start> {
|
|
886
|
+
admit(request.agent, roster);
|
|
887
|
+
results.checkStorage(true);
|
|
888
|
+
const disabled = disabledProblem(roster, request.agent);
|
|
889
|
+
if (disabled !== undefined) throw new Error(disabled);
|
|
605
890
|
if (roster.agents.length === 0) throw new Error(noAgents());
|
|
606
891
|
const agent = roster.agents.find((candidate) => candidate.name === request.agent);
|
|
607
892
|
if (agent === undefined) {
|
|
@@ -610,8 +895,14 @@ export default function delegate(
|
|
|
610
895
|
);
|
|
611
896
|
}
|
|
612
897
|
if (request.task.trim() === "") throw new Error("a task is required");
|
|
613
|
-
const
|
|
614
|
-
const
|
|
898
|
+
const parent = parentDefaults(ctx);
|
|
899
|
+
const own = agentModels(agent, parent.model);
|
|
900
|
+
const defaultThinking = agent.thinking ?? parent.thinking;
|
|
901
|
+
const unlocked = withoutLocked(agent, request, { model: own[0], thinking: defaultThinking });
|
|
902
|
+
const thinking = unlocked.request.thinking ?? defaultThinking;
|
|
903
|
+
const chosen = await chooseModel(unlocked.request.model, agent, ctx.modelRegistry, proactive, own);
|
|
904
|
+
const { model, skipped, rest } = chosen;
|
|
905
|
+
const notices = unlocked.notice === undefined ? chosen.notices : [unlocked.notice, ...chosen.notices];
|
|
615
906
|
const badThinking = thinkingProblem(thinking);
|
|
616
907
|
if (badThinking !== undefined) throw new Error(badThinking);
|
|
617
908
|
const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
|
|
@@ -667,7 +958,11 @@ export default function delegate(
|
|
|
667
958
|
ctx: ExtensionContext;
|
|
668
959
|
/** Chosen once, when the launch or resume began, so no later settings change can refuse it after mail. */
|
|
669
960
|
placement: WindowPlacement;
|
|
961
|
+
/** The delegate's tools, from `toolSelection`, read when the launch began; empty means none. */
|
|
962
|
+
tools: readonly string[];
|
|
670
963
|
onWindowOpened?: () => void;
|
|
964
|
+
identity: Identity;
|
|
965
|
+
sessionId?: string;
|
|
671
966
|
}): Promise<OpenedWindow> {
|
|
672
967
|
const {
|
|
673
968
|
id,
|
|
@@ -681,16 +976,27 @@ export default function delegate(
|
|
|
681
976
|
toolCallId,
|
|
682
977
|
ctx,
|
|
683
978
|
placement,
|
|
979
|
+
tools,
|
|
684
980
|
onWindowOpened,
|
|
685
981
|
} = input;
|
|
686
|
-
|
|
982
|
+
admit(start.agent.name, currentRoster(ctx));
|
|
983
|
+
results.checkStorage(true);
|
|
984
|
+
if (input.sessionId && sessionIsOwned(input.sessionId))
|
|
985
|
+
throw new Error(`delegate session ${input.sessionId} is already open; resume refused before spawn`);
|
|
986
|
+
const own = input.identity;
|
|
987
|
+
assertRootPlacement(own.rootPlacement);
|
|
988
|
+
own.autoExit = start.autoExit;
|
|
989
|
+
writeIdentity(input.sessionId ?? id, own);
|
|
990
|
+
const parent = own.parent;
|
|
687
991
|
const managedContext =
|
|
688
992
|
workspace.mode === "managed"
|
|
689
993
|
? `\n\nYour managed worktree is ${workspace.cwd}, branch ${workspace.createdBranch}, based on commit ${workspace.baseCommit}.`
|
|
690
994
|
: workspace.mode === "existing"
|
|
691
995
|
? `\n\nThe session that started you chose your working directory: ${workspace.cwd}`
|
|
692
996
|
: "";
|
|
693
|
-
const prompt = writePromptFile(
|
|
997
|
+
const prompt = writePromptFile(
|
|
998
|
+
delegatePrompt(start.agent, parent, managedContext, grantsNesting(start.agent, own.depth)),
|
|
999
|
+
);
|
|
694
1000
|
const argv = [
|
|
695
1001
|
...parentCommand(),
|
|
696
1002
|
...session,
|
|
@@ -702,15 +1008,18 @@ export default function delegate(
|
|
|
702
1008
|
start.model,
|
|
703
1009
|
"--thinking",
|
|
704
1010
|
start.thinking,
|
|
705
|
-
...
|
|
706
|
-
...(start.agent.tools.length > 0 ? ["--tools", start.agent.tools.join(",")] : []),
|
|
707
|
-
...(start.agent.excludeTools.length > 0 ? ["--exclude-tools", start.agent.excludeTools.join(",")] : []),
|
|
1011
|
+
...toolArgs(tools),
|
|
708
1012
|
"--append-system-prompt",
|
|
709
1013
|
prompt,
|
|
710
1014
|
];
|
|
711
1015
|
const env: Record<string, string> = {
|
|
712
1016
|
PI_CODING_AGENT_DIR: getAgentDir(),
|
|
1017
|
+
// Private live metadata correlation: resume retains session id, not launch id.
|
|
1018
|
+
PI_SQUIRE_LIVE_LAUNCH: JSON.stringify({ version: 1, sessionId: input.sessionId ?? id, launchId: id }),
|
|
713
1019
|
[PARENT_ENV]: parent,
|
|
1020
|
+
PI_DELEGATE_ROOT: own.root,
|
|
1021
|
+
PI_DELEGATE_DEPTH: String(own.depth),
|
|
1022
|
+
PI_DELEGATE_CHAIN: own.chain.join(","),
|
|
714
1023
|
[AUTO_EXIT_ENV]: start.autoExit ? "1" : "0",
|
|
715
1024
|
[AGENT_ENV]: start.agent.name,
|
|
716
1025
|
// A general signal for any extension: no human watches this session, so don't wait on a dialog.
|
|
@@ -721,22 +1030,72 @@ export default function delegate(
|
|
|
721
1030
|
const envSessionDir = process.env[SESSION_DIR_ENV];
|
|
722
1031
|
if (envSessionDir !== undefined && envSessionDir !== "") env[SESSION_DIR_ENV] = envSessionDir;
|
|
723
1032
|
// Listeners may only append to `args` and add to `env`; there is no veto.
|
|
724
|
-
const payload: LaunchPayload = {
|
|
1033
|
+
const payload: LaunchPayload = {
|
|
1034
|
+
args: argv,
|
|
1035
|
+
env,
|
|
1036
|
+
agent: start.agent.name,
|
|
1037
|
+
cwd,
|
|
1038
|
+
toolCallId,
|
|
1039
|
+
parent: own.parent,
|
|
1040
|
+
root: own.root,
|
|
1041
|
+
depth: own.depth,
|
|
1042
|
+
chain: Object.freeze([...own.chain]),
|
|
1043
|
+
};
|
|
1044
|
+
for (const key of ["parent", "root", "depth", "chain"] as const)
|
|
1045
|
+
Object.defineProperty(payload, key, { writable: false, configurable: false });
|
|
725
1046
|
pi.events.emit(LAUNCH, payload);
|
|
1047
|
+
if (identity.current()) {
|
|
1048
|
+
const prefix = parentCommand().length;
|
|
1049
|
+
const args = payload.args.slice(prefix);
|
|
1050
|
+
enforceToolCeiling({
|
|
1051
|
+
args,
|
|
1052
|
+
available: availableTools(pi).filter(
|
|
1053
|
+
(name) => grantsNesting(start.agent, own.depth) || !DELEGATION_TOOLS.has(name),
|
|
1054
|
+
),
|
|
1055
|
+
registered: pi.getAllTools().map((tool) => tool.name),
|
|
1056
|
+
cwd,
|
|
1057
|
+
agentDir: payload.env.PI_CODING_AGENT_DIR,
|
|
1058
|
+
trusted,
|
|
1059
|
+
notices: start.notices,
|
|
1060
|
+
});
|
|
1061
|
+
payload.args.splice(prefix, payload.args.length - prefix, ...args);
|
|
1062
|
+
}
|
|
1063
|
+
const token: Reservation = { root: own.root, id };
|
|
1064
|
+
bindTree(token, { windowName: name, cwd, workspace });
|
|
1065
|
+
dispatchTree(token);
|
|
726
1066
|
let opened: OpenedWindow;
|
|
727
1067
|
try {
|
|
728
|
-
opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement);
|
|
729
|
-
onWindowOpened?.();
|
|
1068
|
+
opened = await tmux.openWindow({ name, cwd, argv: payload.args, env: payload.env }, placement, own.rootPlacement);
|
|
730
1069
|
} catch (err) {
|
|
1070
|
+
if (err instanceof WindowOpenError && err.certainty === "not-opened") {
|
|
1071
|
+
try {
|
|
1072
|
+
failedTree(token, true);
|
|
1073
|
+
} catch (accounting) {
|
|
1074
|
+
ctx.ui.notify(
|
|
1075
|
+
`delegate: could not refund the pre-start reservation: ${String(accounting)}; it remains charged.`,
|
|
1076
|
+
"warning",
|
|
1077
|
+
);
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
731
1080
|
if (err instanceof WindowOpenError) {
|
|
732
1081
|
throw new WindowOpenError(`could not open window ${name} for ${id}: ${err.message}`, err.certainty);
|
|
733
1082
|
}
|
|
734
1083
|
throw new WindowOpenError(`could not open window ${name} for ${id}: ${(err as Error).message}`, "unknown");
|
|
735
1084
|
}
|
|
1085
|
+
onWindowOpened?.();
|
|
1086
|
+
try {
|
|
1087
|
+
openedTree(token, opened.id);
|
|
1088
|
+
} catch (error) {
|
|
1089
|
+
ctx.ui.notify(
|
|
1090
|
+
`delegate: window ${opened.id} opened, but shared accounting publication failed: ${String(error)}. Local tracking continues; the reservation remains charged until accounting reconciles.`,
|
|
1091
|
+
"warning",
|
|
1092
|
+
);
|
|
1093
|
+
}
|
|
736
1094
|
return opened;
|
|
737
1095
|
}
|
|
738
1096
|
|
|
739
1097
|
async function launchWindow(input: {
|
|
1098
|
+
identity: Identity;
|
|
740
1099
|
id: string;
|
|
741
1100
|
requestId: string;
|
|
742
1101
|
workspace: Workspace;
|
|
@@ -747,15 +1106,17 @@ export default function delegate(
|
|
|
747
1106
|
toolCallId: string | undefined;
|
|
748
1107
|
ctx: ExtensionContext;
|
|
749
1108
|
placement: WindowPlacement;
|
|
1109
|
+
tools: readonly string[];
|
|
750
1110
|
onWindowOpened: () => void;
|
|
751
1111
|
}): Promise<Launched> {
|
|
752
1112
|
const { id, requestId, workspace, trusted, cwd, sessionDir, start, toolCallId, ctx, placement, onWindowOpened } =
|
|
753
1113
|
input;
|
|
754
|
-
const name = delegateName(start.agent.name, id, start.label);
|
|
1114
|
+
const name = delegateName(start.agent.displayName ?? start.agent.name, id, start.label);
|
|
755
1115
|
const { id: windowId, session: windowSession } = await openChild({
|
|
756
1116
|
id,
|
|
757
1117
|
name,
|
|
758
1118
|
session: ["--session-id", id, "--name", name],
|
|
1119
|
+
identity: input.identity,
|
|
759
1120
|
workspace,
|
|
760
1121
|
trusted,
|
|
761
1122
|
cwd,
|
|
@@ -764,13 +1125,16 @@ export default function delegate(
|
|
|
764
1125
|
toolCallId,
|
|
765
1126
|
ctx,
|
|
766
1127
|
placement,
|
|
1128
|
+
tools: input.tools,
|
|
767
1129
|
onWindowOpened,
|
|
768
1130
|
});
|
|
769
1131
|
// No task text in the record: the request copy in `sent/` has it.
|
|
770
1132
|
results.recordStart(
|
|
771
1133
|
{
|
|
772
1134
|
id,
|
|
1135
|
+
identity: readIdentity(id),
|
|
773
1136
|
agent: start.agent.name,
|
|
1137
|
+
...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
|
|
774
1138
|
model: start.model,
|
|
775
1139
|
thinking: start.thinking,
|
|
776
1140
|
name,
|
|
@@ -794,10 +1158,21 @@ export default function delegate(
|
|
|
794
1158
|
signal?: AbortSignal,
|
|
795
1159
|
toolCallId?: string,
|
|
796
1160
|
): Promise<Launched> {
|
|
1161
|
+
admit(start.agent.name, currentRoster(ctx));
|
|
1162
|
+
results.checkStorage(true);
|
|
797
1163
|
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
798
1164
|
await requireTmuxVersion(tmux);
|
|
799
|
-
// Read once, before anything is created or mailed; the launch keeps
|
|
1165
|
+
// Read once, before anything is created or mailed; the launch keeps these values.
|
|
800
1166
|
const placement = windowPlacement(getAgentDir());
|
|
1167
|
+
const parentIdentity = identity.current();
|
|
1168
|
+
const project = parentIdentity
|
|
1169
|
+
? { companion: parentIdentity.rootPlacement, notices: [] }
|
|
1170
|
+
: placementProject(ctx.cwd);
|
|
1171
|
+
assertRootPlacement(project.companion);
|
|
1172
|
+
const own = identity.child(start.agent, ctx, project.companion);
|
|
1173
|
+
if (parentIdentity) assertParentPlacement(readTree(own.root), own);
|
|
1174
|
+
if (placement === "companion") start.notices.push(...project.notices);
|
|
1175
|
+
const tools = childTools(start.agent);
|
|
801
1176
|
const sessionDir = ctx.sessionManager.getSessionDir();
|
|
802
1177
|
if (!isAbsolute(sessionDir))
|
|
803
1178
|
throw new Error(
|
|
@@ -829,13 +1204,23 @@ export default function delegate(
|
|
|
829
1204
|
getAgentDir(),
|
|
830
1205
|
);
|
|
831
1206
|
if (combined?.aborted) throw new Error("the launch was aborted");
|
|
832
|
-
const trust = await trustPlannedWorktree(
|
|
1207
|
+
const trust = await trustPlannedWorktree(
|
|
1208
|
+
plan,
|
|
1209
|
+
ctx,
|
|
1210
|
+
getAgentDir(),
|
|
1211
|
+
combined,
|
|
1212
|
+
trustControl.forAgent(start.agent.name, ctx),
|
|
1213
|
+
);
|
|
833
1214
|
if (combined?.aborted) throw new Error("the launch was aborted");
|
|
834
1215
|
trusted = trust.trusted;
|
|
835
1216
|
revalidateTrust = trust.revalidate;
|
|
836
1217
|
plan.trusted = trusted;
|
|
837
1218
|
} else {
|
|
838
|
-
const prepared = await prepareDirectory(
|
|
1219
|
+
const prepared = await prepareDirectory(
|
|
1220
|
+
{ cwd: cwdOption, signal, trust: trustControl.forAgent(start.agent.name, ctx) },
|
|
1221
|
+
ctx,
|
|
1222
|
+
getAgentDir(),
|
|
1223
|
+
);
|
|
839
1224
|
directory = prepared.workspace;
|
|
840
1225
|
trusted = prepared.trusted;
|
|
841
1226
|
}
|
|
@@ -844,7 +1229,14 @@ export default function delegate(
|
|
|
844
1229
|
let attempt: WorkspaceAttempt | undefined;
|
|
845
1230
|
let managed: Extract<Workspace, { mode: "managed" }> | undefined;
|
|
846
1231
|
let windowOpened = false;
|
|
1232
|
+
let reservation: Reservation | undefined;
|
|
847
1233
|
const run = async (): Promise<Launched> => {
|
|
1234
|
+
admit(start.agent.name, currentRoster(ctx));
|
|
1235
|
+
results.checkStorage(true);
|
|
1236
|
+
await reconcileTree(own.root, tmux);
|
|
1237
|
+
admit(start.agent.name, currentRoster(ctx));
|
|
1238
|
+
if (combined?.aborted) throw new Error("the launch was aborted");
|
|
1239
|
+
reservation = reserveTree({ id, sessionId: id, identity: own, sessionDir });
|
|
848
1240
|
if (plan !== undefined) {
|
|
849
1241
|
revalidate(plan);
|
|
850
1242
|
revalidateTrust?.();
|
|
@@ -894,11 +1286,13 @@ export default function delegate(
|
|
|
894
1286
|
// at session start without a handshake, and a window that fails leaves
|
|
895
1287
|
// mail that is reported rather than cleaned up.
|
|
896
1288
|
const requestId = sendTask(id, start.task);
|
|
1289
|
+
bindTree(reservation, { requestId });
|
|
897
1290
|
if (attempt !== undefined) {
|
|
898
1291
|
attempt = { ...attempt, requestId };
|
|
899
1292
|
results.recordAttempt(attempt, ctx);
|
|
900
1293
|
}
|
|
901
1294
|
return launchWindow({
|
|
1295
|
+
identity: own,
|
|
902
1296
|
id,
|
|
903
1297
|
requestId,
|
|
904
1298
|
workspace,
|
|
@@ -909,6 +1303,7 @@ export default function delegate(
|
|
|
909
1303
|
toolCallId,
|
|
910
1304
|
ctx,
|
|
911
1305
|
placement,
|
|
1306
|
+
tools,
|
|
912
1307
|
onWindowOpened: () => {
|
|
913
1308
|
windowOpened = true;
|
|
914
1309
|
},
|
|
@@ -918,6 +1313,13 @@ export default function delegate(
|
|
|
918
1313
|
try {
|
|
919
1314
|
return await run();
|
|
920
1315
|
} catch (error) {
|
|
1316
|
+
if (reservation) {
|
|
1317
|
+
try {
|
|
1318
|
+
failedTree(reservation);
|
|
1319
|
+
} catch (accounting) {
|
|
1320
|
+
ctx.ui.notify(`delegate: reservation remains charged: ${String(accounting)}`, "error");
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
921
1323
|
if (attempt !== undefined && managed !== undefined) {
|
|
922
1324
|
const cause = (error as Error).message;
|
|
923
1325
|
if (windowOpened || (error instanceof WindowOpenError && error.certainty === "unknown")) {
|
|
@@ -982,15 +1384,55 @@ export default function delegate(
|
|
|
982
1384
|
startPolling: tracking.startPolling,
|
|
983
1385
|
placement: () => windowPlacement(getAgentDir()),
|
|
984
1386
|
roster: rosterFor,
|
|
985
|
-
|
|
1387
|
+
admit: (agent, ctx) => admit(agent, currentRoster(ctx)),
|
|
1388
|
+
authorize: (id, ctx) => {
|
|
1389
|
+
owner.assert();
|
|
1390
|
+
results.checkStorage();
|
|
1391
|
+
const record = results.find(id) ?? viewResults.find(id) ?? historicalRecord(id, ctx);
|
|
1392
|
+
if (record && isStarted(record)) assertResumeAuthority(record, ctx);
|
|
1393
|
+
},
|
|
1394
|
+
reserve: async (record, id, ctx) => {
|
|
1395
|
+
const own = record.identity;
|
|
1396
|
+
if (!own) throw new Error(`delegation ${record.id} has no original root placement identity; resume refused`);
|
|
1397
|
+
assertRootPlacement(own.rootPlacement);
|
|
1398
|
+
await reconcileTree(own.root, tmux);
|
|
1399
|
+
admit(record.agent, currentRoster(ctx));
|
|
1400
|
+
return reserveTree({
|
|
1401
|
+
id,
|
|
1402
|
+
sessionId: sessionIdOf(record),
|
|
1403
|
+
identity: own,
|
|
1404
|
+
resumedFrom: record.id,
|
|
1405
|
+
sessionDir: record.sessionDir,
|
|
1406
|
+
});
|
|
1407
|
+
},
|
|
1408
|
+
parentDefaults,
|
|
1409
|
+
chooseModel: (explicit, agent, models, preferred) =>
|
|
1410
|
+
chooseModel(explicit, agent, models, proactive, agentModels(agent, undefined), preferred),
|
|
986
1411
|
thinkingProblem,
|
|
987
|
-
prepareDirectory: (cwd, ctx, signal) =>
|
|
1412
|
+
prepareDirectory: (cwd, ctx, signal, agent) =>
|
|
1413
|
+
prepareDirectory({ cwd, signal, trust: trustControl.forAgent(agent, ctx) }, ctx, getAgentDir()),
|
|
988
1414
|
sendTask,
|
|
989
|
-
open: ({
|
|
1415
|
+
open: ({
|
|
1416
|
+
id,
|
|
1417
|
+
name,
|
|
1418
|
+
sessionPath,
|
|
1419
|
+
workspace,
|
|
1420
|
+
trusted,
|
|
1421
|
+
cwd,
|
|
1422
|
+
sessionDir,
|
|
1423
|
+
start,
|
|
1424
|
+
toolCallId,
|
|
1425
|
+
ctx,
|
|
1426
|
+
placement,
|
|
1427
|
+
identity: own,
|
|
1428
|
+
sessionId,
|
|
1429
|
+
}) =>
|
|
990
1430
|
openChild({
|
|
991
1431
|
id,
|
|
992
1432
|
name,
|
|
993
1433
|
session: ["--session", sessionPath],
|
|
1434
|
+
identity: own,
|
|
1435
|
+
sessionId,
|
|
994
1436
|
workspace,
|
|
995
1437
|
trusted,
|
|
996
1438
|
cwd,
|
|
@@ -999,6 +1441,8 @@ export default function delegate(
|
|
|
999
1441
|
toolCallId,
|
|
1000
1442
|
ctx,
|
|
1001
1443
|
placement,
|
|
1444
|
+
// Read before the window opens, and a resume mails only after it opens.
|
|
1445
|
+
tools: childTools(start.agent),
|
|
1002
1446
|
}),
|
|
1003
1447
|
});
|
|
1004
1448
|
|
|
@@ -1022,11 +1466,7 @@ export default function delegate(
|
|
|
1022
1466
|
const worktree = normalizeWorkspaceValue(parsed.worktree, "--worktree");
|
|
1023
1467
|
if (worktree_source !== undefined && worktree === undefined)
|
|
1024
1468
|
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
|
-
);
|
|
1469
|
+
const start = await validate({ ...parsed, cwd, worktree, worktree_base, worktree_source }, rosterFor(ctx), ctx);
|
|
1030
1470
|
const launched = await launch(start, ctx);
|
|
1031
1471
|
ctx.ui.notify(started(launched, start), "info");
|
|
1032
1472
|
} catch (err) {
|
|
@@ -1039,6 +1479,11 @@ export default function delegate(
|
|
|
1039
1479
|
unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
|
|
1040
1480
|
roster: rosterFor,
|
|
1041
1481
|
noAgents,
|
|
1482
|
+
defaults: (agent, ctx) => {
|
|
1483
|
+
const parent = parentDefaults(ctx);
|
|
1484
|
+
const model = agentModels(agent, parent.model)[0];
|
|
1485
|
+
return { ...(model === undefined ? {} : { model }), thinking: agent.thinking ?? parent.thinking };
|
|
1486
|
+
},
|
|
1042
1487
|
labelProblem,
|
|
1043
1488
|
start: (request: NewRequest, context) => runDelegate(request, context),
|
|
1044
1489
|
};
|
|
@@ -1057,9 +1502,10 @@ export default function delegate(
|
|
|
1057
1502
|
await runDelegate(outcome.parsed, ctx);
|
|
1058
1503
|
}
|
|
1059
1504
|
|
|
1060
|
-
async function
|
|
1061
|
-
|
|
1062
|
-
|
|
1505
|
+
async function resumePicker(ctx: ExtensionContext, id?: string): Promise<void> {
|
|
1506
|
+
await pickResume(
|
|
1507
|
+
ctx,
|
|
1508
|
+
{
|
|
1063
1509
|
unavailable: () => (tmux.insideTmux() ? undefined : NOT_IN_TMUX),
|
|
1064
1510
|
eligible: resumer.eligible,
|
|
1065
1511
|
resume: async (request, context) => {
|
|
@@ -1069,9 +1515,13 @@ export default function delegate(
|
|
|
1069
1515
|
context.ui.notify(`delegate: ${(err as Error).message}`, "error");
|
|
1070
1516
|
}
|
|
1071
1517
|
},
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1518
|
+
},
|
|
1519
|
+
id,
|
|
1520
|
+
);
|
|
1521
|
+
}
|
|
1522
|
+
|
|
1523
|
+
async function resumeCommand(input: string, ctx: ExtensionContext): Promise<void> {
|
|
1524
|
+
if (input.trim() === "") return resumePicker(ctx);
|
|
1075
1525
|
const outcome = parseResume(input);
|
|
1076
1526
|
if ("error" in outcome) {
|
|
1077
1527
|
ctx.ui.notify(`delegate: ${outcome.error}`, "error");
|
|
@@ -1096,9 +1546,16 @@ export default function delegate(
|
|
|
1096
1546
|
task: Type.String({
|
|
1097
1547
|
description: "The task for the delegate. It sees this and its agent prompt, nothing else of this session.",
|
|
1098
1548
|
}),
|
|
1099
|
-
model: Type.Optional(
|
|
1549
|
+
model: Type.Optional(
|
|
1550
|
+
Type.String({
|
|
1551
|
+
description:
|
|
1552
|
+
"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.",
|
|
1553
|
+
}),
|
|
1554
|
+
),
|
|
1100
1555
|
thinking: Type.Optional(
|
|
1101
|
-
Type.String({
|
|
1556
|
+
Type.String({
|
|
1557
|
+
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.`,
|
|
1558
|
+
}),
|
|
1102
1559
|
),
|
|
1103
1560
|
label: Type.Optional(
|
|
1104
1561
|
Type.String({
|
|
@@ -1179,7 +1636,7 @@ export default function delegate(
|
|
|
1179
1636
|
task: params.task ?? "",
|
|
1180
1637
|
},
|
|
1181
1638
|
rosterFor(ctx),
|
|
1182
|
-
ctx
|
|
1639
|
+
ctx,
|
|
1183
1640
|
);
|
|
1184
1641
|
const launched = await launch(start, ctx, signal, toolCallId);
|
|
1185
1642
|
ctx.ui.notify(started(launched, start), "info");
|
|
@@ -1212,17 +1669,29 @@ export default function delegate(
|
|
|
1212
1669
|
|
|
1213
1670
|
pi.on("session_start", (_event, ctx) => {
|
|
1214
1671
|
registry = ctx.modelRegistry;
|
|
1215
|
-
|
|
1672
|
+
latest = ctx;
|
|
1216
1673
|
// The roster is read at call time; the description is rebuilt here and before each run.
|
|
1217
|
-
|
|
1674
|
+
const roster = currentRoster(ctx);
|
|
1675
|
+
announced.clear();
|
|
1676
|
+
announce(roster, ctx);
|
|
1677
|
+
registerDelegate(toolDescription(roster.agents));
|
|
1678
|
+
exposeDelegation(roster);
|
|
1218
1679
|
});
|
|
1219
1680
|
|
|
1220
1681
|
// One roster/prompt snapshot prepares this run: refresh the description only when
|
|
1221
1682
|
// it changed, and replace only this extension's prompt section from the same snapshot.
|
|
1222
1683
|
pi.on("before_agent_start", (event, ctx) => {
|
|
1223
|
-
|
|
1684
|
+
latest = ctx;
|
|
1685
|
+
const own = identity.current();
|
|
1686
|
+
const roster = currentRoster(ctx);
|
|
1687
|
+
if (own)
|
|
1688
|
+
event.systemPromptOptions.sections.squire_delegate_task = `You are a delegate. ${parentTrust(own.parent, coordinates(roster))}`;
|
|
1689
|
+
else delete event.systemPromptOptions.sections.squire_delegate_task;
|
|
1690
|
+
exposeDelegation(roster);
|
|
1691
|
+
announce(roster, ctx);
|
|
1692
|
+
const description = toolDescription(roster.agents);
|
|
1224
1693
|
const prompt = readParentPrompt();
|
|
1225
|
-
const visible = delegateVisible(event.systemPromptOptions);
|
|
1694
|
+
const visible = delegateVisible(event.systemPromptOptions) && pi.getActiveTools().includes("delegate");
|
|
1226
1695
|
// Report a read failure even when guidance cannot be used, so it does not go unseen.
|
|
1227
1696
|
if (prompt.error !== undefined) ctx.ui.notify(`delegate: ${prompt.error}`, "warning");
|
|
1228
1697
|
if (visible && description !== registeredDescription) registerDelegate(description);
|
|
@@ -1236,6 +1705,7 @@ export default function delegate(
|
|
|
1236
1705
|
pi.on("session_shutdown", () => {
|
|
1237
1706
|
tracking.stop();
|
|
1238
1707
|
});
|
|
1708
|
+
host.on("session_shutdown", () => owner.stop());
|
|
1239
1709
|
|
|
1240
1710
|
/** Agent names, flags, model ids and thinking levels, at the cursor's token. */
|
|
1241
1711
|
function completions(prefix: string): { value: string; label: string }[] | null {
|
|
@@ -1279,12 +1749,20 @@ export default function delegate(
|
|
|
1279
1749
|
handler: resumeCommand,
|
|
1280
1750
|
});
|
|
1281
1751
|
|
|
1282
|
-
const navigator = createNavigator(
|
|
1283
|
-
|
|
1752
|
+
const navigator = createNavigator(
|
|
1753
|
+
viewResults,
|
|
1754
|
+
tmux,
|
|
1755
|
+
tracking,
|
|
1756
|
+
(ctx: ExtensionContext) => startNewDelegate(ctx, newDelegateDeps),
|
|
1757
|
+
{
|
|
1758
|
+
assertActive: () => owner.assert(),
|
|
1759
|
+
close: (id, ctx) => tracking.close(id, ctx),
|
|
1760
|
+
resume: (id, ctx) => resumePicker(ctx, id),
|
|
1761
|
+
},
|
|
1284
1762
|
);
|
|
1285
1763
|
|
|
1286
1764
|
pi.registerCommand("delegates", {
|
|
1287
|
-
description: "Open the
|
|
1765
|
+
description: "Open the shared delegate dashboard: details, switch, peek, close, resume",
|
|
1288
1766
|
handler: async (_args: string, ctx: ExtensionContext) => navigator.panel(ctx),
|
|
1289
1767
|
});
|
|
1290
1768
|
|