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.
Files changed (60) hide show
  1. package/CHANGELOG.md +323 -1
  2. package/README.md +918 -117
  3. package/package.json +2 -1
  4. package/src/agents.ts +57 -36
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +35 -41
  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 +358 -56
  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/poll.ts +1 -0
  16. package/src/render.ts +4 -1
  17. package/src/result-close.ts +37 -0
  18. package/src/result-restore.ts +95 -0
  19. package/src/result-types.ts +29 -1
  20. package/src/results-presentation.ts +47 -0
  21. package/src/results.ts +174 -108
  22. package/src/resume-checks.ts +14 -2
  23. package/src/resume-picker.ts +9 -2
  24. package/src/resume.ts +62 -13
  25. package/src/session-owner.ts +279 -0
  26. package/src/task-replies.ts +247 -0
  27. package/src/task-settlement.ts +81 -0
  28. package/src/task-store.ts +26 -0
  29. package/src/tmux.ts +134 -54
  30. package/src/tool-ceiling.ts +125 -0
  31. package/src/tracking.ts +72 -65
  32. package/src/tree-close.ts +236 -0
  33. package/src/tree-fence.ts +51 -0
  34. package/src/tree-lock.ts +77 -0
  35. package/src/tree-notice.ts +124 -0
  36. package/src/tree-registry.ts +365 -0
  37. package/src/tree-settings.ts +31 -0
  38. package/src/tree-types.ts +32 -0
  39. package/src/tree-view.ts +246 -0
  40. package/src/trust-control.ts +214 -0
  41. package/src/trust-prompt.ts +163 -0
  42. package/src/trust-wire.ts +203 -0
  43. package/src/widget-activity-join.ts +24 -0
  44. package/src/widget-activity-runtime.ts +104 -0
  45. package/src/widget-activity.ts +104 -0
  46. package/src/widget-ancestors.ts +142 -0
  47. package/src/widget-current.ts +41 -0
  48. package/src/widget-data.ts +147 -0
  49. package/src/widget-focus.ts +133 -0
  50. package/src/widget-live-process.ts +31 -0
  51. package/src/widget-live.ts +356 -0
  52. package/src/widget-render.ts +181 -0
  53. package/src/widget-selection.ts +58 -0
  54. package/src/widget-settings.ts +29 -0
  55. package/src/widget-summary.ts +51 -0
  56. package/src/widget-title-file.ts +127 -0
  57. package/src/widget-title.ts +118 -0
  58. package/src/widget.ts +272 -0
  59. package/src/window-placement.ts +30 -5
  60. package/src/workspace.ts +38 -90
@@ -3,17 +3,18 @@
3
3
  // prerequisites once, then per record the window, session file, agent, thinking
4
4
  // level and model. None of them changes anything; killing an exited window and
5
5
  // deciding trust stay with the resume itself.
6
+
6
7
  import { statSync } from "node:fs";
7
8
  import { isAbsolute } from "node:path";
8
-
9
9
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
10
-
11
10
  import { type Agent, disabledProblem, isThinking, withoutLocked } from "./agents.ts";
12
11
  import { sessionHeaderCwd, sessionIdOf, sessionThinking } from "./result-sessions.ts";
13
12
  import { deriveState, finishedModel, locateSession, type StartedRecorded } from "./results.ts";
14
13
  import type { ResumeDeps, ResumeRequest } from "./resume.ts";
14
+ import { ownerDetails, sessionIsOwned } from "./session-owner.ts";
15
15
  import { requireTmuxVersion, type TmuxWindow } from "./tmux.ts";
16
16
  import { ownWindow, windowState } from "./tracking.ts";
17
+ import { assertResumeAuthority } from "./tree-view.ts";
17
18
  import type { WindowPlacement } from "./window-placement.ts";
18
19
 
19
20
  /**
@@ -110,12 +111,23 @@ function lastThinking(record: StartedRecorded, sessionPath: string): string {
110
111
  return level !== undefined && isThinking(level) ? level : record.thinking;
111
112
  }
112
113
 
114
+ export function assertNoOwner(record: StartedRecorded): void {
115
+ if (sessionIsOwned(sessionIdOf(record)))
116
+ throw new Error(
117
+ `delegation ${record.id} is already open in another process: ${ownerDetails(sessionIdOf(record))}; send it a message instead of resuming it`,
118
+ );
119
+ }
120
+
113
121
  /** Steps 5 and 6: the session file and the agent, and no resume of the delegation already under way. */
