pi-squire 0.14.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/CHANGELOG.md +438 -1
  2. package/README.md +1062 -152
  3. package/package.json +4 -3
  4. package/src/agents.ts +395 -124
  5. package/src/child-tasks.ts +221 -0
  6. package/src/child.ts +107 -62
  7. package/src/completion-holds.ts +104 -0
  8. package/src/dashboard.ts +144 -0
  9. package/src/git.ts +6 -1
  10. package/src/identity.ts +152 -0
  11. package/src/index.ts +585 -107
  12. package/src/local-state.ts +21 -0
  13. package/src/navigator-view.ts +186 -0
  14. package/src/navigator.ts +152 -107
  15. package/src/new-delegate.ts +17 -8
  16. package/src/poll.ts +1 -0
  17. package/src/render.ts +4 -1
  18. package/src/result-close.ts +37 -0
  19. package/src/result-restore.ts +95 -0
  20. package/src/result-sessions.ts +39 -0
  21. package/src/result-types.ts +29 -1
  22. package/src/results-presentation.ts +47 -0
  23. package/src/results.ts +176 -108
  24. package/src/resume-checks.ts +59 -14
  25. package/src/resume-picker.ts +9 -2
  26. package/src/resume.ts +69 -15
  27. package/src/session-owner.ts +279 -0
  28. package/src/task-replies.ts +247 -0
  29. package/src/task-settlement.ts +81 -0
  30. package/src/task-store.ts +26 -0
  31. package/src/tmux.ts +134 -54
  32. package/src/tool-ceiling.ts +125 -0
  33. package/src/tracking.ts +72 -65
  34. package/src/tree-close.ts +236 -0
  35. package/src/tree-fence.ts +51 -0
  36. package/src/tree-lock.ts +77 -0
  37. package/src/tree-notice.ts +124 -0
  38. package/src/tree-registry.ts +365 -0
  39. package/src/tree-settings.ts +31 -0
  40. package/src/tree-types.ts +32 -0
  41. package/src/tree-view.ts +246 -0
  42. package/src/trust-control.ts +214 -0
  43. package/src/trust-prompt.ts +163 -0
  44. package/src/trust-wire.ts +203 -0
  45. package/src/widget-activity-join.ts +24 -0
  46. package/src/widget-activity-runtime.ts +104 -0
  47. package/src/widget-activity.ts +104 -0
  48. package/src/widget-ancestors.ts +142 -0
  49. package/src/widget-current.ts +41 -0
  50. package/src/widget-data.ts +147 -0
  51. package/src/widget-focus.ts +133 -0
  52. package/src/widget-live-process.ts +31 -0
  53. package/src/widget-live.ts +356 -0
  54. package/src/widget-render.ts +181 -0
  55. package/src/widget-selection.ts +58 -0
  56. package/src/widget-settings.ts +29 -0
  57. package/src/widget-summary.ts +51 -0
  58. package/src/widget-title-file.ts +127 -0
  59. package/src/widget-title.ts +118 -0
  60. package/src/widget.ts +272 -0
  61. package/src/window-placement.ts +30 -5
  62. package/src/workspace.ts +38 -90
