pi-squire 0.9.4 → 0.10.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 +97 -1
- package/README.md +319 -27
- package/package.json +1 -1
- package/src/git.ts +177 -0
- package/src/index.ts +415 -65
- package/src/render.ts +232 -123
- package/src/result-sessions.ts +47 -0
- package/src/result-types.ts +36 -0
- package/src/results.ts +122 -77
- package/src/snapshot.ts +207 -0
- package/src/tmux.ts +30 -3
- package/src/tracking-attempt.ts +65 -0
- package/src/tracking.ts +78 -20
- package/src/workspace.ts +420 -0
- package/src/worktree-placement.ts +138 -0
- package/src/worktree.ts +474 -0
package/src/results.ts
CHANGED
|
@@ -4,13 +4,25 @@
|
|
|
4
4
|
// the message over, shows the parent one delegate result and records the
|
|
5
5
|
// result. The records are the one source of truth for a delegation's state,
|
|
6
6
|
// for the footer, `delegation_status` and `delegation_close`.
|
|
7
|
-
import { readdirSync, readFileSync } from "node:fs";
|
|
8
|
-
import { join } from "node:path";
|
|
9
7
|
|
|
10
8
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
11
9
|
|
|
12
10
|
import { readMarks } from "./limits.ts";
|
|
13
|
-
import { modelLabel, renderMessage } from "./render.ts";
|
|
11
|
+
import { cwdLine, modelLabel, renderMessage, snapshotLines, workspaceLine } from "./render.ts";
|
|
12
|
+
import { finishedModel, locateSession } from "./result-sessions.ts";
|
|
13
|
+
import {
|
|
14
|
+
type AttemptState,
|
|
15
|
+
isStarted,
|
|
16
|
+
type Recorded,
|
|
17
|
+
type StartedRecorded,
|
|
18
|
+
type WorkspaceAttempt,
|
|
19
|
+
} from "./result-types.ts";
|
|
20
|
+
import { inspectWorkspace, type WorkspaceSnapshot } from "./snapshot.ts";
|
|
21
|
+
import type { Workspace } from "./workspace.ts";
|
|
22
|
+
|
|
23
|
+
export { findSession, finishedModel } from "./result-sessions.ts";
|
|
24
|
+
export type { AttemptState, Recorded, ResourceFacts, StartedRecorded, WorkspaceAttempt } from "./result-types.ts";
|
|
25
|
+
export { isStarted, locateSession };
|
|
14
26
|
|
|
15
27
|
/** The session entry type both delegation records and results use. */
|
|
16
28
|
export const CUSTOM_TYPE = "delegate";
|
|
@@ -36,6 +48,14 @@ export type Delegation = {
|
|
|
36
48
|
requestId: string;
|
|
37
49
|
/** Whether the delegate closes its own window after a normal completion; absent in records from before auto-exit. */
|
|
38
50
|
autoExit?: boolean;
|
|
51
|
+
/** The directory its window opened in; absent in records from before it was recorded, where it is unknown. */
|
|
52
|
+
cwd?: string;
|
|
53
|
+
/** The session-storage directory passed as `--session-dir`; absent in older records. */
|
|
54
|
+
sessionDir?: string;
|
|
55
|
+
/** The delegate's session file once found; never set at launch, when the file may not exist yet. */
|
|
56
|
+
sessionPath?: string;
|
|
57
|
+
/** How its directory was chosen and its Git checkout at launch; absent in older records. */
|
|
58
|
+
workspace?: Workspace;
|
|
39
59
|
};
|
|
40
60
|
|
|
41
61
|
/**
|
|
@@ -67,6 +87,8 @@ export type Result = {
|
|
|
67
87
|
sessionPath?: string;
|
|
68
88
|
/** The `provider/model` of the delegate's last assistant message, when its session file holds one. */
|
|
69
89
|
finishedModel?: string;
|
|
90
|
+
/** The checkout as observed when the result arrived; absent in results from before it was kept. */
|
|
91
|
+
workspaceSnapshot?: WorkspaceSnapshot;
|
|
70
92
|
};
|
|
71
93
|
|
|
72
94
|
/** What both delegate messages carry for their view, beside the text the model reads. */
|
|
@@ -79,6 +101,9 @@ type CommonDetails = {
|
|
|
79
101
|
id: string;
|
|
80
102
|
requestId: string;
|
|
81
103
|
sessionPath?: string;
|
|
104
|
+
cwd?: string;
|
|
105
|
+
sessionDir?: string;
|
|
106
|
+
workspace?: Workspace;
|
|
82
107
|
};
|
|
83
108
|
|
|
84
109
|
/** The task as the result quotes it: its text and copy path, or why there is none. */
|
|
@@ -95,21 +120,13 @@ export type MessageDetails =
|
|
|
95
120
|
task: TaskQuote;
|
|
96
121
|
/** The reply body, capped. */
|
|
97
122
|
body: string;
|
|
123
|
+
/** The checkout when the result arrived, the same value as the recorded result's; absent in older messages. */
|
|
124
|
+
workspaceSnapshot?: WorkspaceSnapshot;
|
|
98
125
|
})
|
|
99
126
|
| (CommonDetails & { kind: "gone"; windowName: string });
|
|
100
127
|
|
|
101
128
|
type ResultDetails = Extract<MessageDetails, { kind: "result" }>;
|
|
102
129
|
|
|
103
|
-
/** One delegation as it stands: what was recorded at its start, plus what came after. */
|
|
104
|
-
export type Recorded = Delegation & {
|
|
105
|
-
/** Set when a result arrived. */
|
|
106
|
-
result?: Result;
|
|
107
|
-
/** Set when `delegation_close` recorded a close. */
|
|
108
|
-
closed?: boolean;
|
|
109
|
-
/** Set when the poll found the window gone with no result; a result outranks it. */
|
|
110
|
-
gone?: boolean;
|
|
111
|
-
};
|
|
112
|
-
|
|
113
130
|
/** The provider's payload on `message:inbound`. */
|
|
114
131
|
export type Inbound = {
|
|
115
132
|
envelope: Envelope;
|
|
@@ -121,12 +138,14 @@ export type Inbound = {
|
|
|
121
138
|
export type Results = {
|
|
122
139
|
/** Rebuild the records from the session's custom entries, as on a resume. */
|
|
123
140
|
restore(ctx: ExtensionContext): void;
|
|
124
|
-
/** Record a delegation and show it as running. */
|
|
141
|
+
/** Record a delegation and show it as running; it supersedes an attempt with the same id. */
|
|
125
142
|
recordStart(delegation: Delegation, ctx: ExtensionContext): void;
|
|
143
|
+
/** Record a workspace attempt, or its next state; never counted as running. */
|
|
144
|
+
recordAttempt(attempt: WorkspaceAttempt, ctx: ExtensionContext): void;
|
|
126
145
|
/** Record a close; does nothing when there is no such delegation or it is already closed. */
|
|
127
146
|
recordClose(id: string): void;
|
|
128
147
|
/** Delegations with no result, no recorded close and no gone-window record. */
|
|
129
|
-
running():
|
|
148
|
+
running(): StartedRecorded[];
|
|
130
149
|
/** Record that the window is gone with no result, and tell the parent once. False when it no longer applies. */
|
|
131
150
|
recordGone(id: string): boolean;
|
|
132
151
|
/** Every delegation, in the order it was recorded. */
|
|
@@ -143,7 +162,14 @@ export type WindowState = "open" | "gone" | "unknown";
|
|
|
143
162
|
* closed without a result (the poll found the window missing); `window-closed`
|
|
144
163
|
* is a window tmux no longer lists, seen when asked directly.
|
|
145
164
|
*/
|
|
146
|
-
export type DelegationState =
|
|
165
|
+
export type DelegationState =
|
|
166
|
+
| "closed"
|
|
167
|
+
| "done"
|
|
168
|
+
| "gone"
|
|
169
|
+
| "running"
|
|
170
|
+
| "window-closed"
|
|
171
|
+
| "unknown"
|
|
172
|
+
| AttemptState["phase"];
|
|
147
173
|
|
|
148
174
|
/** How much of a delegation id a delegate's session name carries. */
|
|
149
175
|
const SHORT_ID = 8;
|
|
@@ -163,6 +189,7 @@ export function recordedName(record: Recorded): string {
|
|
|
163
189
|
|
|
164
190
|
/** A delegation's state: a recorded close, then a result, then a gone record, then the window. */
|
|
165
191
|
export function deriveState(record: Recorded, window: WindowState): DelegationState {
|
|
192
|
+
if (!isStarted(record)) return record.attempt.phase;
|
|
166
193
|
if (record.closed === true) return "closed";
|
|
167
194
|
if (record.result !== undefined) return "done";
|
|
168
195
|
if (record.gone === true) return "gone";
|
|
@@ -180,47 +207,6 @@ function cap(text: string, max: number): string | undefined {
|
|
|
180
207
|
return bytes.subarray(0, end).toString("utf8");
|
|
181
208
|
}
|
|
182
209
|
|
|
183
|
-
/** The delegate's session file in `dir`, found by its `_<id>.jsonl` suffix. */
|
|
184
|
-
export function findSession(dir: string | undefined, id: string): string | undefined {
|
|
185
|
-
if (dir === undefined) return undefined;
|
|
186
|
-
let names: string[];
|
|
187
|
-
try {
|
|
188
|
-
names = readdirSync(dir);
|
|
189
|
-
} catch {
|
|
190
|
-
return undefined;
|
|
191
|
-
}
|
|
192
|
-
const name = names.find((candidate) => candidate.endsWith(`_${id}.jsonl`));
|
|
193
|
-
return name === undefined ? undefined : join(dir, name);
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
/**
|
|
197
|
-
* The `provider/model` of the last assistant message in a delegate's session
|
|
198
|
-
* file: the model that actually answered, after any fallback or `/model`.
|
|
199
|
-
* Undefined when the file is missing or holds no assistant message.
|
|
200
|
-
*/
|
|
201
|
-
export function finishedModel(sessionPath: string | undefined): string | undefined {
|
|
202
|
-
if (sessionPath === undefined) return undefined;
|
|
203
|
-
let lines: string[];
|
|
204
|
-
try {
|
|
205
|
-
lines = readFileSync(sessionPath, "utf8").split("\n");
|
|
206
|
-
} catch {
|
|
207
|
-
return undefined;
|
|
208
|
-
}
|
|
209
|
-
for (let i = lines.length - 1; i >= 0; i--) {
|
|
210
|
-
let entry: { type?: unknown; message?: { role?: unknown; provider?: unknown; model?: unknown } };
|
|
211
|
-
try {
|
|
212
|
-
entry = JSON.parse(lines[i]);
|
|
213
|
-
} catch {
|
|
214
|
-
continue; // a line still being written, or a blank one
|
|
215
|
-
}
|
|
216
|
-
const message = entry?.message;
|
|
217
|
-
if (entry?.type !== "message" || message?.role !== "assistant") continue;
|
|
218
|
-
if (typeof message.provider === "string" && typeof message.model === "string")
|
|
219
|
-
return `${message.provider}/${message.model}`;
|
|
220
|
-
}
|
|
221
|
-
return undefined;
|
|
222
|
-
}
|
|
223
|
-
|
|
224
210
|
/** The line naming a usage-limit mark this delegation recorded, if any; a `done` delegation finished on a fallback. */
|
|
225
211
|
function limitLine(id: string, status: string): string | undefined {
|
|
226
212
|
const now = Date.now();
|
|
@@ -235,10 +221,11 @@ function limitLine(id: string, status: string): string | undefined {
|
|
|
235
221
|
|
|
236
222
|
/** The details of a settled delegation's result message. */
|
|
237
223
|
function resultDetails(
|
|
238
|
-
delegation:
|
|
224
|
+
delegation: StartedRecorded,
|
|
239
225
|
payload: Inbound,
|
|
240
226
|
session: string | undefined,
|
|
241
227
|
finished: string | undefined,
|
|
228
|
+
snapshot: WorkspaceSnapshot,
|
|
242
229
|
): ResultDetails {
|
|
243
230
|
const reply = payload.envelope;
|
|
244
231
|
const copy = payload.requests.find((request) => request.envelope?.id === delegation.requestId);
|
|
@@ -266,12 +253,22 @@ function resultDetails(
|
|
|
266
253
|
requestId: delegation.requestId,
|
|
267
254
|
envelopePath: payload.path,
|
|
268
255
|
...(session === undefined ? {} : { sessionPath: session }),
|
|
256
|
+
...(delegation.cwd === undefined ? {} : { cwd: delegation.cwd }),
|
|
257
|
+
...(delegation.sessionDir === undefined ? {} : { sessionDir: delegation.sessionDir }),
|
|
258
|
+
...(delegation.workspace === undefined ? {} : { workspace: delegation.workspace }),
|
|
269
259
|
...(limit === undefined ? {} : { limit }),
|
|
270
260
|
task,
|
|
271
261
|
body,
|
|
262
|
+
workspaceSnapshot: snapshot,
|
|
272
263
|
};
|
|
273
264
|
}
|
|
274
265
|
|
|
266
|
+
/** The snapshot lines of the result text: a heading line, then its path lists indented. */
|
|
267
|
+
function checkoutText(snapshot: WorkspaceSnapshot): string[] {
|
|
268
|
+
const [first, ...rest] = snapshotLines(snapshot);
|
|
269
|
+
return [`Checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`)];
|
|
270
|
+
}
|
|
271
|
+
|
|
275
272
|
/** The one message the parent model reads for a settled delegation. */
|
|
276
273
|
function resultText(details: ResultDetails): string {
|
|
277
274
|
const { task } = details;
|
|
@@ -287,6 +284,9 @@ function resultText(details: ResultDetails): string {
|
|
|
287
284
|
`Status: ${details.status}`,
|
|
288
285
|
...(details.limit === undefined ? [] : [details.limit]),
|
|
289
286
|
quote,
|
|
287
|
+
`Working directory: ${cwdLine(details.cwd)}`,
|
|
288
|
+
...(details.workspace === undefined ? [] : [`Workspace: ${workspaceLine(details.workspace)}`]),
|
|
289
|
+
...(details.workspaceSnapshot === undefined ? [] : checkoutText(details.workspaceSnapshot)),
|
|
290
290
|
`Envelope: ${details.envelopePath}`,
|
|
291
291
|
`Delegate session: ${details.sessionPath ?? details.id}`,
|
|
292
292
|
"",
|
|
@@ -298,8 +298,8 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
298
298
|
pi.registerMessageRenderer(CUSTOM_TYPE, renderMessage);
|
|
299
299
|
/** Every recorded delegation by its id, in the order recorded. */
|
|
300
300
|
const byId = new Map<string, Recorded>();
|
|
301
|
-
/**
|
|
302
|
-
const byRequest = new Map<string,
|
|
301
|
+
/** Started records by their request's envelope id, for matching replies; attempts never match a reply. */
|
|
302
|
+
const byRequest = new Map<string, StartedRecorded>();
|
|
303
303
|
/** Reply envelope ids a result message has already been sent for. */
|
|
304
304
|
const shown = new Set<string>();
|
|
305
305
|
/** The latest session context, for the footer and the session directory. */
|
|
@@ -309,11 +309,18 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
309
309
|
const icon = Math.random() < 0.5 ? "👨" : "👩";
|
|
310
310
|
|
|
311
311
|
/** A delegation still going: no result and no recorded close. */
|
|
312
|
-
const running = ():
|
|
312
|
+
const running = (): StartedRecorded[] =>
|
|
313
313
|
[...byId.values()].filter(
|
|
314
|
-
(record)
|
|
314
|
+
(record): record is StartedRecorded =>
|
|
315
|
+
isStarted(record) && record.result === undefined && record.closed !== true && record.gone !== true,
|
|
315
316
|
);
|
|
316
317
|
|
|
318
|
+
/** The started record for `id`, if it is one. */
|
|
319
|
+
const startedById = (id: unknown): StartedRecorded | undefined => {
|
|
320
|
+
const record = typeof id === "string" ? byId.get(id) : undefined;
|
|
321
|
+
return record !== undefined && isStarted(record) ? record : undefined;
|
|
322
|
+
};
|
|
323
|
+
|
|
317
324
|
/** The footer counts the running delegations, and hides itself at zero. */
|
|
318
325
|
function updateFooter(): void {
|
|
319
326
|
if (ctx?.hasUI !== true) return;
|
|
@@ -332,22 +339,45 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
332
339
|
shown.clear();
|
|
333
340
|
for (const entry of context.sessionManager.getEntries()) {
|
|
334
341
|
if (entry.type !== "custom" || entry.customType !== CUSTOM_TYPE) continue;
|
|
335
|
-
const data = entry.data as
|
|
342
|
+
const data = entry.data as
|
|
343
|
+
| Partial<Delegation & { kind: string; result: Result; closed: boolean; gone: boolean }>
|
|
344
|
+
| undefined;
|
|
336
345
|
if (data === undefined || typeof data.id !== "string") continue;
|
|
337
|
-
|
|
338
|
-
|
|
346
|
+
// An attempt may carry a real request id; it is checked before the legacy start rule below.
|
|
347
|
+
if (data.kind === "workspace-attempt") {
|
|
348
|
+
const existing = byId.get(data.id);
|
|
349
|
+
if (existing === undefined || !isStarted(existing)) {
|
|
350
|
+
byId.set(data.id, { ...(data as unknown as WorkspaceAttempt) });
|
|
351
|
+
}
|
|
352
|
+
} else if (typeof data.requestId === "string") {
|
|
353
|
+
const record: StartedRecorded = { ...(data as Delegation) };
|
|
339
354
|
byId.set(record.id, record);
|
|
340
355
|
byRequest.set(record.requestId, record);
|
|
341
356
|
} else if (typeof data.result?.replyId === "string") {
|
|
342
357
|
shown.add(data.result.replyId);
|
|
343
|
-
const record =
|
|
358
|
+
const record = startedById(data.id);
|
|
344
359
|
if (record !== undefined) record.result = data.result;
|
|
345
360
|
} else if (data.closed === true) {
|
|
346
|
-
const record =
|
|
361
|
+
const record = startedById(data.id);
|
|
347
362
|
if (record !== undefined) record.closed = true;
|
|
348
363
|
} else if (data.gone === true) {
|
|
349
|
-
const record =
|
|
350
|
-
if (record
|
|
364
|
+
const record = startedById(data.id);
|
|
365
|
+
if (record === undefined) continue;
|
|
366
|
+
record.gone = true;
|
|
367
|
+
if (typeof data.sessionPath === "string") record.sessionPath = data.sessionPath;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
// A parent that stopped mid-launch cannot say how far it got; nothing is cleaned up or relaunched.
|
|
371
|
+
for (const record of byId.values()) {
|
|
372
|
+
if (isStarted(record)) continue;
|
|
373
|
+
const { phase, resources } = record.attempt;
|
|
374
|
+
if (phase === "planned" || phase === "created") {
|
|
375
|
+
record.attempt = {
|
|
376
|
+
phase: "interrupted",
|
|
377
|
+
resources,
|
|
378
|
+
lastPhase: phase,
|
|
379
|
+
reason: `the parent session ended while the launch was ${phase}; whether anything was created is unknown`,
|
|
380
|
+
};
|
|
351
381
|
}
|
|
352
382
|
}
|
|
353
383
|
updateFooter();
|
|
@@ -356,14 +386,20 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
356
386
|
function recordStart(delegation: Delegation, context: ExtensionContext): void {
|
|
357
387
|
ctx = context;
|
|
358
388
|
pi.appendEntry(CUSTOM_TYPE, delegation);
|
|
359
|
-
const record:
|
|
389
|
+
const record: StartedRecorded = { ...delegation };
|
|
360
390
|
byId.set(record.id, record);
|
|
361
391
|
byRequest.set(record.requestId, record);
|
|
362
392
|
updateFooter();
|
|
363
393
|
}
|
|
364
394
|
|
|
395
|
+
function recordAttempt(attempt: WorkspaceAttempt, context: ExtensionContext): void {
|
|
396
|
+
ctx = context;
|
|
397
|
+
pi.appendEntry(CUSTOM_TYPE, attempt);
|
|
398
|
+
byId.set(attempt.id, { ...attempt });
|
|
399
|
+
}
|
|
400
|
+
|
|
365
401
|
function recordClose(id: string): void {
|
|
366
|
-
const record =
|
|
402
|
+
const record = startedById(id);
|
|
367
403
|
if (record === undefined || record.closed === true) return;
|
|
368
404
|
pi.appendEntry(CUSTOM_TYPE, { id, closed: true });
|
|
369
405
|
record.closed = true;
|
|
@@ -371,12 +407,12 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
371
407
|
}
|
|
372
408
|
|
|
373
409
|
function recordGone(id: string): boolean {
|
|
374
|
-
const record =
|
|
410
|
+
const record = startedById(id);
|
|
375
411
|
if (record === undefined || record.result !== undefined || record.closed === true || record.gone === true)
|
|
376
412
|
return false;
|
|
377
|
-
const session =
|
|
413
|
+
const session = locateSession(record, ctx?.sessionManager.getSessionDir());
|
|
378
414
|
const finished = finishedModel(session);
|
|
379
|
-
pi.appendEntry(CUSTOM_TYPE, { id, gone: true });
|
|
415
|
+
pi.appendEntry(CUSTOM_TYPE, { id, gone: true, ...(session === undefined ? {} : { sessionPath: session }) });
|
|
380
416
|
record.gone = true;
|
|
381
417
|
const details: MessageDetails = {
|
|
382
418
|
kind: "gone",
|
|
@@ -387,6 +423,9 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
387
423
|
id: record.id,
|
|
388
424
|
requestId: record.requestId,
|
|
389
425
|
...(session === undefined ? {} : { sessionPath: session }),
|
|
426
|
+
...(record.cwd === undefined ? {} : { cwd: record.cwd }),
|
|
427
|
+
...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
|
|
428
|
+
...(record.workspace === undefined ? {} : { workspace: record.workspace }),
|
|
390
429
|
windowName: record.windowName,
|
|
391
430
|
};
|
|
392
431
|
pi.sendMessage(
|
|
@@ -395,6 +434,8 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
395
434
|
details,
|
|
396
435
|
content: [
|
|
397
436
|
`[delegate] Delegation ${record.id} (${record.agent}, ${modelLabel(record.model, finished)}), request ${record.requestId}, closed without a result: its window ${record.windowName} is gone and no reply arrived.`,
|
|
437
|
+
`Working directory: ${cwdLine(record.cwd)}`,
|
|
438
|
+
...(record.workspace === undefined ? [] : [`Workspace: ${workspaceLine(record.workspace)}`]),
|
|
398
439
|
`Delegate session: ${session ?? record.id}`,
|
|
399
440
|
"A reply that arrives later is still delivered.",
|
|
400
441
|
].join("\n"),
|
|
@@ -415,11 +456,13 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
415
456
|
if (requestId === undefined) return;
|
|
416
457
|
payload.handled = true;
|
|
417
458
|
if (shown.has(reply.id)) return;
|
|
418
|
-
const delegation = byRequest.get(requestId) as
|
|
459
|
+
const delegation = byRequest.get(requestId) as StartedRecorded;
|
|
419
460
|
shown.add(reply.id);
|
|
420
|
-
const session =
|
|
461
|
+
const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
|
|
421
462
|
const finished = finishedModel(session);
|
|
422
|
-
|
|
463
|
+
// After the claim above, so a slow or failing inspection cannot let the provider or a duplicate deliver it too.
|
|
464
|
+
const snapshot = inspectWorkspace(delegation.workspace?.cwd, delegation.workspace?.git);
|
|
465
|
+
const details = resultDetails(delegation, payload, session, finished, snapshot);
|
|
423
466
|
// Steer, not follow-up: a busy parent gets the result before its next model call, not after it has answered.
|
|
424
467
|
pi.sendMessage(
|
|
425
468
|
{ customType: CUSTOM_TYPE, content: resultText(details), details, display: true },
|
|
@@ -431,6 +474,7 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
431
474
|
envelopePath: payload.path,
|
|
432
475
|
...(session === undefined ? {} : { sessionPath: session }),
|
|
433
476
|
...(finished === undefined ? {} : { finishedModel: finished }),
|
|
477
|
+
workspaceSnapshot: snapshot,
|
|
434
478
|
};
|
|
435
479
|
pi.appendEntry(CUSTOM_TYPE, { id: delegation.id, result });
|
|
436
480
|
delegation.result = result;
|
|
@@ -442,6 +486,7 @@ export function createResults(pi: ExtensionAPI): Results {
|
|
|
442
486
|
return {
|
|
443
487
|
restore,
|
|
444
488
|
recordStart,
|
|
489
|
+
recordAttempt,
|
|
445
490
|
recordClose,
|
|
446
491
|
running,
|
|
447
492
|
recordGone,
|
package/src/snapshot.ts
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Read-only observations of a delegation's checkout: on receipt of its result
|
|
2
|
+
// and afresh on each status call. A snapshot compares the checkout now with
|
|
3
|
+
// the launch commit recorded in its workspace, inspects only that recorded
|
|
4
|
+
// directory, stays within time and output limits, and reports unknown with a
|
|
5
|
+
// reason whenever something cannot be established. Nothing acts on it.
|
|
6
|
+
import { realpathSync, statSync } from "node:fs";
|
|
7
|
+
import { resolve } from "node:path";
|
|
8
|
+
|
|
9
|
+
import { Type } from "typebox";
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
askGit,
|
|
13
|
+
fields,
|
|
14
|
+
type GitAnswer,
|
|
15
|
+
type GitLaunch,
|
|
16
|
+
type GitPosition,
|
|
17
|
+
onePath,
|
|
18
|
+
type PathSummary,
|
|
19
|
+
PathSummarySchema,
|
|
20
|
+
pendingPaths,
|
|
21
|
+
summarize,
|
|
22
|
+
} from "./git.ts";
|
|
23
|
+
import { PositionSchema } from "./workspace.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A checkout as observed at one moment: its position and head against the
|
|
27
|
+
* launch baseline, committed changes since the baseline and pending work.
|
|
28
|
+
* An observation, not proof of who made the changes.
|
|
29
|
+
*/
|
|
30
|
+
export type WorkspaceSnapshot =
|
|
31
|
+
| { kind: "non-git" }
|
|
32
|
+
| { kind: "unknown"; reason: string }
|
|
33
|
+
| {
|
|
34
|
+
kind: "git";
|
|
35
|
+
position: GitPosition;
|
|
36
|
+
head: string;
|
|
37
|
+
baseline: string;
|
|
38
|
+
ahead: number;
|
|
39
|
+
workingState: "clean" | "dirty";
|
|
40
|
+
committed: PathSummary;
|
|
41
|
+
pending: { staged: PathSummary; unstaged: PathSummary; untracked: PathSummary; conflicted: PathSummary };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** `WorkspaceSnapshot` as a schema, for `delegation_status`. */
|
|
45
|
+
export const WorkspaceSnapshotSchema = Type.Union(
|
|
46
|
+
[
|
|
47
|
+
Type.Object({ kind: Type.Literal("non-git") }),
|
|
48
|
+
Type.Object({ kind: Type.Literal("unknown"), reason: Type.String() }),
|
|
49
|
+
Type.Object({
|
|
50
|
+
kind: Type.Literal("git"),
|
|
51
|
+
position: PositionSchema,
|
|
52
|
+
head: Type.String(),
|
|
53
|
+
baseline: Type.String({ description: "The launch commit the changes are counted from." }),
|
|
54
|
+
ahead: Type.Number({ description: "Commits reachable from head but not from the baseline." }),
|
|
55
|
+
workingState: Type.Union([Type.Literal("clean"), Type.Literal("dirty")]),
|
|
56
|
+
committed: PathSummarySchema,
|
|
57
|
+
pending: Type.Object({
|
|
58
|
+
staged: PathSummarySchema,
|
|
59
|
+
unstaged: PathSummarySchema,
|
|
60
|
+
untracked: PathSummarySchema,
|
|
61
|
+
conflicted: PathSummarySchema,
|
|
62
|
+
}),
|
|
63
|
+
}),
|
|
64
|
+
],
|
|
65
|
+
{
|
|
66
|
+
description:
|
|
67
|
+
"The checkout observed now: git (changes since the launch commit, committed and pending), non-git, or unknown with a reason.",
|
|
68
|
+
},
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/** Reporting limits for one snapshot; never evidence for deleting anything. */
|
|
72
|
+
const SNAPSHOT_COMMAND_MS = 2000;
|
|
73
|
+
const SNAPSHOT_TOTAL_MS = 5000;
|
|
74
|
+
const SNAPSHOT_CAPTURE_BYTES = 4 * 1024 * 1024;
|
|
75
|
+
/** Why a snapshot is unknown; thrown during an inspection and caught by `inspectWorkspace`. */
|
|
76
|
+
class Unknown extends Error {}
|
|
77
|
+
|
|
78
|
+
/** A Git runner bounded for one inspection: the per-command and total time limits and one capture budget. */
|
|
79
|
+
function budgetedGit(cwd: string): { run(args: string[]): GitAnswer; ask(args: string[]): string } {
|
|
80
|
+
const start = Date.now();
|
|
81
|
+
let captured = 0;
|
|
82
|
+
const run = (args: string[]): GitAnswer => {
|
|
83
|
+
const left = SNAPSHOT_TOTAL_MS - (Date.now() - start);
|
|
84
|
+
if (left <= 0) throw new Unknown(`inspection passed its ${SNAPSHOT_TOTAL_MS / 1000} s total time limit`);
|
|
85
|
+
const answer = askGit(cwd, args, {
|
|
86
|
+
timeout: Math.min(SNAPSHOT_COMMAND_MS, left),
|
|
87
|
+
maxBuffer: SNAPSHOT_CAPTURE_BYTES - captured,
|
|
88
|
+
});
|
|
89
|
+
// Both streams, failed commands included, share one budget.
|
|
90
|
+
captured += answer.bytes;
|
|
91
|
+
if (captured > SNAPSHOT_CAPTURE_BYTES) {
|
|
92
|
+
throw new Unknown(`Git printed more than the ${SNAPSHOT_CAPTURE_BYTES} byte capture limit for one inspection`);
|
|
93
|
+
}
|
|
94
|
+
return answer;
|
|
95
|
+
};
|
|
96
|
+
const ask = (args: string[]): string => {
|
|
97
|
+
const answer = run(args);
|
|
98
|
+
if (!answer.ok) throw new Unknown(`git ${args[args[0] === "-c" ? 2 : 0]} failed: ${answer.message}`);
|
|
99
|
+
return answer.out;
|
|
100
|
+
};
|
|
101
|
+
return { run, ask };
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Refuse a directory that is gone or whose checkout root or common directory
|
|
106
|
+
* is not at its recorded location. A repository replaced at the same
|
|
107
|
+
* locations is observed like any other: this checks location, not continuity.
|
|
108
|
+
*/
|
|
109
|
+
function requireLaunchCheckout(
|
|
110
|
+
cwd: string,
|
|
111
|
+
launch: Extract<GitLaunch, { kind: "git" }>,
|
|
112
|
+
ask: (args: string[]) => string,
|
|
113
|
+
): void {
|
|
114
|
+
if (statSync(cwd, { throwIfNoEntry: false })?.isDirectory() !== true) {
|
|
115
|
+
throw new Unknown(`the checkout ${cwd} is gone or no longer a directory`);
|
|
116
|
+
}
|
|
117
|
+
let checkoutRoot: string;
|
|
118
|
+
let commonDir: string;
|
|
119
|
+
try {
|
|
120
|
+
checkoutRoot = realpathSync(onePath(ask(["rev-parse", "--show-toplevel"])));
|
|
121
|
+
commonDir = realpathSync(resolve(cwd, onePath(ask(["rev-parse", "--git-common-dir"]))));
|
|
122
|
+
} catch (err) {
|
|
123
|
+
if (err instanceof Unknown) throw err;
|
|
124
|
+
throw new Unknown(`could not resolve the repository paths: ${(err as Error).message}`);
|
|
125
|
+
}
|
|
126
|
+
if (checkoutRoot !== launch.identity.checkoutRoot || commonDir !== launch.identity.commonDir) {
|
|
127
|
+
throw new Unknown(
|
|
128
|
+
`${cwd} is now checkout ${checkoutRoot} of ${commonDir}, not the launch checkout ${launch.identity.checkoutRoot} of ${launch.identity.commonDir}`,
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The checkout recorded at launch, observed now against its launch commit. */
|
|
134
|
+
function inspectCheckout(cwd: string, launch: Extract<GitLaunch, { kind: "git" }>): WorkspaceSnapshot {
|
|
135
|
+
const baseline = launch.baseline;
|
|
136
|
+
if (baseline === null) throw new Unknown("the checkout had no commit at launch, so there is no baseline to compare");
|
|
137
|
+
if (!/^[0-9a-f]{40,64}$/.test(baseline))
|
|
138
|
+
throw new Unknown(`the recorded launch commit ${baseline} is not a commit id`);
|
|
139
|
+
const { run, ask } = budgetedGit(cwd);
|
|
140
|
+
requireLaunchCheckout(cwd, launch, ask);
|
|
141
|
+
const ref = run(["symbolic-ref", "-q", "HEAD"]);
|
|
142
|
+
if (!ref.ok && ref.status !== 1) throw new Unknown(`git symbolic-ref failed: ${ref.message}`);
|
|
143
|
+
const name = ref.ok ? onePath(ref.out) : "";
|
|
144
|
+
const position: GitPosition = ref.ok
|
|
145
|
+
? { branch: name.startsWith("refs/heads/") ? name.slice("refs/heads/".length) : name, detached: false }
|
|
146
|
+
: { branch: null, detached: true };
|
|
147
|
+
const head = onePath(ask(["rev-parse", "--verify", "HEAD^{commit}"]));
|
|
148
|
+
onePath(ask(["rev-parse", "--verify", `${baseline}^{commit}`]));
|
|
149
|
+
const ahead = Number.parseInt(ask(["rev-list", "--count", head, "--not", baseline]), 10);
|
|
150
|
+
if (!Number.isSafeInteger(ahead)) throw new Unknown("git rev-list printed no commit count");
|
|
151
|
+
// A configured diff.relative would drop paths outside a subdirectory cwd and reroot the rest; `-c` rather
|
|
152
|
+
// than `--no-relative`, which needs Git 2.28, while older Git ignores the unknown key.
|
|
153
|
+
const committed = fields(
|
|
154
|
+
ask([
|
|
155
|
+
"-c",
|
|
156
|
+
"diff.relative=false",
|
|
157
|
+
"diff",
|
|
158
|
+
"--name-only",
|
|
159
|
+
"-z",
|
|
160
|
+
"--no-renames",
|
|
161
|
+
"--no-ext-diff",
|
|
162
|
+
baseline,
|
|
163
|
+
head,
|
|
164
|
+
"--",
|
|
165
|
+
]),
|
|
166
|
+
);
|
|
167
|
+
const pending = pendingPaths(ask(["status", "--porcelain=v1", "-z", "--no-renames", "--untracked-files=all"]));
|
|
168
|
+
const dirty = Object.values(pending).some((paths) => paths.length > 0);
|
|
169
|
+
return {
|
|
170
|
+
kind: "git",
|
|
171
|
+
position,
|
|
172
|
+
head,
|
|
173
|
+
baseline,
|
|
174
|
+
ahead,
|
|
175
|
+
workingState: dirty ? "dirty" : "clean",
|
|
176
|
+
committed: summarize(committed),
|
|
177
|
+
pending: {
|
|
178
|
+
staged: summarize(pending.staged),
|
|
179
|
+
unstaged: summarize(pending.unstaged),
|
|
180
|
+
untracked: summarize(pending.untracked),
|
|
181
|
+
conflicted: summarize(pending.conflicted),
|
|
182
|
+
},
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The recorded checkout as it stands now, read-only and bounded. Only `cwd`
|
|
188
|
+
* and its launch facts are inspected, never the parent's directory; anything
|
|
189
|
+
* that cannot be established is unknown with a reason. Never throws.
|
|
190
|
+
*/
|
|
191
|
+
export function inspectWorkspace(cwd: string | undefined, git: GitLaunch | undefined): WorkspaceSnapshot {
|
|
192
|
+
try {
|
|
193
|
+
if (git === undefined || cwd === undefined) {
|
|
194
|
+
return { kind: "unknown", reason: "this delegation was recorded before its launch checkout was kept" };
|
|
195
|
+
}
|
|
196
|
+
if (git.kind === "unknown")
|
|
197
|
+
return { kind: "unknown", reason: `its Git state at launch was unknown (${git.reason})` };
|
|
198
|
+
if (git.kind === "non-git") {
|
|
199
|
+
return statSync(cwd, { throwIfNoEntry: false })?.isDirectory() === true
|
|
200
|
+
? { kind: "non-git" }
|
|
201
|
+
: { kind: "unknown", reason: `the directory ${cwd} is gone` };
|
|
202
|
+
}
|
|
203
|
+
return inspectCheckout(cwd, git);
|
|
204
|
+
} catch (err) {
|
|
205
|
+
return { kind: "unknown", reason: (err as Error).message };
|
|
206
|
+
}
|
|
207
|
+
}
|
package/src/tmux.ts
CHANGED
|
@@ -13,6 +13,20 @@ export type WindowSpec = {
|
|
|
13
13
|
/** One window as tmux lists it: its id, its name, and whether the program in its pane has exited. */
|
|
14
14
|
export type TmuxWindow = { id: string; name: string; exited: boolean };
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Why a window could not be opened, and whether one may exist anyway:
|
|
18
|
+
* `not-opened` only when nothing was dispatched to tmux that could create it.
|
|
19
|
+
* Anything else, a timeout or a malformed answer included, is `unknown`.
|
|
20
|
+
*/
|
|
21
|
+
export class WindowOpenError extends Error {
|
|
22
|
+
readonly certainty: "not-opened" | "unknown";
|
|
23
|
+
constructor(message: string, certainty: "not-opened" | "unknown") {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = "WindowOpenError";
|
|
26
|
+
this.certainty = certainty;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
16
30
|
export interface TmuxClient {
|
|
17
31
|
/** Whether this process runs in a tmux pane. */
|
|
18
32
|
insideTmux(): boolean;
|
|
@@ -65,11 +79,24 @@ export function createTmuxClient(options: { timeoutMs?: number } = {}): TmuxClie
|
|
|
65
79
|
async openWindow(spec) {
|
|
66
80
|
const args = ["new-window", "-d", "-P", "-F", "#{window_id}", "-n", spec.name, "-c", spec.cwd];
|
|
67
81
|
for (const [key, value] of Object.entries(spec.env)) args.push("-e", `${key}=${value}`);
|
|
68
|
-
|
|
82
|
+
let session: string | undefined;
|
|
83
|
+
try {
|
|
84
|
+
session = await targetSession();
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// The lookup only reads; no window can exist yet.
|
|
87
|
+
throw new WindowOpenError((err as Error).message, "not-opened");
|
|
88
|
+
}
|
|
69
89
|
if (session !== undefined) args.push("-t", session);
|
|
70
90
|
args.push("--", ...spec.argv);
|
|
71
|
-
|
|
72
|
-
|
|
91
|
+
let out: string;
|
|
92
|
+
try {
|
|
93
|
+
out = await run(args);
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// After dispatch even an error or timeout may have left a window.
|
|
96
|
+
throw new WindowOpenError((err as Error).message, "unknown");
|
|
97
|
+
}
|
|
98
|
+
const windowId = out.trim();
|
|
99
|
+
if (!/^@[0-9]+$/.test(windowId)) throw new WindowOpenError("tmux reported an invalid window id", "unknown");
|
|
73
100
|
return windowId;
|
|
74
101
|
},
|
|
75
102
|
|