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.
- package/CHANGELOG.md +323 -1
- package/README.md +918 -117
- package/package.json +2 -1
- package/src/agents.ts +57 -36
- package/src/child-tasks.ts +221 -0
- package/src/child.ts +35 -41
- 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 +358 -56
- package/src/local-state.ts +21 -0
- package/src/navigator-view.ts +186 -0
- package/src/navigator.ts +152 -107
- 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-types.ts +29 -1
- package/src/results-presentation.ts +47 -0
- package/src/results.ts +174 -108
- package/src/resume-checks.ts +14 -2
- package/src/resume-picker.ts +9 -2
- package/src/resume.ts +62 -13
- 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-checks.ts
CHANGED
|
@@ -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);
|
package/src/resume-picker.ts
CHANGED
|
@@ -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 =
|
|
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 =
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
const {
|
|
299
|
-
|
|
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
|
+
}
|