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/render.ts CHANGED
@@ -1,92 +1,149 @@
1
- // How delegations look in the parent: the `delegate` tool's row and the
2
- // result or gone message that arrives later share one header and preview
3
- // style. Both are drawn from structured details, while their text stays what
4
- // the parent model reads.
1
+ // How delegations look in the parent. A delegation reads as one tool call that
2
+ // changes state: the `delegate` row shows ○ while its arguments arrive and ●
3
+ // once started, and the result message that arrives later is drawn as a
4
+ // finished tool row (✓ or ✗ on Pi's tool success or error background). The
5
+ // style follows amosblomqvist/pi-interactive-subagents (c3e8b53,
6
+ // pi-extension/subagents/index.ts:1891-1944 and 2353-2460). Everything is
7
+ // drawn from structured details; the text the parent model reads is separate.
5
8
  import { homedir } from "node:os";
6
9
 
7
10
  import {
8
11
  getMarkdownTheme,
9
- keyText,
12
+ keyHint,
10
13
  type MessageRenderer,
11
14
  type MessageRenderOptions,
12
15
  type Theme,
13
16
  } from "@earendil-works/pi-coding-agent";
14
- import { Box, type Component, Markdown, Spacer, Text, TruncatedText } from "@earendil-works/pi-tui";
17
+ import { Box, type Component, Container, Markdown, Spacer, Text, TruncatedText } from "@earendil-works/pi-tui";
15
18
  import type { Static } from "typebox";
16
19
 
17
20
  import type { DelegateSchema } from "./index.ts";
18
- import type { MessageDetails } from "./results.ts";
21
+ import type { MessageDetails, ResourceFacts, WorkspaceAttempt } from "./results.ts";
22
+ import type { WorkspaceSnapshot } from "./snapshot.ts";
23
+ import type { PathSummary, Workspace } from "./workspace.ts";
19
24
 
20
25
  /** The launch model, or `launch → finished` when the delegate finished on another one. */
21
26
  export function modelLabel(model: string, finished: string | undefined): string {
22
27
  return finished === undefined || finished === model ? model : `${model} → ${finished}`;
23
28
  }
24
29
 
25
- /** Lines of the body the collapsed view previews. */
26
- const PREVIEW_LINES = 3;
30
+ const displayReason = (reason: string): string => JSON.stringify(reason).slice(1, -1);
27
31
 
28
- /** The icon and theme color for each status; an unknown status gets a neutral icon. */
29
- const STATUS_STYLE: Record<string, { icon: string; color: "accent" | "success" | "error" | "warning" | "muted" }> = {
30
- started: { icon: "◐", color: "accent" },
31
- done: { icon: "✓", color: "success" },
32
- failed: { icon: "✗", color: "error" },
33
- stopped: { icon: "■", color: "warning" },
34
- gone: { icon: "⊘", color: "warning" },
35
- };
32
+ /** Where a delegation ran, or that its record, from before cwd was kept, does not say. */
33
+ export function cwdLine(cwd: string | undefined): string {
34
+ return cwd ?? "unknown (not recorded)";
35
+ }
36
36
 
