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
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 =
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
const {
|
|
296
|
-
|
|
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({
|
|
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
|
+
}
|