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.
Files changed (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +585 -107
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. 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. The user
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. Modelled 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. The provider still arms the
20
- // automatic answer for a request a listener took over.
21
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
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 { STATUS_KEY } from "./results.ts";
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
- /** Switch to the first of `fallback` that is usable and not marked, taking each off the list; the one switched to, if any. */
59
- async function switchModel(pi: ExtensionAPI, ctx: ExtensionContext, fallback: string[]): Promise<string | undefined> {
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 slash = next.indexOf("/");
62
- const model = ctx.modelRegistry.find(next.slice(0, slash), next.slice(slash + 1));
63
- if (model === undefined || !ctx.modelRegistry.hasConfiguredAuth(model)) continue;
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 next;
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
- * fallback is tried once. Returns the setter for what ended the last run, as
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: string | undefined) => void {
95
- /** The models still to move to on a usage limit, in order. */
96
- const fallback = (process.env[FALLBACK_ENV] ?? "").split(",").filter((model) => model.includes("/"));
97
- let failure: string | undefined;
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
- const next = from === undefined ? undefined : await switchModel(pi, ctx, fallback);
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
- /** Why a failed run should move to a fallback, as `<provider/model> <what happened>`; undefined when it should not. */
112
- function fallbackReason(last: Message | undefined): string | undefined {
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
- STATUS_KEY,
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
- export function registerChild(pi: ExtensionAPI, parent: string): void {
138
- registerTakeover(pi, parent);
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 = process.env[AUTO_EXIT_ENV] === "1";
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) => showStatus(ctx, armed));
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
- let last: Message | undefined;
174
- for (let i = event.messages.length - 1; i >= 0 && last === undefined; i--) {
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
- // Later settle handlers (the `message:*` provider's reply among them) run first.
191
- setImmediate(() => ctx.shutdown());
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
+ }