pi-squire 0.9.5 → 0.11.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/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
 
@@ -0,0 +1,65 @@
1
+ import { Type } from "typebox";
2
+ import { WorktreePlanSchema } from "./workspace.ts";
3
+
4
+ const ATTEMPT_PHASES = ["planned", "created", "rolled-back", "retained", "interrupted"] as const;
5
+
6
+ const ResourceSchema = Type.Union(
7
+ [
8
+ Type.Object({ kind: Type.Literal("none") }),
9
+ Type.Object({ kind: Type.Literal("branch"), branch: Type.String(), head: Type.String() }),
10
+ Type.Object({ kind: Type.Literal("worktree"), cwd: Type.String(), branch: Type.String(), head: Type.String() }),
11
+ Type.Object({
12
+ kind: Type.Literal("unknown"),
13
+ reason: Type.String(),
14
+ observedPaths: Type.Array(Type.String()),
15
+ observedRefs: Type.Array(Type.String()),
16
+ }),
17
+ ],
18
+ { description: "What is known to exist for a launch that has no window, as facts." },
19
+ );
20
+
21
+ const AttemptStateSchema = Type.Union([
22
+ Type.Object({ phase: Type.Literal("planned"), resources: ResourceSchema }),
23
+ Type.Object({ phase: Type.Literal("created"), resources: ResourceSchema }),
24
+ Type.Object({
25
+ phase: Type.Literal("rolled-back"),
26
+ resources: Type.Object({ kind: Type.Literal("none") }),
27
+ cause: Type.String(),
28
+ }),
29
+ Type.Object({
30
+ phase: Type.Literal("retained"),
31
+ resources: ResourceSchema,
32
+ cause: Type.String(),
33
+ recovery: Type.Array(Type.String(), {
34
+ description: "Non-forced commands that remove exactly the resources known to remain.",
35
+ }),
36
+ }),
37
+ Type.Object({
38
+ phase: Type.Literal("interrupted"),
39
+ resources: ResourceSchema,
40
+ lastPhase: Type.Union([Type.Literal("planned"), Type.Literal("created")]),
41
+ reason: Type.String(),
42
+ }),
43
+ ]);
44
+
45
+ /** A managed launch that has no window: never running, never closable, with no window id. */
46
+ export const AttemptSchema = Type.Object({
47
+ kind: Type.Literal("workspace-attempt"),
48
+ id: Type.String(),
49
+ parentId: Type.String(),
50
+ agent: Type.String(),
51
+ model: Type.String(),
52
+ thinking: Type.String(),
53
+ name: Type.Optional(Type.String()),
54
+ sessionDir: Type.String(),
55
+ plan: WorktreePlanSchema,
56
+ attempt: AttemptStateSchema,
57
+ requestId: Type.Optional(Type.String({ description: "The task's request id, only when mail really returned one." })),
58
+ state: Type.Union(
59
+ ATTEMPT_PHASES.map((phase) => Type.Literal(phase)),
60
+ {
61
+ description:
62
+ "planned, created: a launch in progress. rolled-back: failed before a window and everything was removed. retained: failed before a window and something remains (see attempt.recovery). interrupted: the parent ended mid-launch; what exists is unknown. No window was opened.",
63
+ },
64
+ ),
65
+ });
@@ -0,0 +1,41 @@
1
+ // How `delegation_status` words a delegation's state, auto-exit and age.
2
+ import type { DelegationState, StartedRecorded } from "./results.ts";
3
+
4
+ /** Records from before auto-exit existed kept their windows open, so they read off. */
5
+ export function autoExitLine(record: StartedRecorded): string {
6
+ return record.autoExit === true
7
+ ? "on — the delegate closes its window after a normal completion unless the user took over there"
8
+ : "off — the window stays open after the result";
9
+ }
10
+
11
+ /** The state, worded for the `state:` line. */
12
+ export function stateLine(state: DelegationState, record: StartedRecorded): string {
13
+ switch (state) {
14
+ case "running":
15
+ return "running — the window is open and no result has arrived";
16
+ case "gone":
17
+ return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
18
+ case "done":
19
+ return `done — result status: ${record.result?.status ?? "unknown"}; the result reaches this session as a [delegate] message on its own`;
20
+ case "closed":
21
+ return "closed — delegation_close recorded it";
22
+ case "window-closed":
23
+ return "closed — the window is gone";
24
+ case "unknown":
25
+ return "unknown — tmux could not be asked whether the window is open";
26
+ case "planned":
27
+ case "created":
28
+ case "rolled-back":
29
+ case "retained":
30
+ case "interrupted":
31
+ return `${state} — no window id/start was recorded; a window may still exist`;
32
+ }
33
+ }
34
+
35
+ /** How long ago, worded for the `state:` line: `just now`, `N min ago` or `N h M min ago`. */
36
+ export function ago(ms: number): string {
37
+ const minutes = Math.floor(ms / 60_000);
38
+ if (minutes < 1) return "just now";
39
+ if (minutes < 60) return `${minutes} min ago`;
40
+ return `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
41
+ }
package/src/tracking.ts CHANGED
@@ -8,18 +8,26 @@ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-a
8
8
  import { Type } from "typebox";
9
9
 
10
10
  import { type Mark, readMarks } from "./limits.ts";
11
- import { modelLabel } from "./render.ts";
11
+ import { attemptLines, cwdLine, modelLabel, snapshotLines, workspaceLine } from "./render.ts";
12
12
  import {
13
13
  type DelegationState,
14
14
  deriveState,
15
- findSession,
15
+ isStarted,
16
+ locateSession,
16
17
  type Recorded,
17
18
  type Results,
18
19
  recordedName,
20
+ type StartedRecorded,
21
+ sessionIdOf,
19
22
  type WindowState,
23
+ type WorkspaceAttempt,
20
24
  } from "./results.ts";
25
+ import { inspectWorkspace, type WorkspaceSnapshot, WorkspaceSnapshotSchema } from "./snapshot.ts";
21
26
  import type { TmuxClient, TmuxWindow } from "./tmux.ts";
27
+ import { AttemptSchema } from "./tracking-attempt.ts";
28
+ import { ago, autoExitLine, stateLine } from "./tracking-text.ts";
22
29
  import type { ReadingError } from "./usage.ts";
30
+ import { WorkspaceSchema } from "./workspace.ts";
23
31
 
24
32
  /** The hook a tick emits so the provider claims waiting replies before windows are checked. */
25
33
  const SCAN = "message:scan";
@@ -49,10 +57,11 @@ export function toolResult(
49
57
  /** Every tool here touches only local state: tmux, the session's entries and files under the agent directory. */
50
58
  const CLOSED_WORLD = { openWorldHint: false } as const;
51
59
 
52
- const DELEGATION_STATES: readonly DelegationState[] = ["closed", "done", "gone", "running", "window-closed", "unknown"];
60
+ const STARTED_STATES = ["closed", "done", "gone", "running", "window-closed", "unknown"] as const;
53
61
 
54
- /** One delegation in `delegation_status`'s structured result; `viewDetails` builds it. */
55
- const DelegationSchema = Type.Object({
62
+ /** One started delegation in `delegation_status`'s structured result; `viewDetails` builds it. */
63
+ const StartedSchema = Type.Object({
64
+ kind: Type.Literal("started"),
56
65
  id: Type.String(),
57
66
  name: Type.String(),
58
67
  agent: Type.String(),
@@ -62,13 +71,20 @@ const DelegationSchema = Type.Object({
62
71
  ),
63
72
  thinking: Type.String(),
64
73
  autoExit: Type.Boolean(),
74
+ resumedFrom: Type.Optional(Type.String({ description: "The delegation whose session this one reopened." })),
65
75
  windowId: Type.String(),
66
76
  windowName: Type.String(),
77
+ cwd: Type.Optional(
78
+ Type.String({ description: "The directory its window opened in; absent for records from before it was kept." }),
79
+ ),
80
+ sessionDir: Type.Optional(Type.String({ description: "The session-storage directory it was told to use." })),
81
+ workspace: Type.Optional(WorkspaceSchema),
82
+ workspaceSnapshot: WorkspaceSnapshotSchema,
67
83
  state: Type.Union(
68
- DELEGATION_STATES.map((state) => Type.Literal(state)),
84
+ STARTED_STATES.map((state) => Type.Literal(state)),
69
85
  {
70
86
  description:
71
- "running: window open, no result. done: a result arrived (see status, envelopePath). gone: the window went with no result; a late reply makes it done. closed: delegation_close recorded it. window-closed: the window is gone. unknown: tmux could not be asked. done and closed are final; gone and window-closed turn done if a late reply arrives.",
87
+ "running: window open, no result. done: a result arrived (see status, envelopePath). gone: the window went with no result; a late reply makes it done. closed: delegation_close recorded it. window-closed: the window is gone. unknown: tmux could not be asked. done and closed are final, though a later delegation_resume continues one as a new delegation; gone and window-closed turn done if a late reply arrives.",
72
88
  },
73
89
  ),
74
90
  closed: Type.Boolean(),
@@ -81,6 +97,8 @@ const DelegationSchema = Type.Object({
81
97
  ),
82
98
  });
83
99
 
100
+ const DelegationSchema = Type.Union([StartedSchema, AttemptSchema]);
101
+
84
102
  export const StatusSchema = Type.Object({
85
103
  delegations: Type.Array(DelegationSchema),
86
104
  marks: Type.Array(
@@ -104,7 +122,7 @@ export const CloseSchema = Type.Object({
104
122
  });
105
123
 
106
124
  /** A tool as registered; `execute` takes the tool's own parameters, which the registration call does not check. */
107
- type ToolDef = {
125
+ export type ToolDef = {
108
126
  name: string;
109
127
  label: string;
110
128
  description: string;
@@ -115,7 +133,7 @@ type ToolDef = {
115
133
  };
116
134
 
117
135
  /** Every window on the server, asked once; a failed ask is `undefined`, never an empty list. */
118
- async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
136
+ export async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined> {
119
137
  try {
120
138
  return await tmux.listWindows();
121
139
  } catch {
@@ -124,64 +142,40 @@ async function listWindows(tmux: TmuxClient): Promise<TmuxWindow[] | undefined>
124
142
  }
125
143
 
126
144
  /** The window a delegation owns: one with both its recorded id and its recorded name, since ids restart with every tmux server. */
127
- function ownWindow(windows: TmuxWindow[], record: Recorded): TmuxWindow | undefined {
145
+ export function ownWindow(windows: TmuxWindow[], record: StartedRecorded): TmuxWindow | undefined {
128
146
  return windows.find((window) => window.id === record.windowId && window.name === record.windowName);
129
147
  }
130
148
 
131
149
  /** What the listing says about a delegation's window; a matching window whose program exited counts as gone. */
132
- function windowState(windows: TmuxWindow[] | undefined, record: Recorded): WindowState {
150
+ export function windowState(windows: TmuxWindow[] | undefined, record: StartedRecorded): WindowState {
133
151
  if (windows === undefined) return "unknown";
134
152
  const own = ownWindow(windows, record);
135
153
  return own !== undefined && !own.exited ? "open" : "gone";
136
154
  }
137
155
 
138
- /** Records from before auto-exit existed kept their windows open, so they read off. */
139
- function autoExitLine(record: Recorded): string {
140
- return record.autoExit === true
141
- ? "on — the delegate closes its window after a normal completion unless the user took over there"
142
- : "off — the window stays open after the result";
143
- }
144
-
145
- /** The state, worded for the `state:` line. */
146
- function stateLine(state: DelegationState, record: Recorded): string {
147
- switch (state) {
148
- case "running":
149
- return "running — the window is open and no result has arrived";
150
- case "gone":
151
- return "closed without a result — the window is gone and no reply arrived; a late reply would make it done";
152
- case "done":
153
- return `done — result status: ${record.result?.status ?? "unknown"}; the result reaches this session as a [delegate] message on its own`;
154
- case "closed":
155
- return "closed — delegation_close recorded it";
156
- case "window-closed":
157
- return "closed — the window is gone";
158
- case "unknown":
159
- return "unknown — tmux could not be asked whether the window is open";
160
- }
161
- }
162
-
163
156
  /** One delegation as `delegation_status` reports it; its text and details both read from this. */
164
157
  type View = {
165
- record: Recorded;
158
+ record: StartedRecorded;
166
159
  state: DelegationState;
167
160
  name: string;
168
161
  sessionPath: string | undefined;
169
162
  /** When the session file was last written; set only for a running delegation with a file. */
170
163
  lastActive: Date | undefined;
164
+ /** The checkout observed for this status call; the recorded result keeps its own. */
165
+ snapshot: WorkspaceSnapshot;
166
+ /** The delegation that resumed this one, if any. */
167
+ resumedAs: string | undefined;
171
168
  };
172
169
 
173
- /** How long ago, worded for the `state:` line: `just now`, `N min ago` or `N h M min ago`. */
174
- function ago(ms: number): string {
175
- const minutes = Math.floor(ms / 60_000);
176
- if (minutes < 1) return "just now";
177
- if (minutes < 60) return `${minutes} min ago`;
178
- return `${Math.floor(minutes / 60)} h ${minutes % 60} min ago`;
179
- }
180
-
181
170
  /** The view of a delegation: its state and the fields the text and details share. */
182
- function view(record: Recorded, windows: TmuxWindow[] | undefined, ctx: ExtensionContext): View {
171
+ function view(
172
+ record: StartedRecorded,
173
+ windows: TmuxWindow[] | undefined,
174
+ ctx: ExtensionContext,
175
+ resumedAs: string | undefined,
176
+ ): View {
183
177
  const state = deriveState(record, windowState(windows, record));
184
- const sessionPath = record.result?.sessionPath ?? findSession(ctx.sessionManager.getSessionDir(), record.id);
178
+ const sessionPath = locateSession(record, ctx.sessionManager.getSessionDir());
185
179
  let lastActive: Date | undefined;
186
180
  if (state === "running" && sessionPath !== undefined) {
187
181
  try {
@@ -190,27 +184,36 @@ function view(record: Recorded, windows: TmuxWindow[] | undefined, ctx: Extensio
190
184
  // a failed stat counts as no file
191
185
  }
192
186
  }
193
- return { record, state, name: recordedName(record), sessionPath, lastActive };
187
+ const snapshot = inspectWorkspace(record.workspace?.cwd, record.workspace?.git);
188
+ return { record, state, name: recordedName(record), sessionPath, lastActive, snapshot, resumedAs };
194
189
  }
195
190
 
196
191
  /** The text block for one delegation. */
197
- function viewText({ record, state, name, sessionPath, lastActive }: View): string {
192
+ function viewText({ record, state, name, sessionPath, lastActive, snapshot, resumedAs }: View): string {
193
+ const [checkout, ...categories] = snapshotLines(snapshot);
198
194
  const seen = lastActive === undefined ? "" : ` (last active ${ago(Date.now() - lastActive.getTime())})`;
199
195
  const lines = [
200
196
  `${record.id} ${record.agent} (${modelLabel(record.model, record.result?.finishedModel)}, thinking ${record.thinking})`,
201
197
  ` name: ${name}`,
198
+ ...(record.resumedFrom === undefined ? [] : [` resumed from ${record.resumedFrom}`]),
199
+ ...(resumedAs === undefined ? [] : [` resumed as ${resumedAs}`]),
202
200
  ` state: ${stateLine(state, record)}${seen}`,
203
201
  ` window: ${record.windowName} (${record.windowId})`,
202
+ ` cwd: ${cwdLine(record.cwd)}`,
203
+ ...(record.workspace === undefined ? [] : [` workspace: ${workspaceLine(record.workspace)}`]),
204
+ ` checkout now: ${checkout}`,
205
+ ...categories.map((line) => ` ${line}`),
204
206
  ` auto-exit: ${autoExitLine(record)}`,
205
- ` session: ${sessionPath ?? `${record.id} (no session file yet)`}`,
207
+ ` session: ${sessionPath ?? `${sessionIdOf(record)} (no session file yet)`}`,
206
208
  ];
207
209
  if (record.result !== undefined) lines.push(` envelope: ${record.result.envelopePath}`);
208
210
  return lines.join("\n");
209
211
  }
210
212
 
211
213
  /** The structured details for one delegation. */
212
- function viewDetails({ record, state, name, sessionPath, lastActive }: View): Record<string, unknown> {
214
+ function viewDetails({ record, state, name, sessionPath, lastActive, snapshot }: View): Record<string, unknown> {
213
215
  return {
216
+ kind: "started",
214
217
  id: record.id,
215
218
  name,
216
219
  agent: record.agent,
@@ -218,8 +221,13 @@ function viewDetails({ record, state, name, sessionPath, lastActive }: View): Re
218
221
  ...(record.result?.finishedModel === undefined ? {} : { finishedModel: record.result.finishedModel }),
219
222
  thinking: record.thinking,
220
223
  autoExit: record.autoExit === true,
224
+ ...(record.resumedFrom === undefined ? {} : { resumedFrom: record.resumedFrom }),
221
225
  windowId: record.windowId,
222
226
  windowName: record.windowName,
227
+ ...(record.cwd === undefined ? {} : { cwd: record.cwd }),
228
+ ...(record.sessionDir === undefined ? {} : { sessionDir: record.sessionDir }),
229
+ ...(record.workspace === undefined ? {} : { workspace: record.workspace }),
230
+ workspaceSnapshot: snapshot,
223
231
  state,
224
232
  closed: record.closed === true,
225
233
  closedWithoutResult: record.gone === true && record.result === undefined && record.closed !== true,
@@ -230,6 +238,16 @@ function viewDetails({ record, state, name, sessionPath, lastActive }: View): Re
230
238
  };
231
239
  }
232
240
 
241
+ /** One unstarted attempt as the status text words it. */
242
+ function attemptText(record: WorkspaceAttempt): string {
243
+ return [
244
+ `${record.id} ${record.agent} (${modelLabel(record.model, undefined)}, thinking ${record.thinking})`,
245
+ ` name: ${recordedName(record)}`,
246
+ ` state: ${record.attempt.phase} — no window id/start was recorded; a window may still exist (close requires a verified window id)`,
247
+ ...attemptLines(record).map((line) => ` ${line}`),
248
+ ].join("\n");
249
+ }
250
+
233
251
  /** The usage-limit marks in force now, as `delegation_status` reports them. */
234
252
  function activeMarks(): { scope: string; source: Mark["source"]; reason: string; clearsAt: string }[] {
235
253
  const now = Date.now();
@@ -325,7 +343,7 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
325
343
  name: "delegation_status",
326
344
  label: "Delegation status",
327
345
  description:
328
- "The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Call it to check on a delegation that seems stuck or to find its files; a result arrives as a message on its own, so there is no need to call it to wait for one. Names the delegation id, agent, session name, model, thinking, window, delegate session path and result envelope path. A running delegation also shows when its session file was last written; a long gap may mean it is stuck but does not prove it. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
346
+ "The delegations this session started, each reported as running (the window is open and no result has arrived yet), done (with the result envelope's status) or closed (closed with delegation_close, or its window is gone). A delegation whose window went with no reply is reported closed without a result until a late reply makes it done. Call it to check on a delegation that seems stuck or to find its files; a result arrives as a message on its own, so there is no need to call it to wait for one. Names the delegation id, agent, session name, model, thinking, window, working directory, delegate session path and result envelope path. A running delegation also shows when its session file was last written; a long gap may mean it is stuck but does not prove it. A delegate's window being open means it is connected, not that its task is unfinished or finished; only a result means done.",
329
347
  parameters: Type.Object({
330
348
  id: Type.Optional(Type.String({ description: "One delegation id; omit to report every delegation." })),
331
349
  }),
@@ -340,10 +358,19 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
340
358
  ): Promise<ReturnType<typeof toolResult>> {
341
359
  const records = selectRecords(results, params.id);
342
360
  const windows = await listWindows(tmux);
343
- const views = records.map((record) => view(record, windows, ctx));
361
+ const resumedAs = (id: string) => results.list().find((item) => isStarted(item) && item.resumedFrom === id)?.id;
362
+ const views = records.map((record) =>
363
+ isStarted(record) ? view(record, windows, ctx, resumedAs(record.id)) : record,
364
+ );
344
365
  const marks = activeMarks();
345
366
  const errors = readingErrors();
346
- const details = { delegations: views.map(viewDetails), marks, readingErrors: errors };
367
+ const details = {
368
+ delegations: views.map((item) =>
369
+ "record" in item ? viewDetails(item) : { ...item, state: item.attempt.phase },
370
+ ),
371
+ marks,
372
+ readingErrors: errors,
373
+ };
347
374
  const block =
348
375
  marks.length === 0 && errors.length === 0
349
376
  ? []
@@ -358,7 +385,10 @@ function statusTool(results: Results, tmux: TmuxClient, readingErrors: () => Rea
358
385
  const parts =
359
386
  views.length === 0
360
387
  ? ["No delegations are recorded in this session.", ...block]
361
- : [`${heading}\n\n${views.map(viewText).join("\n\n")}`, ...block];
388
+ : [
389
+ `${heading}\n\n${views.map((item) => ("record" in item ? viewText(item) : attemptText(item))).join("\n\n")}`,
390
+ ...block,
391
+ ];
362
392
  return toolResult(parts.join("\n\n"), details);
363
393
  },
364
394
  };
@@ -385,6 +415,11 @@ function closeTool(results: Results, tmux: TmuxClient): ToolDef {
385
415
  _ctx: ExtensionContext,
386
416
  ): Promise<ReturnType<typeof toolResult>> {
387
417
  const [record] = selectRecords(results, params.id);
418
+ if (!isStarted(record)) {
419
+ throw new Error(
420
+ `delegation ${record.id} is a workspace attempt (${record.attempt.phase}): no verified window id was recorded; a window may still exist, so close is unavailable`,
421
+ );
422
+ }
388
423
  const closed = (alreadyClosed: boolean, killed: boolean) => ({
389
424
  id: record.id,
390
425
  windowId: record.windowId,