pi-squire 0.15.0 → 0.17.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 +343 -1
- package/README.md +931 -118
- package/package.json +2 -1
- package/src/agents.ts +73 -43
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +35 -41
- 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 +342 -56
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- 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-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +174 -108
- package/src/resume-checks.ts +14 -2
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +62 -13
- 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-bundles.ts +84 -0
- package/src/tool-names.ts +29 -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-squire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.17.0",
|
|
4
4
|
"description": "Starts a delegate Pi session in a background tmux window, sends it the task over message:*, and shows the parent its result",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -55,6 +55,7 @@
|
|
|
55
55
|
"@earendil-works/pi-coding-agent": "1.3.0",
|
|
56
56
|
"@earendil-works/pi-tui": "1.3.0",
|
|
57
57
|
"@types/node": "26.6.5",
|
|
58
|
+
"pi-session-mail": "0.6.0",
|
|
58
59
|
"typebox": "1.4.3",
|
|
59
60
|
"typescript": "7.0.2"
|
|
60
61
|
}
|
package/src/agents.ts
CHANGED
|
@@ -11,19 +11,19 @@
|
|
|
11
11
|
// holding a comma, makes the file invalid; absent `model` means the parent's),
|
|
12
12
|
// `thinking` (absent or unknown means the parent's level), `tools` (the
|
|
13
13
|
// delegate's complete tool list; absent means the parent's tools, empty means
|
|
14
|
-
// none; a list entry holding a comma makes the file invalid
|
|
15
|
-
//
|
|
16
|
-
// missing path leaves the agent out), `auto-exit` (default true),
|
|
14
|
+
// none; a list entry holding a comma makes the file invalid; a `$name` entry
|
|
15
|
+
// stands for that `toolBundles` bundle's tools), `auto_exit` (default true),
|
|
17
16
|
// `prompt_mode` (`append`, the default, or `replace`), `enabled` (false leaves
|
|
18
17
|
// the agent out) and `locked` (true, or a list of `model` and `thinking`). Any
|
|
19
18
|
// other key is ignored, left for whatever else reads the file. The body is the
|
|
20
19
|
// delegate's instructions. A file that cannot be read as an agent is disabled:
|
|
21
20
|
// left out, with a warning, and a call naming it is refused with the reason.
|
|
22
21
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
23
|
-
import {
|
|
24
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
22
|
+
import { join } from "node:path";
|
|
25
23
|
|
|
26
24
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
25
|
+
import { type Bundles, expandBundles, toolBundles } from "./tool-bundles.ts";
|
|
26
|
+
import { toolMatches } from "./tool-names.ts";
|
|
27
27
|
|
|
28
28
|
/** The levels `pi --thinking` accepts. */
|
|
29
29
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
@@ -57,8 +57,12 @@ export type Agent = {
|
|
|
57
57
|
model?: string;
|
|
58
58
|
/** Undefined means the parent's level. */
|
|
59
59
|
thinking?: Thinking;
|
|
60
|
-
/** Whether the delegate closes itself after a normal completion; `
|
|
60
|
+
/** Whether the delegate closes itself after a normal completion; `auto_exit`, default true. */
|
|
61
61
|
autoExit: boolean;
|
|
62
|
+
/** Current grant: true for the roster, or an explicit list of child identities. */
|
|
63
|
+
allowNested?: true | string[];
|
|
64
|
+
/** Presence of a permission map only; not a policy evaluation or privilege comparison. */
|
|
65
|
+
hasPermission?: boolean;
|
|
62
66
|
/**
|
|
63
67
|
* The delegate's complete tool list, from a `tools` line of tool names:
|
|
64
68
|
* undefined when the field is absent or holds only `-name` entries (the
|
|
@@ -67,8 +71,6 @@ export type Agent = {
|
|
|
67
71
|
tools?: string[];
|
|
68
72
|
/** The names or `*` patterns a `tools` line of only `-name` entries takes off the parent's tools; empty otherwise. */
|
|
69
73
|
toolsExcept: string[];
|
|
70
|
-
/** Absolute paths of extra extensions the delegate loads, from `extensions`; empty when absent. */
|
|
71
|
-
extensions: string[];
|
|
72
74
|
/** Models to try, in order, when `model` is usage-limited; from `fallback`, empty when absent. */
|
|
73
75
|
fallback: string[];
|
|
74
76
|
/** The fields a call cannot override, from `locked`. */
|
|
@@ -106,7 +108,7 @@ const field = (frontmatter: Record<string, unknown>, key: string): string | unde
|
|
|
106
108
|
type Parsed = { agent?: Agent; invalid?: string; notes: string[] };
|
|
107
109
|
|
|
108
110
|
/** Parse one agent file; no agent, with why it is invalid when it is, when it cannot be an agent or is disabled. */
|
|
109
|
-
function parseAgent(name: string, path: string, text: string): Parsed {
|
|
111
|
+
function parseAgent(name: string, path: string, text: string, bundles: Bundles): Parsed {
|
|
110
112
|
let frontmatter: unknown;
|
|
111
113
|
let body: string;
|
|
112
114
|
try {
|
|
@@ -121,10 +123,16 @@ function parseAgent(name: string, path: string, text: string): Parsed {
|
|
|
121
123
|
if (enabled === null)
|
|
122
124
|
return { invalid: `${path} has enabled ${JSON.stringify(fields.enabled)}, not true or false`, notes: [] };
|
|
123
125
|
if (enabled === false) return { notes: [] };
|
|
124
|
-
const autoExit = flag(fields
|
|
126
|
+
const autoExit = flag(fields.auto_exit);
|
|
125
127
|
if (autoExit === null)
|
|
126
|
-
return { invalid: `${path} has
|
|
127
|
-
const
|
|
128
|
+
return { invalid: `${path} has auto_exit ${JSON.stringify(fields.auto_exit)}, not true or false`, notes: [] };
|
|
129
|
+
const nested = fields.allow_nested_delegation === false ? undefined : fields.allow_nested_delegation;
|
|
130
|
+
if (!validNested(nested))
|
|
131
|
+
return {
|
|
132
|
+
invalid: `${path} has allow_nested_delegation that is not true, false or a list of agent names`,
|
|
133
|
+
notes: [],
|
|
134
|
+
};
|
|
135
|
+
const launch = toolsField(path, fields, bundles);
|
|
128
136
|
if ("invalid" in launch) return { ...launch, notes: [] };
|
|
129
137
|
const model = field(fields, "model");
|
|
130
138
|
const fallback = toolList(fields.fallback);
|
|
@@ -135,6 +143,7 @@ function parseAgent(name: string, path: string, text: string): Parsed {
|
|
|
135
143
|
// A field that cannot be used is dropped with a note; it never costs the agent.
|
|
136
144
|
const notes: string[] = [];
|
|
137
145
|
const thinking = thinkingField(path, fields, notes);
|
|
146
|
+
warnDelegationTools(path, nested, launch.tools, notes);
|
|
138
147
|
if (fallback === null)
|
|
139
148
|
notes.push(`${path} has fallback ${JSON.stringify(fields.fallback)}, not a list of models; ignored`);
|
|
140
149
|
if (fields["exclude-tools"] !== undefined)
|
|
@@ -148,6 +157,9 @@ function parseAgent(name: string, path: string, text: string): Parsed {
|
|
|
148
157
|
...(model === undefined ? {} : { model }),
|
|
149
158
|
...(thinking === undefined ? {} : { thinking }),
|
|
150
159
|
autoExit: autoExit ?? true,
|
|
160
|
+
...(nested === undefined ? {} : { allowNested: nested === true ? true : [...new Set(nested as string[])] }),
|
|
161
|
+
hasPermission:
|
|
162
|
+
fields.permission !== null && typeof fields.permission === "object" && !Array.isArray(fields.permission),
|
|
151
163
|
...launch,
|
|
152
164
|
fallback: fallback ?? [],
|
|
153
165
|
locked: lockedFields(path, fields, { model, thinking }, notes),
|
|
@@ -158,6 +170,28 @@ function parseAgent(name: string, path: string, text: string): Parsed {
|
|
|
158
170
|
};
|
|
159
171
|
}
|
|
160
172
|
|
|
173
|
+
function warnDelegationTools(path: string, nested: unknown, tools: string[] | undefined, notes: string[]): void {
|
|
174
|
+
if (nested === true || (Array.isArray(nested) && nested.length > 0)) return;
|
|
175
|
+
if (
|
|
176
|
+
tools?.some((name) =>
|
|
177
|
+
["delegate", "delegation_status", "delegation_close", "delegation_resume"].some((tool) =>
|
|
178
|
+
toolMatches(name, tool),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
)
|
|
182
|
+
notes.push(
|
|
183
|
+
`${path} lists delegation tools without allow_nested_delegation; listing tools alone does not grant nesting`,
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function validNested(value: unknown): boolean {
|
|
188
|
+
return (
|
|
189
|
+
value === undefined ||
|
|
190
|
+
value === true ||
|
|
191
|
+
(Array.isArray(value) && value.every((entry) => typeof entry === "string" && NAME.test(entry)))
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
161
195
|
/**
|
|
162
196
|
* Why a `model` or `fallback` entry makes the file invalid: a `*` pattern.
|
|
163
197
|
* Each entry names one model (an exact `provider/id` or an alias Pi resolves
|
|
@@ -240,14 +274,15 @@ function lockedFields(
|
|
|
240
274
|
}
|
|
241
275
|
|
|
242
276
|
/**
|
|
243
|
-
* The `tools` field: tool names (`*` patterns allowed)
|
|
244
|
-
* only `-name` entries take those off the parent's tools.
|
|
245
|
-
* names and `-name` entries mixed,
|
|
246
|
-
* the file invalid.
|
|
277
|
+
* The `tools` field: tool names (`*` patterns allowed) and `$bundle` entries
|
|
278
|
+
* are the whole list; only `-name` entries take those off the parent's tools.
|
|
279
|
+
* A `+name` entry, names and `-name` entries mixed, an unknown bundle, or a
|
|
280
|
+
* YAML list entry holding a comma make the file invalid.
|
|
247
281
|
*/
|
|
248
282
|
function toolsField(
|
|
249
283
|
path: string,
|
|
250
284
|
frontmatter: Record<string, unknown>,
|
|
285
|
+
bundles: Bundles,
|
|
251
286
|
): { tools?: string[]; toolsExcept: string[] } | { invalid: string } {
|
|
252
287
|
// `tools:` with no value is YAML null: present and empty, unlike an absent field.
|
|
253
288
|
const tools = Object.hasOwn(frontmatter, "tools") && frontmatter.tools === null ? [] : toolList(frontmatter.tools);
|
|
@@ -266,8 +301,13 @@ function toolsField(
|
|
|
266
301
|
return {
|
|
267
302
|
invalid: `${path} has tools entry ${JSON.stringify(plus)}: +name entries are not accepted; list every tool by name, or only -name entries to take tools off the parent's`,
|
|
268
303
|
};
|
|
304
|
+
const minusBundle = tools.find((name) => name.startsWith("-$"));
|
|
305
|
+
if (minusBundle !== undefined)
|
|
306
|
+
return {
|
|
307
|
+
invalid: `${path} has tools entry ${JSON.stringify(minusBundle)}: a bundle cannot be taken off; take its tools off by name`,
|
|
308
|
+
};
|
|
269
309
|
const minus = tools.filter((name) => name.startsWith("-"));
|
|
270
|
-
if (minus.length === 0) return
|
|
310
|
+
if (minus.length === 0) return expandBundles(path, tools, bundles);
|
|
271
311
|
if (minus.length < tools.length)
|
|
272
312
|
return {
|
|
273
313
|
invalid: `${path} has tools that mix tool names with -name entries; list every tool by name, or only -name entries to take tools off the parent's`,
|
|
@@ -277,31 +317,6 @@ function toolsField(
|
|
|
277
317
|
return { toolsExcept: minus.map((name) => name.slice(1).trim()) };
|
|
278
318
|
}
|
|
279
319
|
|
|
280
|
-
/** The `tools` and `extensions` fields, or why the agent is invalid. */
|
|
281
|
-
function launchFields(
|
|
282
|
-
path: string,
|
|
283
|
-
frontmatter: Record<string, unknown>,
|
|
284
|
-
): { tools?: string[]; toolsExcept: string[]; extensions: string[] } | { invalid: string } {
|
|
285
|
-
const tools = toolsField(path, frontmatter);
|
|
286
|
-
if ("invalid" in tools) return tools;
|
|
287
|
-
const paths = toolList(frontmatter.extensions);
|
|
288
|
-
if (paths === null)
|
|
289
|
-
return {
|
|
290
|
-
invalid: `${path} has extensions ${JSON.stringify(frontmatter.extensions)}, not a comma-separated list of paths`,
|
|
291
|
-
};
|
|
292
|
-
const extensions = (paths ?? []).map((entry) => resolveExtension(entry, dirname(path)));
|
|
293
|
-
const absent = extensions.find((entry) => !existsSync(entry));
|
|
294
|
-
if (absent !== undefined) return { invalid: `${path} has extension ${absent}, which does not exist` };
|
|
295
|
-
return { ...tools, extensions };
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/** An extension path as an absolute one: `~` is the home directory, a relative path is against the agent file's folder. */
|
|
299
|
-
function resolveExtension(entry: string, dir: string): string {
|
|
300
|
-
if (entry === "~") return homedir();
|
|
301
|
-
if (entry.startsWith("~/")) return join(homedir(), entry.slice(2));
|
|
302
|
-
return isAbsolute(entry) ? entry : resolve(dir, entry);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
320
|
/** A list of names (tools or models), comma-separated or a YAML list of names; undefined when absent, null when it is neither. */
|
|
306
321
|
function toolList(value: unknown): string[] | undefined | null {
|
|
307
322
|
if (value === undefined || value === null) return undefined;
|
|
@@ -364,6 +379,7 @@ export function readRoster(agentDir: string, projectDir?: string): Roster {
|
|
|
364
379
|
const agents: Agent[] = [];
|
|
365
380
|
const disabled: Disabled[] = [];
|
|
366
381
|
const warnings: string[] = [];
|
|
382
|
+
const bundles = toolBundles(agentDir);
|
|
367
383
|
const retired = join(agentDir, "squire", "agents");
|
|
368
384
|
if (existsSync(retired))
|
|
369
385
|
warnings.push(
|
|
@@ -386,11 +402,25 @@ export function readRoster(agentDir: string, projectDir?: string): Roster {
|
|
|
386
402
|
disable(name, path, `${path} could not be read (${(err as Error).message})`);
|
|
387
403
|
continue;
|
|
388
404
|
}
|
|
389
|
-
const { agent, invalid, notes } = parseAgent(name, path, text);
|
|
405
|
+
const { agent, invalid, notes } = parseAgent(name, path, text, bundles);
|
|
390
406
|
warnings.push(...notes);
|
|
391
407
|
if (agent !== undefined) agents.push(agent);
|
|
392
408
|
else if (invalid !== undefined) disable(name, path, invalid);
|
|
393
409
|
}
|
|
410
|
+
for (const agent of agents) {
|
|
411
|
+
const targets = agent.allowNested === true ? agents.map((item) => item.name) : (agent.allowNested ?? []);
|
|
412
|
+
for (const name of targets) {
|
|
413
|
+
const child = agents.find((item) => item.name === name);
|
|
414
|
+
if (!child)
|
|
415
|
+
warnings.push(
|
|
416
|
+
`${agent.path} allows unknown or unavailable nested agent ${JSON.stringify(name)}; it cannot launch until present in the current roster`,
|
|
417
|
+
);
|
|
418
|
+
else if (agent.hasPermission && !child.hasPermission)
|
|
419
|
+
warnings.push(
|
|
420
|
+
`${agent.path} grants nested agent ${JSON.stringify(name)} (${child.path}) without a permission block; this is a delegation grant, not proof of equal or lesser permission authority`,
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
}
|
|
394
424
|
return { agents, disabled, warnings };
|
|
395
425
|
}
|
|
396
426
|
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
// The direct parent's durable task facts and processing inbox. Not a tree
|
|
2
|
+
// index: the shared registry can publish these facts without owning answers.
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import {
|
|
5
|
+
type ContextEvent,
|
|
6
|
+
type ContextEventResult,
|
|
7
|
+
type ExtensionAPI,
|
|
8
|
+
type ExtensionContext,
|
|
9
|
+
getAgentDir,
|
|
10
|
+
} from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { readState, writeState } from "./local-state.ts";
|
|
12
|
+
import type { Recorded } from "./result-types.ts";
|
|
13
|
+
import type { MessageDetails } from "./results.ts";
|
|
14
|
+
|
|
15
|
+
export type ChildOutcome = {
|
|
16
|
+
key: string;
|
|
17
|
+
content: string;
|
|
18
|
+
details: MessageDetails;
|
|
19
|
+
/** Root display was queued; processing still waits for a model request/settle. */
|
|
20
|
+
delivered?: boolean;
|
|
21
|
+
};
|
|
22
|
+
type State = { records: Recorded[]; outcomes: ChildOutcome[]; paused?: boolean };
|
|
23
|
+
export function readChildRecords(session: string): Recorded[] {
|
|
24
|
+
const path = join(getAgentDir(), "squire", "child-tasks", `${encodeURIComponent(session)}.json`);
|
|
25
|
+
const state = readState<State>(path);
|
|
26
|
+
if (state !== undefined && (!state || !Array.isArray(state.records) || !Array.isArray(state.outcomes)))
|
|
27
|
+
throw new Error(`invalid child task record for ${session}`);
|
|
28
|
+
return state?.records ?? [];
|
|
29
|
+
}
|
|
30
|
+
export class ChildTasks {
|
|
31
|
+
path: string | undefined;
|
|
32
|
+
outcomes: ChildOutcome[] = [];
|
|
33
|
+
observed = new Set<string>();
|
|
34
|
+
failed = false;
|
|
35
|
+
unreadable = false;
|
|
36
|
+
restoreFailure: string | undefined;
|
|
37
|
+
paused = false;
|
|
38
|
+
|
|
39
|
+
blockRestore(error: unknown): void {
|
|
40
|
+
this.restoreFailure ??= String(error);
|
|
41
|
+
this.failed = true;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
restore(session: string): Recorded[] {
|
|
45
|
+
this.path = join(getAgentDir(), "squire", "child-tasks", `${encodeURIComponent(session)}.json`);
|
|
46
|
+
this.outcomes = [];
|
|
47
|
+
this.paused = false;
|
|
48
|
+
this.observed.clear();
|
|
49
|
+
this.failed = false;
|
|
50
|
+
this.unreadable = false;
|
|
51
|
+
this.restoreFailure = undefined;
|
|
52
|
+
try {
|
|
53
|
+
const state = readState<State>(this.path);
|
|
54
|
+
if (state !== undefined && (!state || !Array.isArray(state.records) || !Array.isArray(state.outcomes)))
|
|
55
|
+
throw new Error("invalid child task record: expected records and outcomes arrays");
|
|
56
|
+
if (state?.paused !== undefined && typeof state.paused !== "boolean") throw new Error("invalid root stop state");
|
|
57
|
+
this.paused = state?.paused === true;
|
|
58
|
+
this.outcomes = state?.outcomes ?? [];
|
|
59
|
+
return state?.records ?? [];
|
|
60
|
+
} catch (error) {
|
|
61
|
+
this.failed = true;
|
|
62
|
+
this.unreadable = true;
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
save(records: Recorded[]): void {
|
|
68
|
+
try {
|
|
69
|
+
if (this.restoreFailure)
|
|
70
|
+
throw new Error(`child task restoration failed: ${this.restoreFailure}; repair storage and reopen`);
|
|
71
|
+
if (this.unreadable) throw new Error("child tasks were unreadable; repair storage and reload before continuing");
|
|
72
|
+
if (!this.path) throw new Error("child task state has not been restored");
|
|
73
|
+
writeState(this.path, { records, outcomes: this.outcomes, paused: this.paused } satisfies State);
|
|
74
|
+
this.failed = false;
|
|
75
|
+
} catch (error) {
|
|
76
|
+
this.failed = true;
|
|
77
|
+
throw error;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
storageProblem(): string | undefined {
|
|
82
|
+
if (this.unreadable) return "child tasks were unreadable; repair storage and reload before launching";
|
|
83
|
+
if (this.restoreFailure)
|
|
84
|
+
return `child task restoration failed: ${this.restoreFailure}; repair storage and reopen before launching`;
|
|
85
|
+
if (this.failed) return "child task storage failed; repair storage and reload before launching";
|
|
86
|
+
if (!this.path) return "child task storage has not been restored";
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
delivered(key: string): void {
|
|
91
|
+
for (const outcome of this.outcomes) if (outcome.key === key) outcome.delivered = true;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
wakeAllowed(coordinator: boolean): boolean {
|
|
95
|
+
return coordinator || !this.paused;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
replay(pi: ExtensionAPI, coordinator: boolean): void {
|
|
99
|
+
if (!this.wakeAllowed(coordinator)) return;
|
|
100
|
+
let wake = false;
|
|
101
|
+
for (const outcome of this.outcomes) {
|
|
102
|
+
if (!coordinator && outcome.delivered) {
|
|
103
|
+
wake = true;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
pi.sendMessage(
|
|
107
|
+
{ customType: "delegate", content: outcome.content, details: outcome.details, display: true },
|
|
108
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
109
|
+
);
|
|
110
|
+
this.delivered(outcome.key);
|
|
111
|
+
}
|
|
112
|
+
if (wake)
|
|
113
|
+
pi.sendMessage(
|
|
114
|
+
{
|
|
115
|
+
customType: "delegate-recovery",
|
|
116
|
+
display: false,
|
|
117
|
+
content: "[delegate] Saved delegate outcomes still need processing. Continue with them before settling.",
|
|
118
|
+
},
|
|
119
|
+
{ triggerTurn: true, deliverAs: "steer" },
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
observe(): void {
|
|
124
|
+
this.observed = new Set(this.outcomes.map(({ key }) => key));
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
settled(records: Recorded[], stopped: boolean): void {
|
|
128
|
+
if (this.restoreFailure || this.unreadable) throw new Error(this.storageProblem());
|
|
129
|
+
if (stopped) return;
|
|
130
|
+
this.outcomes = this.outcomes.filter(({ key }) => !this.observed.has(key));
|
|
131
|
+
this.observed.clear();
|
|
132
|
+
this.save(records);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
needsProcessing(): boolean {
|
|
136
|
+
return this.outcomes.some(({ key }) => !this.observed.has(key));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Each exact model request receives all unprocessed direct-child outcomes. */
|
|
140
|
+
context(event: ContextEvent): ContextEventResult | undefined {
|
|
141
|
+
this.paused = false;
|
|
142
|
+
this.observe();
|
|
143
|
+
const missing = this.outcomes.filter(
|
|
144
|
+
({ details }) =>
|
|
145
|
+
!event.messages.some(
|
|
146
|
+
(message) =>
|
|
147
|
+
message.role === "custom" &&
|
|
148
|
+
message.customType === "delegate" &&
|
|
149
|
+
(message.details as MessageDetails | undefined)?.id === details.id &&
|
|
150
|
+
(message.details as MessageDetails | undefined)?.kind === details.kind,
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
if (!missing.length) return;
|
|
154
|
+
return {
|
|
155
|
+
messages: [
|
|
156
|
+
...event.messages,
|
|
157
|
+
...missing.map(({ content, details }) => ({
|
|
158
|
+
role: "custom" as const,
|
|
159
|
+
customType: "delegate",
|
|
160
|
+
content,
|
|
161
|
+
details,
|
|
162
|
+
display: true,
|
|
163
|
+
timestamp: Date.now(),
|
|
164
|
+
})),
|
|
165
|
+
],
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
register(
|
|
170
|
+
pi: ExtensionAPI,
|
|
171
|
+
records: () => Recorded[],
|
|
172
|
+
context: () => ExtensionContext | undefined,
|
|
173
|
+
coordinator: () => boolean = () => false,
|
|
174
|
+
): void {
|
|
175
|
+
let stopped = false;
|
|
176
|
+
pi.on("context", (event) => this.context(event));
|
|
177
|
+
pi.on("agent_end", (event, ctx) => {
|
|
178
|
+
const last = event.messages.findLast((message) => message.role === "assistant");
|
|
179
|
+
stopped = last?.stopReason === "aborted" || ctx.signal?.aborted === true;
|
|
180
|
+
if (stopped && !coordinator()) {
|
|
181
|
+
this.paused = true;
|
|
182
|
+
try {
|
|
183
|
+
this.save(records());
|
|
184
|
+
} catch (error) {
|
|
185
|
+
ctx.ui.notify(
|
|
186
|
+
`delegate: could not persist root user stop: ${String(error)}; completion is blocked.`,
|
|
187
|
+
"error",
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
pi.on("agent_before_settle", () => {
|
|
193
|
+
if (stopped || this.failed || !this.wakeAllowed(coordinator()) || !this.needsProcessing()) return;
|
|
194
|
+
// A bare continue can be rejected when the transcript ends with an
|
|
195
|
+
// assistant. Supply runnable context; context() supplies the outcomes.
|
|
196
|
+
return {
|
|
197
|
+
continue: true,
|
|
198
|
+
entries: [
|
|
199
|
+
{
|
|
200
|
+
type: "custom_message",
|
|
201
|
+
customType: "delegate",
|
|
202
|
+
content: coordinator()
|
|
203
|
+
? "[delegate] Child outcomes arrived after the last model request. Process them before your final task report."
|
|
204
|
+
: "[delegate] Delegate outcomes arrived after the last model request. Process them before finishing this response.",
|
|
205
|
+
display: true,
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
};
|
|
209
|
+
});
|
|
210
|
+
pi.on("agent_settled", () => {
|
|
211
|
+
try {
|
|
212
|
+
this.settled(records(), stopped);
|
|
213
|
+
} catch (error) {
|
|
214
|
+
context()?.ui.notify(
|
|
215
|
+
`delegate: could not persist child-result processing: ${String(error)}; completion is blocked.`,
|
|
216
|
+
"error",
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
package/src/child.ts
CHANGED
|
@@ -16,8 +16,7 @@
|
|
|
16
16
|
// it as a user prompt, so the task starts through the same path as a typed
|
|
17
17
|
// prompt, labelled `[delegate]`. Pi 1.2.0 already prepares the system prompt
|
|
18
18
|
// for custom-message runs (earendil-works/pi#5581); this takeover remains the
|
|
19
|
-
// package's pinned startup delivery path.
|
|
20
|
-
// automatic answer for a request a listener took over.
|
|
19
|
+
// package's pinned startup delivery path. task-replies.ts owns the answer.
|
|
21
20
|
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
22
21
|
import {
|
|
23
22
|
type ExtensionAPI,
|
|
@@ -26,9 +25,9 @@ import {
|
|
|
26
25
|
type ModelRuntime,
|
|
27
26
|
resolveCliModel,
|
|
28
27
|
} from "@earendil-works/pi-coding-agent";
|
|
29
|
-
|
|
28
|
+
import { type Identity, writeIdentity } from "./identity.ts";
|
|
30
29
|
import { activeMark, classify, type Response, recordLimit } from "./limits.ts";
|
|
31
|
-
import {
|
|
30
|
+
import type { TaskReplies } from "./task-replies.ts";
|
|
32
31
|
|
|
33
32
|
/** The launch variable that carries the parent's resolved setting. */
|
|
34
33
|
export const AUTO_EXIT_ENV = "PI_DELEGATE_AUTO_EXIT";
|
|
@@ -43,8 +42,6 @@ const ON_NOTICE = "delegate: auto-exit is on; this delegate closes after its nex
|
|
|
43
42
|
|
|
44
43
|
type Message = { role?: string; stopReason?: unknown; errorMessage?: unknown; provider?: unknown; model?: unknown };
|
|
45
44
|
|
|
46
|
-
type Inbound = { envelope?: { from?: unknown; kind?: unknown; body?: unknown }; handled?: boolean };
|
|
47
|
-
|
|
48
45
|
/** A run that ended on a usage-limit error leaves a mark for every Pi session. */
|
|
49
46
|
function recordUsageLimit(last: Message, ctx: ExtensionContext, response?: Response): void {
|
|
50
47
|
const { provider, model, errorMessage } = last;
|
|
@@ -107,24 +104,6 @@ async function switchModel(
|
|
|
107
104
|
return undefined;
|
|
108
105
|
}
|
|
109
106
|
|
|
110
|
-
/** Take over the parent's task request and send it as a user prompt. */
|
|
111
|
-
function registerTakeover(pi: ExtensionAPI, parent: string): void {
|
|
112
|
-
let current: ExtensionContext | undefined;
|
|
113
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
114
|
-
current = ctx;
|
|
115
|
-
});
|
|
116
|
-
pi.events.on("message:inbound", (data) => {
|
|
117
|
-
const payload = data as Inbound;
|
|
118
|
-
const envelope = payload?.envelope;
|
|
119
|
-
if (payload.handled === true || envelope?.kind !== "request" || envelope.from !== parent) return;
|
|
120
|
-
payload.handled = true;
|
|
121
|
-
const text = `[delegate] Your task, from the session that started you (${parent}). Your final message this turn goes back to it automatically.\n\n${String(envelope.body ?? "")}`;
|
|
122
|
-
Promise.resolve(pi.sendUserMessage(text, { deliverAs: "steer" })).catch((err) =>
|
|
123
|
-
current?.ui.notify(`delegate: could not start the task: ${(err as Error).message}`, "error"),
|
|
124
|
-
);
|
|
125
|
-
});
|
|
126
|
-
}
|
|
127
|
-
|
|
128
107
|
/**
|
|
129
108
|
* A usage limit or an unavailable endpoint moves the run to the next fallback
|
|
130
109
|
* that is usable and not marked, and continues it in this session; each
|
|
@@ -173,34 +152,49 @@ function fallbackReason(last: Message | undefined): Failure | undefined {
|
|
|
173
152
|
/** This delegate's footer icon, picked once per load. */
|
|
174
153
|
const ICON = Math.random() < 0.5 ? "👦" : "👧";
|
|
175
154
|
|
|
176
|
-
function showStatus(ctx: ExtensionContext, armed: boolean): void {
|
|
155
|
+
function showStatus(ctx: ExtensionContext, armed: boolean, agent?: string): void {
|
|
177
156
|
const { theme } = ctx.ui;
|
|
178
|
-
const agent = process.env[AGENT_ENV];
|
|
179
157
|
const label = agent === undefined || agent === "" ? "delegate" : `${theme.fg("accent", agent)} delegate`;
|
|
180
158
|
ctx.ui.setStatus(
|
|
181
|
-
|
|
159
|
+
"delegate-self",
|
|
182
160
|
armed
|
|
183
161
|
? `${ICON} ${label} · auto-exit ${theme.fg("success", "on")}`
|
|
184
162
|
: `${ICON} ${label} · auto-exit ${theme.fg("dim", "off")}`,
|
|
185
163
|
);
|
|
186
164
|
}
|
|
187
165
|
|
|
188
|
-
|
|
189
|
-
|
|
166
|
+
function shutdownWhenActive(ctx: ExtensionContext, active: () => boolean): void {
|
|
167
|
+
setImmediate(() => {
|
|
168
|
+
if (active()) ctx.shutdown();
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function registerChild(
|
|
173
|
+
pi: ExtensionAPI,
|
|
174
|
+
replies: TaskReplies,
|
|
175
|
+
identity: () => Identity | undefined,
|
|
176
|
+
active: () => boolean = () => true,
|
|
177
|
+
): void {
|
|
190
178
|
const setFailure = registerFallback(pi);
|
|
191
179
|
|
|
192
|
-
let armed =
|
|
180
|
+
let armed = false;
|
|
193
181
|
/** Whether the run that just ended finished without the user stopping it. */
|
|
194
182
|
let completed = false;
|
|
195
183
|
|
|
196
184
|
// Reload and session replacement clear extension statuses, then start again.
|
|
197
|
-
pi.on("session_start", async (_event, ctx) =>
|
|
185
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
186
|
+
const own = identity();
|
|
187
|
+
armed = own?.autoExit === true;
|
|
188
|
+
if (own) showStatus(ctx, armed, own.agent);
|
|
189
|
+
});
|
|
198
190
|
|
|
199
191
|
function disarm(ctx: ExtensionContext): void {
|
|
200
192
|
if (!armed) return;
|
|
201
193
|
armed = false;
|
|
194
|
+
const own = identity();
|
|
195
|
+
if (own) writeIdentity(ctx.sessionManager.getSessionId(), { ...own, autoExit: false });
|
|
202
196
|
ctx.ui.notify(OFF_NOTICE, "info");
|
|
203
|
-
showStatus(ctx, armed);
|
|
197
|
+
showStatus(ctx, armed, identity()?.agent);
|
|
204
198
|
}
|
|
205
199
|
|
|
206
200
|
// Typed text and RPC input are the user's; a prompt an extension sends is not.
|
|
@@ -221,11 +215,8 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
221
215
|
// call Pi 0.99.1 ends with an `error` message, and only the signal tells a
|
|
222
216
|
// stop from a real error. Either way it is the user taking over.
|
|
223
217
|
pi.on("agent_end", async (event, ctx) => {
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
const message = event.messages[i] as Message;
|
|
227
|
-
if (message?.role === "assistant") last = message;
|
|
228
|
-
}
|
|
218
|
+
const last = event.messages.findLast((message) => message.role === "assistant") as Message | undefined;
|
|
219
|
+
if (!identity()) return;
|
|
229
220
|
const stopped = last?.stopReason === "aborted" || ctx.signal?.aborted === true;
|
|
230
221
|
if (stopped) disarm(ctx);
|
|
231
222
|
if (!stopped && last?.stopReason === "error") recordUsageLimit(last, ctx, response);
|
|
@@ -235,19 +226,22 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
235
226
|
});
|
|
236
227
|
|
|
237
228
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
238
|
-
const exit = armed && completed;
|
|
229
|
+
const exit = armed && completed && replies.handled();
|
|
239
230
|
completed = false;
|
|
240
231
|
if (!exit) return;
|
|
241
|
-
//
|
|
242
|
-
|
|
232
|
+
// Task settlement and its synchronous reply write have succeeded.
|
|
233
|
+
shutdownWhenActive(ctx, active);
|
|
243
234
|
});
|
|
244
235
|
|
|
245
236
|
pi.registerCommand("auto-exit", {
|
|
246
237
|
description: "Close this delegate after its next normal completion",
|
|
247
238
|
handler: async (_args, ctx) => {
|
|
239
|
+
if (!identity()) return ctx.ui.notify("auto-exit is available inside a delegate session", "error");
|
|
248
240
|
armed = true;
|
|
241
|
+
const own = identity();
|
|
242
|
+
if (own) writeIdentity(ctx.sessionManager.getSessionId(), { ...own, autoExit: true });
|
|
249
243
|
ctx.ui.notify(ON_NOTICE, "info");
|
|
250
|
-
showStatus(ctx, armed);
|
|
244
|
+
showStatus(ctx, armed, identity()?.agent);
|
|
251
245
|
},
|
|
252
246
|
});
|
|
253
247
|
}
|