pi-squire 0.15.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 +323 -1
- package/README.md +918 -117
- package/package.json +2 -1
- package/src/agents.ts +57 -36
- 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 +358 -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-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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-squire",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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,17 @@
|
|
|
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
|
-
// (extra extension paths for the delegate only; `~` expands, relative paths resolve against the agent file's folder, and a
|
|
16
|
-
// missing path leaves the agent out), `auto-exit` (default true),
|
|
14
|
+
// none; a list entry holding a comma makes the file invalid), `auto_exit` (default true),
|
|
17
15
|
// `prompt_mode` (`append`, the default, or `replace`), `enabled` (false leaves
|
|
18
16
|
// the agent out) and `locked` (true, or a list of `model` and `thinking`). Any
|
|
19
17
|
// other key is ignored, left for whatever else reads the file. The body is the
|
|
20
18
|
// delegate's instructions. A file that cannot be read as an agent is disabled:
|
|
21
19
|
// left out, with a warning, and a call naming it is refused with the reason.
|
|
22
20
|
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
23
|
-
import {
|
|
24
|
-
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
21
|
+
import { join } from "node:path";
|
|
25
22
|
|
|
26
23
|
import { parseFrontmatter } from "@earendil-works/pi-coding-agent";
|
|
24
|
+
import { toolMatches } from "./tool-ceiling.ts";
|
|
27
25
|
|
|
28
26
|
/** The levels `pi --thinking` accepts. */
|
|
29
27
|
export const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
|
|
@@ -57,8 +55,12 @@ export type Agent = {
|
|
|
57
55
|
model?: string;
|
|
58
56
|
/** Undefined means the parent's level. */
|
|
59
57
|
thinking?: Thinking;
|
|
60
|
-
/** Whether the delegate closes itself after a normal completion; `
|
|
58
|
+
/** Whether the delegate closes itself after a normal completion; `auto_exit`, default true. */
|
|
61
59
|
autoExit: boolean;
|
|
60
|
+
/** Current grant: true for the roster, or an explicit list of child identities. */
|
|
61
|
+
allowNested?: true | string[];
|
|
62
|
+
/** Presence of a permission map only; not a policy evaluation or privilege comparison. */
|
|
63
|
+
hasPermission?: boolean;
|
|
62
64
|
/**
|
|
63
65
|
* The delegate's complete tool list, from a `tools` line of tool names:
|
|
64
66
|
* undefined when the field is absent or holds only `-name` entries (the
|
|
@@ -67,8 +69,6 @@ export type Agent = {
|
|
|
67
69
|
tools?: string[];
|
|
68
70
|
/** The names or `*` patterns a `tools` line of only `-name` entries takes off the parent's tools; empty otherwise. */
|
|
69
71
|
toolsExcept: string[];
|
|
70
|
-
/** Absolute paths of extra extensions the delegate loads, from `extensions`; empty when absent. */
|
|
71
|
-
extensions: string[];
|
|
72
72
|
/** Models to try, in order, when `model` is usage-limited; from `fallback`, empty when absent. */
|
|
73
73
|
fallback: string[];
|
|
74
74
|
/** The fields a call cannot override, from `locked`. */
|
|
@@ -121,10 +121,16 @@ function parseAgent(name: string, path: string, text: string): Parsed {
|
|
|
121
121
|
if (enabled === null)
|
|
122
122
|
return { invalid: `${path} has enabled ${JSON.stringify(fields.enabled)}, not true or false`, notes: [] };
|
|
123
123
|
if (enabled === false) return { notes: [] };
|
|
124
|
-
const autoExit = flag(fields
|
|
124
|
+
const autoExit = flag(fields.auto_exit);
|
|
125
125
|
if (autoExit === null)
|
|
126
|
-
return { invalid: `${path} has
|
|
127
|
-
const
|
|
126
|
+
return { invalid: `${path} has auto_exit ${JSON.stringify(fields.auto_exit)}, not true or false`, notes: [] };
|
|
127
|
+
const nested = fields.allow_nested_delegation === false ? undefined : fields.allow_nested_delegation;
|
|
128
|
+
if (!validNested(nested))
|
|
129
|
+
return {
|
|
130
|
+
invalid: `${path} has allow_nested_delegation that is not true, false or a list of agent names`,
|
|
131
|
+
notes: [],
|
|
132
|
+
};
|
|
133
|
+
const launch = toolsField(path, fields);
|
|
128
134
|
if ("invalid" in launch) return { ...launch, notes: [] };
|
|
129
135
|
const model = field(fields, "model");
|
|
130
136
|
const fallback = toolList(fields.fallback);
|
|
@@ -135,6 +141,7 @@ function parseAgent(name: string, path: string, text: string): Parsed {
|
|
|
135
141
|
// A field that cannot be used is dropped with a note; it never costs the agent.
|
|
136
142
|
const notes: string[] = [];
|
|
137
143
|
const thinking = thinkingField(path, fields, notes);
|
|
144
|
+
warnDelegationTools(path, nested, launch.tools, notes);
|
|
138
145
|
if (fallback === null)
|
|
139
146
|
notes.push(`${path} has fallback ${JSON.stringify(fields.fallback)}, not a list of models; ignored`);
|
|
140
147
|
if (fields["exclude-tools"] !== undefined)
|
|
@@ -148,6 +155,9 @@ function parseAgent(name: string, path: string, text: string): Parsed {
|
|
|
148
155
|
...(model === undefined ? {} : { model }),
|
|
149
156
|
...(thinking === undefined ? {} : { thinking }),
|
|
150
157
|
autoExit: autoExit ?? true,
|
|
158
|
+
...(nested === undefined ? {} : { allowNested: nested === true ? true : [...new Set(nested as string[])] }),
|
|
159
|
+
hasPermission:
|
|
160
|
+
fields.permission !== null && typeof fields.permission === "object" && !Array.isArray(fields.permission),
|
|
151
161
|
...launch,
|
|
152
162
|
fallback: fallback ?? [],
|
|
153
163
|
locked: lockedFields(path, fields, { model, thinking }, notes),
|
|
@@ -158,6 +168,28 @@ function parseAgent(name: string, path: string, text: string): Parsed {
|
|
|
158
168
|
};
|
|
159
169
|
}
|
|
160
170
|
|
|
171
|
+
function warnDelegationTools(path: string, nested: unknown, tools: string[] | undefined, notes: string[]): void {
|
|
172
|
+
if (nested === true || (Array.isArray(nested) && nested.length > 0)) return;
|
|
173
|
+
if (
|
|
174
|
+
tools?.some((name) =>
|
|
175
|
+
["delegate", "delegation_status", "delegation_close", "delegation_resume"].some((tool) =>
|
|
176
|
+
toolMatches(name, tool),
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
notes.push(
|
|
181
|
+
`${path} lists delegation tools without allow_nested_delegation; listing tools alone does not grant nesting`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validNested(value: unknown): boolean {
|
|
186
|
+
return (
|
|
187
|
+
value === undefined ||
|
|
188
|
+
value === true ||
|
|
189
|
+
(Array.isArray(value) && value.every((entry) => typeof entry === "string" && NAME.test(entry)))
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
|
|
161
193
|
/**
|
|
162
194
|
* Why a `model` or `fallback` entry makes the file invalid: a `*` pattern.
|
|
163
195
|
* Each entry names one model (an exact `provider/id` or an alias Pi resolves
|
|
@@ -277,31 +309,6 @@ function toolsField(
|
|
|
277
309
|
return { toolsExcept: minus.map((name) => name.slice(1).trim()) };
|
|
278
310
|
}
|
|
279
311
|
|
|
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
312
|
/** A list of names (tools or models), comma-separated or a YAML list of names; undefined when absent, null when it is neither. */
|
|
306
313
|
function toolList(value: unknown): string[] | undefined | null {
|
|
307
314
|
if (value === undefined || value === null) return undefined;
|
|
@@ -391,6 +398,20 @@ export function readRoster(agentDir: string, projectDir?: string): Roster {
|
|
|
391
398
|
if (agent !== undefined) agents.push(agent);
|
|
392
399
|
else if (invalid !== undefined) disable(name, path, invalid);
|
|
393
400
|
}
|
|
401
|
+
for (const agent of agents) {
|
|
402
|
+
const targets = agent.allowNested === true ? agents.map((item) => item.name) : (agent.allowNested ?? []);
|
|
403
|
+
for (const name of targets) {
|
|
404
|
+
const child = agents.find((item) => item.name === name);
|
|
405
|
+
if (!child)
|
|
406
|
+
warnings.push(
|
|
407
|
+
`${agent.path} allows unknown or unavailable nested agent ${JSON.stringify(name)}; it cannot launch until present in the current roster`,
|
|
408
|
+
);
|
|
409
|
+
else if (agent.hasPermission && !child.hasPermission)
|
|
410
|
+
warnings.push(
|
|
411
|
+
`${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`,
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
394
415
|
return { agents, disabled, warnings };
|
|
395
416
|
}
|
|
396
417
|
|
|
@@ -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
|
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// Process-local completion composition. Providers own hold readiness; Squire
|
|
2
|
+
// advertises only its active exact-session runtime and never owns provider rows.
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
5
|
+
|
|
6
|
+
const KEY: symbol = Symbol.for("pi.completion-holds.v1");
|
|
7
|
+
type Hold = { provider: string; ready: boolean };
|
|
8
|
+
type Table = {
|
|
9
|
+
version: 1;
|
|
10
|
+
hosts: Map<string, Map<string, string>>;
|
|
11
|
+
holds: Map<string, Map<string, Hold>>;
|
|
12
|
+
};
|
|
13
|
+
const shared: Record<symbol, unknown> = globalThis as unknown as Record<symbol, unknown>;
|
|
14
|
+
|
|
15
|
+
function table(create: boolean): Table | undefined {
|
|
16
|
+
const value: unknown = shared[KEY];
|
|
17
|
+
if (value === undefined) {
|
|
18
|
+
if (!create) return undefined;
|
|
19
|
+
const fresh: Table = { version: 1, hosts: new Map(), holds: new Map() };
|
|
20
|
+
shared[KEY] = fresh;
|
|
21
|
+
return fresh;
|
|
22
|
+
}
|
|
23
|
+
if (
|
|
24
|
+
typeof value !== "object" ||
|
|
25
|
+
value === null ||
|
|
26
|
+
!("version" in value) ||
|
|
27
|
+
value.version !== 1 ||
|
|
28
|
+
!("hosts" in value) ||
|
|
29
|
+
!(value.hosts instanceof Map) ||
|
|
30
|
+
!("holds" in value) ||
|
|
31
|
+
!(value.holds instanceof Map)
|
|
32
|
+
)
|
|
33
|
+
throw new Error("invalid pi.completion-holds.v1 table; completion is blocked");
|
|
34
|
+
return value as Table;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function validHosts(hosts: unknown): hosts is Map<string, string> {
|
|
38
|
+
return (
|
|
39
|
+
hosts instanceof Map &&
|
|
40
|
+
[...hosts].every(
|
|
41
|
+
([name, runtime]) => typeof name === "string" && !!name && typeof runtime === "string" && !!runtime,
|
|
42
|
+
)
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Missing table/no holds preserves normal completion; malformed data refuses. */
|
|
47
|
+
export function externalHoldsReady(sessionId: string): boolean {
|
|
48
|
+
try {
|
|
49
|
+
const current: Table | undefined = table(false);
|
|
50
|
+
if (!current) return true;
|
|
51
|
+
const hosts: unknown = current.hosts.get(sessionId);
|
|
52
|
+
if (hosts !== undefined && !validHosts(hosts)) return false;
|
|
53
|
+
const holds: Map<string, Hold> | undefined = current.holds.get(sessionId);
|
|
54
|
+
if (holds === undefined) return true;
|
|
55
|
+
if (!(holds instanceof Map)) return false;
|
|
56
|
+
for (const [id, hold] of holds) {
|
|
57
|
+
if (
|
|
58
|
+
typeof id !== "string" ||
|
|
59
|
+
!id ||
|
|
60
|
+
typeof hold !== "object" ||
|
|
61
|
+
hold === null ||
|
|
62
|
+
typeof hold.provider !== "string" ||
|
|
63
|
+
!hold.provider ||
|
|
64
|
+
hold.ready !== true
|
|
65
|
+
)
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return true;
|
|
69
|
+
} catch {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function removeHost(sessionId: string, token: string): void {
|
|
75
|
+
// Clean our own token even if another field/version has become malformed.
|
|
76
|
+
const value: unknown = shared[KEY];
|
|
77
|
+
if (typeof value !== "object" || value === null || !("hosts" in value) || !(value.hosts instanceof Map)) return;
|
|
78
|
+
const hosts: unknown = value.hosts.get(sessionId);
|
|
79
|
+
if (!(hosts instanceof Map) || hosts.get("pi-squire") !== token) return;
|
|
80
|
+
hosts.delete("pi-squire");
|
|
81
|
+
if (hosts.size === 0) value.hosts.delete(sessionId);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Call with ownedAPI: a losing duplicate runtime must never advertise support. */
|
|
85
|
+
export function registerCompletionHost(pi: ExtensionAPI): () => boolean {
|
|
86
|
+
const token: string = randomUUID();
|
|
87
|
+
let sessionId: string | undefined;
|
|
88
|
+
pi.on("session_start", (_event, ctx) => {
|
|
89
|
+
if (sessionId !== undefined) removeHost(sessionId, token);
|
|
90
|
+
sessionId = ctx.sessionManager.getSessionId();
|
|
91
|
+
const current: Table | undefined = table(true);
|
|
92
|
+
if (!current) throw new Error("completion hold table is unavailable");
|
|
93
|
+
const saved: Map<string, string> | undefined = current.hosts.get(sessionId);
|
|
94
|
+
if (saved !== undefined && !validHosts(saved)) throw new Error("invalid completion hold hosts");
|
|
95
|
+
const hosts: Map<string, string> = saved ?? new Map();
|
|
96
|
+
hosts.set("pi-squire", token);
|
|
97
|
+
current.hosts.set(sessionId, hosts);
|
|
98
|
+
});
|
|
99
|
+
pi.on("session_shutdown", () => {
|
|
100
|
+
if (sessionId !== undefined) removeHost(sessionId, token);
|
|
101
|
+
sessionId = undefined;
|
|
102
|
+
});
|
|
103
|
+
return (): boolean => sessionId !== undefined && externalHoldsReady(sessionId);
|
|
104
|
+
}
|