37
- /** The header line: status icon, the delegate's name and the model it ran on. */
38
- function header(details: MessageDetails, theme: Theme): string {
39
- const status = details.kind === "gone" ? "gone" : details.status;
40
- const known = Object.hasOwn(STATUS_STYLE, status);
41
- const style = known ? STATUS_STYLE[status] : { icon: "•", color: "muted" as const };
42
- let trailer = "";
43
- if (details.kind === "gone") trailer = " closed without a result";
44
- else if (!known) trailer = ` ${details.status}`;
45
- return headerLine(theme, style, details.name, modelLabel(details.model, details.finishedModel), trailer);
37
+ /** How a delegation's directory was chosen and its Git checkout at launch, on one line. */
38
+ export function workspaceLine(workspace: Workspace): string {
39
+ if (workspace.mode === "managed") {
40
+ const { createdBranch, requestedBase, baseCommit, source, cwd } = workspace;
41
+ return `managed worktree ${cwd}, new branch ${createdBranch} created from ${requestedBase} (${baseCommit}) of ${source.checkoutRoot}; ${dirtyNotice(workspace.sourceDirty)}`;
42
+ }
43
+ const { mode, git } = workspace;
44
+ const how = mode === "inherited" ? "the parent's directory" : "existing directory";
45
+ if (git.kind === "non-git") return `${how}; not a Git checkout`;
46
+ if (git.kind === "unknown") return `${how}; Git state unknown (${displayReason(git.reason)})`;
47
+ const { mainRoot, checkoutRoot } = git.identity;
48
+ const linked = checkoutRoot === mainRoot ? "" : ` (linked worktree ${checkoutRoot})`;
49
+ const at = git.position.detached ? "detached HEAD" : `branch ${git.position.branch}`;
50
+ return `${how}; Git checkout ${mainRoot}${linked}, ${at}, launch commit ${git.baseline ?? "none (no commits yet)"}`;
46
51
  }
47
52
 
48
- function headerLine(
49
- theme: Theme,
50
- style: { icon: string; color: "accent" | "success" | "error" | "warning" | "muted" },
51
- name: string,
52
- model: string,
53
- trailer: string,
54
- ): string {
55
- return `${theme.fg(style.color, style.icon)} ${theme.fg("accent", name)} ${theme.fg("muted", model)}${
56
- trailer === "" ? "" : theme.fg(style.color, trailer)
57
- }`;
53
+ /** The source's uncommitted paths a worktree does not carry, bounded to a few names. */
54
+ export function dirtyNotice({ paths, total }: PathSummary): string {
55
+ if (total === 0) return "the source had no uncommitted files";
56
+ const shown = paths.slice(0, 5).map((path) => JSON.stringify(path));
57
+ const rest = total > shown.length ? `, and ${total - shown.length} more` : "";
58
+ return `${total} uncommitted source path${total === 1 ? " is" : "s are"} not copied into it (${shown.join(", ")}${rest})`;
58
59
  }
59
60
 
60
- function expandHint(theme: Theme): Text {
61
- return new Text(`${theme.fg("dim", keyText("app.tools.expand"))}${theme.fg("muted", " to expand")}`, 0, 0);
61
+ /** What is known to exist for an attempt, in words. */
62
+ export function resourceLine(resources: ResourceFacts): string {
63
+ switch (resources.kind) {
64
+ case "none":
65
+ return "nothing created by this attempt remains";
66
+ case "branch":
67
+ return `the branch ${resources.branch} remains at ${resources.head}; its worktree is gone`;
68
+ case "worktree":
69
+ return `the worktree ${resources.cwd} and its branch ${resources.branch} (at ${resources.head}) remain`;
70
+ case "unknown":
71
+ return `unknown: ${displayReason(resources.reason)}; observed paths ${JSON.stringify(resources.observedPaths)}, refs ${JSON.stringify(resources.observedRefs)}`;
72
+ }
62
73
  }
63
74
 
64
- /** `path` with the home directory shown as `~`. */
65
- function tilde(path: string): string {
66
- const home = homedir();
67
- return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
75
+ /** The lines that describe an attempt without a window: its plan, what exists, why it stopped and recovery. */
76
+ export function attemptLines({ plan, attempt, requestId }: WorkspaceAttempt): string[] {
77
+ let why: string[] = [];
78
+ if ("cause" in attempt) why = [`cause: ${attempt.cause}`];
79
+ else if ("reason" in attempt) why = [`reason: ${attempt.reason}`];
80
+ return [
81
+ `source: ${plan.sourceCwd} (repository ${plan.source.mainRoot})`,
82
+ `worktree: ${plan.destination} on new branch ${plan.branch} from ${plan.requestedBase} (${plan.baseCommit})`,
83
+ `resources: ${resourceLine(attempt.resources)}`,
84
+ ...why,
85
+ ...(attempt.phase === "retained" && attempt.recovery.length > 0
86
+ ? ["recovery (non-forced; check each command's result):", ...attempt.recovery.map((line) => ` ${line}`)]
87
+ : []),
88
+ ...(requestId === undefined
89
+ ? []
90
+ : [`request: ${requestId} (the task was mailed; no window id/start was recorded and a window may still exist)`]),
91
+ ];
68
92
  }
69
93
 
70
- function metadata(details: MessageDetails): string[] {
71
- const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
72
- if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
73
- else lines.push(`window ${details.windowName}`);
74
- lines.push(
75
- `session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
76
- );
77
- return lines;
94
+ /** A path list as data: each name JSON-quoted, so spaces, quotes and newlines stay inside one line. */
95
+ function pathList({ paths, total }: PathSummary): string {
96
+ if (total === 0) return "none";
97
+ const more = total > paths.length ? `, and ${total - paths.length} more not listed` : "";
98
+ return `${total} path${total === 1 ? "" : "s"}: ${paths.map((path) => JSON.stringify(path)).join(", ")}${more}`;
78
99
  }
79
100
 
80
101
  /**
81
- * The first non-blank lines of a body, with heading marks, backticks and bold
82
- * markers stripped. Plain text, not Markdown, because a cut-off fragment can
83
- * break mid-list or mid-code-block.
102
+ * A checkout observation: a summary line, then for Git one line per path
103
+ * category. Committed changes are counted from the launch commit; pending
104
+ * ones are the index and working tree now.
84
105
  */
85
- function preview(body: string): string[] {
86
- return body
106
+ export function snapshotLines(snapshot: WorkspaceSnapshot): string[] {
107
+ if (snapshot.kind === "non-git") return ["not a Git checkout"];
108
+ if (snapshot.kind === "unknown") return [`Git state unknown (${displayReason(snapshot.reason)})`];
109
+ const { position, head, baseline, ahead, workingState, committed, pending } = snapshot;
110
+ const at = position.detached ? "detached HEAD" : `branch ${position.branch}`;
111
+ return [
112
+ `${at}, head ${head}, launch commit ${baseline}, ${ahead} commit${ahead === 1 ? "" : "s"} since launch, working tree ${workingState}`,
113
+ `committed since launch: ${pathList(committed)}`,
114
+ `staged: ${pathList(pending.staged)}`,
115
+ `unstaged: ${pathList(pending.unstaged)}`,
116
+ `untracked: ${pathList(pending.untracked)}`,
117
+ `conflicted: ${pathList(pending.conflicted)}`,
118
+ ];
119
+ }
120
+
121
+ /** Lines of a result the collapsed message previews. */
122
+ const PREVIEW_LINES = 5;
123
+ /** Characters of the task's first line the call row shows. */
124
+ const TASK_PREVIEW_CHARS = 100;
125
+
126
+ type Color = "success" | "error" | "warning" | "muted" | "dim";
127
+
128
+ /** The icon, its color and the status word for each way a delegation ends. */
129
+ const ENDINGS: Record<string, { icon: string; color: Color; word: string; wordColor: Color }> = {
130
+ done: { icon: "✓", color: "success", word: "done", wordColor: "dim" },
131
+ failed: { icon: "✗", color: "error", word: "failed", wordColor: "error" },
132
+ stopped: { icon: "✗", color: "warning", word: "stopped", wordColor: "warning" },
133
+ gone: { icon: "✗", color: "warning", word: "closed without a result", wordColor: "warning" },
134
+ };
135
+
136
+ /** `path` with the home directory shown as `~`. */
137
+ function tilde(path: string): string {
138
+ const home = homedir();
139
+ return path === home || path.startsWith(`${home}/`) ? `~${path.slice(home.length)}` : path;
140
+ }
141
+
142
+ /** Non-blank lines with heading marks, backticks and bold markers stripped, for a plain-text preview. */
143
+ function plainLines(text: string): string[] {
144
+ return text
87
145
  .split("\n")
88
146
  .filter((line) => line.trim() !== "")
89
- .slice(0, PREVIEW_LINES)
90
147
  .map((line) =>
91
148
  line
92
149
  .replace(/^#+\s+/, "")
@@ -95,10 +152,26 @@ function preview(body: string): string[] {
95
152
  );
96
153
  }
97
154
 
155
+ function idLines(details: MessageDetails): string[] {
156
+ const lines = [`delegation ${details.id}`, `request ${details.requestId}`];
157
+ if (details.kind === "result") lines.push(`envelope ${tilde(details.envelopePath)}`);
158
+ else lines.push(`window ${details.windowName}`);
159
+ lines.push(`cwd ${details.cwd === undefined ? cwdLine(undefined) : tilde(details.cwd)}`);
160
+ if (details.workspace !== undefined) lines.push(`workspace ${workspaceLine(details.workspace)}`);
161
+ if (details.kind === "result" && details.workspaceSnapshot !== undefined) {
162
+ const [first, ...rest] = snapshotLines(details.workspaceSnapshot);
163
+ lines.push(`checkout when the result arrived: ${first}`, ...rest.map((line) => ` ${line}`));
164
+ }
165
+ lines.push(
166
+ `session ${details.sessionPath === undefined ? `${details.id} (no session file found)` : tilde(details.sessionPath)}`,
167
+ );
168
+ return lines;
169
+ }
170
+
98
171
  /**
99
- * A delegate message drawn from its details. Collapsed: a header, a short
100
- * preview of the result body and an expand hint. Expanded: the header, the
101
- * quoted task, the body as Markdown, then the ids and files. Ctrl+O toggles it
172
+ * A delegate result or gone message, drawn as a finished tool row: a header
173
+ * (icon, name, model, status), then a preview and the expand hint, or,
174
+ * expanded, the result as Markdown, the task and the ids. Ctrl+O toggles it
102
175
  * with tool output; the model always gets the full content.
103
176
  */
104
177
  export function renderMessage(
@@ -108,54 +181,90 @@ export function renderMessage(
108
181
  ): Component | undefined {
109
182
  const details = message.details as MessageDetails | undefined;
110
183
  if (details?.kind !== "result" && details?.kind !== "gone") return undefined;
111
- const box = new Box(outputPad, 1, (text) => theme.bg("customMessageBg", text));
112
- if (!expanded) {
113
- box.addChild(new TruncatedText(header(details, theme)));
114
- if (details.kind === "result") {
115
- for (const line of preview(details.body)) box.addChild(new TruncatedText(theme.fg("muted", line)));
116
- }
117
- box.addChild(expandHint(theme));
118
- return box;
184
+ const status = details.kind === "gone" ? "gone" : details.status;
185
+ const background = status === "done" ? "toolSuccessBg" : "toolErrorBg";
186
+ const box = new Box(outputPad, 1, (text) => theme.bg(background, text));
187
+ const header = headerLine(details, status, theme);
188
+ box.addChild(expanded ? new Text(header, 0, 0) : new TruncatedText(header));
189
+ if (details.kind === "result" && details.limit !== undefined)
190
+ box.addChild(new Text(theme.fg("warning", details.limit), 0, 0));
191
+ for (const child of expanded ? expandedLines(details, theme) : collapsedLines(details, theme)) box.addChild(child);
192
+ return box;
193
+ }
194
+
195
+ /** The icon, the delegate's name, the model and the status word. */
196
+ function headerLine(details: MessageDetails, status: string, theme: Theme): string {
197
+ const ending = Object.hasOwn(ENDINGS, status)
198
+ ? ENDINGS[status]
199
+ : { icon: "•", color: "muted" as const, word: status, wordColor: "muted" as const };
200
+ const model = modelLabel(details.model, details.finishedModel);
201
+ return `${theme.fg(ending.color, ending.icon)} ${theme.fg("toolTitle", theme.bold(details.name))}${theme.fg(
202
+ "dim",
203
+ ` (${model}) —`,
204
+ )} ${theme.fg(ending.wordColor, ending.word)}`;
205
+ }
206
+
207
+ /** A result's first lines, a count of the rest, and the expand hint. */
208
+ function collapsedLines(details: MessageDetails, theme: Theme): Component[] {
209
+ const children: Component[] = [];
210
+ if (details.kind === "result") {
211
+ const lines = plainLines(details.body);
212
+ for (const line of lines.slice(0, PREVIEW_LINES)) children.push(new TruncatedText(theme.fg("dim", line)));
213
+ if (lines.length > PREVIEW_LINES)
214
+ children.push(new Text(theme.fg("muted", `… ${lines.length - PREVIEW_LINES} more lines`), 0, 0));
119
215
  }
120
- box.addChild(new Text(header(details, theme), 0, 0));
121
- const dim = metadata(details);
122
- if (details.kind === "gone") dim.push("A reply that arrives later is still delivered.");
123
- else if (details.limit !== undefined) dim.push(details.limit);
124
- const metadataText = new Text(dim.map((line) => theme.fg("dim", line)).join("\n"), 0, 0);
125
- if (details.kind === "gone") {
126
- box.addChild(metadataText);
127
- return box;
216
+ children.push(new Text(keyHint("app.tools.expand", "to expand"), 0, 0));
217
+ return children;
218
+ }
219
+
220
+ /** The result as Markdown and the quoted task, then the ids and files. */
221
+ function expandedLines(details: MessageDetails, theme: Theme): Component[] {
222
+ const dim = (lines: string[]): Text => new Text(lines.map((line) => theme.fg("dim", line)).join("\n"), 0, 0);
223
+ const children: Component[] = [];
224
+ if (details.kind === "result") {
225
+ const { task } = details;
226
+ const quote = "note" in task ? [task.note] : [`Task (${tilde(task.path)}):`, ...task.text.split("\n")];
227
+ children.push(new Spacer(1), new Markdown(details.body, 0, 0, getMarkdownTheme()), new Spacer(1), dim(quote));
128
228
  }
129
- const { task } = details;
130
- const quote =
131
- "note" in task
132
- ? theme.fg("muted", task.note)
133
- : `${theme.fg("muted", `Task (${tilde(task.path)}):`)}\n${task.text
134
- .split("\n")
135
- .map((line) => theme.fg("muted", `> ${line}`))
136
- .join("\n")}`;
137
- box.addChild(new Text(quote, 0, 0));
138
- box.addChild(new Spacer(1));
139
- box.addChild(new Text(theme.fg("dim", "Result"), 0, 0));
140
- box.addChild(new Markdown(details.body, 0, 0, getMarkdownTheme()));
141
- box.addChild(new Spacer(1));
142
- box.addChild(metadataText);
143
- return box;
229
+ const tail = details.kind === "gone" ? ["A reply that arrives later is still delivered."] : [];
230
+ children.push(new Spacer(1), dim([...idLines(details), ...tail]));
231
+ return children;
232
+ }
233
+
234
+ /** The task's first non-blank line, cut at 100 characters, and its line count when it has more than one. */
235
+ function taskLine(task: unknown, theme: Theme): string {
236
+ if (typeof task !== "string") return "";
237
+ const first = task.split("\n").find((line) => line.trim() !== "") ?? "";
238
+ const shown = first.length > TASK_PREVIEW_CHARS ? `${first.slice(0, TASK_PREVIEW_CHARS)}…` : first;
239
+ const total = task.split("\n").length;
240
+ return `${shown === "" ? "" : theme.fg("toolOutput", shown)}${total > 1 ? theme.fg("muted", ` (${total} lines)`) : ""}`;
144
241
  }
145
242
 
146
- /** The `delegate` tool's call line: the tool, the agent and any label. Arguments may still be streaming. */
147
- export function renderDelegateCall(args: { agent?: unknown; label?: unknown }, theme: Theme): Component {
148
- let line = theme.bold("delegate");
149
- if (typeof args.agent === "string") line += ` ${theme.fg("accent", args.agent)}`;
243
+ /**
244
+ * The `delegate` tool's call: ○, the agent, any label and the task line,
245
+ * while arguments stream and the call runs. Pi redraws the call above the
246
+ * result, so once a launch succeeds the call draws nothing and the result's
247
+ * header is the row's only one; a refused call keeps it above the error.
248
+ */
249
+ export function renderDelegateCall(
250
+ args: { agent?: unknown; label?: unknown; task?: unknown },
251
+ theme: Theme,
252
+ context: { isPartial: boolean; isError: boolean },
253
+ ): Component {
254
+ if (!context.isPartial && !context.isError) return new Container();
255
+ const agent = typeof args.agent === "string" && args.agent !== "" ? args.agent : "…";
256
+ let text = `○ ${theme.fg("toolTitle", theme.bold(agent))}`;
150
257
  if (typeof args.label === "string" && args.label !== "" && args.label !== "null")
151
- line += ` ${theme.fg("muted", `· ${args.label}`)}`;
152
- return new Text(line, 0, 0);
258
+ text += theme.fg("dim", ` [${args.label}]`);
259
+ const task = taskLine(args.task, theme);
260
+ if (task !== "") text += `\n${task}`;
261
+ return new Text(text, 0, 0);
153
262
  }
154
263
 
155
264
  /**
156
- * The `delegate` tool's result, in the result message's style: a started
157
- * header, then a task preview and the hint, or, expanded, the whole task and
158
- * the ids. A refused or blocked call has no details and keeps its plain text.
265
+ * The `delegate` tool's result: ●, the delegate's name, its model and
266
+ * "started", the task line and any skipped fallback models; expanded, the ids too. A
267
+ * refused or blocked call has no details and keeps its plain text.
159
268
  */
160
269
  export function renderDelegateResult(
161
270
  result: { content: { type: string; text?: string }[]; details?: unknown },
@@ -169,29 +278,29 @@ export function renderDelegateResult(
169
278
  return new Text(context.isError ? theme.fg("error", text) : text, 0, 0);
170
279
  }
171
280
  const started = details as Static<typeof DelegateSchema>;
172
- const task = typeof context.args.task === "string" ? context.args.task : "";
173
- const box = new Box(0, 0);
174
- box.addChild(new TruncatedText(headerLine(theme, STATUS_STYLE.started, started.windowName, started.model, "")));
175
- const skipped = started.skipped.map(({ model, reason }) => theme.fg("warning", `skipped ${model}: ${reason}`));
176
- if (!expanded) {
177
- for (const line of preview(task)) box.addChild(new TruncatedText(theme.fg("muted", line)));
178
- for (const line of skipped) box.addChild(new TruncatedText(line));
179
- box.addChild(expandHint(theme));
180
- return box;
181
- }
182
- const quote = task
183
- .split("\n")
184
- .map((line) => theme.fg("muted", `> ${line}`))
185
- .join("\n");
186
- box.addChild(new Text(quote, 0, 0));
187
- if (skipped.length + started.notices.length > 0)
188
- box.addChild(new Text([...skipped, ...started.notices.map((line) => theme.fg("dim", line))].join("\n"), 0, 0));
189
- box.addChild(new Spacer(1));
190
- const ids = [
191
- `delegation ${started.id}`,
192
- `window ${started.windowName} (${started.windowId})`,
193
- `thinking ${started.thinking}, auto-exit ${started.autoExit ? "on" : "off"}`,
281
+ const lines = [
282
+ `${theme.fg("accent", "●")} ${theme.fg("toolTitle", theme.bold(started.windowName))}${theme.fg(
283
+ "dim",
284
+ ` (${started.model}) — started`,
285
+ )}`,
194
286
  ];
195
- box.addChild(new Text(ids.map((line) => theme.fg("dim", line)).join("\n"), 0, 0));
196
- return box;
287
+ const task = taskLine(context.args.task, theme);
288
+ if (task !== "") lines.push(task);
289
+ lines.push(...started.skipped.map(({ model, reason }) => theme.fg("warning", `[Skipped ${model}: ${reason}]`)));
290
+ if (expanded)
291
+ lines.push(
292
+ ...[
293
+ ...started.notices,
294
+ `delegation ${started.id}`,
295
+ `window ${started.windowName} (${started.windowId})`,
296
+ // Results saved before cwd was recorded have none.
297
+ `cwd ${(started.cwd as string | undefined) === undefined ? cwdLine(undefined) : tilde(started.cwd)}`,
298
+ // Results saved before workspaces were recorded have none.
299
+ ...((started.workspace as Workspace | undefined) === undefined
300
+ ? []
301
+ : [`workspace ${workspaceLine(started.workspace)}`]),
302
+ `thinking ${started.thinking}, auto-exit ${started.autoExit ? "on" : "off"}`,
303
+ ].map((line) => theme.fg("dim", line)),
304
+ );
305
+ return new Text(lines.join("\n"), 0, 0);
197
306
  }
@@ -0,0 +1,47 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { isAbsolute, join } from "node:path";
3
+ import type { StartedRecorded } from "./result-types.ts";
4
+
5
+ /** The delegate session file in a directory, found by its `_<id>.jsonl` suffix. */
6
+ export function findSession(dir: string | undefined, id: string): string | undefined {
7
+ if (dir === undefined) return undefined;
8
+ let names: string[];
9
+ try {
10
+ names = readdirSync(dir);
11
+ } catch {
12
+ return undefined;
13
+ }
14
+ const name = names.find((candidate) => candidate.endsWith(`_${id}.jsonl`));
15
+ return name === undefined ? undefined : join(dir, name);
16
+ }
17
+ /** Use a recorded absolute session path, otherwise search its recorded session directory. */
18
+ export function locateSession(record: StartedRecorded, fallbackDir: string | undefined): string | undefined {
19
+ const known = record.sessionPath ?? record.result?.sessionPath;
20
+ if (known !== undefined) return known;
21
+ const found = findSession(record.sessionDir ?? fallbackDir, record.id);
22
+ if (found !== undefined && isAbsolute(found)) record.sessionPath = found;
23
+ return found;
24
+ }
25
+ /** The provider/model of the last assistant message in the delegate session, if available. */
26
+ export function finishedModel(sessionPath: string | undefined): string | undefined {
27
+ if (sessionPath === undefined) return undefined;
28
+ let lines: string[];
29
+ try {
30
+ lines = readFileSync(sessionPath, "utf8").split("\n");
31
+ } catch {
32
+ return undefined;
33
+ }
34
+ for (let i = lines.length - 1; i >= 0; i--) {
35
+ let entry: { type?: unknown; message?: { role?: unknown; provider?: unknown; model?: unknown } };
36
+ try {
37
+ entry = JSON.parse(lines[i]);
38
+ } catch {
39
+ continue;
40
+ }
41
+ const message = entry?.message;
42
+ if (entry?.type !== "message" || message?.role !== "assistant") continue;
43
+ if (typeof message.provider === "string" && typeof message.model === "string")
44
+ return `${message.provider}/${message.model}`;
45
+ }
46
+ return undefined;
47
+ }
@@ -0,0 +1,36 @@
1
+ import type { Delegation } from "./results.ts";
2
+ import type { WorktreePlan } from "./worktree.ts";
3
+
4
+ /** One started delegation as it stands: what was recorded at its start, plus what came after. */
5
+ export type StartedRecorded = Delegation & { result?: import("./results.ts").Result; closed?: boolean; gone?: boolean };
6
+ /** What is known to exist for an attempt; facts, not authorization to remove anything. */
7
+ export type ResourceFacts =
8
+ | { kind: "none" }
9
+ | { kind: "branch"; branch: string; head: string }
10
+ | { kind: "worktree"; cwd: string; branch: string; head: string }
11
+ | { kind: "unknown"; reason: string; observedPaths: string[]; observedRefs: string[] };
12
+ /** Where a managed launch stands before, or instead of, a window. */
13
+ export type AttemptState =
14
+ | { phase: "planned"; resources: ResourceFacts }
15
+ | { phase: "created"; resources: ResourceFacts }
16
+ | { phase: "rolled-back"; resources: { kind: "none" }; cause: string }
17
+ | { phase: "retained"; resources: ResourceFacts; cause: string; recovery: string[] }
18
+ | { phase: "interrupted"; resources: ResourceFacts; lastPhase: "planned" | "created"; reason: string };
19
+ /** A managed launch with no window; its request id exists only if mail returned one. */
20
+ export type WorkspaceAttempt = {
21
+ kind: "workspace-attempt";
22
+ id: string;
23
+ parentId: string;
24
+ agent: string;
25
+ model: string;
26
+ thinking: string;
27
+ name?: string;
28
+ sessionDir: string;
29
+ plan: WorktreePlan;
30
+ attempt: AttemptState;
31
+ requestId?: string;
32
+ };
33
+ export type Recorded = StartedRecorded | WorkspaceAttempt;
34
+ export function isStarted(record: Recorded): record is StartedRecorded {
35
+ return !("kind" in record && record.kind === "workspace-attempt");
36
+ }