@@ -0,0 +1,246 @@
1
+ // Read-only projection. Shared ancestry locates records; only each direct
2
+ // parent's local child facts carry semantic results. Never imports outcomes.
3
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
+ import { readChildRecords } from "./child-tasks.ts";
5
+ import { type Identity, maxDepth, readIdentity } from "./identity.ts";
6
+ import { isStarted, type Recorded, type Results, type StartedRecorded, sessionIdOf } from "./results.ts";
7
+ import { sessionIsOwned } from "./session-owner.ts";
8
+ import { descendants, readTree, type TreeEntry } from "./tree-registry.ts";
9
+ import { assertRootPlacement } from "./window-placement.ts";
10
+
11
+ export function recordedIdentity(record: StartedRecorded): Identity | undefined {
12
+ const durable: Identity | undefined = readIdentity(sessionIdOf(record));
13
+ if (
14
+ durable &&
15
+ record.identity &&
16
+ (durable.parent !== record.identity.parent ||
17
+ durable.root !== record.identity.root ||
18
+ durable.rootPlacement !== record.identity.rootPlacement ||
19
+ durable.depth !== record.identity.depth)
20
+ )
21
+ throw new Error(`conflicting ownership for delegation ${record.id}`);
22
+ const own: Identity | undefined = durable ?? record.identity;
23
+ if (own) assertRootPlacement(own.rootPlacement);
24
+ return own;
25
+ }
26
+ export function belongsTo(record: Recorded, ctx: ExtensionContext): boolean {
27
+ if (!isStarted(record)) return record.parentId === ctx.sessionManager.getSessionId();
28
+ if (record.identity?.parent && record.identity.parent !== ctx.sessionManager.getSessionId()) return false;
29
+ if (!record.identity && ctx.sessionManager.getHeader?.()?.parentSession) return false;
30
+ const identity: Identity | undefined = recordedIdentity(record);
31
+ if (identity) return identity.parent === ctx.sessionManager.getSessionId();
32
+ // Legacy starts without identity may be restored by their original session,
33
+ // never by a branch copied into a fresh session file.
34
+ return !ctx.sessionManager.getHeader?.()?.parentSession;
35
+ }
36
+ export function assertResumeAuthority(record: StartedRecorded, ctx: ExtensionContext): void {
37
+ const child: Identity | undefined = recordedIdentity(record);
38
+ if (!belongsTo(record, ctx))
39
+ throw new Error(
40
+ `only direct parent ${child?.parent ?? "(original session)"} may resume delegation ${record.id}; reopen that parent session`,
41
+ );
42
+ if (!child) throw new Error(`delegation ${record.id} has no original root placement identity; resume refused`);
43
+ const parent: Identity | undefined = readIdentity(ctx.sessionManager.getSessionId());
44
+ if (
45
+ child.root !== (parent?.root ?? ctx.sessionManager.getSessionId()) ||
46
+ child.depth !== (parent?.depth ?? 0) + 1 ||
47
+ (parent && child.rootPlacement !== parent.rootPlacement)
48
+ )
49
+ throw new Error(`delegation ${record.id} has inconsistent root/depth or rootPlacement ownership; resume refused`);
50
+ if (child.depth > maxDepth())
51
+ throw new Error(
52
+ `delegation ${record.id} depth ${child.depth} exceeds maxDepth ${maxDepth()} in squire/settings.json`,
53
+ );
54
+ const state = readTree(child.root);
55
+ const newer: TreeEntry | undefined = newestSuccessor(state.entries, record.id);
56
+ if (newer)
57
+ throw new Error(
58
+ `delegation ${record.id} was already resumed; resume its newest delegation ${newer.id} instead (it continues the same session)`,
59
+ );
60
+ }
61
+ function newestSuccessor(entries: TreeEntry[], id: string): TreeEntry | undefined {
62
+ const seen: Set<string> = new Set([id]);
63
+ let newest: TreeEntry | undefined;
64
+ let current: string = id;
65
+ for (;;) {
66
+ const next: TreeEntry | undefined = entries.find(
67
+ (entry) => entry.resumedFrom === current && (entry.phase === "started" || entry.phase === "dispatched"),
68
+ );
69
+ if (!next) return newest;
70
+ if (seen.has(next.id)) throw new Error("invalid shared resume chain; resume refused");
71
+ seen.add(next.id);
72
+ newest = next;
73
+ current = next.id;
74
+ }
75
+ }
76
+ export type VisibleFact = { record: Recorded; semantic?: Recorded; entry?: TreeEntry };
77
+ export type VisibleFacts = { records: Recorded[]; facts: VisibleFact[]; complete: boolean };
78
+
79
+ export function visibleRecords(results: Results, ctx: ExtensionContext): Recorded[] {
80
+ return visibleRecordFacts(results, ctx).records;
81
+ }
82
+
83
+ /** Same scoped projection, with unoverlaid parent facts and explicit coverage for read-only summaries. */
84
+ export function visibleRecordFacts(
85
+ results: Results,
86
+ ctx: ExtensionContext,
87
+ timeoutMs = 5000,
88
+ notify = true,
89
+ ): VisibleFacts {
90
+ const session: string = ctx.sessionManager.getSessionId();
91
+ const root: string = readIdentity(session)?.root ?? session;
92
+ const records: Map<string, Recorded> = new Map(results.list().map((record) => [record.id, record]));
93
+ const semantic: Map<string, Recorded> = new Map(records);
94
+ const parents: Map<string, Recorded[]> = new Map();
95
+ let complete = true;
96
+ let entries: TreeEntry[];
97
+ try {
98
+ entries = descendants(readTree(root, timeoutMs), session).filter((entry) => entry.phase !== "not-started");
99
+ } catch (error) {
100
+ if (notify)
101
+ ctx.ui.notify(
102
+ `delegate: descendant accounting unavailable: ${String(error)}; showing saved local children only.`,
103
+ "warning",
104
+ );
105
+ return {
106
+ records: [...records.values()],
107
+ facts: [...records.values()].map((record) => ({ record, semantic: record })),
108
+ complete: false,
109
+ };
110
+ }
111
+ for (const entry of entries) {
112
+ const local: Recorded | undefined = cachedParent(parents, entry.identity.parent, ctx, notify).find(
113
+ (record) => record.id === entry.id,
114
+ );
115
+ if (rememberSemantic(semantic, entry.id, local)) complete = false;
116
+ const record: Recorded = local ?? entry.launch ?? registryView(entry);
117
+ if (reattached(entry, record) && isStarted(record)) {
118
+ records.set(entry.id, {
119
+ ...record,
120
+ identity: entry.identity,
121
+ windowId: entry.windowId ?? record.windowId,
122
+ windowName: entry.windowName ?? record.windowName,
123
+ });
124
+ } else if (!records.has(entry.id)) records.set(entry.id, { ...record, identity: entry.identity });
125
+ }
126
+ const ordered: Recorded[] = hierarchy(withCloses(records, entries), session);
127
+ return {
128
+ records: ordered,
129
+ facts: ordered.map((record) => ({
130
+ record,
131
+ semantic: semantic.get(record.id),
132
+ entry: entries.find((entry) => entry.id === record.id),
133
+ })),
134
+ complete,
135
+ };
136
+ }
137
+ function cachedParent(
138
+ parents: Map<string, Recorded[]>,
139
+ parent: string,
140
+ ctx: ExtensionContext,
141
+ notify: boolean,
142
+ ): Recorded[] {
143
+ let records: Recorded[] | undefined = parents.get(parent);
144
+ if (!records) {
145
+ records = parentRecords(parent, ctx, notify);
146
+ parents.set(parent, records);
147
+ }
148
+ return records;
149
+ }
150
+ function rememberSemantic(semantic: Map<string, Recorded>, id: string, local: Recorded | undefined): boolean {
151
+ if (local && !semantic.has(id)) semantic.set(id, local);
152
+ return !semantic.has(id);
153
+ }
154
+ function reattached(entry: TreeEntry, record: Recorded): boolean {
155
+ return entry.gone === true && !entry.finished && sessionIsOwned(entry.sessionId) && isStarted(record);
156
+ }
157
+ function withCloses(records: Map<string, Recorded>, entries: TreeEntry[]): Recorded[] {
158
+ for (const entry of entries) {
159
+ const record: Recorded | undefined = records.get(entry.id);
160
+ if (entry.closed && record && isStarted(record)) records.set(entry.id, { ...record, closed: true });
161
+ }
162
+ return [...records.values()];
163
+ }
164
+ function parentRecords(parent: string, ctx: ExtensionContext, notify: boolean): Recorded[] {
165
+ try {
166
+ return readChildRecords(parent);
167
+ } catch (error) {
168
+ if (notify)
169
+ ctx.ui.notify(
170
+ `delegate: child facts for ${parent} unavailable: ${String(error)}; showing registry metadata only.`,
171
+ "warning",
172
+ );
173
+ return [];
174
+ }
175
+ }
176
+ export function restoredStarts(ctx: ExtensionContext): StartedRecorded[] {
177
+ const session: string = ctx.sessionManager.getSessionId();
178
+ const root: string = readIdentity(session)?.root ?? session;
179
+ return readTree(root)
180
+ .entries.filter((entry) => entry.identity.parent === session && entry.phase === "started")
181
+ .flatMap((entry) => (entry.launch && entry.requestId && entry.windowId ? [entry.launch] : []));
182
+ }
183
+ function registryView(entry: TreeEntry): StartedRecorded {
184
+ return {
185
+ id: entry.id,
186
+ sessionId: entry.sessionId,
187
+ identity: entry.identity,
188
+ accountingOnly: true,
189
+ agent: entry.identity.agent,
190
+ autoExit: entry.identity.autoExit,
191
+ model: "unknown",
192
+ thinking: "unknown",
193
+ windowId: entry.windowId ?? "not-recorded",
194
+ windowName: entry.windowName ?? "not-recorded",
195
+ requestId: entry.requestId ?? "not-recorded",
196
+ cwd: entry.cwd,
197
+ sessionDir: entry.sessionDir,
198
+ workspace: entry.workspace,
199
+ resumedFrom: entry.resumedFrom,
200
+ };
201
+ }
202
+ function hierarchy(records: Recorded[], viewer: string): Recorded[] {
203
+ const byParent: Map<string, Recorded[]> = new Map();
204
+ const latest: Map<string, string> = new Map();
205
+ for (const record of records) {
206
+ const parent: string = isStarted(record) ? (record.identity?.parent ?? viewer) : record.parentId;
207
+ byParent.set(parent, [...(byParent.get(parent) ?? []), record]);
208
+ if (isStarted(record)) latest.set(sessionIdOf(record), record.id);
209
+ }
210
+ const ordered: Recorded[] = [];
211
+ const seen: Set<string> = new Set();
212
+ const walk = (parent: string): void => {
213
+ for (const record of byParent.get(parent) ?? []) {
214
+ if (seen.has(record.id)) continue;
215
+ seen.add(record.id);
216
+ ordered.push(record);
217
+ if (isStarted(record) && latest.get(sessionIdOf(record)) === record.id) walk(sessionIdOf(record));
218
+ }
219
+ };
220
+ walk(viewer);
221
+ return [...ordered, ...records.filter((record) => !seen.has(record.id))];
222
+ }
223
+ /** A view can know terminal accounting while its owner has not received mail. */
224
+ export function accountingFor(record: StartedRecorded): string | undefined {
225
+ if (!record.identity) return undefined;
226
+ let entry: TreeEntry | undefined;
227
+ try {
228
+ entry = readTree(record.identity.root).entries.find((item) => item.id === record.id);
229
+ } catch {
230
+ return "unavailable";
231
+ }
232
+ if (!entry) return undefined;
233
+ if (entry.closed) return "closed";
234
+ if (entry.closing) return "closing";
235
+ if (entry.finished) return "finished";
236
+ if (entry.gone) return sessionIsOwned(entry.sessionId) ? "reattached" : "gone";
237
+ return entry.phase;
238
+ }
239
+ export function historicalRecord(id: string, ctx: ExtensionContext): StartedRecorded | undefined {
240
+ for (const entry of ctx.sessionManager.getEntries())
241
+ if (entry.type === "custom" && entry.customType === "delegate") {
242
+ const value = entry.data as StartedRecorded | undefined;
243
+ if (value?.id === id && typeof value.requestId === "string") return value;
244
+ }
245
+ return undefined;
246
+ }
@@ -0,0 +1,214 @@
1
+ // Root-owned, non-model project-trust control. Each runtime has a fresh token;
2
+ // sockets and pending prompts die on replacement. Nothing is replayed on reopen.
3
+ import { randomUUID } from "node:crypto";
4
+ import { unlinkSync } from "node:fs";
5
+ import { createServer, type Server, type Socket } from "node:net";
6
+ import { isAbsolute } from "node:path";
7
+ import { type ExtensionAPI, type ExtensionContext, getAgentDir } from "@earendil-works/pi-coding-agent";
8
+ import { type Identity, readIdentity } from "./identity.ts";
9
+ import { readState, writeState } from "./local-state.ts";
10
+ import {
11
+ TRUST_TIMEOUT_MS,
12
+ type TrustDecision,
13
+ TrustPrompts,
14
+ type TrustQuestion,
15
+ type TrustResolver,
16
+ trustLifetime,
17
+ trustStopped,
18
+ } from "./trust-prompt.ts";
19
+ import {
20
+ readTrustFrame,
21
+ requestRootTrust,
22
+ type TrustEndpoint,
23
+ type TrustRequest,
24
+ trustEndpointPath,
25
+ trustProof,
26
+ writeTrustFrame,
27
+ } from "./trust-wire.ts";
28
+
29
+ function validRequest(value: unknown, root: string, token: string): TrustRequest {
30
+ const request: TrustRequest = value as TrustRequest;
31
+ if (
32
+ !request ||
33
+ request.token !== token ||
34
+ request.root !== root ||
35
+ typeof request.id !== "string" ||
36
+ typeof request.requester !== "string" ||
37
+ !Array.isArray(request.chain) ||
38
+ !request.chain.every((name) => typeof name === "string" && /^[a-zA-Z0-9_-]+$/.test(name)) ||
39
+ typeof request.directory !== "string" ||
40
+ !isAbsolute(request.directory) ||
41
+ typeof request.key !== "string" ||
42
+ !isAbsolute(request.key) ||
43
+ !Number.isSafeInteger(request.expiresAt) ||
44
+ request.expiresAt <= Date.now() ||
45
+ request.expiresAt > Date.now() + TRUST_TIMEOUT_MS
46
+ )
47
+ throw new Error("invalid or expired project trust request; nothing was launched");
48
+ const own: Identity | undefined = readIdentity(request.requester);
49
+ if (
50
+ !own ||
51
+ own.root !== root ||
52
+ request.chain.length !== own.chain.length + 1 ||
53
+ !own.chain.every((name, index) => name === request.chain[index])
54
+ )
55
+ throw new Error("project trust requester does not belong to this root; nothing was launched");
56
+ return request;
57
+ }
58
+
59
+ class TrustRuntime {
60
+ readonly stopped: AbortController = new AbortController();
61
+ readonly prompts: TrustPrompts;
62
+ private readonly agentDir: string;
63
+ private readonly root: string;
64
+ private readonly token: string = randomUUID();
65
+ private server: Server | undefined;
66
+ private readonly sockets: Set<Socket> = new Set();
67
+
68
+ constructor(ctx: ExtensionContext, agentDir: string, attended: boolean) {
69
+ this.agentDir = agentDir;
70
+ this.root = ctx.sessionManager.getSessionId();
71
+ this.prompts = new TrustPrompts(ctx, agentDir, attended);
72
+ }
73
+
74
+ async start(): Promise<void> {
75
+ const server: Server = createServer((socket) => this.accept(socket));
76
+ this.server = server;
77
+ await new Promise<void>((resolve, reject) => {
78
+ server.once("error", reject);
79
+ server.listen(0, "127.0.0.1", () => {
80
+ server.removeListener("error", reject);
81
+ resolve();
82
+ });
83
+ });
84
+ server.unref();
85
+ server.on("error", () => this.stop());
86
+ if (this.stopped.signal.aborted) {
87
+ server.close();
88
+ return;
89
+ }
90
+ const address: ReturnType<Server["address"]> = server.address();
91
+ if (!address || typeof address === "string") throw new Error("project trust endpoint has no loopback port");
92
+ writeState(trustEndpointPath(this.agentDir, this.root), { port: address.port, token: this.token });
93
+ }
94
+
95
+ stop(): void {
96
+ if (this.stopped.signal.aborted) return;
97
+ this.stopped.abort(new Error("the project trust session was replaced or stopped; nothing was launched"));
98
+ for (const socket of this.sockets) socket.destroy();
99
+ this.server?.close(() => {});
100
+ const path: string = trustEndpointPath(this.agentDir, this.root);
101
+ try {
102
+ if (readState<TrustEndpoint>(path)?.token === this.token) unlinkSync(path);
103
+ } catch {
104
+ /* A stale descriptor cannot authenticate a new runtime. */
105
+ }
106
+ }
107
+
108
+ private accept(socket: Socket): void {
109
+ this.sockets.add(socket);
110
+ const closed: AbortController = new AbortController();
111
+ socket.on("error", () => socket.destroy());
112
+ socket.on("close", () => {
113
+ this.sockets.delete(socket);
114
+ closed.abort(new Error("the requesting launch was canceled or stopped; nothing was launched"));
115
+ });
116
+ // Bound connections which never provide a complete first frame.
117
+ socket.setTimeout(1000, () => socket.destroy());
118
+ readTrustFrame(
119
+ socket,
120
+ (value) => {
121
+ if (
122
+ !value ||
123
+ typeof value !== "object" ||
124
+ !("nonce" in value) ||
125
+ typeof value.nonce !== "string" ||
126
+ value.nonce.length > 128
127
+ ) {
128
+ socket.destroy();
129
+ return;
130
+ }
131
+ writeTrustFrame(socket, { proof: trustProof(this.token, value.nonce) });
132
+ readTrustFrame(
133
+ socket,
134
+ (request) => {
135
+ socket.setTimeout(0);
136
+ this.answer(socket, request, closed.signal).catch(() => socket.destroy());
137
+ },
138
+ () => socket.destroy(),
139
+ );
140
+ },
141
+ () => socket.destroy(),
142
+ );
143
+ }
144
+
145
+ private async answer(socket: Socket, value: unknown, disconnected: AbortSignal): Promise<void> {
146
+ const id: string | undefined =
147
+ typeof value === "object" && value !== null && "id" in value && typeof value.id === "string"
148
+ ? value.id
149
+ : undefined;
150
+ let request: TrustRequest | undefined;
151
+ try {
152
+ request = validRequest(value, this.root, this.token);
153
+ const lifetime: ReturnType<typeof trustLifetime> = trustLifetime(request.expiresAt, [
154
+ disconnected,
155
+ this.stopped.signal,
156
+ ]);
157
+ try {
158
+ const decision: TrustDecision = await this.prompts.ask(request, lifetime.signal, request.expiresAt);
159
+ decision.assertLive();
160
+ socket.once("close", decision.release);
161
+ writeTrustFrame(socket, { id: request.id, answer: decision.answer });
162
+ } finally {
163
+ lifetime.dispose();
164
+ }
165
+ } catch (error) {
166
+ writeTrustFrame(socket, { id, error: error instanceof Error ? error.message : String(error) });
167
+ socket.end();
168
+ }
169
+ }
170
+ }
171
+
172
+ export type TrustControl = { forAgent(agent: string, ctx: ExtensionContext): TrustResolver };
173
+ export function registerTrustControl(pi: ExtensionAPI, identity: () => Identity | undefined): TrustControl {
174
+ const agentDir: string = getAgentDir();
175
+ const attended: boolean = process.env.PI_UNATTENDED !== "1";
176
+ let runtime: TrustRuntime | undefined;
177
+ pi.on("session_start", async (_event, ctx) => {
178
+ runtime?.stop();
179
+ const active: TrustRuntime = new TrustRuntime(ctx, agentDir, attended);
180
+ runtime = active;
181
+ if (identity() || !ctx.hasUI || !attended) return;
182
+ try {
183
+ await active.start();
184
+ } catch (error) {
185
+ active.stop();
186
+ // A transport failure must not take away the root's existing local UI.
187
+ if (runtime === active) runtime = new TrustRuntime(ctx, agentDir, attended);
188
+ ctx.ui.notify(`delegate: could not start root project trust control: ${String(error)}`, "error");
189
+ }
190
+ });
191
+ pi.on("session_shutdown", () => runtime?.stop());
192
+ return {
193
+ forAgent: (agent: string, ctx: ExtensionContext): TrustResolver => {
194
+ const active: TrustRuntime | undefined = runtime;
195
+ if (!active) throw new Error("project trust session has not started");
196
+ const own: Identity | undefined = identity();
197
+ return (directory: string, key: string, signal?: AbortSignal): Promise<TrustDecision> => {
198
+ const question: TrustQuestion = {
199
+ directory,
200
+ key,
201
+ requester: ctx.sessionManager.getSessionId(),
202
+ chain: [...(own?.chain ?? []), agent],
203
+ };
204
+ const combined: AbortSignal = AbortSignal.any([
205
+ active.stopped.signal,
206
+ ...[signal].filter((item): item is AbortSignal => item !== undefined),
207
+ ]);
208
+ trustStopped(combined);
209
+ if (own) return requestRootTrust(agentDir, own.root, question, combined);
210
+ return active.prompts.ask(question, combined);
211
+ };
212
+ },
213
+ };
214
+ }
@@ -0,0 +1,163 @@
1
+ // Project-trust presentation shared by local and root-forwarded requests.
2
+ // Decisions are returned, not saved here: only the live requester may save one.
3
+
4
+ import { dirname } from "node:path";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { ProjectTrustStore } from "@earendil-works/pi-coding-agent";
7
+
8
+ export const TRUST_TIMEOUT_MS = 10 * 60 * 1000;
9
+ export type TrustQuestion = { directory: string; key: string; requester: string; chain: readonly string[] };
10
+ export type TrustAnswer = { saved: boolean } | { label: string };
11
+ export type TrustDecision = { answer: TrustAnswer; assertLive(): void; release(): void };
12
+ export type TrustResolver = (directory: string, key: string, signal?: AbortSignal) => Promise<TrustDecision>;
13
+ export type TrustChoice = { label: string; trusted: boolean; updates: { path: string; decision: boolean | null }[] };
14
+
15
+ export function trustChoices(key: string): TrustChoice[] {
16
+ const parent: string = dirname(key);
17
+ return [
18
+ { label: "Trust", trusted: true, updates: [{ path: key, decision: true }] },
19
+ ...(parent === key
20
+ ? []
21
+ : [
22
+ {
23
+ label: `Trust parent folder (${parent})`,
24
+ trusted: true,
25
+ updates: [
26
+ { path: parent, decision: true },
27
+ { path: key, decision: null },
28
+ ],
29
+ },
30
+ ]),
31
+ { label: "Trust (this session only)", trusted: true, updates: [] },
32
+ { label: "Do not trust", trusted: false, updates: [{ path: key, decision: false }] },
33
+ { label: "Do not trust (this session only)", trusted: false, updates: [] },
34
+ ];
35
+ }
36
+
37
+ export function trustStopped(signal?: AbortSignal): void {
38
+ if (signal?.aborted)
39
+ throw signal.reason instanceof Error ? signal.reason : new Error("the launch was aborted; nothing was launched");
40
+ }
41
+
42
+ /** Also bounds UIs which ignore their cancellation signal; late resolutions have no side effects. */
43
+ export async function waitForTrust<T>(work: Promise<T>, signal: AbortSignal): Promise<T> {
44
+ trustStopped(signal);
45
+ let abort: () => void = () => {};
46
+ const canceled: Promise<never> = new Promise((_, reject) => {
47
+ abort = (): void =>
48
+ reject(
49
+ signal.reason instanceof Error ? signal.reason : new Error("the launch was aborted; nothing was launched"),
50
+ );
51
+ signal.addEventListener("abort", abort, { once: true });
52
+ });
53
+ try {
54
+ return await Promise.race([work, canceled]);
55
+ } finally {
56
+ signal.removeEventListener("abort", abort);
57
+ }
58
+ }
59
+
60
+ export function trustLifetime(
61
+ expiresAt: number,
62
+ signals: (AbortSignal | undefined)[],
63
+ ): { signal: AbortSignal; dispose(): void } {
64
+ const timeout: AbortController = new AbortController();
65
+ const remaining: number = expiresAt - Date.now();
66
+ const expire = (): void => timeout.abort(new Error("the project trust request expired; nothing was launched"));
67
+ const timer: ReturnType<typeof setTimeout> = setTimeout(expire, Math.max(0, remaining));
68
+ timer.unref();
69
+ if (remaining <= 0) expire();
70
+ const signal: AbortSignal = AbortSignal.any([
71
+ timeout.signal,
72
+ ...signals.filter((item): item is AbortSignal => item !== undefined),
73
+ ]);
74
+ return { signal, dispose: () => clearTimeout(timer) };
75
+ }
76
+
77
+ export class TrustPrompts {
78
+ private readonly ctx: ExtensionContext;
79
+ private readonly agentDir: string;
80
+ private readonly attended: boolean;
81
+ private queue: Promise<unknown> = Promise.resolve();
82
+
83
+ constructor(ctx: ExtensionContext, agentDir: string, attended = true) {
84
+ this.ctx = ctx;
85
+ this.agentDir = agentDir;
86
+ this.attended = attended;
87
+ }
88
+
89
+ async ask(
90
+ question: TrustQuestion,
91
+ signal?: AbortSignal,
92
+ expiresAt = Date.now() + TRUST_TIMEOUT_MS,
93
+ ): Promise<TrustDecision> {
94
+ const lifetime: ReturnType<typeof trustLifetime> = trustLifetime(expiresAt, [signal]);
95
+ let deliver: (decision: TrustDecision) => void = () => {};
96
+ let fail: (error: unknown) => void = () => {};
97
+ const response: Promise<TrustDecision> = new Promise((resolve, reject) => {
98
+ deliver = resolve;
99
+ fail = reject;
100
+ });
101
+ const assertLive = (): void => {
102
+ trustStopped(lifetime.signal);
103
+ if (Date.now() >= expiresAt) throw new Error("the project trust request expired; nothing was launched");
104
+ };
105
+ const run = async (): Promise<void> => {
106
+ assertLive();
107
+ if (!this.ctx.hasUI || !this.attended)
108
+ throw new Error(
109
+ `the target exposes project resources and project trust needs an attended root with a dialog-capable UI; open Pi in ${question.key}, answer its trust question, then delegate again; nothing was launched`,
110
+ );
111
+ const answer: TrustAnswer = await this.select(question, lifetime.signal, expiresAt);
112
+ let release: () => void = () => {};
113
+ const released: Promise<void> = new Promise((resolve) => {
114
+ release = resolve;
115
+ });
116
+ assertLive();
117
+ deliver({ answer, assertLive, release });
118
+ // Keep the queue through requester persistence (or disconnect/cancel).
119
+ await waitForTrust(released, lifetime.signal);
120
+ };
121
+ const turn: Promise<void> = this.queue.then(run, run);
122
+ this.queue = turn.catch(fail).finally(() => lifetime.dispose());
123
+ return waitForTrust(response, lifetime.signal);
124
+ }
125
+
126
+ private async select(question: TrustQuestion, signal: AbortSignal, expiresAt: number): Promise<TrustAnswer> {
127
+ const saved: boolean | null = new ProjectTrustStore(this.agentDir).get(question.key);
128
+ if (saved !== null) return { saved };
129
+ const title: string = [
130
+ "A delegate is about to start in a directory with project resources Pi loads only when trusted.",
131
+ ...(question.chain.length
132
+ ? [`Agent chain: ${question.chain.join(" → ")}`, `Requester session: ${question.requester}`]
133
+ : []),
134
+ `Directory: ${question.directory}`,
135
+ ...(question.key === question.directory ? [] : [`Main repository: ${question.key}`]),
136
+ `"Trust" and "Do not trust" save the decision for ${question.key}; "Trust parent folder" saves it for the folder named; "this session only" applies to this launch alone.`,
137
+ ].join("\n");
138
+ const options: string[] = trustChoices(question.key).map((option) => option.label);
139
+ const label: string | undefined = await waitForTrust(
140
+ this.ctx.ui.select(title, options, {
141
+ signal,
142
+ timeout: Math.max(1, expiresAt - Date.now()),
143
+ }),
144
+ signal,
145
+ );
146
+ trustStopped(signal);
147
+ if (label === undefined || !options.includes(label))
148
+ throw new Error(`the trust question for ${question.directory} was cancelled; nothing was launched`);
149
+ return { label };
150
+ }
151
+ }
152
+
153
+ const standalone: WeakMap<ExtensionContext, TrustPrompts> = new WeakMap();
154
+ export function localTrust(ctx: ExtensionContext, agentDir: string): TrustResolver {
155
+ let prompts: TrustPrompts | undefined = standalone.get(ctx);
156
+ if (!prompts) {
157
+ prompts = new TrustPrompts(ctx, agentDir, process.env.PI_UNATTENDED !== "1");
158
+ standalone.set(ctx, prompts);
159
+ }
160
+ const queue: TrustPrompts = prompts;
161
+ return (directory: string, key: string, signal?: AbortSignal): Promise<TrustDecision> =>
162
+ queue.ask({ directory, key, chain: [], requester: ctx.sessionManager.getSessionId() }, signal);
163
+ }