pi-jar 0.1.3 → 0.1.4
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/README.md +33 -7
- package/docs/DEVELOPMENT.md +1 -1
- package/docs/WORKFLOWS.md +7 -4
- package/extensions/index.ts +55 -37
- package/package.json +1 -1
- package/src/activity-view.ts +314 -41
- package/src/advisor.ts +9 -3
- package/src/changes.ts +35 -11
- package/src/child-baselines.ts +42 -0
- package/src/compact-tools.ts +58 -24
- package/src/composer.ts +39 -12
- package/src/delegate.ts +371 -65
- package/src/dialogs.ts +9 -5
- package/src/diff-view.ts +13 -1
- package/src/flame.ts +18 -5
- package/src/footer.ts +34 -16
- package/src/history-ui.ts +19 -12
- package/src/history.ts +3 -1
- package/src/model-roles.ts +62 -7
- package/src/panel.ts +5 -1
- package/src/prompt-search.ts +16 -8
- package/src/row-cache.ts +26 -0
- package/src/session-ui.ts +9 -4
- package/src/settings-ui.ts +5 -9
- package/src/shells.ts +2 -1
- package/src/side-model.ts +45 -25
- package/src/split-view.ts +29 -12
- package/src/task-tool.ts +140 -53
- package/src/tasks-ui.ts +15 -5
- package/src/tasks.ts +224 -38
- package/src/welcome.ts +26 -13
package/src/activity-view.ts
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
|
|
3
|
-
import type
|
|
3
|
+
import { duration, leafTodos, type DelegateRegistry, type DelegateRun, type SubagentRecord, type TranscriptEntry } from "./delegate.ts";
|
|
4
4
|
import { safeLine } from "./diff-view.ts";
|
|
5
5
|
import { icon, withIcon } from "./icons.ts";
|
|
6
6
|
import { describe, elapsed, type ShellJob, type ShellManager } from "./shells.ts";
|
|
7
7
|
import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
|
|
8
8
|
import { ACTIVE_STATES, cleanText } from "./status.ts";
|
|
9
|
+
import type { Todo } from "./tasks.ts";
|
|
9
10
|
|
|
10
11
|
/** What to select when the view opens. */
|
|
11
12
|
export type ActivityTarget = { kind: "subagent" | "shell"; id: string };
|
|
@@ -25,11 +26,18 @@ type Entry =
|
|
|
25
26
|
| { kind: "shell"; id: string; status: Status; label: string; job: Omit<ShellJob, "lines"> }
|
|
26
27
|
| { kind: "role"; id: string; status: Status; label: string; role: ActivityRole };
|
|
27
28
|
type Item = Entry | { kind: "header"; label: string };
|
|
29
|
+
type Focus = "list" | "transcript" | "input";
|
|
28
30
|
|
|
29
31
|
const COLOR: Record<Status, string> = { running: "accent", pending: "dim", success: "success", error: "error", stopped: "dim" };
|
|
30
32
|
const ACTIONS = [{ key: "x" }, { key: "f" }] as const;
|
|
31
33
|
const SHELL_TAIL = 400;
|
|
32
34
|
const MAX_TASK_CHARS = 8000;
|
|
35
|
+
/** Expanded tool output shows at most this many wrapped rows; the rest is summarized. */
|
|
36
|
+
const MAX_OUTPUT_ROWS = 80;
|
|
37
|
+
const MAX_ARGS_ROWS = 12;
|
|
38
|
+
const LIVE_ROWS = 4;
|
|
39
|
+
const MAX_DRAFT = 2000;
|
|
40
|
+
const SPINNER = ["◐", "◓", "◑", "◒"];
|
|
33
41
|
|
|
34
42
|
/** Left-pane rows: a header per non-empty section, then its entries. */
|
|
35
43
|
function collect(sources: ActivitySources): Item[] {
|
|
@@ -60,39 +68,165 @@ function collect(sources: ActivitySources): Item[] {
|
|
|
60
68
|
}
|
|
61
69
|
|
|
62
70
|
/** `head` stays pinned above the scrolling `body`, so following a transcript never hides who is running and how. */
|
|
63
|
-
interface Details {
|
|
71
|
+
interface Details {
|
|
72
|
+
head: string[];
|
|
73
|
+
body: readonly string[];
|
|
74
|
+
/** Styles one body line; applied to the visible window only, so a 400-line shell tail costs a screenful. */
|
|
75
|
+
format?: (line: string) => string;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* One collapsible piece of a subagent's details. `key` is stable across renders (the transcript entry
|
|
80
|
+
* itself, or a per-run object), so expansion state and cached rows survive live updates.
|
|
81
|
+
*/
|
|
82
|
+
interface Block {
|
|
83
|
+
key: object;
|
|
84
|
+
/** Changes whenever the block's content does. */
|
|
85
|
+
rev: string;
|
|
86
|
+
expandable: boolean;
|
|
87
|
+
/** Expanded unless the user toggled it. */
|
|
88
|
+
open: boolean;
|
|
89
|
+
/** Rows for the given state, without the two-column gutter. */
|
|
90
|
+
rows(expanded: boolean, width: number): string[];
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const wrap = (text: string, width: number) => {
|
|
94
|
+
const rows: string[] = [];
|
|
95
|
+
for (const raw of text.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), Math.max(1, width))) rows.push(line);
|
|
96
|
+
return rows;
|
|
97
|
+
};
|
|
98
|
+
const firstLine = (text: string) => { const end = text.indexOf("\n"); return end < 0 ? text : text.slice(0, end); };
|
|
99
|
+
const lineCount = (text: string) => { let count = 1; for (let index = text.indexOf("\n"); index >= 0; index = text.indexOf("\n", index + 1)) count++; return count; };
|
|
100
|
+
const clipped = (rows: string[], limit: number, fg: Fg) => rows.length <= limit ? rows : [...rows.slice(0, limit), fg("dim", `… ${rows.length - limit} more lines`)];
|
|
101
|
+
|
|
102
|
+
function todoRows(todos: readonly Todo[], width: number, fg: Fg): string[] {
|
|
103
|
+
const counts = new Map<string, { done: number; total: number }>();
|
|
104
|
+
for (const todo of todos) if (todo.parentId) {
|
|
105
|
+
const count = counts.get(todo.parentId) ?? { done: 0, total: 0 };
|
|
106
|
+
count.total++;
|
|
107
|
+
if (todo.done) count.done++;
|
|
108
|
+
counts.set(todo.parentId, count);
|
|
109
|
+
}
|
|
110
|
+
return todos.map((todo) => {
|
|
111
|
+
const indent = todo.parentId ? " " : " ";
|
|
112
|
+
const count = counts.get(todo.id);
|
|
113
|
+
const suffix = count ? fg("dim", ` (${count.done}/${count.total})`) : "";
|
|
114
|
+
const room = Math.max(1, width - indent.length - 2 - (count ? `${count.done}/${count.total}`.length + 3 : 0));
|
|
115
|
+
const title = truncateToWidth(safeLine(todo.title), room);
|
|
116
|
+
if (todo.status === "completed") return fg("success", `${indent}✔ `) + fg("dim", "\x1b[9m" + title + "\x1b[29m") + suffix;
|
|
117
|
+
if (todo.status === "in_progress") return fg("accent", `${indent}◼ ${title}`) + suffix;
|
|
118
|
+
return fg("muted", `${indent}☐ ${title}`) + suffix;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Per-run keys for the blocks that are not transcript entries. */
|
|
123
|
+
const runKeys = new WeakMap<DelegateRun, { task: object; todos: object; live: object; report: object }>();
|
|
124
|
+
const keysOf = (run: DelegateRun) => {
|
|
125
|
+
let keys = runKeys.get(run);
|
|
126
|
+
if (!keys) { keys = { task: {}, todos: {}, live: {}, report: {} }; runKeys.set(run, keys); }
|
|
127
|
+
return keys;
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
function entryBlock(entry: TranscriptEntry, fg: Fg): Block {
|
|
131
|
+
if (entry.kind === "tool") {
|
|
132
|
+
const glyph = entry.status === "running" ? fg("accent", "●") : entry.status === "error" ? fg("error", "✖") : fg("success", "✔");
|
|
133
|
+
const took = entry.endedAt !== undefined ? fg("dim", ` · ${duration(entry.endedAt - entry.startedAt)}`) : fg("dim", " · running");
|
|
134
|
+
return {
|
|
135
|
+
key: entry, rev: `${entry.rev}`, expandable: true, open: false,
|
|
136
|
+
rows(expanded, width) {
|
|
137
|
+
const head = fg("dim", expanded ? "▾ " : "▸ ") + glyph + " " + fg("accent", entry.name) + (entry.hint ? " " + fg("muted", safeLine(entry.hint)) : "") + took;
|
|
138
|
+
if (!expanded) return [truncateToWidth(head, width)];
|
|
139
|
+
const rows = [truncateToWidth(head, width)];
|
|
140
|
+
if (entry.args && entry.args !== JSON.stringify(entry.hint)) rows.push(...clipped(wrap(entry.args, width - 4).map((line) => fg("dim", " " + line)), MAX_ARGS_ROWS, fg));
|
|
141
|
+
if (entry.output) rows.push(...clipped(wrap(entry.output, width - 4).map((line) => fg(entry.status === "error" ? "error" : "muted", " " + line)), MAX_OUTPUT_ROWS, fg));
|
|
142
|
+
else rows.push(fg("dim", entry.status === "running" ? " (running…)" : " (no output)"));
|
|
143
|
+
return rows;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
const multi = entry.text.length > 60 || entry.text.includes("\n");
|
|
148
|
+
const [mark, tone] = entry.kind === "steer" ? ["› you: ", "accent"] : entry.kind === "note" ? ["! ", "warning"] : ["◆ ", "text"];
|
|
149
|
+
return {
|
|
150
|
+
key: entry, rev: `${entry.rev}`, expandable: multi, open: false,
|
|
151
|
+
rows(expanded, width) {
|
|
152
|
+
const chevron = multi ? fg("dim", expanded ? "▾ " : "▸ ") : " ";
|
|
153
|
+
if (!expanded || !multi) {
|
|
154
|
+
const more = lineCount(entry.text) > 1 ? fg("dim", ` +${lineCount(entry.text) - 1} lines`) : "";
|
|
155
|
+
return [truncateToWidth(chevron + fg(tone, mark + safeLine(firstLine(entry.text))), Math.max(1, width - visibleWidth(more))) + more];
|
|
156
|
+
}
|
|
157
|
+
return [chevron + fg(tone, mark.trimEnd()), ...wrap(entry.text, width - 4).map((line) => fg(tone === "text" ? "text" : tone, " " + line))];
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Everything under a subagent's pinned head: its task, checklist, transcript, live text and report. */
|
|
163
|
+
function subagentBlocks(run: DelegateRun, fg: Fg): Block[] {
|
|
164
|
+
const keys = keysOf(run);
|
|
165
|
+
const blocks: Block[] = [{
|
|
166
|
+
key: keys.task, rev: "task", expandable: true, open: false,
|
|
167
|
+
rows(expanded, width) {
|
|
168
|
+
if (!expanded) return [truncateToWidth(fg("dim", "▸ TASK ") + fg("text", safeLine(firstLine(run.task))), width)];
|
|
169
|
+
return [fg("dim", "▾ TASK"), ...wrap(run.task.slice(0, MAX_TASK_CHARS), width - 4).map((line) => fg("text", " " + line))];
|
|
170
|
+
}
|
|
171
|
+
}];
|
|
172
|
+
if (run.todos.length) {
|
|
173
|
+
const leaves = leafTodos(run.todos);
|
|
174
|
+
const done = leaves.filter((todo) => todo.done).length;
|
|
175
|
+
blocks.push({
|
|
176
|
+
key: keys.todos, rev: run.todos.map((todo) => todo.id + todo.status).join(), expandable: true, open: true,
|
|
177
|
+
rows(expanded, width) {
|
|
178
|
+
const head = fg("dim", (expanded ? "▾ " : "▸ ") + "TASKS ") + fg(done === leaves.length ? "success" : "accent", `${done}/${leaves.length} done`);
|
|
179
|
+
return expanded ? [head, ...todoRows(run.todos, width - 2, fg).map((row) => " " + row)] : [head];
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
if (!run.transcript.length && !run.live) blocks.push({ key: keys.live, rev: "empty", expandable: false, open: true, rows: () => [fg("dim", " (nothing yet)")] });
|
|
184
|
+
// Once finished, the last message is the report shown below: list it once.
|
|
185
|
+
const reported = !!run.output && run.state !== "working" && run.state !== "queued";
|
|
186
|
+
let last = -1;
|
|
187
|
+
if (reported) for (let index = run.transcript.length - 1; index >= 0 && last < 0; index--) if (run.transcript[index]!.kind === "text") last = index;
|
|
188
|
+
for (let index = 0; index < run.transcript.length; index++) if (index !== last) blocks.push(entryBlock(run.transcript[index]!, fg));
|
|
189
|
+
if (run.live) {
|
|
190
|
+
const live = run.live;
|
|
191
|
+
blocks.push({
|
|
192
|
+
// The text itself is the revision (it is capped, so its length stops changing); only a tail is wrapped.
|
|
193
|
+
key: keys.live, rev: live, expandable: false, open: true,
|
|
194
|
+
rows: (_expanded, width) => [fg("accent", " ✎ writing…"),
|
|
195
|
+
...wrap(live.slice(-Math.max(200, width * (LIVE_ROWS + 1))), width - 4).slice(-LIVE_ROWS).map((line) => fg("muted", " " + line))]
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
if (reported) {
|
|
199
|
+
blocks.push({
|
|
200
|
+
key: keys.report, rev: run.output, expandable: true, open: true,
|
|
201
|
+
rows(expanded, width) {
|
|
202
|
+
const head = fg("dim", (expanded ? "▾ " : "▸ ") + "REPORT");
|
|
203
|
+
return expanded ? [head, ...wrap(run.output, width - 4).map((line) => fg("text", " " + line))] : [head];
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return blocks;
|
|
208
|
+
}
|
|
64
209
|
|
|
65
|
-
function
|
|
210
|
+
function subagentHead(run: DelegateRun, status: Status, width: number, fg: Fg): string[] {
|
|
66
211
|
const facts = [
|
|
67
212
|
run.startedAt !== undefined ? elapsed({ startedAt: run.startedAt, endedAt: run.endedAt }) : "",
|
|
68
|
-
`${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}
|
|
213
|
+
`${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`,
|
|
214
|
+
run.filesEdited.length ? `${run.filesEdited.length} edited` : "", run.steered ? `steered ${run.steered}×` : ""
|
|
69
215
|
].filter(Boolean);
|
|
70
216
|
const head = [
|
|
71
217
|
fg("accent", [run.name, run.role, run.model].filter(Boolean).join(" · ")),
|
|
72
218
|
fg(COLOR[status], withIcon(status, status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
|
|
73
219
|
];
|
|
74
|
-
|
|
220
|
+
// A turning glyph on the 1 s tick: a long tool call or a slow model still looks alive.
|
|
221
|
+
if (run.activity) head.push(fg("muted", (run.state === "working" ? SPINNER[Math.floor(Date.now() / 1000) % SPINNER.length] + " " : "") + run.activity));
|
|
75
222
|
if (run.error && status !== "stopped") for (const line of wrapTextWithAnsi(safeLine(run.error), width).slice(0, 2)) head.push(fg("error", line));
|
|
76
|
-
|
|
77
|
-
for (const raw of run.task.slice(0, MAX_TASK_CHARS).split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
|
|
78
|
-
body.push("", fg("dim", "── transcript ──"));
|
|
79
|
-
if (!run.log.length) body.push(fg("dim", "(nothing yet)"));
|
|
80
|
-
for (const entry of run.log) {
|
|
81
|
-
const tone = entry.startsWith("▸ ") ? "accent" : "muted";
|
|
82
|
-
for (const line of wrapTextWithAnsi(entry, width)) body.push(fg(tone, line));
|
|
83
|
-
}
|
|
84
|
-
if (run.output) {
|
|
85
|
-
body.push("", fg("dim", "── report ──"));
|
|
86
|
-
for (const raw of run.output.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
|
|
87
|
-
}
|
|
88
|
-
return { head, body };
|
|
223
|
+
return head;
|
|
89
224
|
}
|
|
90
225
|
|
|
91
226
|
function shellDetails(job: Omit<ShellJob, "lines">, output: readonly string[], width: number, fg: Fg): Details {
|
|
92
227
|
const head = [fg("accent", safeLine(describe(job))), fg("muted", "$ " + cleanText(job.command, 400)), fg("dim", withIcon("folder", safeLine(job.cwd)))];
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
return { head, body };
|
|
228
|
+
if (!output.length) return { head, body: ["", fg("dim", "(no output yet)")] };
|
|
229
|
+
return { head, body: ["", ...output], format: (line) => line && fg("muted", truncateToWidth(safeLine(line), width)) };
|
|
96
230
|
}
|
|
97
231
|
|
|
98
232
|
function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg): Details {
|
|
@@ -103,7 +237,8 @@ function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg):
|
|
|
103
237
|
|
|
104
238
|
/**
|
|
105
239
|
* Overlay: subagents, shells and other extensions' teammates on the left; the selected one's live
|
|
106
|
-
* details on the right, following new output until scrolled up.
|
|
240
|
+
* details on the right, following new output until scrolled up. For subagents the details are
|
|
241
|
+
* collapsible blocks (task, checklist, each tool call, text, report) and `s` steers the run.
|
|
107
242
|
*/
|
|
108
243
|
export async function openActivityView(ctx: ExtensionContext, sources: ActivitySources, initial?: ActivityTarget): Promise<void> {
|
|
109
244
|
if (!ctx.hasUI || ctx.mode !== "tui") return;
|
|
@@ -124,8 +259,18 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
124
259
|
let listTop = 0;
|
|
125
260
|
let width = 80;
|
|
126
261
|
let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
|
|
262
|
+
let pinnedRows = 0;
|
|
127
263
|
let timer: NodeJS.Timeout | undefined;
|
|
128
264
|
let closed = false;
|
|
265
|
+
let focus: Focus = "list";
|
|
266
|
+
let draft = "";
|
|
267
|
+
let inputRow = -1;
|
|
268
|
+
// Subagent body layout of the last render: which block starts at which body row.
|
|
269
|
+
let blocks: Block[] = [];
|
|
270
|
+
let offsets: number[] = [];
|
|
271
|
+
const cursors = new Map<string, object>();
|
|
272
|
+
const toggled = new WeakMap<object, boolean>();
|
|
273
|
+
const rowCache = new WeakMap<object, { sig: string; rows: string[] }>();
|
|
129
274
|
const fg: Fg = (color, text) => theme.fg(color as never, text);
|
|
130
275
|
const shells = sources.shells;
|
|
131
276
|
const previous = shells?.onChange;
|
|
@@ -140,6 +285,19 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
140
285
|
if (shells) shells.onChange = previous;
|
|
141
286
|
};
|
|
142
287
|
const close = () => { cleanup(); done(); };
|
|
288
|
+
const selected = (): Entry | undefined => { const item = items[at]; return item && item.kind !== "header" ? item : undefined; };
|
|
289
|
+
const steerable = () => { const item = selected(); return item?.kind === "subagent" && item.record.run.state === "working"; };
|
|
290
|
+
const isOpen = (block: Block) => block.expandable ? toggled.get(block.key) ?? block.open : true;
|
|
291
|
+
/** Rows of one block, rebuilt only when its content, state or width changed. */
|
|
292
|
+
const rowsOf = (block: Block, bodyWidth: number) => {
|
|
293
|
+
const expanded = isOpen(block);
|
|
294
|
+
const sig = `${block.rev}|${expanded}|${bodyWidth}`;
|
|
295
|
+
const cached = rowCache.get(block.key);
|
|
296
|
+
if (cached?.sig === sig) return cached.rows;
|
|
297
|
+
const rows = block.rows(expanded, bodyWidth);
|
|
298
|
+
rowCache.set(block.key, { sig, rows });
|
|
299
|
+
return rows;
|
|
300
|
+
};
|
|
143
301
|
// Rows reorder live (finished subagents sink, old ones drop off): keep the selection by identity.
|
|
144
302
|
const refresh = () => {
|
|
145
303
|
const current = items[at];
|
|
@@ -155,6 +313,7 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
155
313
|
for (let index = at + delta; index >= 0 && index < items.length; index += delta) {
|
|
156
314
|
if (items[index]!.kind === "header") continue;
|
|
157
315
|
at = index; follow = true; scroll = 0;
|
|
316
|
+
if (focus === "input") focus = "list";
|
|
158
317
|
return;
|
|
159
318
|
}
|
|
160
319
|
};
|
|
@@ -164,25 +323,83 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
164
323
|
// Scrolling up pauses follow; scrolling back down to the end resumes it.
|
|
165
324
|
follow = delta > 0 && scroll >= maxScroll;
|
|
166
325
|
};
|
|
326
|
+
const cursorIndex = () => {
|
|
327
|
+
const item = selected();
|
|
328
|
+
const key = item?.kind === "subagent" ? cursors.get(item.id) : undefined;
|
|
329
|
+
const index = key ? blocks.findIndex((block) => block.key === key) : -1;
|
|
330
|
+
return index >= 0 ? index : blocks.length - 1;
|
|
331
|
+
};
|
|
332
|
+
const setCursor = (index: number) => {
|
|
333
|
+
const item = selected();
|
|
334
|
+
if (item?.kind !== "subagent" || !blocks.length) return;
|
|
335
|
+
const target = Math.max(0, Math.min(blocks.length - 1, index));
|
|
336
|
+
cursors.set(item.id, blocks[target]!.key);
|
|
337
|
+
follow = false;
|
|
338
|
+
// Keep the block's first row in view.
|
|
339
|
+
const row = offsets[target] ?? 0;
|
|
340
|
+
if (row < scroll) scroll = row;
|
|
341
|
+
else if (row >= scroll + bodyRows) scroll = Math.min(maxScroll, row - bodyRows + 1);
|
|
342
|
+
};
|
|
343
|
+
const toggle = (index: number) => {
|
|
344
|
+
const block = blocks[index];
|
|
345
|
+
if (!block?.expandable) return;
|
|
346
|
+
toggled.set(block.key, !isOpen(block));
|
|
347
|
+
setCursor(index);
|
|
348
|
+
};
|
|
167
349
|
const stopSelected = () => {
|
|
168
|
-
const item =
|
|
169
|
-
if (!item || item.kind === "
|
|
350
|
+
const item = selected();
|
|
351
|
+
if (!item || item.kind === "role" || (item.status !== "running" && item.status !== "pending")) return;
|
|
170
352
|
try {
|
|
171
353
|
if (item.kind === "subagent") sources.subagents.stop(item.id);
|
|
172
354
|
else shells?.kill(item.id);
|
|
173
355
|
} catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
174
356
|
};
|
|
357
|
+
const send = () => {
|
|
358
|
+
const item = selected();
|
|
359
|
+
const text = draft.trim();
|
|
360
|
+
if (!text || item?.kind !== "subagent") return;
|
|
361
|
+
if (sources.subagents.steer(item.id, text)) { draft = ""; focus = "transcript"; follow = true; cursors.delete(item.id); }
|
|
362
|
+
else ctx.ui.notify("pi-jar: that subagent is not taking messages (it is not working)", "warning");
|
|
363
|
+
};
|
|
364
|
+
const input = (data: string) => {
|
|
365
|
+
if (matchesKey(data, Key.escape)) { focus = "transcript"; return; }
|
|
366
|
+
if (matchesKey(data, Key.enter)) { send(); return; }
|
|
367
|
+
if (matchesKey(data, Key.backspace) || data === "\x7f" || data === "\b") { draft = draft.slice(0, -1); return; }
|
|
368
|
+
// Typed or pasted text: drop terminal sequences and control characters; line breaks become spaces.
|
|
369
|
+
const text = stripTerminalSequences(data).replace(/[\r\n\t]+/g, " ").replace(/[\x00-\x1f\x7f]/g, "");
|
|
370
|
+
if (text) draft = (draft + text).slice(0, MAX_DRAFT);
|
|
371
|
+
};
|
|
175
372
|
const component = {
|
|
176
373
|
invalidate() {},
|
|
177
374
|
dispose: cleanup,
|
|
178
375
|
handleInput(data: string) {
|
|
376
|
+
if (focus === "input") { input(data); tui.requestRender(); return; }
|
|
377
|
+
const item = selected();
|
|
378
|
+
const subagent = item?.kind === "subagent";
|
|
379
|
+
if (focus === "transcript") {
|
|
380
|
+
if (matchesKey(data, Key.escape) || matchesKey(data, Key.tab) || matchesKey(data, Key.left) || data === "h") focus = "list";
|
|
381
|
+
else if (data === "q") return close();
|
|
382
|
+
else if (matchesKey(data, Key.up) || data === "k") setCursor(cursorIndex() - 1);
|
|
383
|
+
else if (matchesKey(data, Key.down) || data === "j") setCursor(cursorIndex() + 1);
|
|
384
|
+
else if (matchesKey(data, Key.enter) || data === " ") toggle(cursorIndex());
|
|
385
|
+
else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
|
|
386
|
+
else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
|
|
387
|
+
else if (data === "f" || data === "G") follow = true;
|
|
388
|
+
else if (data === "s" && steerable()) focus = "input";
|
|
389
|
+
else if (data === "x") stopSelected();
|
|
390
|
+
else return;
|
|
391
|
+
tui.requestRender();
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
179
394
|
if (matchesKey(data, Key.escape) || data === "q") return close();
|
|
180
395
|
if (matchesKey(data, Key.up) || data === "k") move(-1);
|
|
181
396
|
else if (matchesKey(data, Key.down) || data === "j") move(1);
|
|
397
|
+
else if (subagent && (matchesKey(data, Key.tab) || matchesKey(data, Key.right) || data === "l" || matchesKey(data, Key.enter))) focus = "transcript";
|
|
182
398
|
else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
|
|
183
399
|
else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
|
|
184
400
|
else if (data === "f" || data === "G") follow = true;
|
|
185
401
|
else if (matchesKey(data, Key.enter)) follow = !follow;
|
|
402
|
+
else if (data === "s" && steerable()) focus = "input";
|
|
186
403
|
else if (data === "x") stopSelected();
|
|
187
404
|
else return;
|
|
188
405
|
tui.requestRender();
|
|
@@ -196,31 +413,92 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
196
413
|
const index = listTop + row;
|
|
197
414
|
if (!items[index] || items[index].kind === "header") return;
|
|
198
415
|
if (index !== at) { at = index; follow = true; scroll = 0; }
|
|
416
|
+
focus = "list";
|
|
417
|
+
tui.requestRender();
|
|
418
|
+
return { handled: true, focus: true };
|
|
419
|
+
}
|
|
420
|
+
if (row >= pinnedRows && row < layout.rows && blocks.length && selected()?.kind === "subagent") {
|
|
421
|
+
// A click on a block's first row expands or collapses it; any other row just points the cursor at it.
|
|
422
|
+
const line = scroll + row - pinnedRows;
|
|
423
|
+
let index = -1;
|
|
424
|
+
for (let block = 0; block < offsets.length && offsets[block]! <= line; block++) index = block;
|
|
425
|
+
if (index < 0) return;
|
|
426
|
+
focus = "transcript";
|
|
427
|
+
if (offsets[index] === line) toggle(index); else setCursor(index);
|
|
199
428
|
tui.requestRender();
|
|
200
429
|
return { handled: true, focus: true };
|
|
201
430
|
}
|
|
431
|
+
if (inputRow >= 0 && event.y === inputRow && steerable()) { focus = "input"; tui.requestRender(); return { handled: true, focus: true }; }
|
|
202
432
|
const action = ACTIONS[event.y - layout.footerTop];
|
|
203
|
-
if (action) { component.handleInput(action.key); return { handled: true }; }
|
|
433
|
+
if (action) { const previousFocus = focus; focus = "list"; component.handleInput(action.key); focus = previousFocus; return { handled: true }; }
|
|
204
434
|
},
|
|
205
435
|
render(available: number): string[] {
|
|
206
436
|
width = Math.max(24, available);
|
|
207
|
-
const rows = contentRows(6 + ACTIONS.length, 6);
|
|
208
437
|
refresh();
|
|
209
|
-
const
|
|
210
|
-
const
|
|
438
|
+
const item = selected();
|
|
439
|
+
const subagent = item?.kind === "subagent" ? item : undefined;
|
|
440
|
+
if (!subagent && focus !== "list") focus = "list";
|
|
441
|
+
if (focus === "input" && !steerable()) focus = "transcript";
|
|
211
442
|
const listWidth = sidebarWidth(width, 20, 34);
|
|
212
443
|
const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
|
|
213
444
|
const entries = items.filter((entry): entry is Entry => entry.kind !== "header");
|
|
214
|
-
|
|
215
|
-
|
|
445
|
+
// Footer: the two actions, the steering input for subagents, then key hints.
|
|
446
|
+
const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
|
|
447
|
+
"Follow the latest output"], -1, ACTIONS.map((action) => action.key));
|
|
448
|
+
const stoppable = item?.kind === "subagent" ? item.status === "running" || item.status === "pending" : item?.kind === "shell" && item.status === "running";
|
|
449
|
+
if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
|
|
450
|
+
const footer = [...actions];
|
|
451
|
+
if (subagent) {
|
|
452
|
+
const room = Math.max(4, width - 16);
|
|
453
|
+
footer.push(focus === "input"
|
|
454
|
+
? fg("accent", "› steer: ") + fg("text", truncateToWidth(draft.length > room ? "…" + draft.slice(-room + 1) : draft, room)) + fg("accent", "█")
|
|
455
|
+
: fg("dim", steerable() ? "› s steer this subagent (type a message, Enter sends)" : "› steering works while a subagent is running"));
|
|
456
|
+
}
|
|
457
|
+
footer.push(fg("dim", focus === "input" ? `type a message · ${withIcon("enter", "send")} · ${withIcon("esc", "cancel")}`
|
|
458
|
+
: focus === "transcript" ? `↑↓ step · ${withIcon("enter", "expand")} · tab list · s steer · ${withIcon("esc", "back")}`
|
|
459
|
+
: `↑↓ select · ${subagent ? "tab transcript" : "PgUp/PgDn scroll"} · ${withIcon("enter", subagent ? "open" : "follow")} · ${withIcon("esc", "close")}`));
|
|
460
|
+
const rows = contentRows(3 + footer.length, 6);
|
|
461
|
+
const details: Details | undefined = !item || item.kind === "subagent" ? undefined
|
|
216
462
|
: item.kind === "shell" ? shellDetails(item.job, shells?.output(item.id, SHELL_TAIL) ?? [], bodyWidth, fg)
|
|
217
463
|
: roleDetails(item.role, item.status, bodyWidth, fg);
|
|
464
|
+
const head = !item ? [fg("dim", "Nothing to show.")] : item.kind === "subagent" ? subagentHead(item.record.run, item.status, bodyWidth, fg) : details!.head;
|
|
218
465
|
// Narrow terminals drop the list: a pager row above the pinned facts says what is selected.
|
|
219
466
|
const pinned = [...(!listWidth && item ? [fg("accent", `‹ ${entries.indexOf(item) + 1}/${entries.length} ${withIcon(item.status, item.label)} ›`)] : []),
|
|
220
|
-
...
|
|
467
|
+
...head].slice(0, rows - 1);
|
|
468
|
+
pinnedRows = pinned.length;
|
|
221
469
|
bodyRows = rows - pinned.length;
|
|
222
|
-
|
|
223
|
-
|
|
470
|
+
let body: string[];
|
|
471
|
+
if (subagent) {
|
|
472
|
+
blocks = subagentBlocks(subagent.record.run, fg);
|
|
473
|
+
const blockRows = blocks.map((block) => rowsOf(block, bodyWidth - 2));
|
|
474
|
+
offsets = [];
|
|
475
|
+
let total = 0;
|
|
476
|
+
for (const block of blockRows) { offsets.push(total); total += block.length; }
|
|
477
|
+
// A blank row separates the pinned head from the blocks.
|
|
478
|
+
offsets = offsets.map((offset) => offset + 1);
|
|
479
|
+
total += 1;
|
|
480
|
+
maxScroll = Math.max(0, total - bodyRows);
|
|
481
|
+
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
482
|
+
// Only the visible window gets its gutter: long transcripts cost nothing off-screen.
|
|
483
|
+
const cursor = focus === "transcript" ? cursorIndex() : -1;
|
|
484
|
+
body = scroll === 0 ? [""] : [];
|
|
485
|
+
for (let index = 0; index < blocks.length && body.length < bodyRows; index++) {
|
|
486
|
+
const start = offsets[index]!;
|
|
487
|
+
const rowsInBlock = blockRows[index]!;
|
|
488
|
+
if (start + rowsInBlock.length <= scroll) continue;
|
|
489
|
+
for (let line = Math.max(0, scroll - start); line < rowsInBlock.length && body.length < bodyRows; line++) {
|
|
490
|
+
body.push((line === 0 && index === cursor ? fg("accent", "❯ ") : " ") + rowsInBlock[line]!);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
} else {
|
|
494
|
+
blocks = [];
|
|
495
|
+
offsets = [];
|
|
496
|
+
const lines = details?.body ?? [];
|
|
497
|
+
maxScroll = Math.max(0, lines.length - bodyRows);
|
|
498
|
+
scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
|
|
499
|
+
body = lines.slice(scroll, scroll + bodyRows);
|
|
500
|
+
if (details?.format) body = body.map(details.format);
|
|
501
|
+
}
|
|
224
502
|
if (at >= 0 && at < listTop) listTop = items[at - 1]?.kind === "header" ? at - 1 : at;
|
|
225
503
|
if (at >= listTop + rows) listTop = at - rows + 1;
|
|
226
504
|
listTop = Math.max(0, Math.min(listTop, items.length - rows));
|
|
@@ -228,18 +506,13 @@ export async function openActivityView(ctx: ExtensionContext, sources: ActivityS
|
|
|
228
506
|
if (entry.kind === "header") return fg("dim", truncateToWidth(entry.label, listWidth));
|
|
229
507
|
const active = listTop + offset === at;
|
|
230
508
|
const glyph = icon(entry.status);
|
|
231
|
-
return fg(active ? "accent" : "muted", active ? "▌" : " ") + fg(COLOR[entry.status], glyph + " ")
|
|
509
|
+
return fg(active ? "accent" : "muted", active ? (focus === "list" ? "▌" : "▏") : " ") + fg(COLOR[entry.status], glyph + " ")
|
|
232
510
|
+ fg(active ? "accent" : "muted", truncateToWidth(entry.label, Math.max(1, listWidth - 2 - visibleWidth(glyph))));
|
|
233
511
|
}) : [];
|
|
234
512
|
const running = entries.filter((entry) => entry.status === "running").length;
|
|
235
|
-
const
|
|
236
|
-
const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
|
|
237
|
-
"Follow the latest output"], -1, ACTIONS.map((action) => action.key));
|
|
238
|
-
if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
|
|
239
|
-
const footer = [...actions, fg("dim", `↑↓ select · PgUp/PgDn scroll · ${withIcon("enter", "follow")} · ${withIcon("esc", "close")}`)];
|
|
240
|
-
const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list,
|
|
241
|
-
[...pinned, ...details.body.slice(scroll, scroll + bodyRows)], footer, rows, listWidth);
|
|
513
|
+
const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list, [...pinned, ...body], footer, rows, listWidth);
|
|
242
514
|
layout = split.layout;
|
|
515
|
+
inputRow = subagent ? layout.footerTop + ACTIONS.length : -1;
|
|
243
516
|
// Subagent events and shell exits repaint through the listeners; elapsed times and shell output
|
|
244
517
|
// advance on a 1s tick that exists only while something runs.
|
|
245
518
|
if (running && !timer && !closed) { timer = setInterval(() => tui.requestRender(), 1000); timer.unref?.(); }
|
package/src/advisor.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
3
|
import { Type } from "typebox";
|
|
3
4
|
import type { ModelRoleManager } from "./model-roles.ts";
|
|
@@ -16,6 +17,8 @@ export const FAILURE_STREAK = 3;
|
|
|
16
17
|
/** Automatic consultations allowed per user prompt. */
|
|
17
18
|
export const GATES_PER_PROMPT = 2;
|
|
18
19
|
const WINDOW = 8;
|
|
20
|
+
/** Serialized inputs up to this length stay verbatim in a loop key; longer ones keep a prefix and a digest. */
|
|
21
|
+
const KEY_INPUT_CHARS = 1024;
|
|
19
22
|
|
|
20
23
|
export const ADVISOR_SYSTEM = [
|
|
21
24
|
"You are the advisor: a senior engineer giving a second opinion to a coding agent (the executor) mid-task.",
|
|
@@ -85,8 +88,11 @@ export function advisorPrompt(request: AdvisorRequest, conversation: string, git
|
|
|
85
88
|
].filter(Boolean).join("\n\n");
|
|
86
89
|
}
|
|
87
90
|
|
|
88
|
-
/** Stable key for loop detection. */
|
|
89
|
-
export
|
|
91
|
+
/** Stable key for loop detection. A `write` of a large file must not pin its whole content in the recent window. */
|
|
92
|
+
export function callKey(tool: string, input: unknown): string {
|
|
93
|
+
const json = String(JSON.stringify(input ?? {}));
|
|
94
|
+
return tool + " " + (json.length <= KEY_INPUT_CHARS ? json : json.slice(0, 200) + "…#" + createHash("sha1").update(json).digest("hex"));
|
|
95
|
+
}
|
|
90
96
|
|
|
91
97
|
/** Tracks repeated calls and failure streaks within one user prompt. */
|
|
92
98
|
export class StuckDetector {
|
|
@@ -181,7 +187,7 @@ export function registerAdvisor(pi: ExtensionAPI, roles: ModelRoleManager, optio
|
|
|
181
187
|
const focus = args.trim();
|
|
182
188
|
ctx.ui.notify("Consulting the advisor…", "info");
|
|
183
189
|
try {
|
|
184
|
-
const answer = await consult(ctx, focus ? { question: focus } : {});
|
|
190
|
+
const answer = await consult(ctx, focus ? { question: focus } : {}, ctx.signal);
|
|
185
191
|
deliver(ctx, `◆ Advisor · ${answer.model}${focus ? " · " + focus : ""}`, answer.text);
|
|
186
192
|
} catch (error) { ctx.ui.notify("Advisor failed: " + (error instanceof Error ? error.message : String(error)), "error"); }
|
|
187
193
|
}
|
package/src/changes.ts
CHANGED
|
@@ -104,7 +104,8 @@ export class ChangeTracker {
|
|
|
104
104
|
private readonly cwd: () => string;
|
|
105
105
|
constructor(cwd: () => string) { this.cwd = cwd; }
|
|
106
106
|
|
|
107
|
-
|
|
107
|
+
/** Resolve like every other tracker method; baselines are keyed by this absolute path. */
|
|
108
|
+
absolute(path: string): string { return isAbsolute(path) ? resolve(path) : resolve(this.cwd(), path); }
|
|
108
109
|
private bytes(content: string | null): number { return content === null ? 0 : Buffer.byteLength(content, "utf8"); }
|
|
109
110
|
private drop(target: string): void {
|
|
110
111
|
if (this.baselines.has(target)) this.baselineBytes = Math.max(0, this.baselineBytes - this.bytes(this.baselines.get(target)!));
|
|
@@ -112,23 +113,46 @@ export class ChangeTracker {
|
|
|
112
113
|
this.dirty.delete(target);
|
|
113
114
|
}
|
|
114
115
|
|
|
115
|
-
/**
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
/**
|
|
117
|
+
* Store a baseline when the file is inside the project (plan files and scratch space elsewhere are
|
|
118
|
+
* not reviewed) and within the file/byte caps. `content` is lazy so outside files are never read.
|
|
119
|
+
*/
|
|
120
|
+
private admit(target: string, content: () => string | null | undefined): boolean {
|
|
120
121
|
const rel = relative(this.cwd(), target);
|
|
121
122
|
if (!rel || rel.startsWith("..") || isAbsolute(rel)) return false;
|
|
122
123
|
if (this.baselines.size >= MAX_TRACKED_FILES) return false;
|
|
123
|
-
const
|
|
124
|
-
if (
|
|
125
|
-
const bytes = this.bytes(
|
|
126
|
-
if (this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
|
|
127
|
-
this.baselines.set(target,
|
|
124
|
+
const before = content();
|
|
125
|
+
if (before === undefined) return false;
|
|
126
|
+
const bytes = this.bytes(before);
|
|
127
|
+
if (bytes > MAX_TRACKED_BYTES || this.baselineBytes + bytes > MAX_TRACKED_TOTAL_BYTES) return false;
|
|
128
|
+
this.baselines.set(target, before);
|
|
128
129
|
this.baselineBytes += bytes;
|
|
129
130
|
return true;
|
|
130
131
|
}
|
|
131
132
|
|
|
133
|
+
/** Record the pre-change content once; returns false when the file cannot be tracked. */
|
|
134
|
+
capture(path: string): boolean {
|
|
135
|
+
const target = this.absolute(path);
|
|
136
|
+
return this.baselines.has(target) || this.admit(target, () => readText(target));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Captured pre-change content (`null` = file did not exist), or `undefined` when untracked. */
|
|
140
|
+
baseline(path: string): string | null | undefined {
|
|
141
|
+
const target = this.absolute(path);
|
|
142
|
+
return this.baselines.has(target) ? this.baselines.get(target)! : undefined;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Take over a baseline captured elsewhere (a subagent's first-edit content) and mark the file
|
|
147
|
+
* dirty. An existing baseline is older and wins, so each absolute path counts once.
|
|
148
|
+
*/
|
|
149
|
+
adopt(path: string, baseline: string | null): boolean {
|
|
150
|
+
const target = this.absolute(path);
|
|
151
|
+
if (!this.baselines.has(target) && !this.admit(target, () => baseline)) return false;
|
|
152
|
+
this.dirty.add(target);
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
|
|
132
156
|
/**
|
|
133
157
|
* Mark one captured file after a successful edit/write. Do not synchronously re-read the file on
|
|
134
158
|
* every tool result; the review UI reconciles content lazily when /diff is actually opened.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { existsSync, linkSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Directory a parent hands to a subagent so the child can report each file's content before its
|
|
7
|
+
* first edit/write. The parent then adopts those baselines into its own `/diff` review.
|
|
8
|
+
*/
|
|
9
|
+
export const CHILD_BASELINE_ENV = "PI_JAR_BASELINE_DIR";
|
|
10
|
+
|
|
11
|
+
type BaselineRecord = { path: string; content: string | null };
|
|
12
|
+
|
|
13
|
+
/** Writer and reader must agree on the name: one JSON file per absolute path. */
|
|
14
|
+
const fileFor = (dir: string, absPath: string) => join(dir, createHash("sha1").update(absPath).digest("hex") + ".json");
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Store the pre-change content (`null` = file did not exist) once per absolute path. The first
|
|
18
|
+
* write wins: the temp file is hard-linked into place, which fails instead of replacing an
|
|
19
|
+
* existing baseline, so readers never see a partial or newer record. Throws on I/O failure.
|
|
20
|
+
*/
|
|
21
|
+
export function writeChildBaseline(dir: string, absPath: string, content: string | null): void {
|
|
22
|
+
const target = fileFor(dir, absPath);
|
|
23
|
+
const temp = `${target}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
24
|
+
if (existsSync(target)) return;
|
|
25
|
+
mkdirSync(dir, { recursive: true });
|
|
26
|
+
try {
|
|
27
|
+
writeFileSync(temp, JSON.stringify({ path: absPath, content } satisfies BaselineRecord));
|
|
28
|
+
linkSync(temp, target);
|
|
29
|
+
} catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
|
|
30
|
+
finally { rmSync(temp, { force: true }); }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The recorded baseline, or `undefined` when absent, unreadable or malformed. */
|
|
34
|
+
export function readChildBaseline(dir: string, absPath: string): string | null | undefined {
|
|
35
|
+
let record: unknown;
|
|
36
|
+
try { record = JSON.parse(readFileSync(fileFor(dir, absPath), "utf8")); }
|
|
37
|
+
catch { return undefined; } // absent or corrupt: the parent simply does not adopt this file
|
|
38
|
+
if (!record || typeof record !== "object") return undefined;
|
|
39
|
+
const { path, content } = record as Partial<BaselineRecord>;
|
|
40
|
+
if (path !== absPath || (content !== null && typeof content !== "string")) return undefined;
|
|
41
|
+
return content;
|
|
42
|
+
}
|