114
122
  export function recordCheck(
115
123
  deps: ResumeDeps,
116
124
  record: StartedRecorded,
117
125
  ctx: ExtensionContext,
118
126
  ): { session: SessionFile; agent: Agent } {
127
+ assertResumeAuthority(record, ctx);
128
+ assertNoOwner(record);
129
+ deps.authorize?.(record.id, ctx);
130
+ deps.admit?.(record.agent, ctx);
119
131
  const session = sessionFor(record, ctx);
120
132
  const roster = deps.roster(ctx);
121
133
  const disabled = disabledProblem(roster, record.agent);
@@ -19,7 +19,7 @@ export type ResumePickerDeps = {
19
19
  const optionFor = (record: StartedRecorded): string =>
20
20
  `${recordedName(record)} · ${record.agent} · ${record.id.slice(0, 8)}`;
21
21
 
22
- export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps): Promise<void> {
22
+ export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps, id?: string): Promise<void> {
23
23
  const unavailable = deps.unavailable();
24
24
  if (unavailable !== undefined) {
25
25
  ctx.ui.notify(`delegate: ${unavailable}`, "error");
@@ -37,7 +37,14 @@ export async function pickResume(ctx: ExtensionContext, deps: ResumePickerDeps):
37
37
  return;
38
38
  }
39
39
  const options = records.map(optionFor);
40
- const picked = await ctx.ui.select("Resume which delegation?", options);
40
+ const picked =
41
+ id === undefined
42
+ ? await ctx.ui.select("Resume which delegation?", options)
43
+ : options[records.findIndex((record) => record.id === id)];
44
+ if (id !== undefined && picked === undefined) {
45
+ ctx.ui.notify("delegate: selected delegation cannot be resumed now", "warning");
46
+ return;
47
+ }
41
48
  const record = records[options.indexOf(picked ?? "")];
42
49
  if (record === undefined) return;
43
50
  const task = await ctx.ui.editor(`Follow-up for ${recordedName(record)}`);
package/src/resume.ts CHANGED
@@ -10,9 +10,11 @@ import type { ExtensionContext, ModelRegistry } from "@earendil-works/pi-coding-
10
10
  import { type TObject, Type } from "typebox";
11
11
 
12
12
  import { type Agent, type Roster, THINKING_LEVELS } from "./agents.ts";
13
+ import { type Identity, readIdentity } from "./identity.ts";
13
14
  import { sessionIdOf } from "./result-sessions.ts";
14
15
  import { isStarted, type Results, recordedName, type StartedRecorded } from "./results.ts";
15
16
  import {
17
+ assertNoOwner,
16
18
  inFlight,
17
19
  passes,
18
20
  preflight,
@@ -23,6 +25,8 @@ import {
23
25
  } from "./resume-checks.ts";
24
26
  import type { OpenedWindow, TmuxClient } from "./tmux.ts";
25
27
  import { listWindows, type ToolDef, toolResult } from "./tracking.ts";
28
+ import { bindTree, failedTree, type Reservation } from "./tree-registry.ts";
29
+ import { recordedIdentity } from "./tree-view.ts";
26
30
  import type { WindowPlacement } from "./window-placement.ts";
27
31
  import type { Workspace } from "./workspace.ts";
28
32
 
@@ -52,6 +56,10 @@ export type ResumeDeps = {
52
56
  /** The window placement for this call; throws on an invalid setting. */
53
57
  placement(): WindowPlacement;
54
58
  roster(ctx: ExtensionContext): Roster;
59
+ /** Depth/current-parent grant check, before even an exited window is killed. */
60
+ admit?(agent: string, ctx: ExtensionContext): void;
61
+ authorize?(id: string, ctx: ExtensionContext): void;
62
+ reserve(record: StartedRecorded, id: string, ctx: ExtensionContext): Promise<Reservation>;
55
63
  /** The parent's current model and thinking level, which a field the agent locks but does not set keeps. */
56
64
  parentDefaults(ctx: ExtensionContext): { model?: string; thinking: string };
57
65
  chooseModel(
@@ -66,6 +74,7 @@ export type ResumeDeps = {
66
74
  cwd: string,
67
75
  ctx: ExtensionContext,
68
76
  signal: AbortSignal | undefined,
77
+ agent: string,
69
78
  ): Promise<{ workspace: Workspace; trusted: boolean }>;
70
79
  /** Mail the task to a session id; returns the request id. */
71
80
  sendTask(to: string, task: string): string;
@@ -78,10 +87,12 @@ export type ResumeDeps = {
78
87
  trusted: boolean;
79
88
  cwd: string;
80
89
  sessionDir: string;
81
- start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean };
90
+ start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean; notices: string[] };
82
91
  toolCallId: string | undefined;
83
92
  ctx: ExtensionContext;
84
93
  placement: WindowPlacement;
94
+ identity: Identity;
95
+ sessionId: string;
85
96
  }): Promise<OpenedWindow>;
86
97
  };
87
98
 
@@ -166,7 +177,7 @@ function resultSchema(delegateSchema: TObject): TObject {
166
177
 
167
178
  /** The model, thinking and auto-exit a resume runs with, and the directory and workspace it opens in. */
168
179
  type Plan = {
169
- start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean };
180
+ start: { agent: Agent; model: string; rest: string[]; thinking: string; autoExit: boolean; notices: string[] };
170
181
  chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>;
171
182
  workspace: Workspace;
172
183
  trusted: boolean;
@@ -182,11 +193,11 @@ async function planResume(
182
193
  const { record, agent, session, request } = input;
183
194
  const { thinking, chosen } = await settleModel(deps, input, ctx);
184
195
  const autoExit = request.autoExit ?? record.autoExit === true;
185
- const prepared = await deps.prepareDirectory(session.cwd, ctx, signal);
196
+ const prepared = await deps.prepareDirectory(session.cwd, ctx, signal, agent.name);
186
197
  // A managed worktree keeps its launch facts while the session still runs in it.
187
198
  const workspace = record.workspace?.cwd === prepared.workspace.cwd ? record.workspace : prepared.workspace;
188
199
  return {
189
- start: { agent, model: chosen.model, rest: chosen.rest, thinking, autoExit },
200
+ start: { agent, model: chosen.model, rest: chosen.rest, thinking, autoExit, notices: chosen.notices },
190
201
  chosen,
191
202
  workspace,
192
203
  trusted: prepared.trusted,
@@ -210,26 +221,40 @@ async function mailTask(deps: ResumeDeps, windowId: string, sessionId: string, t
210
221
  }
211
222
  }
212
223
 
224
+ function publishResumeRequest(token: Reservation, requestId: string, windowId: string, ctx: ExtensionContext): void {
225
+ try {
226
+ bindTree(token, { requestId });
227
+ } catch (error) {
228
+ ctx.ui.notify(
229
+ `delegate: window ${windowId} opened and task mail was sent, but shared request publication failed: ${String(error)}. Local tracking continues; accounting reconciles before the next admission.`,
230
+ "warning",
231
+ );
232
+ }
233
+ }
234
+
213
235
  async function startResume(
214
236
  deps: ResumeDeps,
215
237
  input: {
216
- record: StartedRecorded;
238
+ record: StartedRecorded & { identity: Identity };
217
239
  session: SessionFile;
218
240
  plan: Plan;
219
241
  task: string;
220
242
  toolCallId: string | undefined;
221
243
  placement: WindowPlacement;
244
+ reservation: Reservation;
222
245
  },
223
246
  ctx: ExtensionContext,
224
247
  ): Promise<Resumed> {
225
248
  const { record, session, plan, task, toolCallId, placement } = input;
226
249
  const { start, chosen, workspace } = plan;
227
- const id = randomUUID();
250
+ const id = input.reservation.id;
228
251
  const sessionId = sessionIdOf(record);
229
252
  const name = recordedName(record);
230
253
  const { id: windowId, session: windowSession } = await deps.open({
231
254
  id,
232
255
  name,
256
+ identity: record.identity,
257
+ sessionId,
233
258
  sessionPath: session.path,
234
259
  workspace,
235
260
  trusted: plan.trusted,
@@ -241,9 +266,11 @@ async function startResume(
241
266
  placement,
242
267
  });
243
268
  const requestId = await mailTask(deps, windowId, sessionId, task);
269
+ publishResumeRequest(input.reservation, requestId, windowId, ctx);
244
270
  deps.results.recordStart(
245
271
  {
246
272
  id,
273
+ identity: readIdentity(sessionId) ?? record.identity,
247
274
  agent: start.agent.name,
248
275
  ...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
249
276
  model: start.model,
@@ -284,6 +311,13 @@ async function startResume(
284
311
  };
285
312
  }
286
313
 
314
+ /** Resolve once before any exited-window kill; never reconstruct a lost root from cwd. */
315
+ function requiredIdentity(record: StartedRecorded): Identity {
316
+ const own = recordedIdentity(record);
317
+ if (!own) throw new Error(`delegation ${record.id} has no original root placement identity; resume refused`);
318
+ return own;
319
+ }
320
+
287
321
  /** Resume `request.id`: every eligibility check in order, then the launch. */
288
322
  async function resume(
289
323
  deps: ResumeDeps,
@@ -293,19 +327,34 @@ async function resume(
293
327
  ): Promise<Resumed> {
294
328
  const placement = await preflight(deps); // read once, before the exited window is killed or anything is mailed
295
329
  if (request.task.trim() === "") throw new Error("a task is required");
296
- const record = newestStarted(deps.results, request.id);
297
- await ensureExited(record, deps.tmux);
298
- const { session, agent } = recordCheck(deps, record, ctx);
299
- inFlight.add(record.id);
330
+ deps.authorize?.(request.id, ctx);
331
+ const found = newestStarted(deps.results, request.id);
332
+ const record = { ...found, identity: requiredIdentity(found) };
333
+ deps.admit?.(record.agent, ctx);
334
+ assertNoOwner(record);
335
+ deps.results.checkStorage(true);
336
+ const reservation = await deps.reserve(record, randomUUID(), ctx);
337
+ let acquired = false;
300
338
  try {
339
+ await ensureExited(record, deps.tmux);
340
+ const { session, agent } = recordCheck(deps, record, ctx);
341
+ inFlight.add(record.id);
342
+ acquired = true;
301
343
  const plan = await planResume(deps, { record, agent, session, request }, ctx, signal);
302
344
  return await startResume(
303
345
  deps,
304
- { record, session, plan, task: request.task, toolCallId: request.toolCallId, placement },
346
+ { record, session, plan, task: request.task, toolCallId: request.toolCallId, placement, reservation },
305
347
  ctx,
306
348
  );
349
+ } catch (error) {
350
+ try {
351
+ failedTree(reservation);
352
+ } catch (accounting) {
353
+ ctx.ui.notify(`delegate: reservation remains charged: ${String(accounting)}`, "error");
354
+ }
355
+ throw error;
307
356
  } finally {
308
- inFlight.delete(record.id);
357
+ if (acquired) inFlight.delete(record.id);
309
358
  }
310
359
  }
311
360
 
@@ -342,7 +391,7 @@ function resumeTool(deps: ResumeDeps): ToolDef {
342
391
  name: "delegation_resume",
343
392
  label: "Resume delegation",
344
393
  description:
345
- "Continue an exited delegate with a follow-up task: reopen its own session, so it keeps the context it built, in a new window. The follow-up is recorded as a new delegation, and its result arrives as a message on its own, like a delegate's. Use it when the next task needs what the delegate already read or decided; for unrelated work, use delegate. It refuses a delegation that is still running (send it a message instead), one whose window is still open, an older delegation of a chain that was already resumed (it names the newest), and one whose working directory is gone, a removed worktree included. A delegate whose window was killed mid-turn can be resumed.",
394
+ "Only this session’s direct children may be resumed; viewing a descendant does not grant authority, and fork/clone copies must return to the original parent. Current depth, grants, caps, tools and trust are checked again. Continue an exited delegate with a follow-up task: reopen its own session, so it keeps the context it built, in a new window. The follow-up is recorded as a new delegation, and its result arrives as a message on its own, like a delegate's. Use it when the next task needs what the delegate already read or decided; for unrelated work, use delegate. It refuses a delegation that is still running (send it a message instead), one whose window is still open, an older delegation of a chain that was already resumed (it names the newest), and one whose working directory is gone, a removed worktree included. A delegate whose window was killed mid-turn can be resumed.",
346
395
  parameters: Type.Object({
347
396
  id: Type.String({ description: "The delegation to resume, from a delegate result or delegation_status." }),
348
397
  task: Type.String({
@@ -0,0 +1,279 @@
1
+ // One Squire actor per exact Pi session. Already-claimed Squire mail is staged
2
+ // before dispatch so a duplicate provider cannot steal it from the live owner.
3
+ import { randomUUID } from "node:crypto";
4
+ import { mkdirSync, readdirSync, unlinkSync } from "node:fs";
5
+ import { basename, join } from "node:path";
6
+ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
7
+ import { readChildRecords } from "./child-tasks.ts";
8
+ import { readIdentity } from "./identity.ts";
9
+ import { readState, writeState } from "./local-state.ts";
10
+ import { type Inbound, isStarted, sessionIdOf } from "./results.ts";
11
+ import { processAlive, withTreeLock } from "./tree-lock.ts";
12
+ import { readTree } from "./tree-registry.ts";
13
+
14
+ type OwnerRecord = { pid: number; token: string };
15
+ type Callback = (...args: unknown[]) => unknown;
16
+ const base = (session: string): string => join(getAgentDir(), "squire", "owners", encodeURIComponent(session));
17
+ const lock = <T>(session: string, work: () => T): T => withTreeLock(`${base(session)}.lock`, work);
18
+ function ownerRecord(session: string): OwnerRecord | undefined {
19
+ const saved: OwnerRecord | undefined = readState<OwnerRecord>(`${base(session)}.json`);
20
+ if (saved && (!Number.isSafeInteger(saved.pid) || saved.pid < 1 || typeof saved.token !== "string"))
21
+ throw new Error(`invalid Squire process owner ${base(session)}.json; repair that owner record before reopening`);
22
+ return saved;
23
+ }
24
+ export function ownerDetails(session: string): string {
25
+ const path: string = `${base(session)}.json`;
26
+ try {
27
+ const saved: OwnerRecord | undefined = ownerRecord(session);
28
+ return `${path} (PID ${saved?.pid ?? "not recorded"})`;
29
+ } catch (error) {
30
+ return `${path} (unreadable: ${String(error)})`;
31
+ }
32
+ }
33
+ /** Observation only: matches the elected incarnation, never grants action authority. */
34
+ export function matchesSessionOwner(session: string, pid: number, token: string): boolean {
35
+ const saved: OwnerRecord | undefined = ownerRecord(session);
36
+ return saved?.pid === pid && saved.token === token;
37
+ }
38
+ export function sessionIsOwned(session: string): boolean {
39
+ const saved: OwnerRecord | undefined = ownerRecord(session);
40
+ return saved !== undefined && processAlive(saved.pid);
41
+ }
42
+ function recognizes(session: string, input: Inbound): boolean {
43
+ const envelope = input.envelope as Inbound["envelope"] & { kind?: string };
44
+ if (envelope.kind === "request") return readIdentity(session)?.parent === envelope.from;
45
+ if (!Array.isArray(envelope.in_reply_to) || !envelope.in_reply_to.length) return false;
46
+ const matches = (record: import("./results.ts").Recorded): boolean =>
47
+ isStarted(record) && sessionIdOf(record) === envelope.from && envelope.in_reply_to.includes(record.requestId);
48
+ return readChildRecords(session).some(matches) || registryStarts(session).some(matches);
49
+ }
50
+ function registryStarts(session: string): import("./results.ts").Delegation[] {
51
+ try {
52
+ return readTree(readIdentity(session)?.root ?? session, 0)
53
+ .entries.filter((entry) => entry.identity.parent === session && entry.launch !== undefined)
54
+ .flatMap((entry) => (entry.launch ? [entry.launch] : []));
55
+ } catch {
56
+ return [];
57
+ }
58
+ }
59
+ export class SessionOwner {
60
+ readonly token: string = randomUUID();
61
+ session: string | undefined;
62
+ owned = false;
63
+ ctx: ExtensionContext | undefined;
64
+ private timer: ReturnType<typeof setInterval> | undefined;
65
+ private readonly inbound: Callback[] = [];
66
+ private draining = false;
67
+ private ready = false;
68
+ private failed = false;
69
+ private readonly warned: Set<string> = new Set();
70
+
71
+ private warn(message: string): void {
72
+ if (this.warned.has(message)) return;
73
+ this.warned.add(message);
74
+ if (this.ctx) this.ctx.ui.notify(message, "error");
75
+ else console.error(message);
76
+ }
77
+
78
+ active(): boolean {
79
+ if (!this.owned || this.failed || !this.session) return false;
80
+ try {
81
+ return ownerRecord(this.session)?.token === this.token;
82
+ } catch (error) {
83
+ this.warn(
84
+ `delegate: could not read Squire owner ${ownerDetails(this.session)}: ${String(error)}; processing is blocked until storage is repaired and this session reopened.`,
85
+ );
86
+ return false;
87
+ }
88
+ }
89
+ assert(): void {
90
+ if (this.failed)
91
+ throw new Error(
92
+ "Squire input processing failed; staged input is retained. Repair storage and reopen this session.",
93
+ );
94
+ if (!this.owned || !this.session || ownerRecord(this.session)?.token !== this.token)
95
+ throw new Error(
96
+ `Squire is inactive: this session is already open in another process, or has shut down. ${this.session ? ownerDetails(this.session) : "No owner session"}. Reopen the owning session only once.`,
97
+ );
98
+ }
99
+ start(ctx: ExtensionContext): void {
100
+ this.ctx = ctx;
101
+ this.session = ctx.sessionManager.getSessionId();
102
+ this.ready = false;
103
+ this.failed = false;
104
+ this.warned.clear();
105
+ this.owned = false;
106
+ this.owned = lock(this.session, () => {
107
+ const saved: OwnerRecord | undefined = ownerRecord(this.session as string);
108
+ if (saved && saved.token !== this.token && processAlive(saved.pid)) return false;
109
+ writeState(`${base(this.session as string)}.json`, { pid: process.pid, token: this.token });
110
+ return true;
111
+ });
112
+ if (!this.owned) {
113
+ ctx.ui.notify(
114
+ `delegate: this Pi session is already open in another process: ${ownerDetails(this.session)}. Squire actions are disabled here; return to the existing owner. Inspect the PID/record if stale; never evict a live owner.`,
115
+ "error",
116
+ );
117
+ ctx.shutdown();
118
+ }
119
+ }
120
+ listen(callback: Callback): () => void {
121
+ this.inbound.push(callback);
122
+ return (): void => {
123
+ const i: number = this.inbound.indexOf(callback);
124
+ if (i >= 0) this.inbound.splice(i, 1);
125
+ };
126
+ }
127
+ receive(payload: Inbound): void {
128
+ if (payload?.handled || !payload?.envelope) return;
129
+ const session: string | undefined = payload.envelope.to || this.session;
130
+ if (!session) return;
131
+ if (!recognizes(session, payload)) {
132
+ if (this.active() && this.ready) for (const callback of this.inbound) callback(payload);
133
+ return;
134
+ }
135
+ try {
136
+ const dir: string = join(base(session), "inbound");
137
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
138
+ writeState(join(dir, arrivalName(payload)), { ...payload, handled: false });
139
+ } catch (error) {
140
+ this.unstaged(payload, error);
141
+ return;
142
+ }
143
+ payload.handled = true;
144
+ this.drain();
145
+ }
146
+ drain(): void {
147
+ if (!this.active() || !this.ready || !this.session || this.draining) return;
148
+ this.draining = true;
149
+ try {
150
+ this.deliver();
151
+ } catch (error) {
152
+ this.failed = true;
153
+ this.ctx?.ui.notify(
154
+ `delegate: staged task input retained: ${String(error)}; repair storage and reopen this session.`,
155
+ "error",
156
+ );
157
+ } finally {
158
+ this.draining = false;
159
+ }
160
+ }
161
+ private unstaged(payload: Inbound, error: unknown): void {
162
+ this.warn(`delegate: could not stage claimed Squire input ${payload.path}: ${String(error)}.`);
163
+ try {
164
+ if (this.active() && this.ready) for (const callback of this.inbound) callback(payload);
165
+ if (payload.handled) return;
166
+ } catch (failure) {
167
+ this.failed = true;
168
+ this.warn(`delegate: direct Squire input processing failed: ${String(failure)}.`);
169
+ }
170
+ payload.handled = true;
171
+ this.warn(
172
+ `delegate: claimed Squire input ${payload.envelope.id} at ${payload.path} could not be retained or delivered to its owner. Delivery is NOT lossless with unwritable staging here: repair storage and ask the sender to resend; no plain-mail fallback was used.`,
173
+ );
174
+ }
175
+ private deliver(): void {
176
+ if (!this.session || ownerRecord(this.session)?.token !== this.token) throw new Error("Squire ownership changed");
177
+ const dir: string = join(base(this.session), "inbound");
178
+ let names: string[];
179
+ try {
180
+ names = readdirSync(dir)
181
+ .filter((name) => name.endsWith(".json"))
182
+ .sort();
183
+ } catch (error) {
184
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return;
185
+ throw error;
186
+ }
187
+ for (const name of names) {
188
+ const path: string = join(dir, name);
189
+ const payload: Inbound | undefined = readState<Inbound>(path);
190
+ if (!payload) continue;
191
+ this.assert();
192
+ for (const callback of this.inbound) callback(payload);
193
+ if (!payload.handled) throw new Error("staged Squire input did not match restored task state");
194
+ this.assert();
195
+ unlinkSync(path);
196
+ }
197
+ }
198
+ arm(): void {
199
+ if (!this.owned) return;
200
+ this.ready = true;
201
+ this.drain();
202
+ if (this.timer) clearInterval(this.timer);
203
+ this.timer = setInterval(() => this.drain(), 250).unref();
204
+ }
205
+ stop(): void {
206
+ if (this.timer) clearInterval(this.timer);
207
+ this.timer = undefined;
208
+ this.ready = false;
209
+ try {
210
+ if (this.session && this.owned)
211
+ lock(this.session, () => {
212
+ if (ownerRecord(this.session as string)?.token === this.token)
213
+ unlinkSync(`${base(this.session as string)}.json`);
214
+ });
215
+ } finally {
216
+ this.owned = false;
217
+ this.ctx = undefined;
218
+ }
219
+ }
220
+ }
221
+
222
+ function arrivalName(payload: Inbound): string {
223
+ const parsed: number = Date.parse(payload.envelope.ts);
224
+ const time: string = String(Number.isFinite(parsed) ? parsed : 0).padStart(16, "0");
225
+ return `${time}-${encodeURIComponent(basename(payload.path || ""))}-${encodeURIComponent(payload.envelope.id)}.json`;
226
+ }
227
+
228
+ /** Wrap only Squire registrations; mail and other extensions keep their own API. */
229
+ export function ownedAPI(pi: ExtensionAPI, owner: SessionOwner): ExtensionAPI {
230
+ const events: ExtensionAPI["events"] = {
231
+ ...pi.events,
232
+ on: (name: string, handler: Parameters<ExtensionAPI["events"]["on"]>[1]) =>
233
+ name === "message:inbound" ? owner.listen(handler as Callback) : pi.events.on(name, handler),
234
+ emit: (name: string, payload: unknown) => {
235
+ if (name === "message:scan" && !owner.active()) return;
236
+ owner.assert();
237
+ pi.events.emit(name, payload);
238
+ },
239
+ };
240
+ return new Proxy(pi, {
241
+ get(target: ExtensionAPI, key: string | symbol, receiver: unknown): unknown {
242
+ if (key === "events") return events;
243
+ const value: unknown = Reflect.get(target, key, receiver);
244
+ if (typeof value !== "function") return value;
245
+ return (...args: unknown[]): unknown => dispatchAPI(target, key, value as Callback, args, owner);
246
+ },
247
+ });
248
+ }
249
+ function dispatchAPI(
250
+ target: ExtensionAPI,
251
+ key: string | symbol,
252
+ call: Callback,
253
+ args: unknown[],
254
+ owner: SessionOwner,
255
+ ): unknown {
256
+ if (key === "on") {
257
+ const [name, handler] = args as [string, Callback];
258
+ return Reflect.apply(call, target, [
259
+ name,
260
+ (...params: unknown[]) => (name === "session_shutdown" || owner.active() ? handler(...params) : undefined),
261
+ ]);
262
+ }
263
+ if (key === "registerTool" || key === "registerCommand" || key === "registerShortcut") {
264
+ const index: number = key === "registerTool" ? 0 : 1;
265
+ const definition: Record<string, unknown> = args[index] as Record<string, unknown>;
266
+ const method: string = key === "registerTool" ? "execute" : "handler";
267
+ const callback: Callback = definition[method] as Callback;
268
+ args[index] = {
269
+ ...definition,
270
+ [method]: (...params: unknown[]): unknown => {
271
+ owner.assert();
272
+ return callback(...params);
273
+ },
274
+ };
275
+ }
276
+ if (["appendEntry", "sendMessage", "sendUserMessage", "setModel", "setActiveTools"].includes(String(key)))
277
+ owner.assert();
278
+ return Reflect.apply(call, target, args);
279
+ }