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/widget.ts
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Results } from "./results.ts";
|
|
3
|
+
import type { TmuxClient, TmuxWindow } from "./tmux.ts";
|
|
4
|
+
import { type VisibleFacts, visibleRecordFacts } from "./tree-view.ts";
|
|
5
|
+
import { joinActivities } from "./widget-activity-join.ts";
|
|
6
|
+
import { type AncestorRow, AncestorView, switchAncestor } from "./widget-ancestors.ts";
|
|
7
|
+
import { currentChildWindows } from "./widget-current.ts";
|
|
8
|
+
import { type DelegateRow, DirectDelegateView, type MetadataViewport } from "./widget-data.ts";
|
|
9
|
+
import { WidgetFocus } from "./widget-focus.ts";
|
|
10
|
+
import { LiveActivityReader } from "./widget-live.ts";
|
|
11
|
+
import { renderDelegateWidget } from "./widget-render.ts";
|
|
12
|
+
import { WidgetSelection } from "./widget-selection.ts";
|
|
13
|
+
import { DEFAULT_WIDGET_SETTINGS, type WidgetSettings, widgetSettings } from "./widget-settings.ts";
|
|
14
|
+
import { taskSummary } from "./widget-summary.ts";
|
|
15
|
+
import { titlePublisher } from "./widget-title.ts";
|
|
16
|
+
import { listWindows } from "./windows.ts";
|
|
17
|
+
|
|
18
|
+
export const DELEGATE_WIDGET_KEY = "squire-delegates";
|
|
19
|
+
export const WIDGET_REFRESH_MS = 1000;
|
|
20
|
+
type OpenDashboard = (ctx: ExtensionContext, sessionId: string) => Promise<void>;
|
|
21
|
+
|
|
22
|
+
/** Observation and UI only; direct Results/task authority and the configured editor/footer stay untouched. */
|
|
23
|
+
class Overview {
|
|
24
|
+
private readonly results: Results;
|
|
25
|
+
private readonly tmux: TmuxClient;
|
|
26
|
+
private readonly view = new DirectDelegateView();
|
|
27
|
+
private readonly ancestorView = new AncestorView();
|
|
28
|
+
private readonly live = new LiveActivityReader();
|
|
29
|
+
private ancestors: AncestorRow[] = [];
|
|
30
|
+
private ancestryError = "";
|
|
31
|
+
private readonly ctx: ExtensionContext;
|
|
32
|
+
private readonly selection = new WidgetSelection();
|
|
33
|
+
private readonly focus: WidgetFocus;
|
|
34
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
35
|
+
private immediate: ReturnType<typeof setImmediate> | undefined;
|
|
36
|
+
private unsubscribe: (() => void) | undefined;
|
|
37
|
+
private stopped = false;
|
|
38
|
+
private reading = false;
|
|
39
|
+
private again = false;
|
|
40
|
+
private signature = "";
|
|
41
|
+
private warning = "";
|
|
42
|
+
private countVisible: boolean | undefined;
|
|
43
|
+
private factsRevision = 0;
|
|
44
|
+
private layoutRevision = 0;
|
|
45
|
+
private rows: DelegateRow[] = [];
|
|
46
|
+
private configuration: WidgetSettings = { ...DEFAULT_WIDGET_SETTINGS };
|
|
47
|
+
|
|
48
|
+
constructor(
|
|
49
|
+
results: Results,
|
|
50
|
+
tmux: TmuxClient,
|
|
51
|
+
ctx: ExtensionContext,
|
|
52
|
+
open: OpenDashboard,
|
|
53
|
+
assertActive: () => void,
|
|
54
|
+
) {
|
|
55
|
+
this.results = results;
|
|
56
|
+
this.tmux = tmux;
|
|
57
|
+
this.ctx = ctx;
|
|
58
|
+
this.focus = new WidgetFocus({
|
|
59
|
+
ui: ctx.ui,
|
|
60
|
+
selection: this.selection,
|
|
61
|
+
changed: (): void => this.changed(),
|
|
62
|
+
open: async (key: string): Promise<void> => {
|
|
63
|
+
assertActive();
|
|
64
|
+
const ancestor: AncestorRow | undefined = this.ancestors.find((row) => row.key === key);
|
|
65
|
+
if (ancestor) return switchAncestor(ctx, ancestor.sessionId, tmux, assertActive, this.live);
|
|
66
|
+
if (!this.rows.some((row) => row.key === key)) throw new Error("delegate is no longer visible");
|
|
67
|
+
await open(ctx, key);
|
|
68
|
+
},
|
|
69
|
+
error: (error: unknown): void =>
|
|
70
|
+
ctx.ui.notify(`delegate: ${error instanceof Error ? error.message : String(error)}`, "error"),
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
start(): void {
|
|
75
|
+
this.unsubscribe = this.results.subscribe(() => {
|
|
76
|
+
this.factsRevision++;
|
|
77
|
+
this.queue();
|
|
78
|
+
});
|
|
79
|
+
this.focus.start();
|
|
80
|
+
this.configureCount(this.settings().maxEntries === 0);
|
|
81
|
+
this.queue();
|
|
82
|
+
this.timer = setInterval(() => this.queue(), WIDGET_REFRESH_MS);
|
|
83
|
+
this.timer.unref();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
suspend(suspended: boolean): void {
|
|
87
|
+
this.focus.suspend(suspended);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
stop(): void {
|
|
91
|
+
if (this.stopped) return;
|
|
92
|
+
this.stopped = true;
|
|
93
|
+
if (this.timer) clearInterval(this.timer);
|
|
94
|
+
if (this.immediate) clearImmediate(this.immediate);
|
|
95
|
+
this.unsubscribe?.();
|
|
96
|
+
this.focus.stop();
|
|
97
|
+
this.view.clear();
|
|
98
|
+
this.ancestorView.clear();
|
|
99
|
+
this.ctx.ui.setWidget(DELEGATE_WIDGET_KEY, undefined);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
private changed(): void {
|
|
103
|
+
if (this.stopped) return;
|
|
104
|
+
this.layoutRevision++;
|
|
105
|
+
this.show(this.rows, this.configuration);
|
|
106
|
+
this.queue();
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private configureCount(visible: boolean): void {
|
|
110
|
+
if (this.countVisible === visible) return;
|
|
111
|
+
this.countVisible = visible;
|
|
112
|
+
this.results.setCountVisible(visible);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private settings(): WidgetSettings {
|
|
116
|
+
try {
|
|
117
|
+
const settings: WidgetSettings = widgetSettings();
|
|
118
|
+
this.warning = "";
|
|
119
|
+
return settings;
|
|
120
|
+
} catch (error) {
|
|
121
|
+
const warning: string = String(error);
|
|
122
|
+
if (warning !== this.warning) this.ctx.ui.notify(`delegate: ${warning}; using widget defaults.`, "warning");
|
|
123
|
+
this.warning = warning;
|
|
124
|
+
return { ...DEFAULT_WIDGET_SETTINGS };
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
private queue(): void {
|
|
129
|
+
if (this.stopped) return;
|
|
130
|
+
if (this.reading) {
|
|
131
|
+
this.again = true;
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
if (this.immediate) return;
|
|
135
|
+
this.immediate = setImmediate(() => {
|
|
136
|
+
this.immediate = undefined;
|
|
137
|
+
void this.refresh();
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private async readAncestors(): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
this.ancestors = await this.ancestorView.read(this.ctx, this.live);
|
|
144
|
+
this.ancestryError = "";
|
|
145
|
+
} catch {
|
|
146
|
+
this.ancestors = [];
|
|
147
|
+
this.ancestryError = "Ancestor information unavailable";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
private summarize(rows: DelegateRow[], viewport: MetadataViewport, windows: TmuxWindow[] | undefined): void {
|
|
152
|
+
const visible: DelegateRow[] = rows.slice(viewport.offset, viewport.offset + viewport.limit);
|
|
153
|
+
if (!visible.length) return;
|
|
154
|
+
try {
|
|
155
|
+
const facts: VisibleFacts = visibleRecordFacts(this.results, this.ctx, 20, false);
|
|
156
|
+
for (const row of visible) row.summary = taskSummary(facts, row.sessionId, windows);
|
|
157
|
+
} catch {
|
|
158
|
+
// Optional descendant facts must not freeze fresh ancestors or direct-child state.
|
|
159
|
+
for (const row of visible) row.summary = "tasks: coverage unavailable";
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
private async refresh(): Promise<void> {
|
|
164
|
+
this.reading = true;
|
|
165
|
+
const revision: number = this.factsRevision;
|
|
166
|
+
try {
|
|
167
|
+
const settings: WidgetSettings = this.settings();
|
|
168
|
+
this.configureCount(settings.maxEntries === 0);
|
|
169
|
+
const windows =
|
|
170
|
+
settings.maxEntries > 0 && this.view.needsWindows(this.results) ? await listWindows(this.tmux) : [];
|
|
171
|
+
if (this.stopped || revision !== this.factsRevision) return;
|
|
172
|
+
const sessionDir: string = this.ctx.sessionManager.getSessionDir();
|
|
173
|
+
const current = await currentChildWindows(this.results, this.tmux, windows, this.live);
|
|
174
|
+
await this.readAncestors();
|
|
175
|
+
if (this.stopped || revision !== this.factsRevision) return;
|
|
176
|
+
const eligible: DelegateRow[] = this.view.read(
|
|
177
|
+
this.results,
|
|
178
|
+
windows,
|
|
179
|
+
sessionDir,
|
|
180
|
+
{ offset: 0, limit: 0 },
|
|
181
|
+
current,
|
|
182
|
+
);
|
|
183
|
+
this.selection.update(
|
|
184
|
+
eligible.map((row) => row.key),
|
|
185
|
+
settings.maxEntries,
|
|
186
|
+
this.ancestors.map((row) => row.key),
|
|
187
|
+
);
|
|
188
|
+
const viewport = { offset: this.selection.offset, limit: settings.maxEntries };
|
|
189
|
+
const layout: number = this.layoutRevision;
|
|
190
|
+
const rows: DelegateRow[] = this.view.read(this.results, windows, sessionDir, viewport, current);
|
|
191
|
+
this.summarize(rows, viewport, windows);
|
|
192
|
+
await joinActivities(rows, viewport, this.results);
|
|
193
|
+
// Only Results notifications change factsRevision; timer ticks must not
|
|
194
|
+
// starve a read, and selection changes must discard/requeue its old viewport.
|
|
195
|
+
if (this.stopped || revision !== this.factsRevision || layout !== this.layoutRevision) return;
|
|
196
|
+
this.show(rows, settings);
|
|
197
|
+
} catch {
|
|
198
|
+
// Optional metadata/UI failures cannot interrupt launch, poll or result processing.
|
|
199
|
+
} finally {
|
|
200
|
+
this.reading = false;
|
|
201
|
+
if (this.again) {
|
|
202
|
+
this.again = false;
|
|
203
|
+
this.queue();
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private show(rows: DelegateRow[], settings: WidgetSettings): void {
|
|
209
|
+
this.rows = rows;
|
|
210
|
+
this.configuration = settings;
|
|
211
|
+
const options = {
|
|
212
|
+
offset: this.selection.offset,
|
|
213
|
+
selectedKey: this.selection.selectedKey,
|
|
214
|
+
focused: this.focus.focused,
|
|
215
|
+
ancestors: this.ancestors,
|
|
216
|
+
ancestryError: this.ancestryError,
|
|
217
|
+
};
|
|
218
|
+
const signature: string = JSON.stringify([rows, settings, options, Math.floor(Date.now() / 60_000)]);
|
|
219
|
+
if (signature === this.signature) return;
|
|
220
|
+
this.signature = signature;
|
|
221
|
+
if (!this.ancestors.length && !this.ancestryError && (!rows.length || !settings.maxEntries)) {
|
|
222
|
+
this.ctx.ui.setWidget(DELEGATE_WIDGET_KEY, undefined);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.ctx.ui.setWidget(
|
|
226
|
+
DELEGATE_WIDGET_KEY,
|
|
227
|
+
(tui) => {
|
|
228
|
+
this.focus.attach(tui); // Pi 1.4.0 stable proxy follows renderer replacement.
|
|
229
|
+
return {
|
|
230
|
+
render: (width: number) =>
|
|
231
|
+
renderDelegateWidget(rows, settings.maxEntries, width, this.ctx.ui.theme, Date.now(), {
|
|
232
|
+
...options,
|
|
233
|
+
focused: this.focus.focused,
|
|
234
|
+
}),
|
|
235
|
+
invalidate: () => {},
|
|
236
|
+
};
|
|
237
|
+
},
|
|
238
|
+
{ placement: settings.placement },
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Register after direct restore, preserving ownership/answer/settlement ordering. */
|
|
244
|
+
export function registerDelegateWidget(
|
|
245
|
+
pi: ExtensionAPI,
|
|
246
|
+
results: Results,
|
|
247
|
+
tmux: TmuxClient,
|
|
248
|
+
open: OpenDashboard,
|
|
249
|
+
assertActive: () => void,
|
|
250
|
+
): void {
|
|
251
|
+
let overview: Overview | undefined;
|
|
252
|
+
let publisher: ReturnType<typeof titlePublisher> | undefined;
|
|
253
|
+
const stop = (): void => {
|
|
254
|
+
overview?.stop();
|
|
255
|
+
overview = undefined;
|
|
256
|
+
publisher?.stop();
|
|
257
|
+
publisher = undefined;
|
|
258
|
+
};
|
|
259
|
+
pi.on("session_start", (_event, ctx) => {
|
|
260
|
+
stop();
|
|
261
|
+
publisher = titlePublisher(ctx.sessionManager.getSessionId());
|
|
262
|
+
publisher.publish(pi.getSessionName());
|
|
263
|
+
if (ctx.hasUI && ctx.mode === "tui") {
|
|
264
|
+
overview = new Overview(results, tmux, ctx, open, assertActive);
|
|
265
|
+
overview.start();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
pi.on("ui_prompt_start", () => overview?.suspend(true));
|
|
269
|
+
pi.on("ui_prompt_end", () => overview?.suspend(false));
|
|
270
|
+
pi.on("session_info_changed", (event) => publisher?.publish(event.name));
|
|
271
|
+
pi.on("session_shutdown", stop);
|
|
272
|
+
}
|
package/src/window-placement.ts
CHANGED
|
@@ -1,17 +1,42 @@
|
|
|
1
1
|
// Call-time choice of where delegate windows open. `companion` (the default)
|
|
2
|
-
// opens them in a
|
|
2
|
+
// opens them in a shared project session; `current`
|
|
3
3
|
// opens them in the parent's own session, as before. Read from
|
|
4
4
|
// `<agent dir>/squire/settings.json` at every launch, like `worktreeRoot`.
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
6
|
-
import { join } from "node:path";
|
|
6
|
+
import { basename, join } from "node:path";
|
|
7
|
+
|
|
8
|
+
import { inspectGit } from "./git.ts";
|
|
7
9
|
|
|
8
10
|
export type WindowPlacement = "companion" | "current";
|
|
9
11
|
|
|
12
|
+
/** A preferred base, never a selected numbered companion or a tmux locator. */
|
|
13
|
+
export function assertRootPlacement(value: unknown): asserts value is string {
|
|
14
|
+
if (typeof value !== "string" || !/^[A-Za-z0-9_](?:[A-Za-z0-9_-]*[A-Za-z0-9_])?-delegate-session$/.test(value))
|
|
15
|
+
throw new Error(
|
|
16
|
+
"invalid or missing rootPlacement in delegation identity; original root companion preference is required",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The owning root's project, never the delegate's chosen working directory. */
|
|
21
|
+
export function placementProject(cwd: string): { companion: string; notices: string[] } {
|
|
22
|
+
const git = inspectGit(cwd);
|
|
23
|
+
const project = basename(git.kind === "git" ? git.identity.mainRoot : cwd);
|
|
24
|
+
const normalized = project.replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "project";
|
|
25
|
+
return {
|
|
26
|
+
companion: `${normalized}-delegate-session`,
|
|
27
|
+
notices:
|
|
28
|
+
git.kind === "unknown"
|
|
29
|
+
? [
|
|
30
|
+
`Could not inspect the root project for companion placement: ${git.reason}. Using the root working-directory basename ${JSON.stringify(basename(cwd))}.`,
|
|
31
|
+
]
|
|
32
|
+
: [],
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
10
36
|
/**
|
|
11
37
|
* The placement for this call. An unknown value refuses with the file named.
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* that never read it, so those keep the default.
|
|
38
|
+
* This reader defaults malformed/unreadable files to companion; mandatory
|
|
39
|
+
* tree-cap admission separately refuses those files for every launch/resume.
|
|
15
40
|
*/
|
|
16
41
|
export function windowPlacement(agentDir: string): WindowPlacement {
|
|
17
42
|
const file = join(agentDir, "squire", "settings.json");
|
package/src/workspace.ts
CHANGED
|
@@ -12,15 +12,14 @@
|
|
|
12
12
|
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
13
13
|
import { homedir } from "node:os";
|
|
14
14
|
import { dirname, join, resolve } from "node:path";
|
|
15
|
-
|
|
16
15
|
import {
|
|
17
16
|
type ExtensionContext,
|
|
18
17
|
hasTrustRequiringProjectResources,
|
|
19
18
|
ProjectTrustStore,
|
|
20
19
|
} from "@earendil-works/pi-coding-agent";
|
|
21
20
|
import { Type } from "typebox";
|
|
22
|
-
|
|
23
21
|
import { askGit, type GitLaunch, inspectGit, PathSummarySchema } from "./git.ts";
|
|
22
|
+
import { localTrust, type TrustDecision, type TrustResolver, trustChoices } from "./trust-prompt.ts";
|
|
24
23
|
import type { ManagedWorkspace } from "./worktree.ts";
|
|
25
24
|
|
|
26
25
|
export {
|
|
@@ -141,32 +140,6 @@ export function resolveDirectory(value: string, base: string): string {
|
|
|
141
140
|
return real;
|
|
142
141
|
}
|
|
143
142
|
|
|
144
|
-
/** The trust dialog's choices, as Pi's own selector offers them. */
|
|
145
|
-
type Choice = { label: string; trusted: boolean; updates: { path: string; decision: boolean | null }[] };
|
|
146
|
-
|
|
147
|
-
function choices(key: string): Choice[] {
|
|
148
|
-
const parent = dirname(key);
|
|
149
|
-
return [
|
|
150
|
-
{ label: "Trust", trusted: true, updates: [{ path: key, decision: true }] },
|
|
151
|
-
// The parent folder's approval clears the nearer key, which would otherwise outrank it.
|
|
152
|
-
...(parent === key
|
|
153
|
-
? []
|
|
154
|
-
: [
|
|
155
|
-
{
|
|
156
|
-
label: `Trust parent folder (${parent})`,
|
|
157
|
-
trusted: true,
|
|
158
|
-
updates: [
|
|
159
|
-
{ path: parent, decision: true },
|
|
160
|
-
{ path: key, decision: null },
|
|
161
|
-
],
|
|
162
|
-
},
|
|
163
|
-
]),
|
|
164
|
-
{ label: "Trust (this session only)", trusted: true, updates: [] },
|
|
165
|
-
{ label: "Do not trust", trusted: false, updates: [{ path: key, decision: false }] },
|
|
166
|
-
{ label: "Do not trust (this session only)", trusted: false, updates: [] },
|
|
167
|
-
];
|
|
168
|
-
}
|
|
169
|
-
|
|
170
143
|
/** The saved decision for `key` or its closest ancestor; a broken store refuses the launch. */
|
|
171
144
|
function savedDecision(store: ProjectTrustStore, key: string): boolean | null {
|
|
172
145
|
try {
|
|
@@ -178,66 +151,47 @@ function savedDecision(store: ProjectTrustStore, key: string): boolean | null {
|
|
|
178
151
|
}
|
|
179
152
|
}
|
|
180
153
|
|
|
181
|
-
/** The end of the trust dialog queue: one question at a time in this parent, so an answer cannot reach another target. */
|
|
182
|
-
let dialogs: Promise<unknown> = Promise.resolve();
|
|
183
|
-
|
|
184
154
|
/** Why an aborted launch stopped. */
|
|
185
155
|
const ABORTED = "the launch was aborted; nothing was launched";
|
|
186
156
|
|
|
187
|
-
/** Run `ask` once every earlier question is settled; an abort while waiting rejects at once and skips `ask`. */
|
|
188
|
-
function oneAtATime<T>(ask: () => Promise<T>, signal: AbortSignal | undefined): Promise<T> {
|
|
189
|
-
const run = (): Promise<T> => {
|
|
190
|
-
if (signal?.aborted === true) return Promise.reject(new Error(ABORTED));
|
|
191
|
-
return ask();
|
|
192
|
-
};
|
|
193
|
-
const turn = dialogs.then(run, run);
|
|
194
|
-
dialogs = turn.catch(() => undefined);
|
|
195
|
-
if (signal === undefined) return turn;
|
|
196
|
-
const aborted = new Promise<never>((_, reject) => {
|
|
197
|
-
signal.addEventListener("abort", () => reject(new Error(ABORTED)), { once: true });
|
|
198
|
-
});
|
|
199
|
-
return Promise.race([turn, aborted]);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
157
|
/** Refuse to go on once the launch's operation is aborted. */
|
|
203
158
|
function stopIfAborted(signal: AbortSignal | undefined): void {
|
|
204
159
|
if (signal?.aborted === true) throw new Error(ABORTED);
|
|
205
160
|
}
|
|
206
161
|
|
|
207
|
-
/**
|
|
162
|
+
/** Apply only an answer returned to this still-live requesting operation. */
|
|
208
163
|
async function askTrust(
|
|
209
|
-
ctx: ExtensionContext,
|
|
210
164
|
signal: AbortSignal | undefined,
|
|
211
165
|
store: ProjectTrustStore,
|
|
212
166
|
cwd: string,
|
|
213
167
|
key: string,
|
|
168
|
+
resolver: TrustResolver,
|
|
214
169
|
): Promise<boolean> {
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
store.setMany(choice.updates);
|
|
236
|
-
} catch (err) {
|
|
237
|
-
throw new Error(`could not save the trust decision for ${key}: ${(err as Error).message}; nothing was launched`);
|
|
170
|
+
const decision: TrustDecision = await resolver(cwd, key, signal);
|
|
171
|
+
try {
|
|
172
|
+
stopIfAborted(signal);
|
|
173
|
+
decision.assertLive();
|
|
174
|
+
if ("saved" in decision.answer) {
|
|
175
|
+
if (savedDecision(store, key) !== decision.answer.saved)
|
|
176
|
+
throw new Error("the scoped project trust changed while waiting; delegate again");
|
|
177
|
+
return decision.answer.saved;
|
|
178
|
+
}
|
|
179
|
+
const label = decision.answer.label;
|
|
180
|
+
const choice = trustChoices(key).find((option) => option.label === label);
|
|
181
|
+
if (choice === undefined) throw new Error(`the trust question for ${cwd} was cancelled; nothing was launched`);
|
|
182
|
+
if (choice.updates.length > 0) {
|
|
183
|
+
try {
|
|
184
|
+
store.setMany(choice.updates);
|
|
185
|
+
} catch (err) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`could not save the trust decision for ${key}: ${(err as Error).message}; nothing was launched`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
238
190
|
}
|
|
191
|
+
return choice.trusted;
|
|
192
|
+
} finally {
|
|
193
|
+
decision.release();
|
|
239
194
|
}
|
|
240
|
-
return choice.trusted;
|
|
241
195
|
}
|
|
242
196
|
|
|
243
197
|
/**
|
|
@@ -252,6 +206,7 @@ async function targetTrust(
|
|
|
252
206
|
ctx: ExtensionContext,
|
|
253
207
|
agentDir: string,
|
|
254
208
|
signal: AbortSignal | undefined,
|
|
209
|
+
resolver?: TrustResolver,
|
|
255
210
|
): Promise<boolean> {
|
|
256
211
|
if (git.kind === "git") {
|
|
257
212
|
const parent = inspectGit(ctx.cwd);
|
|
@@ -264,12 +219,7 @@ async function targetTrust(
|
|
|
264
219
|
const saved = savedDecision(store, key);
|
|
265
220
|
if (saved !== null) return saved;
|
|
266
221
|
if (!hasTrustRequiringProjectResources(cwd)) return false;
|
|
267
|
-
|
|
268
|
-
throw new Error(
|
|
269
|
-
`cwd ${cwd} has project resources that need trust and no trust decision is saved for ${key}; open Pi in ${key}, answer its trust question, then delegate again`,
|
|
270
|
-
);
|
|
271
|
-
}
|
|
272
|
-
return oneAtATime(() => askTrust(ctx, signal, store, cwd, key), signal);
|
|
222
|
+
return askTrust(signal, store, cwd, key, resolver ?? localTrust(ctx, agentDir));
|
|
273
223
|
}
|
|
274
224
|
|
|
275
225
|
const PROTECTED = [
|
|
@@ -320,29 +270,26 @@ export type PlannedTrust = { trusted: boolean; revalidate(): void };
|
|
|
320
270
|
|
|
321
271
|
async function askPlannedResourceTrust(
|
|
322
272
|
plan: WorktreePlan,
|
|
323
|
-
ctx: ExtensionContext,
|
|
324
273
|
store: ProjectTrustStore,
|
|
325
|
-
signal
|
|
274
|
+
signal: AbortSignal | undefined,
|
|
275
|
+
resolver: TrustResolver,
|
|
326
276
|
): Promise<{ trusted: boolean; exposure?: { tree: string | undefined; ancestors: boolean } }> {
|
|
327
277
|
const tree = plannedTreeHasResources(plan.source.checkoutRoot, plan.baseCommit);
|
|
328
278
|
const ancestors = destinationHasAncestorSkills(plan.destination);
|
|
329
279
|
const exposure = { tree, ancestors };
|
|
330
280
|
if (tree === undefined && !ancestors) return { trusted: false, exposure };
|
|
331
|
-
|
|
332
|
-
throw new Error(
|
|
333
|
-
`the planned worktree in ${plan.destination} exposes project resources and has no saved trust decision for ${plan.source.mainRoot}; open Pi and establish trust, then delegate again`,
|
|
334
|
-
);
|
|
335
|
-
const trusted = await oneAtATime(async () => {
|
|
281
|
+
const decide = async (): Promise<boolean> => {
|
|
336
282
|
const savedAtTurn = savedDecision(store, plan.source.mainRoot);
|
|
337
283
|
if (savedAtTurn !== null) return savedAtTurn;
|
|
338
|
-
const answer = await askTrust(
|
|
284
|
+
const answer = await askTrust(signal, store, plan.destination, plan.source.mainRoot, resolver);
|
|
339
285
|
if (
|
|
340
286
|
(tree !== undefined && plannedTreeHasResources(plan.source.checkoutRoot, plan.baseCommit) !== tree) ||
|
|
341
287
|
destinationHasAncestorSkills(plan.destination) !== ancestors
|
|
342
288
|
)
|
|
343
289
|
throw new Error("the planned resources changed while trust was being decided; delegate again");
|
|
344
290
|
return answer;
|
|
345
|
-
}
|
|
291
|
+
};
|
|
292
|
+
const trusted = await decide();
|
|
346
293
|
return { trusted, exposure };
|
|
347
294
|
}
|
|
348
295
|
|
|
@@ -352,6 +299,7 @@ export async function trustPlannedWorktree(
|
|
|
352
299
|
ctx: ExtensionContext,
|
|
353
300
|
agentDir: string,
|
|
354
301
|
signal?: AbortSignal,
|
|
302
|
+
resolver?: TrustResolver,
|
|
355
303
|
): Promise<PlannedTrust> {
|
|
356
304
|
const parent = inspectGit(ctx.cwd);
|
|
357
305
|
const sameRepository = parent.kind === "git" && parent.identity.commonDir === plan.source.commonDir;
|
|
@@ -364,7 +312,7 @@ export async function trustPlannedWorktree(
|
|
|
364
312
|
const saved = savedDecision(store, plan.source.mainRoot);
|
|
365
313
|
if (saved !== null) trusted = saved;
|
|
366
314
|
else {
|
|
367
|
-
const answer = await askPlannedResourceTrust(plan,
|
|
315
|
+
const answer = await askPlannedResourceTrust(plan, store, signal, resolver ?? localTrust(ctx, agentDir));
|
|
368
316
|
trusted = answer.trusted;
|
|
369
317
|
exposure = answer.exposure;
|
|
370
318
|
}
|
|
@@ -399,7 +347,7 @@ export async function trustPlannedWorktree(
|
|
|
399
347
|
* `ctx.signal` aborts it too.
|
|
400
348
|
*/
|
|
401
349
|
export async function prepareDirectory(
|
|
402
|
-
request: { cwd?: string; signal?: AbortSignal },
|
|
350
|
+
request: { cwd?: string; signal?: AbortSignal; trust?: TrustResolver },
|
|
403
351
|
ctx: ExtensionContext,
|
|
404
352
|
agentDir: string,
|
|
405
353
|
): Promise<{ workspace: DirectoryWorkspace; trusted: boolean }> {
|
|
@@ -414,7 +362,7 @@ export async function prepareDirectory(
|
|
|
414
362
|
stopIfAborted(signal);
|
|
415
363
|
const cwd = resolveDirectory(request.cwd, ctx.cwd);
|
|
416
364
|
const git = inspectGit(cwd);
|
|
417
|
-
const trusted = await targetTrust(cwd, git, ctx, agentDir, signal);
|
|
365
|
+
const trusted = await targetTrust(cwd, git, ctx, agentDir, signal, request.trust);
|
|
418
366
|
stopIfAborted(signal);
|
|
419
367
|
return { workspace: { mode: "existing", cwd, git }, trusted };
|
|
420
368
|
}
|