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
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,12 @@ 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>;
63
+ /** The parent's current model and thinking level, which a field the agent locks but does not set keeps. */
64
+ parentDefaults(ctx: ExtensionContext): { model?: string; thinking: string };
55
65
  chooseModel(
56
66
  explicit: string | undefined,
57
67
  agent: Agent,
@@ -64,6 +74,7 @@ export type ResumeDeps = {
64
74
  cwd: string,
65
75
  ctx: ExtensionContext,
66
76
  signal: AbortSignal | undefined,
77
+ agent: string,
67
78
  ): Promise<{ workspace: Workspace; trusted: boolean }>;
68
79
  /** Mail the task to a session id; returns the request id. */
69
80
  sendTask(to: string, task: string): string;
@@ -76,10 +87,12 @@ export type ResumeDeps = {
76
87
  trusted: boolean;
77
88
  cwd: string;
78
89
  sessionDir: string;
79
- 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[] };
80
91
  toolCallId: string | undefined;
81
92
  ctx: ExtensionContext;
82
93
  placement: WindowPlacement;
94
+ identity: Identity;
95
+ sessionId: string;
83
96
  }): Promise<OpenedWindow>;
84
97
  };
85
98
 
@@ -164,7 +177,7 @@ function resultSchema(delegateSchema: TObject): TObject {
164
177
 
165
178
  /** The model, thinking and auto-exit a resume runs with, and the directory and workspace it opens in. */
166
179
  type Plan = {
167
- 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[] };
168
181
  chosen: Awaited<ReturnType<ResumeDeps["chooseModel"]>>;
169
182
  workspace: Workspace;
170
183
  trusted: boolean;
@@ -180,11 +193,11 @@ async function planResume(
180
193
  const { record, agent, session, request } = input;
181
194
  const { thinking, chosen } = await settleModel(deps, input, ctx);
182
195
  const autoExit = request.autoExit ?? record.autoExit === true;
183
- const prepared = await deps.prepareDirectory(session.cwd, ctx, signal);
196
+ const prepared = await deps.prepareDirectory(session.cwd, ctx, signal, agent.name);
184
197
  // A managed worktree keeps its launch facts while the session still runs in it.
185
198
  const workspace = record.workspace?.cwd === prepared.workspace.cwd ? record.workspace : prepared.workspace;
186
199
  return {
187
- start: { agent, model: chosen.model, rest: chosen.rest, thinking, autoExit },
200
+ start: { agent, model: chosen.model, rest: chosen.rest, thinking, autoExit, notices: chosen.notices },
188
201
  chosen,
189
202
  workspace,
190
203
  trusted: prepared.trusted,
@@ -208,26 +221,40 @@ async function mailTask(deps: ResumeDeps, windowId: string, sessionId: string, t
208
221
  }
209
222
  }
210
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
+
211
235
  async function startResume(
212
236
  deps: ResumeDeps,
213
237
  input: {
214
- record: StartedRecorded;
238
+ record: StartedRecorded & { identity: Identity };
215
239
  session: SessionFile;
216
240
  plan: Plan;
217
241
  task: string;
218
242
  toolCallId: string | undefined;
219
243
  placement: WindowPlacement;
244
+ reservation: Reservation;
220
245
  },
221
246
  ctx: ExtensionContext,
222
247
  ): Promise<Resumed> {
223
248
  const { record, session, plan, task, toolCallId, placement } = input;
224
249
  const { start, chosen, workspace } = plan;
225
- const id = randomUUID();
250
+ const id = input.reservation.id;
226
251
  const sessionId = sessionIdOf(record);
227
252
  const name = recordedName(record);
228
253
  const { id: windowId, session: windowSession } = await deps.open({
229
254
  id,
230
255
  name,
256
+ identity: record.identity,
257
+ sessionId,
231
258
  sessionPath: session.path,
232
259
  workspace,
233
260
  trusted: plan.trusted,
@@ -239,10 +266,13 @@ async function startResume(
239
266
  placement,
240
267
  });
241
268
  const requestId = await mailTask(deps, windowId, sessionId, task);
269
+ publishResumeRequest(input.reservation, requestId, windowId, ctx);
242
270
  deps.results.recordStart(
243
271
  {
244
272
  id,
273
+ identity: readIdentity(sessionId) ?? record.identity,
245
274
  agent: start.agent.name,
275
+ ...(start.agent.displayName === undefined ? {} : { displayName: start.agent.displayName }),
246
276
  model: start.model,
247
277
  thinking: start.thinking,
248
278
  name,
@@ -281,6 +311,13 @@ async function startResume(
281
311
  };
282
312
  }
283
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
+
284
321
  /** Resume `request.id`: every eligibility check in order, then the launch. */
285
322
  async function resume(
286
323
  deps: ResumeDeps,
@@ -290,19 +327,34 @@ async function resume(
290
327
  ): Promise<Resumed> {
291
328
  const placement = await preflight(deps); // read once, before the exited window is killed or anything is mailed
292
329
  if (request.task.trim() === "") throw new Error("a task is required");
293
- const record = newestStarted(deps.results, request.id);
294
- await ensureExited(record, deps.tmux);
295
- const { session, agent } = recordCheck(deps, record, ctx);
296
- 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;
297
338
  try {
339
+ await ensureExited(record, deps.tmux);
340
+ const { session, agent } = recordCheck(deps, record, ctx);
341
+ inFlight.add(record.id);
342
+ acquired = true;
298
343
  const plan = await planResume(deps, { record, agent, session, request }, ctx, signal);
299
344
  return await startResume(
300
345
  deps,
301
- { record, session, plan, task: request.task, toolCallId: request.toolCallId, placement },
346
+ { record, session, plan, task: request.task, toolCallId: request.toolCallId, placement, reservation },
302
347
  ctx,
303
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;
304
356
  } finally {
305
- inFlight.delete(record.id);
357
+ if (acquired) inFlight.delete(record.id);
306
358
  }
307
359
  }
308
360
 
@@ -339,7 +391,7 @@ function resumeTool(deps: ResumeDeps): ToolDef {
339
391
  name: "delegation_resume",
340
392
  label: "Resume delegation",
341
393
  description:
342
- "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.",
343
395
  parameters: Type.Object({
344
396
  id: Type.String({ description: "The delegation to resume, from a delegate result or delegation_status." }),
345
397
  task: Type.String({
@@ -348,11 +400,13 @@ function resumeTool(deps: ResumeDeps): ToolDef {
348
400
  model: Type.Optional(
349
401
  Type.String({
350
402
  description:
351
- "provider/id to resume on; used as given. Omit to continue on the model the delegate finished on, or its agent's next fallback when that one is unavailable.",
403
+ "provider/id to resume on; used as given. Omit to continue on the model the delegate finished on, or its agent's next fallback when that one is unavailable. Not used when the agent locks model: the agent's current model applies.",
352
404
  }),
353
405
  ),
354
406
  thinking: Type.Optional(
355
- Type.String({ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the delegation's.` }),
407
+ Type.String({
408
+ description: `Thinking level (${THINKING_LEVELS.join(", ")}); defaults to the level the delegate last ran on, from its session. Not used when the agent locks thinking: the agent's current level applies.`,
409
+ }),
356
410
  ),
357
411
  auto_exit: Type.Optional(
358
412
  Type.Boolean({ description: "Close the window once it finishes; defaults to the delegation's setting." }),
@@ -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
+ }