pi-squire 0.1.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 +26 -0
- package/LICENSE +21 -0
- package/README.md +351 -0
- package/agents.ts +161 -0
- package/child.ts +67 -0
- package/index.ts +717 -0
- package/package.json +61 -0
- package/results.ts +293 -0
- package/tmux.ts +75 -0
package/index.ts
ADDED
|
@@ -0,0 +1,717 @@
|
|
|
1
|
+
// pi-squire: hand a task to a delegate Pi session in a background tmux
|
|
2
|
+
// window, over the `message:*` hooks.
|
|
3
|
+
//
|
|
4
|
+
// `/delegate <agent> [--model <provider/id>] [--thinking <level>]
|
|
5
|
+
// [--label <name>] [--auto-exit | --no-auto-exit] <task…>`
|
|
6
|
+
// reads the agent from `<agent dir>/agents/<name>/AGENT.md`, writes the task
|
|
7
|
+
// to the delegate's inbox through `message:send`, emits `session:launch` so
|
|
8
|
+
// listeners can add arguments and environment, then opens a window in the
|
|
9
|
+
// parent's working directory running the parent's own Pi. The window and the
|
|
10
|
+
// session share one name: `<agent>-<label>`, or `<agent>-<first 8 of the id>`
|
|
11
|
+
// without a label. Starting a delegation adds nothing to
|
|
12
|
+
// the model's context. Each delegation is recorded
|
|
13
|
+
// in the parent session; its answer is taken over on `message:inbound` and
|
|
14
|
+
// shown as one result message, so the parent hears back on its own.
|
|
15
|
+
//
|
|
16
|
+
// With auto-exit on (the agent's `auto-exit`, default true, overridden per
|
|
17
|
+
// call) the delegate closes its own window after a normal completion; the
|
|
18
|
+
// delegate's side lives in `child.ts`. With it off the window stays open.
|
|
19
|
+
// An agent's `exclude-tools` goes to the child as `--exclude-tools`; a name the
|
|
20
|
+
// session has no tool for leaves the agent out of the roster.
|
|
21
|
+
//
|
|
22
|
+
// The model gets three tools. `delegate` takes agent, task and optional model
|
|
23
|
+
// and thinking, validates them the way the command does, and starts the
|
|
24
|
+
// delegation at once, opening no dialog, so parallel calls cannot block one
|
|
25
|
+
// another. `delegation_status`
|
|
26
|
+
// reports each delegation as running (window open, no result yet), done (with
|
|
27
|
+
// its result status) or closed, with the session name, window, session and
|
|
28
|
+
// envelope paths.
|
|
29
|
+
// `delegation_close` kills the window and records the close. Inside a delegate
|
|
30
|
+
// (`PI_DELEGATE_PARENT` set) the extension registers only `child.ts`'s
|
|
31
|
+
// `/auto-exit` and handlers, so a delegate cannot delegate.
|
|
32
|
+
//
|
|
33
|
+
// The `session:launch` contract this package provides, and the `message:*`
|
|
34
|
+
// hooks it consumes, live in this package's README.
|
|
35
|
+
import { randomUUID } from "node:crypto";
|
|
36
|
+
import { basename, join } from "node:path";
|
|
37
|
+
|
|
38
|
+
import {
|
|
39
|
+
type ExtensionAPI,
|
|
40
|
+
type ExtensionContext,
|
|
41
|
+
getAgentDir,
|
|
42
|
+
type ModelRegistry,
|
|
43
|
+
} from "@earendil-works/pi-coding-agent";
|
|
44
|
+
import { Type } from "typebox";
|
|
45
|
+
|
|
46
|
+
import { type Agent, isThinking, type Roster, readRoster, THINKING_LEVELS } from "./agents.ts";
|
|
47
|
+
import { AUTO_EXIT_ENV, registerChild } from "./child.ts";
|
|
48
|
+
import { createResults, findSession, type Recorded } from "./results.ts";
|
|
49
|
+
import { createTmuxClient, type TmuxClient } from "./tmux.ts";
|
|
50
|
+
|
|
51
|
+
/** The hook a provider of `message:*` answers with the written request. */
|
|
52
|
+
const SEND = "message:send";
|
|
53
|
+
/** The hook emitted just before a delegate's window opens. */
|
|
54
|
+
const LAUNCH = "session:launch";
|
|
55
|
+
/** The hook a tick emits so the provider claims waiting replies before windows are checked. */
|
|
56
|
+
const SCAN = "message:scan";
|
|
57
|
+
/** How often the parent checks its running delegations. */
|
|
58
|
+
const POLL_MS = 5000;
|
|
59
|
+
/** Set in a delegate's environment, so this extension stays off there. */
|
|
60
|
+
const PARENT_ENV = "PI_DELEGATE_PARENT";
|
|
61
|
+
/** Forwarded to the child when the parent has it set. */
|
|
62
|
+
const SESSION_DIR_ENV = "PI_CODING_AGENT_SESSION_DIR";
|
|
63
|
+
/** Asked of every delegate, so the parent gets an answer it can use alone. */
|
|
64
|
+
const FINAL_LINE =
|
|
65
|
+
"End with one self-contained final message: the parent session sees only that message, never this conversation.";
|
|
66
|
+
/** How much of a delegation id a delegate's session name carries. */
|
|
67
|
+
const SHORT_ID = 8;
|
|
68
|
+
|
|
69
|
+
/** The longest label a delegation can carry. */
|
|
70
|
+
const LABEL_MAX = 32;
|
|
71
|
+
/** A label is one safe tmux window name and session name segment. */
|
|
72
|
+
const LABEL = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* A delegate's name, used for both its session and its window: its agent and
|
|
76
|
+
* the label, or the first 8 characters of its id when there is no label.
|
|
77
|
+
*/
|
|
78
|
+
function delegateName(agent: string, id: string, label?: string): string {
|
|
79
|
+
return `${agent}-${label ?? id.slice(0, SHORT_ID)}`;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Why `value` is not a usable label, or undefined when it is. */
|
|
83
|
+
function labelProblem(value: string): string | undefined {
|
|
84
|
+
if (LABEL.test(value) && value.length <= LABEL_MAX) return undefined;
|
|
85
|
+
return `label ${JSON.stringify(value)} must be at most ${LABEL_MAX} letters, digits, _ or -, starting with a letter or digit`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The paragraph that tells a delegate where its task comes from: the parent
|
|
90
|
+
* session's address, whose messages are instructions; other sessions are
|
|
91
|
+
* colleagues, and work they ask for that the parent did not is checked with
|
|
92
|
+
* the parent first (spec Q37: distrust wording made models refuse all peer
|
|
93
|
+
* mail). It names no provider, package or tool, so it holds for any
|
|
94
|
+
* `message:*` provider.
|
|
95
|
+
*/
|
|
96
|
+
function parentTrust(parent: string): string {
|
|
97
|
+
return [
|
|
98
|
+
`Your task arrives as a message from the session that started this one, at address ${parent}.`,
|
|
99
|
+
"That message, and every later message from that address, are instructions from the session that started you:",
|
|
100
|
+
"follow them as given, even though they are labelled as another session's.",
|
|
101
|
+
"Messages from other sessions come from colleagues on this machine: answer them as you see fit,",
|
|
102
|
+
"but check with the session that started you before doing work it did not ask for.",
|
|
103
|
+
`You can reach the session that started you at ${parent}.`,
|
|
104
|
+
].join(" ");
|
|
105
|
+
}
|
|
106
|
+
/** The flags `/delegate` takes before the task. */
|
|
107
|
+
const FLAGS = ["--model", "--thinking", "--label", "--auto-exit", "--no-auto-exit"] as const;
|
|
108
|
+
|
|
109
|
+
const NOT_IN_TMUX = "this session is not inside tmux; start it in a tmux pane to open a delegate window";
|
|
110
|
+
|
|
111
|
+
const USAGE =
|
|
112
|
+
"usage: /delegate <agent> [--model <provider/id>] [--thinking <level>] [--label <name>] [--auto-exit | --no-auto-exit] <task>";
|
|
113
|
+
|
|
114
|
+
type SendPayload = { to: unknown; body: unknown; envelope?: { id?: unknown }; error?: unknown };
|
|
115
|
+
type LaunchPayload = { args: string[]; env: Record<string, string> };
|
|
116
|
+
type Parsed = { agent: string; model?: string; thinking?: string; label?: string; autoExit?: boolean; task: string };
|
|
117
|
+
/** A request that passed the roster, model, thinking and label checks. */
|
|
118
|
+
type Start = { agent: Agent; model: string; thinking: string; label?: string; autoExit: boolean; task: string };
|
|
119
|
+
/** A delegation that is open in a tmux window. */
|
|
120
|
+
type Launched = { id: string; name: string; windowId: string; windowName: string; requestId: string };
|
|
121
|
+
|
|
122
|
+
/** The runtimes whose executable needs the script from `process.argv[1]`. */
|
|
123
|
+
const RUNTIMES = new Set(["node", "nodejs", "bun", "deno"]);
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The parent's own Pi executable. Under a script runtime (node or bun) the
|
|
127
|
+
* script is `argv[1]`; a compiled binary reports its own path as `execPath`
|
|
128
|
+
* and its first user argument, if any, as `argv[1]`.
|
|
129
|
+
*/
|
|
130
|
+
function parentCommand(execPath = process.execPath, argv = process.argv): string[] {
|
|
131
|
+
const runner = basename(execPath)
|
|
132
|
+
.replace(/\.exe$/i, "")
|
|
133
|
+
.toLowerCase();
|
|
134
|
+
const script = argv[1];
|
|
135
|
+
if (RUNTIMES.has(runner) && typeof script === "string" && script !== "") return [execPath, script];
|
|
136
|
+
return [execPath];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Edit distance, for the "close matches" lists. */
|
|
140
|
+
function distance(a: string, b: string): number {
|
|
141
|
+
let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
|
|
142
|
+
for (let i = 1; i <= a.length; i++) {
|
|
143
|
+
const current = [i];
|
|
144
|
+
for (let j = 1; j <= b.length; j++) {
|
|
145
|
+
current[j] = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
|
|
146
|
+
}
|
|
147
|
+
previous = current;
|
|
148
|
+
}
|
|
149
|
+
return previous[b.length];
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Up to three candidates close to `value`; empty when nothing is close. */
|
|
153
|
+
function closest(value: string, candidates: readonly string[]): string[] {
|
|
154
|
+
const limit = Math.max(2, Math.ceil(value.length * 0.4));
|
|
155
|
+
return candidates
|
|
156
|
+
.map((candidate) => ({ candidate, score: distance(value.toLowerCase(), candidate.toLowerCase()) }))
|
|
157
|
+
.filter(({ score }) => score <= limit)
|
|
158
|
+
.sort((a, b) => a.score - b.score || a.candidate.localeCompare(b.candidate))
|
|
159
|
+
.slice(0, 3)
|
|
160
|
+
.map(({ candidate }) => candidate);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** `/delegate`'s arguments: an agent, optional flags, then a verbatim task. */
|
|
164
|
+
function parse(text: string): { parsed: Parsed } | { error: string } {
|
|
165
|
+
const tokens = [...text.matchAll(/\S+/g)].map((match) => ({ text: match[0], start: match.index }));
|
|
166
|
+
if (tokens.length === 0) return { error: USAGE };
|
|
167
|
+
if (tokens[0].text.startsWith("-")) return { error: `the first argument is the agent name; ${USAGE}` };
|
|
168
|
+
const parsed: Parsed = { agent: tokens[0].text, task: "" };
|
|
169
|
+
let i = 1;
|
|
170
|
+
while (i < tokens.length && tokens[i].text.startsWith("-")) {
|
|
171
|
+
const flag = tokens[i].text;
|
|
172
|
+
if (flag === "--auto-exit" || flag === "--no-auto-exit") {
|
|
173
|
+
parsed.autoExit = flag === "--auto-exit";
|
|
174
|
+
i += 1;
|
|
175
|
+
continue;
|
|
176
|
+
}
|
|
177
|
+
if (flag !== "--model" && flag !== "--thinking" && flag !== "--label") {
|
|
178
|
+
return { error: `unknown flag ${flag}; ${USAGE}` };
|
|
179
|
+
}
|
|
180
|
+
const value = tokens[i + 1]?.text;
|
|
181
|
+
if (value === undefined || value.startsWith("--")) return { error: `${flag} needs a value; ${USAGE}` };
|
|
182
|
+
if (flag === "--model") parsed.model = value;
|
|
183
|
+
else if (flag === "--thinking") parsed.thinking = value;
|
|
184
|
+
else parsed.label = value;
|
|
185
|
+
i += 2;
|
|
186
|
+
}
|
|
187
|
+
parsed.task = text.slice(tokens[i]?.start ?? text.length).trim();
|
|
188
|
+
if (parsed.task === "") return { error: `a task is required after the agent and flags; ${USAGE}` };
|
|
189
|
+
return { parsed };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** Why `value` is not a usable model, or undefined when it is. */
|
|
193
|
+
function modelProblem(value: string, registry: ModelRegistry): string | undefined {
|
|
194
|
+
const slash = value.indexOf("/");
|
|
195
|
+
if (slash <= 0 || slash === value.length - 1) return `--model takes provider/id, got ${JSON.stringify(value)}`;
|
|
196
|
+
if (registry.find(value.slice(0, slash), value.slice(slash + 1)) !== undefined) return undefined;
|
|
197
|
+
const names = registry.getAll().map((model) => `${model.provider}/${model.id}`);
|
|
198
|
+
const close = closest(value, names);
|
|
199
|
+
return `unknown model ${JSON.stringify(value)}${close.length > 0 ? `; close matches: ${close.join(", ")}` : ""}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Why `value` is not a thinking level, or undefined when it is. */
|
|
203
|
+
function thinkingProblem(value: string): string | undefined {
|
|
204
|
+
if (isThinking(value)) return undefined;
|
|
205
|
+
const close = closest(value, THINKING_LEVELS);
|
|
206
|
+
return `unknown thinking level ${JSON.stringify(value)}; levels: ${THINKING_LEVELS.join(", ")}${
|
|
207
|
+
close.length > 0 ? `; close matches: ${close.join(", ")}` : ""
|
|
208
|
+
}`;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The `delegate` tool's description: what delegation does, and the roster as it stands. */
|
|
212
|
+
function toolDescription(agents: readonly Agent[]): string {
|
|
213
|
+
const intro =
|
|
214
|
+
"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 and agent root and reports back later as one message. Use it when the work is self-contained and you would rather keep your own context for it.";
|
|
215
|
+
if (agents.length === 0) {
|
|
216
|
+
return `${intro}\n\nNo agents are defined yet. Add ${join(
|
|
217
|
+
getAgentDir(),
|
|
218
|
+
"agents",
|
|
219
|
+
"<name>",
|
|
220
|
+
"AGENT.md",
|
|
221
|
+
)} with frontmatter description, model and thinking and the prompt as its body; /delegate explains the format too.`;
|
|
222
|
+
}
|
|
223
|
+
return [
|
|
224
|
+
intro,
|
|
225
|
+
"",
|
|
226
|
+
"Agents:",
|
|
227
|
+
...agents.map(
|
|
228
|
+
(agent) =>
|
|
229
|
+
`- ${agent.name} — ${agent.description} (default ${agent.model}, thinking ${agent.thinking}${agent.autoExit ? "" : ", auto-exit off"}${
|
|
230
|
+
agent.excludeTools.length > 0 ? `, no ${agent.excludeTools.join("/")}` : ""
|
|
231
|
+
})`,
|
|
232
|
+
),
|
|
233
|
+
"",
|
|
234
|
+
"`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.",
|
|
235
|
+
"`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.",
|
|
236
|
+
"",
|
|
237
|
+
"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.",
|
|
238
|
+
].join("\n");
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** The one result every tool returns. */
|
|
242
|
+
function toolResult(
|
|
243
|
+
payload: string,
|
|
244
|
+
details: unknown = {},
|
|
245
|
+
): { content: { type: "text"; text: string }[]; details: unknown } {
|
|
246
|
+
return { content: [{ type: "text", text: payload }], details };
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Register `/delegate` and the three tools. The second parameter is a
|
|
251
|
+
* test-only seam for tmux: Pi passes only `pi`, so it is not part of the
|
|
252
|
+
* package's documented contract.
|
|
253
|
+
*/
|
|
254
|
+
export default function delegate(pi: ExtensionAPI, tmux: TmuxClient = createTmuxClient()): void {
|
|
255
|
+
// A delegate registers only its auto-exit side: `delegate` is one level deep (Q27).
|
|
256
|
+
if ((process.env[PARENT_ENV] ?? "") !== "") {
|
|
257
|
+
registerChild(pi);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const results = createResults(pi);
|
|
262
|
+
|
|
263
|
+
/** The poll timer; set only while some delegation is running. */
|
|
264
|
+
let poll: ReturnType<typeof setInterval> | undefined;
|
|
265
|
+
let ticking = false;
|
|
266
|
+
/** False once the session shuts down, so a tick still awaiting tmux declares nothing. */
|
|
267
|
+
let live = true;
|
|
268
|
+
|
|
269
|
+
function stopPolling(): void {
|
|
270
|
+
if (poll !== undefined) clearInterval(poll);
|
|
271
|
+
poll = undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Claim waiting replies first, then declare gone every window that is missing with still no result. */
|
|
275
|
+
async function tick(): Promise<void> {
|
|
276
|
+
if (ticking) return;
|
|
277
|
+
ticking = true;
|
|
278
|
+
try {
|
|
279
|
+
if (results.running().length === 0) return stopPolling();
|
|
280
|
+
const scan: { scanned?: boolean } = {};
|
|
281
|
+
pi.events.emit(SCAN, scan);
|
|
282
|
+
if (scan.scanned !== true) return; // no message:* provider: a waiting reply cannot be ruled out
|
|
283
|
+
for (const record of results.running()) {
|
|
284
|
+
let alive: boolean;
|
|
285
|
+
try {
|
|
286
|
+
alive = await tmux.isAlive(record.windowId);
|
|
287
|
+
} catch {
|
|
288
|
+
continue; // tmux could not be asked; never read that as gone
|
|
289
|
+
}
|
|
290
|
+
if (alive || !live) continue;
|
|
291
|
+
// A reply may have landed while the windows before this one were checked.
|
|
292
|
+
pi.events.emit(SCAN, {});
|
|
293
|
+
results.recordGone(record.id);
|
|
294
|
+
}
|
|
295
|
+
if (results.running().length === 0) stopPolling();
|
|
296
|
+
} finally {
|
|
297
|
+
ticking = false;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/** Start the timer when something is running; it never keeps the process alive. */
|
|
302
|
+
function startPolling(): void {
|
|
303
|
+
if (poll !== undefined || results.running().length === 0) return;
|
|
304
|
+
poll = setInterval(() => void tick(), POLL_MS);
|
|
305
|
+
poll.unref?.();
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** Captured because `getArgumentCompletions` is called without a context. */
|
|
309
|
+
let registry: ModelRegistry | undefined;
|
|
310
|
+
|
|
311
|
+
const modelIds = (source: ModelRegistry | undefined): string[] =>
|
|
312
|
+
(source?.getAll() ?? []).map((model) => `${model.provider}/${model.id}`);
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* The session's tool names, which `exclude-tools` must name; undefined
|
|
316
|
+
* while Pi is still loading extensions and cannot list them yet.
|
|
317
|
+
*/
|
|
318
|
+
function sessionTools(): string[] | undefined {
|
|
319
|
+
try {
|
|
320
|
+
return pi.getAllTools().map((tool) => tool.name);
|
|
321
|
+
} catch {
|
|
322
|
+
return undefined;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The roster as it stands, checked against the session's tools. */
|
|
327
|
+
const currentRoster = (): Roster => readRoster(getAgentDir(), sessionTools());
|
|
328
|
+
|
|
329
|
+
/** Read the roster now, warning about every file left out. */
|
|
330
|
+
function rosterFor(ctx: ExtensionContext): Roster {
|
|
331
|
+
const roster = currentRoster();
|
|
332
|
+
for (const warning of roster.warnings) ctx.ui.notify(`delegate: ${warning}`, "warning");
|
|
333
|
+
return roster;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** The request as a start, or an error naming what to fix. */
|
|
337
|
+
function validate(request: Parsed, roster: Roster, models: ModelRegistry): Start {
|
|
338
|
+
if (roster.agents.length === 0) {
|
|
339
|
+
throw new Error(
|
|
340
|
+
`no agents are defined; add ${join(
|
|
341
|
+
getAgentDir(),
|
|
342
|
+
"agents",
|
|
343
|
+
"<name>",
|
|
344
|
+
"AGENT.md",
|
|
345
|
+
)} with frontmatter description, model and thinking, and the prompt as its body`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
const agent = roster.agents.find((candidate) => candidate.name === request.agent);
|
|
349
|
+
if (agent === undefined) {
|
|
350
|
+
throw new Error(
|
|
351
|
+
`no agent named ${JSON.stringify(request.agent)}; available: ${roster.agents.map((item) => item.name).join(", ")}`,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
if (request.task.trim() === "") throw new Error("a task is required");
|
|
355
|
+
const model = request.model ?? agent.model;
|
|
356
|
+
const thinking = request.thinking ?? agent.thinking;
|
|
357
|
+
const badModel = modelProblem(model, models);
|
|
358
|
+
if (badModel !== undefined) throw new Error(badModel);
|
|
359
|
+
const badThinking = thinkingProblem(thinking);
|
|
360
|
+
if (badThinking !== undefined) throw new Error(badThinking);
|
|
361
|
+
const badLabel = request.label === undefined ? undefined : labelProblem(request.label);
|
|
362
|
+
if (badLabel !== undefined) throw new Error(badLabel);
|
|
363
|
+
return {
|
|
364
|
+
agent,
|
|
365
|
+
model,
|
|
366
|
+
thinking,
|
|
367
|
+
label: request.label,
|
|
368
|
+
autoExit: request.autoExit ?? agent.autoExit,
|
|
369
|
+
task: request.task,
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The launch order of the spec: task, argv, env, listeners, window, record. */
|
|
374
|
+
async function launch(start: Start, ctx: ExtensionContext): Promise<Launched> {
|
|
375
|
+
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
376
|
+
|
|
377
|
+
// The task goes on disk before the window exists: the delegate finds it
|
|
378
|
+
// at session start without a handshake, and a window that fails leaves
|
|
379
|
+
// mail that is reported rather than cleaned up.
|
|
380
|
+
const id = randomUUID();
|
|
381
|
+
const sent: SendPayload = { to: id, body: start.task };
|
|
382
|
+
pi.events.emit(SEND, sent);
|
|
383
|
+
if (typeof sent.error === "string") throw new Error(`could not send the task to ${id}: ${sent.error}`);
|
|
384
|
+
if (sent.envelope === undefined) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`needs a provider of message:* to send the task; install a mailbox extension (for example pi-session-mail)`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const requestId = sent.envelope.id;
|
|
390
|
+
if (typeof requestId !== "string") throw new Error(`the message:send provider wrote no request id for ${id}`);
|
|
391
|
+
|
|
392
|
+
const parent = ctx.sessionManager.getSessionId();
|
|
393
|
+
const name = delegateName(start.agent.name, id, start.label);
|
|
394
|
+
const argv = [
|
|
395
|
+
...parentCommand(),
|
|
396
|
+
"--session-id",
|
|
397
|
+
id,
|
|
398
|
+
"--name",
|
|
399
|
+
name,
|
|
400
|
+
"--model",
|
|
401
|
+
start.model,
|
|
402
|
+
"--thinking",
|
|
403
|
+
start.thinking,
|
|
404
|
+
...(start.agent.excludeTools.length > 0 ? ["--exclude-tools", start.agent.excludeTools.join(",")] : []),
|
|
405
|
+
"--append-system-prompt",
|
|
406
|
+
`${start.agent.prompt}\n\n${FINAL_LINE}\n\n${parentTrust(parent)}`,
|
|
407
|
+
];
|
|
408
|
+
const env: Record<string, string> = {
|
|
409
|
+
PI_CODING_AGENT_DIR: getAgentDir(),
|
|
410
|
+
[PARENT_ENV]: parent,
|
|
411
|
+
[AUTO_EXIT_ENV]: start.autoExit ? "1" : "0",
|
|
412
|
+
};
|
|
413
|
+
const sessionDir = process.env[SESSION_DIR_ENV];
|
|
414
|
+
if (sessionDir !== undefined && sessionDir !== "") env[SESSION_DIR_ENV] = sessionDir;
|
|
415
|
+
// Listeners may only append to `args` and add to `env`; there is no veto.
|
|
416
|
+
const payload: LaunchPayload = { args: argv, env };
|
|
417
|
+
pi.events.emit(LAUNCH, payload);
|
|
418
|
+
|
|
419
|
+
const windowName = name;
|
|
420
|
+
let windowId: string;
|
|
421
|
+
try {
|
|
422
|
+
windowId = await tmux.openWindow({ name: windowName, cwd: ctx.cwd, argv: payload.args, env: payload.env });
|
|
423
|
+
} catch (err) {
|
|
424
|
+
throw new Error(`could not open window ${windowName} for ${id}: ${(err as Error).message}`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// No task text in the record: the request copy in `sent/` has it.
|
|
428
|
+
results.recordStart(
|
|
429
|
+
{
|
|
430
|
+
id,
|
|
431
|
+
agent: start.agent.name,
|
|
432
|
+
model: start.model,
|
|
433
|
+
thinking: start.thinking,
|
|
434
|
+
name,
|
|
435
|
+
windowId,
|
|
436
|
+
windowName,
|
|
437
|
+
requestId,
|
|
438
|
+
autoExit: start.autoExit,
|
|
439
|
+
},
|
|
440
|
+
ctx,
|
|
441
|
+
);
|
|
442
|
+
startPolling();
|
|
443
|
+
return { id, name, windowId, windowName, requestId };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const started = (launched: Launched, start: Start): string =>
|
|
447
|
+
`delegate: ${start.agent.name} ${launched.id} started in window ${launched.windowName}`;
|
|
448
|
+
|
|
449
|
+
async function command(input: string, ctx: ExtensionContext): Promise<void> {
|
|
450
|
+
const fail = (message: string) => ctx.ui.notify(`delegate: ${message}`, "error");
|
|
451
|
+
const outcome = parse(input);
|
|
452
|
+
if ("error" in outcome) {
|
|
453
|
+
fail(outcome.error);
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
try {
|
|
457
|
+
const start = validate(outcome.parsed, rosterFor(ctx), ctx.modelRegistry);
|
|
458
|
+
const launched = await launch(start, ctx);
|
|
459
|
+
ctx.ui.notify(started(launched, start), "info");
|
|
460
|
+
} catch (err) {
|
|
461
|
+
fail((err as Error).message);
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** The tool's description is fixed at registration, so it is rebuilt at every session start. */
|
|
466
|
+
const delegateTool = () => ({
|
|
467
|
+
name: "delegate",
|
|
468
|
+
label: "Delegate",
|
|
469
|
+
description: toolDescription(currentRoster().agents),
|
|
470
|
+
parameters: Type.Object({
|
|
471
|
+
agent: Type.String({ description: "Roster agent name to run." }),
|
|
472
|
+
task: Type.String({
|
|
473
|
+
description: "The task for the delegate. It sees this and its agent prompt, nothing else of this session.",
|
|
474
|
+
}),
|
|
475
|
+
model: Type.Optional(Type.String({ description: "provider/id model for this call; defaults to the agent's." })),
|
|
476
|
+
thinking: Type.Optional(
|
|
477
|
+
Type.String({ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the agent's.` }),
|
|
478
|
+
),
|
|
479
|
+
label: Type.Optional(
|
|
480
|
+
Type.String({
|
|
481
|
+
description: `A short name for this delegation (letters, digits, _ or -, at most ${LABEL_MAX}), such as roster-research. Its session and tmux window are named <agent>-<label>; without it, <agent>-<first 8 id characters>.`,
|
|
482
|
+
}),
|
|
483
|
+
),
|
|
484
|
+
auto_exit: Type.Optional(
|
|
485
|
+
Type.Boolean({
|
|
486
|
+
description:
|
|
487
|
+
"Close the delegate's window once it finishes; defaults to the agent's setting. False keeps it open to talk to it by mail after its result.",
|
|
488
|
+
}),
|
|
489
|
+
),
|
|
490
|
+
}),
|
|
491
|
+
async execute(
|
|
492
|
+
_toolCallId: string,
|
|
493
|
+
params: { agent: string; task: string; model?: string; thinking?: string; label?: string; auto_exit?: boolean },
|
|
494
|
+
_signal: unknown,
|
|
495
|
+
_onUpdate: unknown,
|
|
496
|
+
ctx: ExtensionContext,
|
|
497
|
+
) {
|
|
498
|
+
if (!tmux.insideTmux()) throw new Error(NOT_IN_TMUX);
|
|
499
|
+
const start = validate(
|
|
500
|
+
{
|
|
501
|
+
agent: params.agent,
|
|
502
|
+
model: params.model,
|
|
503
|
+
thinking: params.thinking,
|
|
504
|
+
label: params.label,
|
|
505
|
+
autoExit: typeof params.auto_exit === "boolean" ? params.auto_exit : undefined,
|
|
506
|
+
task: params.task ?? "",
|
|
507
|
+
},
|
|
508
|
+
rosterFor(ctx),
|
|
509
|
+
ctx.modelRegistry,
|
|
510
|
+
);
|
|
511
|
+
const launched = await launch(start, ctx);
|
|
512
|
+
ctx.ui.notify(started(launched, start), "info");
|
|
513
|
+
return toolResult(
|
|
514
|
+
`Started delegation ${launched.id}: ${start.agent.name} (${start.model}, thinking ${start.thinking}) in window ${launched.windowName}. It runs in the background and its result arrives as a message; delegation_status reports it and delegation_close ends it.`,
|
|
515
|
+
{
|
|
516
|
+
outcome: "started",
|
|
517
|
+
id: launched.id,
|
|
518
|
+
agent: start.agent.name,
|
|
519
|
+
model: start.model,
|
|
520
|
+
thinking: start.thinking,
|
|
521
|
+
autoExit: start.autoExit,
|
|
522
|
+
windowId: launched.windowId,
|
|
523
|
+
windowName: launched.windowName,
|
|
524
|
+
},
|
|
525
|
+
);
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
|
|
529
|
+
/** One delegation's state, from its records and the window tmux reports. */
|
|
530
|
+
async function describe(record: Recorded, ctx: ExtensionContext): Promise<string> {
|
|
531
|
+
let alive: boolean | undefined;
|
|
532
|
+
try {
|
|
533
|
+
alive = await tmux.isAlive(record.windowId);
|
|
534
|
+
} catch {
|
|
535
|
+
alive = undefined; // tmux could not be asked; never read that as gone
|
|
536
|
+
}
|
|
537
|
+
const state =
|
|
538
|
+
record.closed === true
|
|
539
|
+
? "closed"
|
|
540
|
+
: record.result !== undefined
|
|
541
|
+
? "done"
|
|
542
|
+
: record.gone === true
|
|
543
|
+
? "gone"
|
|
544
|
+
: alive === true
|
|
545
|
+
? "running"
|
|
546
|
+
: alive === false
|
|
547
|
+
? "closed"
|
|
548
|
+
: "unknown";
|
|
549
|
+
const session = record.result?.sessionPath ?? findSession(ctx.sessionManager.getSessionDir(), record.id);
|
|
550
|
+
const lines = [
|
|
551
|
+
`${record.id} ${record.agent} (${record.model}, thinking ${record.thinking})`,
|
|
552
|
+
` name: ${record.name ?? delegateName(record.agent, record.id)}`,
|
|
553
|
+
` state: ${stateLine(state, record)}`,
|
|
554
|
+
` window: ${record.windowName} (${record.windowId})`,
|
|
555
|
+
` auto-exit: ${autoExitLine(record)}`,
|
|
556
|
+
` session: ${session ?? `${record.id} (no session file yet)`}`,
|
|
557
|
+
];
|
|
558
|
+
if (record.result !== undefined) lines.push(` envelope: ${record.result.envelopePath}`);
|
|
559
|
+
return lines.join("\n");
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/** Records from before auto-exit existed kept their windows open, so they read off. */
|
|
563
|
+
function autoExitLine(record: Recorded): string {
|
|
564
|
+
return record.autoExit === true
|
|
565
|
+
? "on — the delegate closes its window after a normal completion unless the user took over there"
|
|
566
|
+
: "off — the window stays open after the result";
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
function stateLine(state: string, record: Recorded): string {
|
|
570
|
+
if (state === "running") return "running — the window is open and no result has arrived";
|
|
571
|
+
if (state === "gone")
|
|
572
|
+
return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
|
|
573
|
+
if (state === "done") return `done — result status: ${record.result?.status ?? "unknown"}`;
|
|
574
|
+
if (state === "closed") {
|
|
575
|
+
return record.closed === true ? "closed — delegation_close recorded it" : "closed — the window is gone";
|
|
576
|
+
}
|
|
577
|
+
return "unknown — tmux could not be asked whether the window is open";
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
const statusTool = {
|
|
581
|
+
name: "delegation_status",
|
|
582
|
+
label: "Delegation status",
|
|
583
|
+
description:
|
|
584
|
+
"The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Names the delegation id, agent, session name, model, thinking, window, delegate session path and result envelope path. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
|
|
585
|
+
parameters: Type.Object({
|
|
586
|
+
id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
|
|
587
|
+
}),
|
|
588
|
+
async execute(
|
|
589
|
+
_toolCallId: string,
|
|
590
|
+
params: { id?: string },
|
|
591
|
+
_signal: unknown,
|
|
592
|
+
_onUpdate: unknown,
|
|
593
|
+
ctx: ExtensionContext,
|
|
594
|
+
) {
|
|
595
|
+
const all = results.list();
|
|
596
|
+
const records = params.id === undefined ? all : all.filter((record) => record.id === params.id);
|
|
597
|
+
if (params.id !== undefined && records.length === 0) {
|
|
598
|
+
throw new Error(
|
|
599
|
+
`no delegation ${JSON.stringify(params.id)}; known: ${all.map((record) => record.id).join(", ") || "none"}`,
|
|
600
|
+
);
|
|
601
|
+
}
|
|
602
|
+
const details = records.map((record) => ({
|
|
603
|
+
id: record.id,
|
|
604
|
+
name: delegateName(record.agent, record.id),
|
|
605
|
+
agent: record.agent,
|
|
606
|
+
model: record.model,
|
|
607
|
+
thinking: record.thinking,
|
|
608
|
+
autoExit: record.autoExit === true,
|
|
609
|
+
windowId: record.windowId,
|
|
610
|
+
windowName: record.windowName,
|
|
611
|
+
closed: record.closed === true,
|
|
612
|
+
closedWithoutResult: record.gone === true && record.result === undefined && record.closed !== true,
|
|
613
|
+
status: record.result?.status,
|
|
614
|
+
envelopePath: record.result?.envelopePath,
|
|
615
|
+
sessionPath: record.result?.sessionPath ?? findSession(ctx.sessionManager.getSessionDir(), record.id),
|
|
616
|
+
}));
|
|
617
|
+
if (records.length === 0)
|
|
618
|
+
return toolResult("No delegations are recorded in this session.", { delegations: details });
|
|
619
|
+
const blocks: string[] = [];
|
|
620
|
+
for (const record of records) blocks.push(await describe(record, ctx));
|
|
621
|
+
const heading = `${records.length} delegation${records.length === 1 ? "" : "s"}:`;
|
|
622
|
+
return toolResult(`${heading}\n\n${blocks.join("\n\n")}`, { delegations: details });
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
|
|
626
|
+
const closeTool = {
|
|
627
|
+
name: "delegation_close",
|
|
628
|
+
label: "Close delegation",
|
|
629
|
+
description:
|
|
630
|
+
"Close one delegation: kill its tmux window through tmux and record the close, so delegation_status reports it closed. The delegate's session file and result envelope stay on disk. An unknown or already closed id returns a message with nothing killed.",
|
|
631
|
+
parameters: Type.Object({
|
|
632
|
+
id: Type.String({ description: "The delegation id to close." }),
|
|
633
|
+
}),
|
|
634
|
+
async execute(
|
|
635
|
+
_toolCallId: string,
|
|
636
|
+
params: { id: string },
|
|
637
|
+
_signal: unknown,
|
|
638
|
+
_onUpdate: unknown,
|
|
639
|
+
_ctx: ExtensionContext,
|
|
640
|
+
) {
|
|
641
|
+
const record = results.find(params.id);
|
|
642
|
+
if (record === undefined) {
|
|
643
|
+
const known =
|
|
644
|
+
results
|
|
645
|
+
.list()
|
|
646
|
+
.map((item) => item.id)
|
|
647
|
+
.join(", ") || "none";
|
|
648
|
+
throw new Error(`no delegation ${JSON.stringify(params.id)}; known: ${known}`);
|
|
649
|
+
}
|
|
650
|
+
if (record.closed === true)
|
|
651
|
+
return toolResult(`Delegation ${record.id} is already closed.`, { id: record.id, closed: true });
|
|
652
|
+
let alive: boolean;
|
|
653
|
+
try {
|
|
654
|
+
alive = await tmux.isAlive(record.windowId);
|
|
655
|
+
} catch (err) {
|
|
656
|
+
throw new Error(`could not ask tmux about window ${record.windowName}: ${(err as Error).message}`);
|
|
657
|
+
}
|
|
658
|
+
if (alive) {
|
|
659
|
+
try {
|
|
660
|
+
await tmux.kill(record.windowId);
|
|
661
|
+
} catch (err) {
|
|
662
|
+
throw new Error(`could not kill window ${record.windowName} (${record.windowId}): ${(err as Error).message}`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
results.recordClose(record.id);
|
|
666
|
+
const how = alive
|
|
667
|
+
? `killed window ${record.windowName} (${record.windowId})`
|
|
668
|
+
: `its window ${record.windowName} was already gone`;
|
|
669
|
+
return toolResult(`Closed delegation ${record.id}: ${how}.`, {
|
|
670
|
+
id: record.id,
|
|
671
|
+
windowId: record.windowId,
|
|
672
|
+
killed: alive,
|
|
673
|
+
});
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
pi.on("session_start", (_event, ctx) => {
|
|
678
|
+
registry = ctx.modelRegistry;
|
|
679
|
+
stopPolling();
|
|
680
|
+
live = true;
|
|
681
|
+
results.restore(ctx);
|
|
682
|
+
startPolling();
|
|
683
|
+
// The roster is read at call time; the description can only be fixed here.
|
|
684
|
+
pi.registerTool(delegateTool() as never);
|
|
685
|
+
});
|
|
686
|
+
|
|
687
|
+
pi.on("session_shutdown", () => {
|
|
688
|
+
live = false;
|
|
689
|
+
stopPolling();
|
|
690
|
+
});
|
|
691
|
+
|
|
692
|
+
/** Agent names, flags, model ids and thinking levels, at the cursor's token. */
|
|
693
|
+
function completions(prefix: string): { value: string; label: string }[] | null {
|
|
694
|
+
const partial = /(?:^|\s)(\S*)$/.exec(prefix)?.[1] ?? "";
|
|
695
|
+
const head = prefix.slice(0, prefix.length - partial.length);
|
|
696
|
+
const pick = (values: readonly string[]) => {
|
|
697
|
+
const hits = values.filter((value) => value.startsWith(partial));
|
|
698
|
+
return hits.length > 0 ? hits.map((value) => ({ value: head + value, label: value })) : null;
|
|
699
|
+
};
|
|
700
|
+
const tokens = head.trim() === "" ? [] : head.trim().split(/\s+/);
|
|
701
|
+
if (tokens.length === 0) return pick(currentRoster().agents.map((agent) => agent.name));
|
|
702
|
+
const previous = tokens[tokens.length - 1];
|
|
703
|
+
if (previous === "--model") return pick(modelIds(registry));
|
|
704
|
+
if (previous === "--thinking") return pick(THINKING_LEVELS);
|
|
705
|
+
return pick(FLAGS);
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
pi.registerCommand("delegate", {
|
|
709
|
+
description: `Start a delegate Pi session in a background tmux window: ${USAGE}`,
|
|
710
|
+
getArgumentCompletions: completions,
|
|
711
|
+
handler: command,
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
pi.registerTool(delegateTool() as never);
|
|
715
|
+
pi.registerTool(statusTool as never);
|
|
716
|
+
pi.registerTool(closeTool as never);
|
|
717
|
+
}
|