pi-do-always 0.10.0 → 0.12.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/README.md +13 -11
- package/extensions/pi-do-always/do-always.json +2 -2
- package/extensions/pi-do-always/index.ts +480 -205
- package/extensions/pi-do-always/tasks.ts +189 -39
- package/package.json +1 -1
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
* If no config file exists, built-in default tasks are used.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import {
|
|
36
|
+
import { execFile } from "node:child_process";
|
|
37
37
|
import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
38
38
|
import { join, relative } from "node:path";
|
|
39
|
-
import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
39
|
+
import type { AgentEndEvent, ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
40
40
|
import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
41
41
|
import {
|
|
42
42
|
type KeyId,
|
|
@@ -59,6 +59,7 @@ import {
|
|
|
59
59
|
chainClear,
|
|
60
60
|
chainRemove,
|
|
61
61
|
chainRunLabel,
|
|
62
|
+
chainSummary,
|
|
62
63
|
chainUndo,
|
|
63
64
|
evaluateGuards,
|
|
64
65
|
evaluateWhen,
|
|
@@ -68,7 +69,10 @@ import {
|
|
|
68
69
|
isValidKeyId,
|
|
69
70
|
mergeTasks,
|
|
70
71
|
parseConfig,
|
|
72
|
+
parseConfigRegexpValueForKey,
|
|
73
|
+
parseCommitSubject,
|
|
71
74
|
parseStatusPorcelain,
|
|
75
|
+
parseStatusStagedUnstaged,
|
|
72
76
|
orderTasksByCategory,
|
|
73
77
|
reportAbandonedFooter,
|
|
74
78
|
reportFooter,
|
|
@@ -80,8 +84,10 @@ import {
|
|
|
80
84
|
resolveShortcut,
|
|
81
85
|
resolveTask,
|
|
82
86
|
shouldAutoRun,
|
|
87
|
+
stepSummary,
|
|
83
88
|
toPromptContext,
|
|
84
89
|
validateChain,
|
|
90
|
+
type ChainStepOutcome,
|
|
85
91
|
type DoAlwaysTask,
|
|
86
92
|
type TaskContext,
|
|
87
93
|
type TableRow,
|
|
@@ -102,6 +108,20 @@ type SelectorResult =
|
|
|
102
108
|
*/
|
|
103
109
|
type Cursor = { kind: "cell"; row: number; col: "task" | "order" } | { kind: "run" };
|
|
104
110
|
|
|
111
|
+
/**
|
|
112
|
+
* Safely read a config file's contents, returning null if missing or unreadable.
|
|
113
|
+
* Read errors are reported through `onError`.
|
|
114
|
+
*/
|
|
115
|
+
function readConfigFile(filePath: string, onError: (message: string) => void): string | null {
|
|
116
|
+
if (!existsSync(filePath)) return null;
|
|
117
|
+
try {
|
|
118
|
+
return readFileSync(filePath, "utf-8");
|
|
119
|
+
} catch (err) {
|
|
120
|
+
onError(`do-always: could not read ${filePath}: ${err}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
105
125
|
/**
|
|
106
126
|
* Load tasks and the selector shortcut from config files.
|
|
107
127
|
* Project-local tasks override global tasks with the same name (or are
|
|
@@ -124,11 +144,14 @@ function loadConfig(
|
|
|
124
144
|
const globalPath = join(getAgentDir(), "do-always.json");
|
|
125
145
|
const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
|
|
126
146
|
|
|
127
|
-
const
|
|
128
|
-
|
|
147
|
+
const globalRaw = readConfigFile(globalPath, onError);
|
|
148
|
+
const projectRaw = readConfigFile(projectPath, onError);
|
|
149
|
+
|
|
150
|
+
const global = globalRaw !== null
|
|
151
|
+
? parseConfig(globalRaw, globalPath, onError)
|
|
129
152
|
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
130
|
-
const project =
|
|
131
|
-
? parseConfig(
|
|
153
|
+
const project = projectRaw !== null
|
|
154
|
+
? parseConfig(projectRaw, projectPath, onError)
|
|
132
155
|
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
133
156
|
|
|
134
157
|
// The project file's merge mode wins; otherwise the global value; otherwise
|
|
@@ -152,36 +175,25 @@ function loadConfig(
|
|
|
152
175
|
* empty repo, …) so callers can fall back to a neutral value.
|
|
153
176
|
* No shell is involved (argument array), so file names cannot inject commands.
|
|
154
177
|
*/
|
|
155
|
-
function git(cwd: string, args: string[]): string | undefined {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
function createContextCache(): {
|
|
175
|
-
get(cwd: string): TaskContext;
|
|
176
|
-
} {
|
|
177
|
-
let cached: { cwd: string; ctx: TaskContext } | null = null;
|
|
178
|
-
return {
|
|
179
|
-
get(cwd: string): TaskContext {
|
|
180
|
-
if (cached && cached.cwd === cwd) return cached.ctx;
|
|
181
|
-
cached = { cwd, ctx: buildContext(cwd) };
|
|
182
|
-
return cached.ctx;
|
|
183
|
-
},
|
|
184
|
-
};
|
|
178
|
+
function git(cwd: string, args: string[]): Promise<string | undefined> {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
// No stdio option: execFile's string-encoding overload doesn't accept
|
|
181
|
+
// it, and stdin/stderr need no special handling here (no input is
|
|
182
|
+
// written; stderr is simply ignored by the callback).
|
|
183
|
+
execFile(
|
|
184
|
+
"git",
|
|
185
|
+
args,
|
|
186
|
+
{ cwd, encoding: "utf-8" },
|
|
187
|
+
(error, stdout) => {
|
|
188
|
+
if (error) {
|
|
189
|
+
resolve(undefined);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const trimmed = stdout.trim();
|
|
193
|
+
resolve(trimmed === "" ? undefined : trimmed);
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
});
|
|
185
197
|
}
|
|
186
198
|
|
|
187
199
|
/**
|
|
@@ -189,57 +201,67 @@ function createContextCache(): {
|
|
|
189
201
|
* to neutral values when unavailable (non-git dir, no git, empty repo) so
|
|
190
202
|
* default prompts read cleanly in any directory.
|
|
191
203
|
*
|
|
192
|
-
*
|
|
193
|
-
* 1. rev-parse --
|
|
194
|
-
* 2.
|
|
195
|
-
* 3.
|
|
196
|
-
* 4.
|
|
197
|
-
* 5.
|
|
204
|
+
* Collects the git facts the prompts interpolate:
|
|
205
|
+
* 1. rev-parse --is-inside-work-tree → isGitRepo
|
|
206
|
+
* 2. branch --show-current → branch (fallback: rev-parse --abbrev-ref HEAD)
|
|
207
|
+
* 3. log -1 --format="%H %s" → commit hash + subject
|
|
208
|
+
* 4. config --get-regexp "^(user.name|remote.origin.url)$" → user + repo
|
|
209
|
+
* 5. status --porcelain → files + stagedFiles + unstagedFiles
|
|
210
|
+
* 6. diff --shortstat → diffStat
|
|
211
|
+
*
|
|
212
|
+
* Outside a work tree only call 1 runs; inside one, calls 2–6 run in
|
|
213
|
+
* parallel, so wall time is about one spawn.
|
|
198
214
|
*/
|
|
199
|
-
function buildContext(cwd: string): TaskContext {
|
|
200
|
-
// 1.
|
|
201
|
-
// rev-parse --
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
215
|
+
async function buildContext(cwd: string): Promise<TaskContext> {
|
|
216
|
+
// 1. Authoritative check: are we inside a work tree?
|
|
217
|
+
// rev-parse --is-inside-work-tree outputs "true" when inside a work tree,
|
|
218
|
+
// and fails (exit != 0) when outside.
|
|
219
|
+
const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
220
|
+
const isGitRepo = inside === "true";
|
|
221
|
+
|
|
222
|
+
// Outside a work tree the remaining facts don't exist; skip the spawns
|
|
223
|
+
// and return the neutral fallbacks directly.
|
|
224
|
+
if (!isGitRepo) {
|
|
225
|
+
return {
|
|
226
|
+
cwd,
|
|
227
|
+
date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
|
|
228
|
+
branch: "unknown",
|
|
229
|
+
lastCommit: "unknown",
|
|
230
|
+
files: [],
|
|
231
|
+
user: "unknown",
|
|
232
|
+
diffStat: "none",
|
|
233
|
+
repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown",
|
|
234
|
+
stagedFiles: [],
|
|
235
|
+
unstagedFiles: [],
|
|
236
|
+
isGitRepo: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2–6. In a work tree: query branch and the remaining facts in parallel.
|
|
241
|
+
const [branchOut, lastCommitLine, configLine, porcelain, diffStat] = await Promise.all([
|
|
242
|
+
// branch --show-current works on both normal and unborn (empty repo) branches;
|
|
243
|
+
// returns empty on detached HEAD.
|
|
244
|
+
git(cwd, ["branch", "--show-current"]),
|
|
245
|
+
// "%H %s" prints "<hash> <subject>"; the subject may contain spaces.
|
|
246
|
+
git(cwd, ["log", "-1", "--format=%H %s"]),
|
|
247
|
+
// --get-regexp prints "key value" lines; one call covers both keys.
|
|
248
|
+
git(cwd, ["config", "--get-regexp", "^(user\\.name|remote\\.origin\\.url)$"]),
|
|
249
|
+
git(cwd, ["status", "--porcelain"]),
|
|
250
|
+
git(cwd, ["diff", "--shortstat"]),
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
const branch = branchOut || (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])) || "unknown";
|
|
254
|
+
const lastCommit = parseCommitSubject(lastCommitLine);
|
|
255
|
+
const user = parseConfigRegexpValueForKey(configLine, "user.name");
|
|
256
|
+
const remoteUrl = parseConfigRegexpValueForKey(configLine, "remote.origin.url");
|
|
217
257
|
const repo = remoteUrl
|
|
218
258
|
? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
|
|
219
259
|
: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
|
|
220
260
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
const stagedFiles:
|
|
225
|
-
const unstagedFiles: string[] = [];
|
|
226
|
-
const fileSet = new Set<string>();
|
|
227
|
-
if (porcelain) {
|
|
228
|
-
for (const line of porcelain.split("\n")) {
|
|
229
|
-
if (line.length < 2) continue;
|
|
230
|
-
const file = line.slice(2);
|
|
231
|
-
if (fileSet.has(file)) continue; // deduplicate
|
|
232
|
-
fileSet.add(file);
|
|
233
|
-
if (line[0] === " ") {
|
|
234
|
-
unstagedFiles.push(file);
|
|
235
|
-
} else {
|
|
236
|
-
stagedFiles.push(file);
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
// 5. diff --shortstat.
|
|
242
|
-
const diffStat = isGitRepo ? git(cwd, ["diff", "--shortstat"]) : undefined;
|
|
261
|
+
// status --porcelain gives staged + unstaged in one call.
|
|
262
|
+
// Porcelain v1 lines are "XY <path>" (path starts at index 3); a space
|
|
263
|
+
// in the X column means unstaged, anything else is staged/untracked.
|
|
264
|
+
const { staged: stagedFiles, unstaged: unstagedFiles } = parseStatusStagedUnstaged(porcelain ?? "");
|
|
243
265
|
|
|
244
266
|
return {
|
|
245
267
|
cwd,
|
|
@@ -267,8 +289,6 @@ const PREVIEW_DELAY_MS = 2000;
|
|
|
267
289
|
const PREVIEW_MAX_LINES = 3;
|
|
268
290
|
|
|
269
291
|
/** Outcome of one chain step's run (see sendAndWait). */
|
|
270
|
-
type ChainStepOutcome = "completed" | "aborted" | "error" | "failed-to-start";
|
|
271
|
-
|
|
272
292
|
/**
|
|
273
293
|
* Status of one chain step for the below-prompt status widget: pending
|
|
274
294
|
* (not reached yet), running (its turn is in flight), waiting (fill-first:
|
|
@@ -319,6 +339,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
319
339
|
resolve: (outcome: ChainStepOutcome) => void;
|
|
320
340
|
/** Which chain step (0-based) this waiter belongs to — for the report. */
|
|
321
341
|
stepIndex: number;
|
|
342
|
+
startTime?: number;
|
|
322
343
|
} | null = null;
|
|
323
344
|
|
|
324
345
|
function settleChainWaiter(outcome: ChainStepOutcome) {
|
|
@@ -326,6 +347,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
326
347
|
const waiter = chainWaiter;
|
|
327
348
|
chainWaiter = null;
|
|
328
349
|
if (waiter.timer) clearTimeout(waiter.timer);
|
|
350
|
+
if (waiter.startTime !== undefined) {
|
|
351
|
+
chainDurations[waiter.stepIndex] = performance.now() - waiter.startTime;
|
|
352
|
+
}
|
|
329
353
|
waiter.resolve(outcome);
|
|
330
354
|
}
|
|
331
355
|
|
|
@@ -343,18 +367,49 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
343
367
|
// their event waiters (the old chain's sendAndWait would resolve on the
|
|
344
368
|
// new chain's step), so starting one is refused until the first ends.
|
|
345
369
|
let chainActive = false;
|
|
370
|
+
// Per-step durations (ms) for the chain-end summary. Indexed by step
|
|
371
|
+
// position; 0 means the step was not timed (e.g. blocked before running).
|
|
372
|
+
let chainDurations: number[] = [];
|
|
373
|
+
// For single auto-run tasks: the task name whose summary we post after
|
|
374
|
+
// agent_end. Only set when a Plan task (auto-run) is sent via
|
|
375
|
+
// pi.sendUserMessage (which does NOT arm a chainWaiter). The grace timer
|
|
376
|
+
// mirrors the chain's failed-to-start handling: a send that fails before
|
|
377
|
+
// the run starts emits no agent events at all, so without it the stale
|
|
378
|
+
// flag would post a spurious summary for the next unrelated turn.
|
|
379
|
+
let pendingSummaryTask: string | null = null;
|
|
380
|
+
let pendingSummaryTimer: NodeJS.Timeout | null = null;
|
|
381
|
+
|
|
382
|
+
/** Clear the auto-run summary flag and its grace timer (session start). */
|
|
383
|
+
function resetPendingSummary(): void {
|
|
384
|
+
pendingSummaryTask = null;
|
|
385
|
+
if (pendingSummaryTimer) {
|
|
386
|
+
clearTimeout(pendingSummaryTimer);
|
|
387
|
+
pendingSummaryTimer = null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
346
390
|
// Whether chain runs write a Markdown report file (config `report`,
|
|
347
391
|
// default true). Refreshed whenever the config is (re)loaded.
|
|
348
392
|
let reportEnabled = true;
|
|
349
393
|
// The in-flight chain's report file: its path (absolute + relative for
|
|
350
|
-
// display),
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
394
|
+
// display), the precomputed header (deferred — written together with the
|
|
395
|
+
// first step section, so a chain that dies before that leaves no
|
|
396
|
+
// header-only file behind), whether the file exists on disk yet, when the
|
|
397
|
+
// current step's run actually started (agent_start; null until then and
|
|
398
|
+
// for failed-to-start steps), whether the footer has been appended, the
|
|
399
|
+
// index of the last step section written (dedupes retried runs), and
|
|
400
|
+
// whether any step section carried result text (the file is worth
|
|
401
|
+
// keeping).
|
|
402
|
+
// Max in-memory sections kept for inline display; the file on disk retains
|
|
403
|
+
// all steps. Bounded to avoid holding 400KB–2MB of assistant text per chain.
|
|
404
|
+
const MAX_INLINE_SECTIONS = 3;
|
|
405
|
+
|
|
355
406
|
let chainReport: {
|
|
356
407
|
path: string;
|
|
357
408
|
display: string;
|
|
409
|
+
/** Precomputed header; written together with the first section. */
|
|
410
|
+
header: string;
|
|
411
|
+
/** True once the file exists on disk (header written). */
|
|
412
|
+
written: boolean;
|
|
358
413
|
stepStartedAt: Date | null;
|
|
359
414
|
/** True once the summary (or abandoned) footer has been appended. */
|
|
360
415
|
footerWritten: boolean;
|
|
@@ -362,8 +417,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
362
417
|
lastStepSection: number;
|
|
363
418
|
/** True once a step section with result text was appended. */
|
|
364
419
|
hasContent: boolean;
|
|
365
|
-
/**
|
|
366
|
-
|
|
420
|
+
/**
|
|
421
|
+
* In-memory section data for inline display (populated in agent_end,
|
|
422
|
+
* bounded to the last MAX_INLINE_SECTIONS). `index` is the step's
|
|
423
|
+
* position in the chain (retries share it); `markdown` is the formatted section.
|
|
424
|
+
*/
|
|
425
|
+
sections: Array<{ index: number; markdown: string }>;
|
|
367
426
|
} | null = null;
|
|
368
427
|
|
|
369
428
|
/** Status marker glyph (all one column wide) with its color. */
|
|
@@ -425,7 +484,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
425
484
|
* removes the status widget so nothing lingers below the prompt; otherwise
|
|
426
485
|
* keeps it as a trace with the report path in the note.
|
|
427
486
|
*/
|
|
428
|
-
|
|
487
|
+
/**
|
|
488
|
+
* Write the deferred report header so the file exists before the first
|
|
489
|
+
* section/footer append. Returns false when there is no report or the
|
|
490
|
+
* write failed (a warning was shown); the chain runs on without a file.
|
|
491
|
+
*/
|
|
492
|
+
function ensureReportFile(ctx: ExtensionContext | null): boolean {
|
|
493
|
+
const report = chainReport;
|
|
494
|
+
if (!report || report.written) return report !== null;
|
|
495
|
+
try {
|
|
496
|
+
writeFileSync(report.path, report.header);
|
|
497
|
+
report.written = true;
|
|
498
|
+
return true;
|
|
499
|
+
} catch (err) {
|
|
500
|
+
ctx?.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function finishReport(ctx: ExtensionContext): Promise<void> {
|
|
429
506
|
if (!chainReport || !chainStatus) return;
|
|
430
507
|
const statuses = chainStatus.steps.map((s) => s.status);
|
|
431
508
|
// Nothing worth keeping — remove the (mostly) empty file so a quick
|
|
@@ -435,19 +512,21 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
435
512
|
chainReport = null;
|
|
436
513
|
return;
|
|
437
514
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
515
|
+
if (ensureReportFile(ctx)) {
|
|
516
|
+
try {
|
|
517
|
+
appendFileSync(chainReport.path, reportFooter(statuses, new Date()));
|
|
518
|
+
chainReport.footerWritten = true;
|
|
519
|
+
} catch (err) {
|
|
520
|
+
ctx.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
|
|
521
|
+
}
|
|
443
522
|
}
|
|
444
523
|
const prev = chainStatus.note ? `${chainStatus.note} • ` : "";
|
|
445
524
|
chainStatus.note = `${prev}📄 ${chainReport.display}`;
|
|
446
|
-
// Chain fully done → show inline report
|
|
525
|
+
// Chain fully done → show the inline report, then clear the
|
|
447
526
|
// trace widget. Chain stopped early → keep the trace widget.
|
|
448
527
|
const allDone = statuses.every((s) => s === "completed");
|
|
449
528
|
if (allDone) {
|
|
450
|
-
showInlineReport();
|
|
529
|
+
await showInlineReport(ctx);
|
|
451
530
|
clearChainWidget(ctx);
|
|
452
531
|
} else {
|
|
453
532
|
updateChainWidget(ctx);
|
|
@@ -455,63 +534,69 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
455
534
|
}
|
|
456
535
|
|
|
457
536
|
/**
|
|
458
|
-
*
|
|
459
|
-
* completes. Built from in-memory section data (no file read
|
|
460
|
-
*
|
|
537
|
+
* Show the full chain report in an ephemeral editor view when a chain
|
|
538
|
+
* completes (TUI only). Built from in-memory section data (no file read
|
|
539
|
+
* needed). Nothing is persisted to the session — the view closes with
|
|
540
|
+
* Esc and the report file on disk is the permanent artifact. In non-TUI
|
|
541
|
+
* modes the editor is a no-op; the final notification carries the file
|
|
542
|
+
* path.
|
|
461
543
|
*/
|
|
462
|
-
function showInlineReport(): void {
|
|
544
|
+
async function showInlineReport(ctx: ExtensionContext): Promise<void> {
|
|
545
|
+
if (ctx.mode !== "tui") return;
|
|
463
546
|
if (!chainReport || !chainStatus || chainReport.sections.length === 0) return;
|
|
464
|
-
// Reconstruct the
|
|
547
|
+
// Reconstruct the report from in-memory sections. Only the last
|
|
548
|
+
// MAX_INLINE_SECTIONS are kept (the file on disk has all of them), so
|
|
549
|
+
// flag the omission when a step has no section in the window.
|
|
550
|
+
const shownSteps = new Set(chainReport.sections.map((s) => s.index));
|
|
551
|
+
const omitted = chainStatus.steps.length - shownSteps.size;
|
|
465
552
|
const lines: string[] = [];
|
|
466
553
|
lines.push(`# do-always chain report — ${new Date().toISOString().slice(0, 10)}`);
|
|
467
554
|
lines.push("");
|
|
468
555
|
lines.push(`- Project: ${chainReport.display}`);
|
|
469
556
|
lines.push(`- Steps: ${chainStatus.steps.map((s) => s.name).join(" → ")}`);
|
|
470
557
|
lines.push("");
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
0, // index not meaningful for display
|
|
474
|
-
sec.name,
|
|
475
|
-
sec.outcome,
|
|
476
|
-
new Date(), // approximate start
|
|
477
|
-
new Date(), // approximate end
|
|
478
|
-
sec.text,
|
|
479
|
-
);
|
|
480
|
-
lines.push(section);
|
|
558
|
+
if (omitted > 0) {
|
|
559
|
+
lines.push(`> … ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted — see ${chainReport.display}`);
|
|
481
560
|
lines.push("");
|
|
482
561
|
}
|
|
562
|
+
for (const sec of chainReport.sections) {
|
|
563
|
+
lines.push(sec.markdown);
|
|
564
|
+
}
|
|
483
565
|
// Footer.
|
|
484
566
|
const statuses = chainStatus.steps.map((s) => s.status);
|
|
485
567
|
lines.push(reportFooter(statuses, new Date()));
|
|
486
568
|
|
|
487
|
-
//
|
|
488
|
-
pi.sendMessage
|
|
489
|
-
|
|
490
|
-
content: lines.join("\n"),
|
|
491
|
-
display: true,
|
|
492
|
-
details: chainReport.display,
|
|
493
|
-
});
|
|
569
|
+
// Ephemeral editor view: read the report, Esc to dismiss. Unlike
|
|
570
|
+
// pi.sendMessage this persists nothing to the session.
|
|
571
|
+
await ctx.ui.editor("do-always — chain report", lines.join("\n"));
|
|
494
572
|
}
|
|
495
573
|
|
|
496
574
|
/**
|
|
497
575
|
* Close an in-flight report that never reached a terminal path (e.g.,
|
|
498
576
|
* the session ended mid-chain): delete the file when the run produced
|
|
499
|
-
* nothing worth keeping
|
|
500
|
-
* does not stay
|
|
577
|
+
* nothing worth keeping (a no-op when the deferred header was never
|
|
578
|
+
* written), otherwise append an "abandoned" footer so it does not stay
|
|
579
|
+
* header-only on disk.
|
|
501
580
|
*/
|
|
502
581
|
function closeAbandonedReport(statuses: string[]): void {
|
|
503
582
|
if (!chainReport || chainReport.footerWritten) return;
|
|
504
583
|
if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
|
|
505
|
-
// Nothing worth keeping — remove the
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
584
|
+
// Nothing worth keeping — remove the file if it was written.
|
|
585
|
+
if (chainReport.written) {
|
|
586
|
+
try {
|
|
587
|
+
unlinkSync(chainReport.path);
|
|
588
|
+
} catch {
|
|
589
|
+
// Best effort — the file stays on disk.
|
|
590
|
+
}
|
|
510
591
|
}
|
|
511
592
|
return;
|
|
512
593
|
}
|
|
513
594
|
try {
|
|
514
|
-
|
|
595
|
+
if (chainReport.written) {
|
|
596
|
+
appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
|
|
597
|
+
} else {
|
|
598
|
+
writeFileSync(chainReport.path, chainReport.header + reportAbandonedFooter(statuses, new Date()));
|
|
599
|
+
}
|
|
515
600
|
} catch {
|
|
516
601
|
// Best effort — the report file stays as-is on disk.
|
|
517
602
|
}
|
|
@@ -565,7 +650,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
565
650
|
}
|
|
566
651
|
|
|
567
652
|
pi.on("agent_start", () => {
|
|
568
|
-
if (chainWaiter)
|
|
653
|
+
if (chainWaiter) {
|
|
654
|
+
chainWaiter.started = true;
|
|
655
|
+
chainWaiter.startTime = performance.now();
|
|
656
|
+
}
|
|
657
|
+
// The run actually began — cancel the auto-run task's failed-to-start
|
|
658
|
+
// grace timer (its summary posts at agent_end).
|
|
659
|
+
if (pendingSummaryTimer) {
|
|
660
|
+
clearTimeout(pendingSummaryTimer);
|
|
661
|
+
pendingSummaryTimer = null;
|
|
662
|
+
}
|
|
569
663
|
// The run actually began — time the step for the report.
|
|
570
664
|
if (chainReport) chainReport.stepStartedAt = new Date();
|
|
571
665
|
// Fill-first: step 1 left the editor and is running — update the
|
|
@@ -579,12 +673,47 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
579
673
|
clearChainWidget(lastCtx);
|
|
580
674
|
}
|
|
581
675
|
});
|
|
676
|
+
/** The turn's last assistant message (backward scan — no copy/reverse). */
|
|
677
|
+
function lastAssistantMessage(messages: AgentEndEvent["messages"]) {
|
|
678
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
679
|
+
const m = messages[i];
|
|
680
|
+
if (m.role === "assistant") return m;
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Map an assistant message's stopReason to a chain step outcome. */
|
|
686
|
+
function outcomeFromStopReason(stopReason: string | undefined): "completed" | "aborted" | "error" {
|
|
687
|
+
if (stopReason === "aborted") return "aborted";
|
|
688
|
+
if (stopReason === "error") return "error";
|
|
689
|
+
return "completed";
|
|
690
|
+
}
|
|
691
|
+
|
|
582
692
|
pi.on("agent_end", (event) => {
|
|
693
|
+
// The agent may have changed the repo — drop the TTL context cache so
|
|
694
|
+
// the next action sees the new tree (a running chain keeps its own
|
|
695
|
+
// per-action snapshot and is unaffected).
|
|
696
|
+
contextCache = null;
|
|
697
|
+
// Single auto-run task summary (no chainWaiter: pi.sendUserMessage is
|
|
698
|
+
// fire-and-forget, so no armWaiter is called).
|
|
699
|
+
if (!chainWaiter && pendingSummaryTask) {
|
|
700
|
+
if (pendingSummaryTimer) {
|
|
701
|
+
clearTimeout(pendingSummaryTimer);
|
|
702
|
+
pendingSummaryTimer = null;
|
|
703
|
+
}
|
|
704
|
+
const lastAssistant = lastAssistantMessage(event.messages);
|
|
705
|
+
if (lastAssistant) {
|
|
706
|
+
const outcome = outcomeFromStopReason(lastAssistant.stopReason);
|
|
707
|
+
const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
|
|
708
|
+
lastCtx?.ui.notify(`do-always: ${summary}`, "info");
|
|
709
|
+
}
|
|
710
|
+
pendingSummaryTask = null;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
583
713
|
if (!chainWaiter) return;
|
|
584
|
-
const lastAssistant =
|
|
714
|
+
const lastAssistant = lastAssistantMessage(event.messages);
|
|
585
715
|
if (lastAssistant) {
|
|
586
|
-
const
|
|
587
|
-
const outcome = stopReason === "aborted" ? "aborted" : stopReason === "error" ? "error" : "completed";
|
|
716
|
+
const outcome = outcomeFromStopReason(lastAssistant.stopReason);
|
|
588
717
|
chainWaiter.outcome = outcome;
|
|
589
718
|
// Append this step's result to the report while the transcript is
|
|
590
719
|
// fresh (the step's final assistant message is its result).
|
|
@@ -595,23 +724,30 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
595
724
|
// report shows both attempts without a duplicate heading.
|
|
596
725
|
const isRetry = chainReport.lastStepSection === idx;
|
|
597
726
|
const name = chainStatus.steps[idx]?.name ?? `step ${idx + 1}`;
|
|
727
|
+
const displayName = isRetry ? `${name} (retry)` : name;
|
|
598
728
|
const text = assistantText(lastAssistant.content);
|
|
599
729
|
const section = reportStepSection(
|
|
600
730
|
idx,
|
|
601
|
-
|
|
731
|
+
displayName,
|
|
602
732
|
outcome,
|
|
603
733
|
chainReport.stepStartedAt,
|
|
604
734
|
new Date(),
|
|
605
735
|
text,
|
|
606
736
|
);
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
737
|
+
if (ensureReportFile(lastCtx)) {
|
|
738
|
+
try {
|
|
739
|
+
appendFileSync(chainReport.path, section);
|
|
740
|
+
chainReport.lastStepSection = idx;
|
|
741
|
+
if (text.trim() !== "") chainReport.hasContent = true;
|
|
742
|
+
// Keep in-memory section data for inline display (bounded);
|
|
743
|
+
// index lets it number sections like the file.
|
|
744
|
+
chainReport.sections.push({ index: idx, markdown: section });
|
|
745
|
+
if (chainReport.sections.length > MAX_INLINE_SECTIONS) {
|
|
746
|
+
chainReport.sections.splice(0, chainReport.sections.length - MAX_INLINE_SECTIONS);
|
|
747
|
+
}
|
|
748
|
+
} catch (err) {
|
|
749
|
+
lastCtx?.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
|
|
750
|
+
}
|
|
615
751
|
}
|
|
616
752
|
chainReport.stepStartedAt = null;
|
|
617
753
|
}
|
|
@@ -677,7 +813,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
677
813
|
});
|
|
678
814
|
}
|
|
679
815
|
|
|
680
|
-
pi.on("session_start", (_event, ctx) => {
|
|
816
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
681
817
|
lastCtx = ctx;
|
|
682
818
|
// Surface config validation problems (the README promises warnings);
|
|
683
819
|
// in non-TUI modes there is no UI, so fall back to the console.
|
|
@@ -688,14 +824,55 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
688
824
|
// A failed chain's status widget is a trace of the previous session;
|
|
689
825
|
// start each session clean.
|
|
690
826
|
clearChainWidget(ctx);
|
|
827
|
+
// Likewise a stale auto-run summary flag (e.g., a send that never
|
|
828
|
+
// started) must not post a spurious summary for this session's turns.
|
|
829
|
+
resetPendingSummary();
|
|
691
830
|
loadedCwd = ctx.cwd;
|
|
692
831
|
const config = loadConfig(ctx.cwd, onError);
|
|
693
832
|
tasks = config.tasks;
|
|
694
833
|
reportEnabled = config.report;
|
|
695
|
-
refreshVisible(ctx.cwd,
|
|
834
|
+
refreshVisible(ctx.cwd, await getContext(ctx.cwd));
|
|
696
835
|
registerShortcut(config.shortcut, onError);
|
|
697
836
|
});
|
|
698
837
|
|
|
838
|
+
// Extension-level git context cache, keyed by cwd with a short TTL:
|
|
839
|
+
// buildContext spawns git, and the repo rarely changes between actions,
|
|
840
|
+
// so the session_start precompute feeds the first /do-always and
|
|
841
|
+
// repeated invocations within the window reuse the same context.
|
|
842
|
+
// Invalidated on agent_end (the agent may have changed the repo); a
|
|
843
|
+
// running chain keeps its own per-action snapshot (createContextCache),
|
|
844
|
+
// so its steps reuse one context across the run.
|
|
845
|
+
let contextCache: { cwd: string; at: number; ctx: TaskContext } | null = null;
|
|
846
|
+
const CONTEXT_TTL_MS = 5_000;
|
|
847
|
+
|
|
848
|
+
async function getContext(cwd: string): Promise<TaskContext> {
|
|
849
|
+
if (contextCache && contextCache.cwd === cwd && Date.now() - contextCache.at < CONTEXT_TTL_MS) {
|
|
850
|
+
return contextCache.ctx;
|
|
851
|
+
}
|
|
852
|
+
const ctx = await buildContext(cwd);
|
|
853
|
+
contextCache = { cwd, at: Date.now(), ctx };
|
|
854
|
+
return ctx;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Per-action context cache: the first build consults the extension-level
|
|
859
|
+
* TTL cache (getContext), and later builds within the same action reuse
|
|
860
|
+
* that snapshot — what the user saw in the preview is what gets injected.
|
|
861
|
+
* Created fresh at each action entry point (runDoAlways, runChain).
|
|
862
|
+
*/
|
|
863
|
+
function createContextCache(): {
|
|
864
|
+
get(cwd: string): Promise<TaskContext>;
|
|
865
|
+
} {
|
|
866
|
+
let cached: { cwd: string; ctx: TaskContext } | null = null;
|
|
867
|
+
return {
|
|
868
|
+
async get(cwd: string): Promise<TaskContext> {
|
|
869
|
+
if (cached && cached.cwd === cwd) return cached.ctx;
|
|
870
|
+
cached = { cwd, ctx: await getContext(cwd) };
|
|
871
|
+
return cached.ctx;
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
699
876
|
/** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
|
|
700
877
|
async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
|
|
701
878
|
const blocked = evaluateGuards(task, context);
|
|
@@ -709,6 +886,19 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
709
886
|
if (shouldAutoRun(task)) {
|
|
710
887
|
// Fire-and-forget: sendUserMessage returns void; the run proceeds
|
|
711
888
|
// independently (see the chain control notes for why).
|
|
889
|
+
// Track the task so we can post a summary after agent_end.
|
|
890
|
+
pendingSummaryTask = task.name;
|
|
891
|
+
// If the run never starts (no agent events at all — same failure
|
|
892
|
+
// mode the chain's grace timer handles), drop the flag so a later
|
|
893
|
+
// unrelated turn can't post a spurious summary for this task.
|
|
894
|
+
if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
|
|
895
|
+
pendingSummaryTimer = setTimeout(() => {
|
|
896
|
+
pendingSummaryTimer = null;
|
|
897
|
+
if (!pendingSummaryTask) return;
|
|
898
|
+
const name = pendingSummaryTask;
|
|
899
|
+
pendingSummaryTask = null;
|
|
900
|
+
lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
|
|
901
|
+
}, 10_000);
|
|
712
902
|
pi.sendUserMessage(prompt);
|
|
713
903
|
ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
|
|
714
904
|
return;
|
|
@@ -747,33 +937,37 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
747
937
|
return;
|
|
748
938
|
}
|
|
749
939
|
// Fail fast: report the first blocked step before sending anything.
|
|
750
|
-
const blocked = validateChain(tasks, { items: names, history: [] }, cache.get(ctx.cwd));
|
|
940
|
+
const blocked = validateChain(tasks, { items: names, history: [] }, await cache.get(ctx.cwd));
|
|
751
941
|
if (blocked) {
|
|
752
942
|
ctx.ui.notify(`do-always: chain blocked at step ${blocked.step} (${blocked.task.name}): ${blocked.message}`, "warning");
|
|
753
943
|
return;
|
|
754
944
|
}
|
|
755
945
|
chainActive = true;
|
|
756
|
-
//
|
|
757
|
-
//
|
|
946
|
+
// Per-step durations for the chain-end summary. Re-initialized each
|
|
947
|
+
// chain run so stale durations from a previous run don't leak in.
|
|
948
|
+
// Pre-filled with 0 so the fill-first step (index 0, timed outside
|
|
949
|
+
// runChainSteps) and any unrun steps don't leave `undefined` holes
|
|
950
|
+
// that would poison the reduce with NaN.
|
|
951
|
+
chainDurations = Array.from({ length: steps.length }, () => 0);
|
|
952
|
+
// Report file: one per run, in the project root. The header is
|
|
953
|
+
// deferred until the first step section (a chain that dies before
|
|
954
|
+
// that leaves no file), and each step is appended as it finishes
|
|
955
|
+
// (see the report section in tasks.ts). A write failure is not
|
|
758
956
|
// fatal — the chain still runs, just without a report.
|
|
759
957
|
if (reportEnabled) {
|
|
760
958
|
const now = new Date();
|
|
761
959
|
const path = resolveReportPath(ctx.cwd, now);
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
} catch (err) {
|
|
774
|
-
ctx.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
|
|
775
|
-
chainReport = null;
|
|
776
|
-
}
|
|
960
|
+
chainReport = {
|
|
961
|
+
path,
|
|
962
|
+
display: relative(ctx.cwd, path),
|
|
963
|
+
header: reportHeader(ctx.cwd, steps.map((t) => t.name), now),
|
|
964
|
+
written: false,
|
|
965
|
+
stepStartedAt: null,
|
|
966
|
+
footerWritten: false,
|
|
967
|
+
lastStepSection: -1,
|
|
968
|
+
hasContent: false,
|
|
969
|
+
sections: [],
|
|
970
|
+
};
|
|
777
971
|
}
|
|
778
972
|
const first = steps[0];
|
|
779
973
|
if (shouldAutoRun(first) || ctx.mode !== "tui") {
|
|
@@ -782,7 +976,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
782
976
|
ctx,
|
|
783
977
|
steps.map((t, i) => ({ name: t.name, status: i === 0 ? "running" : "pending" })),
|
|
784
978
|
);
|
|
785
|
-
await runChainSteps(steps, ctx, 0
|
|
979
|
+
await runChainSteps(steps, ctx, 0);
|
|
786
980
|
} finally {
|
|
787
981
|
chainActive = false;
|
|
788
982
|
}
|
|
@@ -798,7 +992,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
798
992
|
steps.map((t, i) => ({ name: t.name, status: i === 0 ? "waiting" : "pending" })),
|
|
799
993
|
"step 1 is in the editor — press Enter to start",
|
|
800
994
|
);
|
|
801
|
-
|
|
995
|
+
const firstContext = await cache.get(ctx.cwd);
|
|
996
|
+
ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(firstContext)));
|
|
802
997
|
ctx.ui.notify(
|
|
803
998
|
`do-always: step 1 of ${steps.length} in the editor — press Enter to run; steps 2–${steps.length} follow automatically`,
|
|
804
999
|
"info",
|
|
@@ -807,12 +1002,17 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
807
1002
|
try {
|
|
808
1003
|
if (outcome !== "completed") {
|
|
809
1004
|
markChainStopped(ctx, 0, outcome);
|
|
810
|
-
finishReport(ctx);
|
|
1005
|
+
await finishReport(ctx);
|
|
811
1006
|
ctx.ui.notify(`do-always: step 1 — ${outcome}; chain stopped`, "error");
|
|
812
1007
|
return;
|
|
813
1008
|
}
|
|
814
1009
|
setChainStep(ctx, 0, "completed");
|
|
815
|
-
|
|
1010
|
+
// Post-step summary for step 1 (fill-first)
|
|
1011
|
+
const postContext = await getContext(ctx.cwd);
|
|
1012
|
+
const fileCount = postContext.files.length;
|
|
1013
|
+
const summary = stepSummary(outcome, first.name, chainDurations[0], fileCount);
|
|
1014
|
+
ctx.ui.notify(`do-always: step 1/${steps.length} — ${summary}`, "info");
|
|
1015
|
+
await runChainSteps(steps, ctx, 1);
|
|
816
1016
|
} finally {
|
|
817
1017
|
chainActive = false;
|
|
818
1018
|
}
|
|
@@ -820,24 +1020,23 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
820
1020
|
}
|
|
821
1021
|
|
|
822
1022
|
/**
|
|
823
|
-
* Send chain steps `startAt..end` sequentially. Each step
|
|
824
|
-
*
|
|
825
|
-
* its run has fully settled; an aborted/errored step (or a
|
|
826
|
-
* failed to start) stops the chain.
|
|
1023
|
+
* Send chain steps `startAt..end` sequentially. Each step evaluates guards
|
|
1024
|
+
* and renders prompts against fresh repository context, and each step is
|
|
1025
|
+
* awaited until its run has fully settled; an aborted/errored step (or a
|
|
1026
|
+
* send that failed to start) stops the chain.
|
|
827
1027
|
*/
|
|
828
1028
|
async function runChainSteps(
|
|
829
1029
|
steps: DoAlwaysTask[],
|
|
830
1030
|
ctx: ExtensionContext,
|
|
831
1031
|
startAt: number,
|
|
832
|
-
cache: ReturnType<typeof createContextCache>,
|
|
833
1032
|
): Promise<void> {
|
|
834
1033
|
for (let i = startAt; i < steps.length; i++) {
|
|
835
1034
|
const step = steps[i];
|
|
836
|
-
const context =
|
|
1035
|
+
const context = await getContext(ctx.cwd);
|
|
837
1036
|
const blocked = evaluateGuards(step, context);
|
|
838
1037
|
if (blocked) {
|
|
839
1038
|
markChainStopped(ctx, i, "skipped", blocked);
|
|
840
|
-
finishReport(ctx);
|
|
1039
|
+
await finishReport(ctx);
|
|
841
1040
|
ctx.ui.notify(`do-always: chain stopped at step ${i + 1} (${step.name}): ${blocked}`, "warning");
|
|
842
1041
|
return;
|
|
843
1042
|
}
|
|
@@ -845,19 +1044,28 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
845
1044
|
const label = `do-always: step ${i + 1}/${steps.length} — ${step.name}`;
|
|
846
1045
|
setChainStep(ctx, i, "running");
|
|
847
1046
|
ctx.ui.notify(`${label} — starting`, "info");
|
|
1047
|
+
const stepStart = performance.now();
|
|
848
1048
|
const outcome = await sendAndWait(prompt, 10_000, i);
|
|
1049
|
+
const stepDuration = chainDurations[i] || performance.now() - stepStart;
|
|
1050
|
+
chainDurations[i] = stepDuration;
|
|
849
1051
|
if (outcome === "completed") {
|
|
850
1052
|
setChainStep(ctx, i, "completed");
|
|
1053
|
+
// Post-step summary: files changed + duration.
|
|
1054
|
+
const postContext = await getContext(ctx.cwd);
|
|
1055
|
+
const fileCount = postContext.files.length;
|
|
1056
|
+
const summary = stepSummary(outcome, step.name, stepDuration, fileCount);
|
|
1057
|
+
ctx.ui.notify(`do-always: step ${i + 1}/${steps.length} — ${summary}`, "info");
|
|
851
1058
|
continue;
|
|
852
1059
|
}
|
|
853
1060
|
markChainStopped(ctx, i, outcome);
|
|
854
|
-
finishReport(ctx);
|
|
1061
|
+
await finishReport(ctx);
|
|
1062
|
+
const summary = stepSummary(outcome, step.name, stepDuration, 0);
|
|
855
1063
|
if (outcome === "failed-to-start") {
|
|
856
|
-
ctx.ui.notify(`${label} —
|
|
1064
|
+
ctx.ui.notify(`${label} — ${summary} (check model/API key); chain stopped`, "error");
|
|
857
1065
|
} else if (outcome === "aborted") {
|
|
858
|
-
ctx.ui.notify(`${label} —
|
|
1066
|
+
ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
|
|
859
1067
|
} else {
|
|
860
|
-
ctx.ui.notify(`${label} —
|
|
1068
|
+
ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
|
|
861
1069
|
}
|
|
862
1070
|
return;
|
|
863
1071
|
}
|
|
@@ -866,12 +1074,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
866
1074
|
// the prompt. Capture the display path first — finishReport clears
|
|
867
1075
|
// `chainReport` when the chain is fully done.
|
|
868
1076
|
const reportDisplay = chainReport?.display;
|
|
1077
|
+
// Chain-end summary.
|
|
1078
|
+
const completed = chainDurations.filter((d) => d > 0).length;
|
|
1079
|
+
const totalMs = chainDurations.reduce((a, b) => a + b, 0);
|
|
1080
|
+
const chainSum = chainSummary(completed, steps.length, totalMs);
|
|
869
1081
|
if (chainReport) {
|
|
870
|
-
finishReport(ctx);
|
|
871
|
-
ctx.ui.notify(`do-always:
|
|
1082
|
+
await finishReport(ctx);
|
|
1083
|
+
ctx.ui.notify(`do-always: ${chainSum} — report: ${reportDisplay}`, "info");
|
|
872
1084
|
} else {
|
|
873
1085
|
clearChainWidget(ctx);
|
|
874
|
-
ctx.ui.notify(`do-always:
|
|
1086
|
+
ctx.ui.notify(`do-always: ${chainSum}`, "info");
|
|
875
1087
|
}
|
|
876
1088
|
}
|
|
877
1089
|
|
|
@@ -1004,12 +1216,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1004
1216
|
);
|
|
1005
1217
|
return { bodyRows, itemRows, winStart, visibleHeaderNames };
|
|
1006
1218
|
}
|
|
1219
|
+
// One pass's visible state, computed once per input event / render
|
|
1220
|
+
// and shared by clampCursor, buildTable, and the navigation logic.
|
|
1221
|
+
type Visible = ReturnType<typeof getVisible>;
|
|
1007
1222
|
|
|
1008
1223
|
// Keep the cursor valid after the rows or the chain change. (The
|
|
1009
1224
|
// ORDER cell of a non-chained row is a valid cursor position: it is
|
|
1010
1225
|
// the "add" state.)
|
|
1011
|
-
function clampCursor() {
|
|
1012
|
-
const { itemRows } =
|
|
1226
|
+
function clampCursor(visible: Visible) {
|
|
1227
|
+
const { itemRows } = visible;
|
|
1013
1228
|
if (itemRows.length === 0) {
|
|
1014
1229
|
cursor = { kind: "cell", row: 0, col: "task" };
|
|
1015
1230
|
return;
|
|
@@ -1044,15 +1259,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1044
1259
|
}
|
|
1045
1260
|
|
|
1046
1261
|
// Cached last buildTable result (avoids double-work on mouse hit-test).
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
|
|
1262
|
+
// The table depends on cursor and preview state, so render() must
|
|
1263
|
+
// rebuild it on every pass; handleMouse only reuses the last render's
|
|
1264
|
+
// table (or builds one if no render has happened yet, e.g. the very
|
|
1265
|
+
// first mouse event).
|
|
1266
|
+
type Table = { lines: string[]; itemLine: Map<number, DoAlwaysTask>; runLine: number; orderColX: number | null };
|
|
1267
|
+
let lastTable: Table | null = null;
|
|
1050
1268
|
|
|
1051
1269
|
// Build the full selector output for a width, plus the line map for
|
|
1052
1270
|
// mouse handling (itemLine: line -> task, runLine: the Run row,
|
|
1053
1271
|
// orderColX: where the ORDER cell starts, or null in the narrow tier).
|
|
1054
|
-
function buildTable(width: number) {
|
|
1055
|
-
const { bodyRows, itemRows, winStart, visibleHeaderNames } =
|
|
1272
|
+
function buildTable(width: number, visible: Visible = getVisible()): Table {
|
|
1273
|
+
const { bodyRows, itemRows, winStart, visibleHeaderNames } = visible;
|
|
1056
1274
|
const { tier, taskCol, descCol, orderColX } = tableGeometry(width);
|
|
1057
1275
|
const lines: string[] = [];
|
|
1058
1276
|
const itemLine = new Map<number, DoAlwaysTask>();
|
|
@@ -1114,7 +1332,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1114
1332
|
cursor.kind === "cell" && cursor.row === idx && cursor.col === "order";
|
|
1115
1333
|
const num = inTaskCol
|
|
1116
1334
|
? `${cursorMark} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
|
|
1117
|
-
:
|
|
1335
|
+
: inOrderCol && tier === "narrow"
|
|
1336
|
+
? `${theme.fg("warning", "►")} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
|
|
1337
|
+
: ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
|
|
1118
1338
|
// ⚡ is 2 columns wide, so "⚡ " takes 3 — reserve it so
|
|
1119
1339
|
// auto-run rows align with the others (ORDER cell is
|
|
1120
1340
|
// hit-tested at a fixed x).
|
|
@@ -1213,10 +1433,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1213
1433
|
|
|
1214
1434
|
return {
|
|
1215
1435
|
render(width: number) {
|
|
1216
|
-
lastTable = buildTable(width);
|
|
1217
|
-
return lastTable
|
|
1436
|
+
lastTable = buildTable(width, getVisible());
|
|
1437
|
+
return lastTable.lines;
|
|
1438
|
+
},
|
|
1439
|
+
invalidate() {
|
|
1440
|
+
clearPreviewTimer();
|
|
1218
1441
|
},
|
|
1219
|
-
invalidate() {},
|
|
1220
1442
|
handleInput(data: string) {
|
|
1221
1443
|
// Direct pick by number (1-9) — runs the task immediately (the
|
|
1222
1444
|
// classic fast path), closing the selector and discarding the
|
|
@@ -1227,16 +1449,17 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1227
1449
|
return;
|
|
1228
1450
|
}
|
|
1229
1451
|
// Filter typing (Backspace edits the filter; with an empty
|
|
1230
|
-
// filter it undoes the last chain add).
|
|
1452
|
+
// filter it undoes the last chain add). The filter change
|
|
1453
|
+
// invalidates the visible set, so the clamp gets a fresh pass.
|
|
1231
1454
|
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
|
|
1232
1455
|
if (filter.length > 0) {
|
|
1233
1456
|
filter = filter.slice(0, -1);
|
|
1234
|
-
clampCursor();
|
|
1457
|
+
clampCursor(getVisible());
|
|
1235
1458
|
} else {
|
|
1236
1459
|
const { state, removed } = chainUndo(chain);
|
|
1237
1460
|
if (removed) {
|
|
1238
1461
|
chain = state;
|
|
1239
|
-
clampCursor();
|
|
1462
|
+
clampCursor(getVisible());
|
|
1240
1463
|
}
|
|
1241
1464
|
}
|
|
1242
1465
|
resetPreview();
|
|
@@ -1245,12 +1468,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1245
1468
|
}
|
|
1246
1469
|
if (isPrintable(data)) {
|
|
1247
1470
|
filter += data;
|
|
1248
|
-
clampCursor();
|
|
1471
|
+
clampCursor(getVisible());
|
|
1249
1472
|
resetPreview();
|
|
1250
1473
|
tui.requestRender();
|
|
1251
1474
|
return;
|
|
1252
1475
|
}
|
|
1253
|
-
|
|
1476
|
+
// One pass for the rest of the event: navigation, confirm,
|
|
1477
|
+
// and clear below all share this result (chain edits don't
|
|
1478
|
+
// change itemRows, so it stays valid).
|
|
1479
|
+
const visible = getVisible();
|
|
1480
|
+
const { itemRows } = visible;
|
|
1254
1481
|
if (itemRows.length === 0) {
|
|
1255
1482
|
// Nothing to navigate; only Esc is useful here.
|
|
1256
1483
|
if (kb.matches(data, "tui.select.cancel")) finishCancel();
|
|
@@ -1317,6 +1544,51 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1317
1544
|
tui.requestRender();
|
|
1318
1545
|
return;
|
|
1319
1546
|
}
|
|
1547
|
+
// Extended navigation: home, end, pageup, pagedown.
|
|
1548
|
+
if (matchesKey(data, "home")) {
|
|
1549
|
+
cursor = { kind: "cell", row: 0, col: cursor.kind === "cell" ? cursor.col : "task" };
|
|
1550
|
+
lastCellRow = 0;
|
|
1551
|
+
resetPreview();
|
|
1552
|
+
tui.requestRender();
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
if (matchesKey(data, "end")) {
|
|
1556
|
+
cursor = { kind: "run" };
|
|
1557
|
+
lastCellRow = itemRows.length - 1;
|
|
1558
|
+
resetPreview();
|
|
1559
|
+
tui.requestRender();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
if (matchesKey(data, "pageUp")) {
|
|
1563
|
+
if (cursor.kind === "run") {
|
|
1564
|
+
cursor = { kind: "cell", row: Math.max(0, itemRows.length - maxVisible), col: "task" };
|
|
1565
|
+
} else {
|
|
1566
|
+
cursor = {
|
|
1567
|
+
kind: "cell",
|
|
1568
|
+
row: Math.max(0, cursor.row - maxVisible),
|
|
1569
|
+
col: cursor.col,
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
lastCellRow = cursor.row;
|
|
1573
|
+
resetPreview();
|
|
1574
|
+
tui.requestRender();
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
if (matchesKey(data, "pageDown")) {
|
|
1578
|
+
if (cursor.kind === "cell") {
|
|
1579
|
+
const next = cursor.row + maxVisible;
|
|
1580
|
+
if (next >= itemRows.length) {
|
|
1581
|
+
cursor = { kind: "run" };
|
|
1582
|
+
lastCellRow = itemRows.length - 1;
|
|
1583
|
+
} else {
|
|
1584
|
+
cursor = { kind: "cell", row: next, col: cursor.col };
|
|
1585
|
+
lastCellRow = next;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
resetPreview();
|
|
1589
|
+
tui.requestRender();
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1320
1592
|
// Confirm: context-dependent.
|
|
1321
1593
|
if (kb.matches(data, "tui.select.confirm")) {
|
|
1322
1594
|
if (cursor.kind === "run") {
|
|
@@ -1354,7 +1626,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1354
1626
|
if (matchesKey(data, "ctrl+u")) {
|
|
1355
1627
|
if (chain.items.length > 0) {
|
|
1356
1628
|
chain = chainClear();
|
|
1357
|
-
clampCursor();
|
|
1629
|
+
clampCursor(visible);
|
|
1358
1630
|
resetPreview();
|
|
1359
1631
|
tui.requestRender();
|
|
1360
1632
|
}
|
|
@@ -1378,11 +1650,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1378
1650
|
return { handled: true, render: true };
|
|
1379
1651
|
}
|
|
1380
1652
|
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
|
|
1381
|
-
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
|
|
1653
|
+
// Reuse the last render's table for hit-testing (avoids rebuilding
|
|
1654
|
+
// the table twice per mouse event). The table is always fresh
|
|
1655
|
+
// because handleInput calls requestRender before the next mouse
|
|
1656
|
+
// event can arrive; build one if no render has happened yet.
|
|
1657
|
+
// One getVisible() per event, shared by the (possibly fresh)
|
|
1658
|
+
// table and the itemRows lookup below.
|
|
1659
|
+
const visible = getVisible();
|
|
1660
|
+
const { itemLine, runLine, orderColX } = lastTable ?? buildTable(event.width, visible);
|
|
1386
1661
|
// Pinned Run row: press runs the chain.
|
|
1387
1662
|
if (runLine >= 0 && event.y === runLine) {
|
|
1388
1663
|
if (event.type === "press" && chain.items.length > 0) {
|
|
@@ -1392,14 +1667,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1392
1667
|
}
|
|
1393
1668
|
const task = itemLine.get(event.y);
|
|
1394
1669
|
if (!task) return undefined;
|
|
1395
|
-
const { itemRows } =
|
|
1670
|
+
const { itemRows } = visible;
|
|
1396
1671
|
const idx = itemRows.findIndex((r) => r.task === task);
|
|
1397
1672
|
if (idx < 0) return undefined;
|
|
1398
1673
|
// ORDER cell: press toggles chain membership.
|
|
1399
1674
|
if (orderColX !== null && event.x >= orderColX) {
|
|
1400
1675
|
if (event.type === "press") {
|
|
1401
1676
|
chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
|
|
1402
|
-
clampCursor();
|
|
1677
|
+
clampCursor(visible);
|
|
1403
1678
|
resetPreview();
|
|
1404
1679
|
return { handled: true, render: true };
|
|
1405
1680
|
}
|
|
@@ -1471,7 +1746,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1471
1746
|
// One context per command run: shared by visibility filtering, rendering,
|
|
1472
1747
|
// and the completion cache (never inside a render loop).
|
|
1473
1748
|
const cache = createContextCache();
|
|
1474
|
-
const context = cache.get(ctx.cwd);
|
|
1749
|
+
const context = await cache.get(ctx.cwd);
|
|
1475
1750
|
const visible = refreshVisible(ctx.cwd, context);
|
|
1476
1751
|
|
|
1477
1752
|
const arg = args.trim();
|