pi-squire 0.14.0 → 0.16.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +438 -1
- package/README.md +1062 -152
- package/package.json +4 -3
- package/src/agents.ts +395 -124
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +107 -62
- package/src/completion-holds.ts +104 -0
- package/src/dashboard.ts +144 -0
- package/src/git.ts +6 -1
- package/src/identity.ts +152 -0
- package/src/index.ts +585 -107
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- package/src/new-delegate.ts +17 -8
- package/src/poll.ts +1 -0
- package/src/render.ts +4 -1
- package/src/result-close.ts +37 -0
- package/src/result-restore.ts +95 -0
- package/src/result-sessions.ts +39 -0
- package/src/result-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +176 -108
- package/src/resume-checks.ts +59 -14
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +69 -15
- package/src/session-owner.ts +279 -0
- package/src/task-replies.ts +247 -0
- package/src/task-settlement.ts +81 -0
- package/src/task-store.ts +26 -0
- package/src/tmux.ts +134 -54
- package/src/tool-ceiling.ts +125 -0
- package/src/tracking.ts +72 -65
- package/src/tree-close.ts +236 -0
- package/src/tree-fence.ts +51 -0
- package/src/tree-lock.ts +77 -0
- package/src/tree-notice.ts +124 -0
- package/src/tree-registry.ts +365 -0
- package/src/tree-settings.ts +31 -0
- package/src/tree-types.ts +32 -0
- package/src/tree-view.ts +246 -0
- package/src/trust-control.ts +214 -0
- package/src/trust-prompt.ts +163 -0
- package/src/trust-wire.ts +203 -0
- package/src/widget-activity-join.ts +24 -0
- package/src/widget-activity-runtime.ts +104 -0
- package/src/widget-activity.ts +104 -0
- package/src/widget-ancestors.ts +142 -0
- package/src/widget-current.ts +41 -0
- package/src/widget-data.ts +147 -0
- package/src/widget-focus.ts +133 -0
- package/src/widget-live-process.ts +31 -0
- package/src/widget-live.ts +356 -0
- package/src/widget-render.ts +181 -0
- package/src/widget-selection.ts +58 -0
- package/src/widget-settings.ts +29 -0
- package/src/widget-summary.ts +51 -0
- package/src/widget-title-file.ts +127 -0
- package/src/widget-title.ts +118 -0
- package/src/widget.ts +272 -0
- package/src/window-placement.ts +30 -5
- package/src/workspace.ts +38 -90
|
@@ -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
|
@@ -7,21 +7,27 @@
|
|
|
7
7
|
// exits too (the parent gets the error and the session file stays); only a
|
|
8
8
|
// run the user stopped never exits. A run that ends on a usage limit first
|
|
9
9
|
// moves to the agent's next fallback, from `PI_DELEGATE_FALLBACK`, and
|
|
10
|
-
// continues there; it exits only when no fallback is left.
|
|
10
|
+
// continues there; it exits only when no fallback is left. An entry that is
|
|
11
|
+
// an alias resolves to one model through Pi's own `--model` resolver. The user
|
|
11
12
|
// taking over — typing here, or stopping a run — turns it off for the rest of
|
|
12
|
-
// the session, with a notice; `/auto-exit` turns it back on.
|
|
13
|
-
// edxeth/pi-subagents' `auto-exit` (README "Child lifecycle", `cf6dbf4`).
|
|
13
|
+
// the session, with a notice; `/auto-exit` turns it back on.
|
|
14
14
|
//
|
|
15
15
|
// It also takes over the parent's task request on `message:inbound` and sends
|
|
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
|
-
|
|
21
|
-
import
|
|
22
|
-
|
|
19
|
+
// package's pinned startup delivery path. task-replies.ts owns the answer.
|
|
20
|
+
import type { Api, Model } from "@earendil-works/pi-ai";
|
|
21
|
+
import {
|
|
22
|
+
type ExtensionAPI,
|
|
23
|
+
type ExtensionContext,
|
|
24
|
+
type ModelRegistry,
|
|
25
|
+
type ModelRuntime,
|
|
26
|
+
resolveCliModel,
|
|
27
|
+
} from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import { type Identity, writeIdentity } from "./identity.ts";
|
|
23
29
|
import { activeMark, classify, type Response, recordLimit } from "./limits.ts";
|
|
24
|
-
import {
|
|
30
|
+
import type { TaskReplies } from "./task-replies.ts";
|
|
25
31
|
|
|
26
32
|
/** The launch variable that carries the parent's resolved setting. */
|
|
27
33
|
export const AUTO_EXIT_ENV = "PI_DELEGATE_AUTO_EXIT";
|
|
@@ -36,8 +42,6 @@ const ON_NOTICE = "delegate: auto-exit is on; this delegate closes after its nex
|
|
|
36
42
|
|
|
37
43
|
type Message = { role?: string; stopReason?: unknown; errorMessage?: unknown; provider?: unknown; model?: unknown };
|
|
38
44
|
|
|
39
|
-
type Inbound = { envelope?: { from?: unknown; kind?: unknown; body?: unknown }; handled?: boolean };
|
|
40
|
-
|
|
41
45
|
/** A run that ended on a usage-limit error leaves a mark for every Pi session. */
|
|
42
46
|
function recordUsageLimit(last: Message, ctx: ExtensionContext, response?: Response): void {
|
|
43
47
|
const { provider, model, errorMessage } = last;
|
|
@@ -55,52 +59,75 @@ function recordUsageLimit(last: Message, ctx: ExtensionContext, response?: Respo
|
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
61
|
|
|
58
|
-
/**
|
|
59
|
-
|
|
62
|
+
/**
|
|
63
|
+
* The one model a fallback entry names, as Pi's `--model` resolves it: an
|
|
64
|
+
* exact `provider/id`, or a bare id or alias such as `sonnet`. Undefined when
|
|
65
|
+
* the registry has no such model; a custom model id Pi would make up for an
|
|
66
|
+
* unknown one is not used.
|
|
67
|
+
*/
|
|
68
|
+
function resolveEntry(registry: ModelRegistry, entry: string): Model<Api> | undefined {
|
|
69
|
+
// The resolver reads only these two from the runtime; the registry is the extension's view of it.
|
|
70
|
+
const runtime = {
|
|
71
|
+
getModels: () => registry.getAll(),
|
|
72
|
+
hasConfiguredAuth: (provider: string) =>
|
|
73
|
+
registry.getAll().some((model) => model.provider === provider && registry.hasConfiguredAuth(model)),
|
|
74
|
+
} as unknown as ModelRuntime;
|
|
75
|
+
try {
|
|
76
|
+
const { model } = resolveCliModel({ cliModel: entry, modelRuntime: runtime });
|
|
77
|
+
return model === undefined ? undefined : registry.find(model.provider, model.id);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined; // an entry Pi cannot read is passed over like one that names no model
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Switch to the first model `fallback` names that is usable, not marked and
|
|
85
|
+
* not tried yet, taking each entry off the list in order. Returns the
|
|
86
|
+
* `provider/id` switched to, if any.
|
|
87
|
+
*/
|
|
88
|
+
async function switchModel(
|
|
89
|
+
pi: ExtensionAPI,
|
|
90
|
+
ctx: ExtensionContext,
|
|
91
|
+
fallback: string[],
|
|
92
|
+
tried: Set<string>,
|
|
93
|
+
): Promise<string | undefined> {
|
|
60
94
|
for (let next = fallback.shift(); next !== undefined; next = fallback.shift()) {
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
95
|
+
const model = resolveEntry(ctx.modelRegistry, next);
|
|
96
|
+
if (model === undefined) continue;
|
|
97
|
+
const name = `${model.provider}/${model.id}`;
|
|
98
|
+
if (tried.has(name)) continue;
|
|
99
|
+
tried.add(name);
|
|
100
|
+
if (!ctx.modelRegistry.hasConfiguredAuth(model)) continue;
|
|
64
101
|
if (activeMark(ctx.modelRegistry, model.provider, model.id) !== undefined) continue;
|
|
65
|
-
if (await pi.setModel(model)) return
|
|
102
|
+
if (await pi.setModel(model)) return name;
|
|
66
103
|
}
|
|
67
104
|
return undefined;
|
|
68
105
|
}
|
|
69
106
|
|
|
70
|
-
/** Take over the parent's task request and send it as a user prompt. */
|
|
71
|
-
function registerTakeover(pi: ExtensionAPI, parent: string): void {
|
|
72
|
-
let current: ExtensionContext | undefined;
|
|
73
|
-
pi.on("session_start", async (_event, ctx) => {
|
|
74
|
-
current = ctx;
|
|
75
|
-
});
|
|
76
|
-
pi.events.on("message:inbound", (data) => {
|
|
77
|
-
const payload = data as Inbound;
|
|
78
|
-
const envelope = payload?.envelope;
|
|
79
|
-
if (payload.handled === true || envelope?.kind !== "request" || envelope.from !== parent) return;
|
|
80
|
-
payload.handled = true;
|
|
81
|
-
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 ?? "")}`;
|
|
82
|
-
Promise.resolve(pi.sendUserMessage(text, { deliverAs: "steer" })).catch((err) =>
|
|
83
|
-
current?.ui.notify(`delegate: could not start the task: ${(err as Error).message}`, "error"),
|
|
84
|
-
);
|
|
85
|
-
});
|
|
86
|
-
}
|
|
87
|
-
|
|
88
107
|
/**
|
|
89
108
|
* A usage limit or an unavailable endpoint moves the run to the next fallback
|
|
90
109
|
* that is usable and not marked, and continues it in this session; each
|
|
91
|
-
*
|
|
110
|
+
* model is tried once, and never the one that just failed. Returns the setter
|
|
111
|
+
* for what ended the last run: the failed model as `provider/id`, and why as
|
|
92
112
|
* `<provider/model> <what happened>`.
|
|
93
113
|
*/
|
|
94
|
-
function registerFallback(pi: ExtensionAPI): (failure:
|
|
95
|
-
/** The
|
|
96
|
-
const fallback = (process.env[FALLBACK_ENV] ?? "")
|
|
97
|
-
|
|
114
|
+
function registerFallback(pi: ExtensionAPI): (failure: Failure | undefined) => void {
|
|
115
|
+
/** The entries still to move to on a usage limit, in order: models or aliases. */
|
|
116
|
+
const fallback = (process.env[FALLBACK_ENV] ?? "")
|
|
117
|
+
.split(",")
|
|
118
|
+
.map((entry) => entry.trim())
|
|
119
|
+
.filter((entry) => entry !== "");
|
|
120
|
+
/** The models already switched to or failed on, as `provider/id`. */
|
|
121
|
+
const tried = new Set<string>();
|
|
122
|
+
let failure: Failure | undefined;
|
|
98
123
|
pi.on("agent_before_settle", async (_event, ctx) => {
|
|
99
124
|
const from = failure;
|
|
100
125
|
failure = undefined;
|
|
101
|
-
|
|
126
|
+
if (from === undefined) return;
|
|
127
|
+
tried.add(from.model);
|
|
128
|
+
const next = await switchModel(pi, ctx, fallback, tried);
|
|
102
129
|
if (next === undefined) return;
|
|
103
|
-
const content = `[delegate] ${from}, so this session switched to ${next}. Carry on with the task from where it stopped.`;
|
|
130
|
+
const content = `[delegate] ${from.reason}, so this session switched to ${next}. Carry on with the task from where it stopped.`;
|
|
104
131
|
return { continue: true, entries: [{ type: "custom_message", customType: "delegate", content, display: true }] };
|
|
105
132
|
});
|
|
106
133
|
return (value) => {
|
|
@@ -108,48 +135,66 @@ function registerFallback(pi: ExtensionAPI): (failure: string | undefined) => vo
|
|
|
108
135
|
};
|
|
109
136
|
}
|
|
110
137
|
|
|
111
|
-
/**
|
|
112
|
-
|
|
138
|
+
/** A failed run that should move to a fallback: the model it failed on, and why. */
|
|
139
|
+
type Failure = { model: string; reason: string };
|
|
140
|
+
|
|
141
|
+
/** Why a failed run should move to a fallback, with `reason` as `<provider/model> <what happened>`; undefined when it should not. */
|
|
142
|
+
function fallbackReason(last: Message | undefined): Failure | undefined {
|
|
113
143
|
const error = last?.errorMessage;
|
|
114
144
|
if (typeof error !== "string") return undefined;
|
|
115
145
|
const model = `${String(last?.provider)}/${String(last?.model)}`;
|
|
116
|
-
if (classify(error) !== undefined) return `${model} hit a usage limit
|
|
146
|
+
if (classify(error) !== undefined) return { model, reason: `${model} hit a usage limit` };
|
|
117
147
|
// Pi has already retried an unavailable endpoint; it marks nothing, but this run moves on.
|
|
118
|
-
if (/unavailable/i.test(error)) return `${model} was unavailable (${error})
|
|
148
|
+
if (/unavailable/i.test(error)) return { model, reason: `${model} was unavailable (${error})` };
|
|
119
149
|
return undefined;
|
|
120
150
|
}
|
|
121
151
|
|
|
122
152
|
/** This delegate's footer icon, picked once per load. */
|
|
123
153
|
const ICON = Math.random() < 0.5 ? "👦" : "👧";
|
|
124
154
|
|
|
125
|
-
function showStatus(ctx: ExtensionContext, armed: boolean): void {
|
|
155
|
+
function showStatus(ctx: ExtensionContext, armed: boolean, agent?: string): void {
|
|
126
156
|
const { theme } = ctx.ui;
|
|
127
|
-
const agent = process.env[AGENT_ENV];
|
|
128
157
|
const label = agent === undefined || agent === "" ? "delegate" : `${theme.fg("accent", agent)} delegate`;
|
|
129
158
|
ctx.ui.setStatus(
|
|
130
|
-
|
|
159
|
+
"delegate-self",
|
|
131
160
|
armed
|
|
132
161
|
? `${ICON} ${label} · auto-exit ${theme.fg("success", "on")}`
|
|
133
162
|
: `${ICON} ${label} · auto-exit ${theme.fg("dim", "off")}`,
|
|
134
163
|
);
|
|
135
164
|
}
|
|
136
165
|
|
|
137
|
-
|
|
138
|
-
|
|
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 {
|
|
139
178
|
const setFailure = registerFallback(pi);
|
|
140
179
|
|
|
141
|
-
let armed =
|
|
180
|
+
let armed = false;
|
|
142
181
|
/** Whether the run that just ended finished without the user stopping it. */
|
|
143
182
|
let completed = false;
|
|
144
183
|
|
|
145
184
|
// Reload and session replacement clear extension statuses, then start again.
|
|
146
|
-
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
|
+
});
|
|
147
190
|
|
|
148
191
|
function disarm(ctx: ExtensionContext): void {
|
|
149
192
|
if (!armed) return;
|
|
150
193
|
armed = false;
|
|
194
|
+
const own = identity();
|
|
195
|
+
if (own) writeIdentity(ctx.sessionManager.getSessionId(), { ...own, autoExit: false });
|
|
151
196
|
ctx.ui.notify(OFF_NOTICE, "info");
|
|
152
|
-
showStatus(ctx, armed);
|
|
197
|
+
showStatus(ctx, armed, identity()?.agent);
|
|
153
198
|
}
|
|
154
199
|
|
|
155
200
|
// Typed text and RPC input are the user's; a prompt an extension sends is not.
|
|
@@ -170,11 +215,8 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
170
215
|
// call Pi 0.99.1 ends with an `error` message, and only the signal tells a
|
|
171
216
|
// stop from a real error. Either way it is the user taking over.
|
|
172
217
|
pi.on("agent_end", async (event, ctx) => {
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
const message = event.messages[i] as Message;
|
|
176
|
-
if (message?.role === "assistant") last = message;
|
|
177
|
-
}
|
|
218
|
+
const last = event.messages.findLast((message) => message.role === "assistant") as Message | undefined;
|
|
219
|
+
if (!identity()) return;
|
|
178
220
|
const stopped = last?.stopReason === "aborted" || ctx.signal?.aborted === true;
|
|
179
221
|
if (stopped) disarm(ctx);
|
|
180
222
|
if (!stopped && last?.stopReason === "error") recordUsageLimit(last, ctx, response);
|
|
@@ -184,19 +226,22 @@ export function registerChild(pi: ExtensionAPI, parent: string): void {
|
|
|
184
226
|
});
|
|
185
227
|
|
|
186
228
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
187
|
-
const exit = armed && completed;
|
|
229
|
+
const exit = armed && completed && replies.handled();
|
|
188
230
|
completed = false;
|
|
189
231
|
if (!exit) return;
|
|
190
|
-
//
|
|
191
|
-
|
|
232
|
+
// Task settlement and its synchronous reply write have succeeded.
|
|
233
|
+
shutdownWhenActive(ctx, active);
|
|
192
234
|
});
|
|
193
235
|
|
|
194
236
|
pi.registerCommand("auto-exit", {
|
|
195
237
|
description: "Close this delegate after its next normal completion",
|
|
196
238
|
handler: async (_args, ctx) => {
|
|
239
|
+
if (!identity()) return ctx.ui.notify("auto-exit is available inside a delegate session", "error");
|
|
197
240
|
armed = true;
|
|
241
|
+
const own = identity();
|
|
242
|
+
if (own) writeIdentity(ctx.sessionManager.getSessionId(), { ...own, autoExit: true });
|
|
198
243
|
ctx.ui.notify(ON_NOTICE, "info");
|
|
199
|
-
showStatus(ctx, armed);
|
|
244
|
+
showStatus(ctx, armed, identity()?.agent);
|
|
200
245
|
},
|
|
201
246
|
});
|
|
202
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
|
+
}
|