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/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(): Recorded[];
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 = "closed" | "done" | "gone" | "running" | "window-closed" | "unknown";
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: 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
- /** The same records by their request's envelope id, for matching replies. */
302
- const byRequest = new Map<string, Recorded>();
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 = (): Recorded[] =>
312
+ const running = (): StartedRecorded[] =>
313
313
  [...byId.values()].filter(
314
- (record) => record.result === undefined && record.closed !== true && record.gone !== true,
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 Partial<Delegation & { result: Result; closed: boolean; gone: boolean }> | undefined;
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
- if (typeof data.requestId === "string") {
338
- const record: Recorded = { ...(data as Delegation) };
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 = byId.get(data.id);
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 = byId.get(data.id);
361
+ const record = startedById(data.id);
347
362
  if (record !== undefined) record.closed = true;
348
363
  } else if (data.gone === true) {
349
- const record = byId.get(data.id);
350
- if (record !== undefined) record.gone = true;
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: Recorded = { ...delegation };
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 = byId.get(id);
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 = byId.get(id);
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 = findSession(ctx?.sessionManager.getSessionDir(), record.id);
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 Recorded;
459
+ const delegation = byRequest.get(requestId) as StartedRecorded;
419
460
  shown.add(reply.id);
420
- const session = findSession(ctx?.sessionManager.getSessionDir(), delegation.id);
461
+ const session = locateSession(delegation, ctx?.sessionManager.getSessionDir());
421
462
  const finished = finishedModel(session);
422
- const details = resultDetails(delegation, payload, session, finished);
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,
@@ -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
- const session = await targetSession();
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
- const windowId = (await run(args)).trim();
72
- if (windowId === "") throw new Error("tmux reported no window id");
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