pi-do-always 0.10.0 → 0.13.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.
@@ -33,10 +33,10 @@
33
33
  * If no config file exists, built-in default tasks are used.
34
34
  */
35
35
 
36
- import { execFileSync } from "node:child_process";
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,
@@ -51,6 +51,8 @@ import {
51
51
  } from "@earendil-works/pi-tui";
52
52
  import {
53
53
  CHAIN_MAX,
54
+ COMMIT_BROWSER_MAX,
55
+ COMMIT_SELECT_MAX,
54
56
  DEFAULT_SHORTCUT,
55
57
  DEFAULT_TASKS,
56
58
  assistantText,
@@ -59,16 +61,24 @@ import {
59
61
  chainClear,
60
62
  chainRemove,
61
63
  chainRunLabel,
64
+ chainSummary,
62
65
  chainUndo,
63
66
  evaluateGuards,
64
67
  evaluateWhen,
65
68
  formatChainSequence,
66
69
  formatList,
70
+ formatSelectedCommits,
71
+ groupCommitsByDate,
67
72
  groupTasksByCategory,
73
+ isTaskVisible,
68
74
  isValidKeyId,
69
75
  mergeTasks,
70
76
  parseConfig,
77
+ parseConfigRegexpValueForKey,
78
+ parseCommitSubject,
79
+ parseGitLogOutput,
71
80
  parseStatusPorcelain,
81
+ parseStatusStagedUnstaged,
72
82
  orderTasksByCategory,
73
83
  reportAbandonedFooter,
74
84
  reportFooter,
@@ -80,9 +90,14 @@ import {
80
90
  resolveShortcut,
81
91
  resolveTask,
82
92
  shouldAutoRun,
93
+ stepSummary,
83
94
  toPromptContext,
84
95
  validateChain,
96
+ type ChainStepOutcome,
97
+ type CommitInfo,
98
+ type DateGroup,
85
99
  type DoAlwaysTask,
100
+ type SelectedCommit,
86
101
  type TaskContext,
87
102
  type TableRow,
88
103
  } from "./tasks";
@@ -102,6 +117,20 @@ type SelectorResult =
102
117
  */
103
118
  type Cursor = { kind: "cell"; row: number; col: "task" | "order" } | { kind: "run" };
104
119
 
120
+ /**
121
+ * Safely read a config file's contents, returning null if missing or unreadable.
122
+ * Read errors are reported through `onError`.
123
+ */
124
+ function readConfigFile(filePath: string, onError: (message: string) => void): string | null {
125
+ if (!existsSync(filePath)) return null;
126
+ try {
127
+ return readFileSync(filePath, "utf-8");
128
+ } catch (err) {
129
+ onError(`do-always: could not read ${filePath}: ${err}`);
130
+ return null;
131
+ }
132
+ }
133
+
105
134
  /**
106
135
  * Load tasks and the selector shortcut from config files.
107
136
  * Project-local tasks override global tasks with the same name (or are
@@ -124,11 +153,14 @@ function loadConfig(
124
153
  const globalPath = join(getAgentDir(), "do-always.json");
125
154
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
126
155
 
127
- const global = existsSync(globalPath)
128
- ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath, onError)
156
+ const globalRaw = readConfigFile(globalPath, onError);
157
+ const projectRaw = readConfigFile(projectPath, onError);
158
+
159
+ const global = globalRaw !== null
160
+ ? parseConfig(globalRaw, globalPath, onError)
129
161
  : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
130
- const project = existsSync(projectPath)
131
- ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath, onError)
162
+ const project = projectRaw !== null
163
+ ? parseConfig(projectRaw, projectPath, onError)
132
164
  : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
133
165
 
134
166
  // The project file's merge mode wins; otherwise the global value; otherwise
@@ -152,36 +184,36 @@ function loadConfig(
152
184
  * empty repo, …) so callers can fall back to a neutral value.
153
185
  * No shell is involved (argument array), so file names cannot inject commands.
154
186
  */
155
- function git(cwd: string, args: string[]): string | undefined {
156
- try {
157
- const out = execFileSync("git", args, {
158
- cwd,
159
- encoding: "utf-8",
160
- stdio: ["ignore", "pipe", "ignore"],
161
- });
162
- const trimmed = out.trim();
163
- return trimmed === "" ? undefined : trimmed;
164
- } catch {
165
- return undefined;
166
- }
187
+ function git(cwd: string, args: string[]): Promise<string | undefined> {
188
+ return new Promise((resolve) => {
189
+ // No stdio option: execFile's string-encoding overload doesn't accept
190
+ // it, and stdin/stderr need no special handling here (no input is
191
+ // written; stderr is simply ignored by the callback).
192
+ execFile(
193
+ "git",
194
+ args,
195
+ { cwd, encoding: "utf-8" },
196
+ (error, stdout) => {
197
+ if (error) {
198
+ resolve(undefined);
199
+ return;
200
+ }
201
+ const trimmed = stdout.trim();
202
+ resolve(trimmed === "" ? undefined : trimmed);
203
+ },
204
+ );
205
+ });
167
206
  }
168
207
 
169
208
  /**
170
- * Cache that deduplicates `buildContext` calls for the same cwd within a
171
- * single user action (chain run, /do-always invocation, session_start).
172
- * The cache is created fresh for each action entry point.
209
+ * Count changed files (staged, unstaged, untracked) with a single git spawn.
210
+ * Lighter than a full `buildContext` when only the count is needed (chain
211
+ * step summaries). Returns 0 when git is unavailable — the same neutral
212
+ * result `buildContext` yields via its empty file list.
173
213
  */
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
- };
214
+ async function changedFileCount(cwd: string): Promise<number> {
215
+ const porcelain = await git(cwd, ["status", "--porcelain"]);
216
+ return porcelain ? parseStatusPorcelain(porcelain).length : 0;
185
217
  }
186
218
 
187
219
  /**
@@ -189,57 +221,68 @@ function createContextCache(): {
189
221
  * to neutral values when unavailable (non-git dir, no git, empty repo) so
190
222
  * default prompts read cleanly in any directory.
191
223
  *
192
- * Batches the 9 individual git calls into 5:
193
- * 1. rev-parse --abbrev-ref HEAD --is-inside-work-tree → branch + isGitRepo
194
- * 2. log -1 --format="%H %s" → commit hash + subject
195
- * 3. config --get-regexp "^user.name$|^remote.origin.url$" → user + remote
196
- * 4. status --porcelain → stagedFiles + unstagedFiles (parsed from porcelain)
197
- * 5. diff --shortstat → diffStat
224
+ * Collects the git facts the prompts interpolate:
225
+ * 1. rev-parse --is-inside-work-tree → isGitRepo
226
+ * 2. branch --show-current → branch (fallback: rev-parse --abbrev-ref HEAD)
227
+ * 3. log -1 --format="%H %s" → commit hash + subject
228
+ * 4. config --get-regexp "^(user.name|remote.origin.url)$" → user + repo
229
+ * 5. status --porcelain → files + stagedFiles + unstagedFiles
230
+ * 6. diff --shortstat → diffStat
231
+ *
232
+ * Outside a work tree only call 1 runs; inside one, calls 2–6 run in
233
+ * parallel, so wall time is about one spawn.
198
234
  */
199
- function buildContext(cwd: string): TaskContext {
200
- // 1. Branch + isGitRepo in one call.
201
- // rev-parse --abbrev-ref HEAD --is-inside-work-tree outputs two lines:
202
- // <branch-name>
203
- // true|false
204
- // Fails (exit != 0) when not in a work tree.
205
- const revParse = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD", "--is-inside-work-tree"]);
206
- const lines = revParse ? revParse.split("\n") : [];
207
- const branch = lines[0] ?? "unknown";
208
- const isGitRepo = lines[1] === "true";
209
-
210
- // 2. Commit subject (hash is unused but cheap to fetch alongside).
211
- const commitLine = isGitRepo ? git(cwd, ["log", "-1", "--format=%H %s"]) : undefined;
212
- const lastCommit = commitLine ? commitLine.split(" ", 2).pop() ?? "unknown" : "unknown";
213
-
214
- // 3. User + remote in one config call.
215
- const user = isGitRepo ? git(cwd, ["config", "--get-regexp", "^user\\.name$"]) : undefined;
216
- const remoteUrl = isGitRepo ? git(cwd, ["config", "--get-regexp", "^remote\\.origin\\.url$"]) : undefined;
235
+ async function buildContext(cwd: string): Promise<TaskContext> {
236
+ // 1. Authoritative check: are we inside a work tree?
237
+ // rev-parse --is-inside-work-tree outputs "true" when inside a work tree,
238
+ // and fails (exit != 0) when outside.
239
+ const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
240
+ const isGitRepo = inside === "true";
241
+
242
+ // Outside a work tree the remaining facts don't exist; skip the spawns
243
+ // and return the neutral fallbacks directly.
244
+ if (!isGitRepo) {
245
+ return {
246
+ cwd,
247
+ date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
248
+ branch: "unknown",
249
+ lastCommit: "unknown",
250
+ files: [],
251
+ user: "unknown",
252
+ diffStat: "none",
253
+ repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown",
254
+ stagedFiles: [],
255
+ unstagedFiles: [],
256
+ selectedCommits: "none",
257
+ isGitRepo: false,
258
+ };
259
+ }
260
+
261
+ // 2–6. In a work tree: query branch and the remaining facts in parallel.
262
+ const [branchOut, lastCommitLine, configLine, porcelain, diffStat] = await Promise.all([
263
+ // branch --show-current works on both normal and unborn (empty repo) branches;
264
+ // returns empty on detached HEAD.
265
+ git(cwd, ["branch", "--show-current"]),
266
+ // "%H %s" prints "<hash> <subject>"; the subject may contain spaces.
267
+ git(cwd, ["log", "-1", "--format=%H %s"]),
268
+ // --get-regexp prints "key value" lines; one call covers both keys.
269
+ git(cwd, ["config", "--get-regexp", "^(user\\.name|remote\\.origin\\.url)$"]),
270
+ git(cwd, ["status", "--porcelain"]),
271
+ git(cwd, ["diff", "--shortstat"]),
272
+ ]);
273
+
274
+ const branch = branchOut || (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])) || "unknown";
275
+ const lastCommit = parseCommitSubject(lastCommitLine);
276
+ const user = parseConfigRegexpValueForKey(configLine, "user.name");
277
+ const remoteUrl = parseConfigRegexpValueForKey(configLine, "remote.origin.url");
217
278
  const repo = remoteUrl
218
279
  ? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
219
280
  : cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
220
281
 
221
- // 4. status --porcelain gives staged + unstaged in one call.
222
- // Lines starting with a space are unstaged; others are staged/untracked.
223
- const porcelain = isGitRepo ? git(cwd, ["status", "--porcelain"]) : "";
224
- const stagedFiles: string[] = [];
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;
282
+ // status --porcelain gives staged + unstaged in one call.
283
+ // Porcelain v1 lines are "XY <path>" (path starts at index 3); a space
284
+ // in the X column means unstaged, anything else is staged/untracked.
285
+ const { staged: stagedFiles, unstaged: unstagedFiles } = parseStatusStagedUnstaged(porcelain ?? "");
243
286
 
244
287
  return {
245
288
  cwd,
@@ -252,23 +295,572 @@ function buildContext(cwd: string): TaskContext {
252
295
  repo,
253
296
  stagedFiles,
254
297
  unstagedFiles,
298
+ selectedCommits: "none",
255
299
  isGitRepo,
256
300
  };
257
301
  }
258
302
 
303
+ /**
304
+ * Fetch a page of commits from git with lightweight stats via a single log call.
305
+ * Fetches pageSize + 1 commits to determine if more commits are available.
306
+ */
307
+ async function fetchCommitsPage(
308
+ cwd: string,
309
+ page = 0,
310
+ pageSize = COMMIT_BROWSER_MAX,
311
+ ): Promise<{ commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean }> {
312
+ const countToFetch = pageSize + 1;
313
+ const skip = page * pageSize;
314
+ const format = "COMMIT%x09%H%x09%h%x09%s%x09%ad%x09%an";
315
+ const logOutput = await git(cwd, [
316
+ "log",
317
+ "-n",
318
+ `${countToFetch}`,
319
+ `--skip=${skip}`,
320
+ `--format=${format}`,
321
+ "--shortstat",
322
+ "--date=short",
323
+ ]);
324
+ if (!logOutput) {
325
+ return { commits: [], groups: [], hasMore: false };
326
+ }
327
+
328
+ const allParsed = parseGitLogOutput(logOutput);
329
+ const hasMore = allParsed.length > pageSize;
330
+ const commits = hasMore ? allParsed.slice(0, pageSize) : allParsed;
331
+ const groups = groupCommitsByDate(commits);
332
+ return { commits, groups, hasMore };
333
+ }
334
+
335
+ /**
336
+ * Date-grouped commit browser: browse recent commits in pages of 20, select some,
337
+ * then review them. Reuses the task-selector interaction model (↑↓, Space toggle,
338
+ * type-to-filter, Ctrl+U clear, Esc cancel, ←/→ or PgUp/PgDn for paging).
339
+ *
340
+ * Returns the selected commits (ordered by user selection), or null on cancel.
341
+ */
342
+ function browseCommits(
343
+ ctx: ExtensionContext,
344
+ initialData?: { commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean },
345
+ ): Promise<SelectedCommit[] | null> {
346
+ return new Promise<SelectedCommit[] | null>((resolve) => {
347
+ const pageCache = new Map<number, { commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean }>();
348
+ let page = 0;
349
+ if (initialData) {
350
+ pageCache.set(0, initialData);
351
+ }
352
+
353
+ type CommitCursor = { kind: "commit"; index: number } | { kind: "run" };
354
+ const maxVisible = 14; // commits visible in the scroll window
355
+ const selectedMap = new Map<string, SelectedCommit>(); // hash -> SelectedCommit
356
+ let cursor: CommitCursor = { kind: "commit", index: 0 };
357
+ let lastCommitIndex = 0;
358
+ let filter = "";
359
+ let settled = false;
360
+ let isLoading = false;
361
+
362
+ ctx.ui.custom<SelectedCommit[] | null>((tui, theme, _kb, done) => {
363
+ const kb = getKeybindings();
364
+
365
+ function finish(selectedCommits: SelectedCommit[]) {
366
+ if (settled) return;
367
+ settled = true;
368
+ done(selectedCommits);
369
+ resolve(selectedCommits);
370
+ }
371
+
372
+ function finishCancel() {
373
+ if (settled) return;
374
+ settled = true;
375
+ done(null);
376
+ resolve(null);
377
+ }
378
+
379
+ async function loadPage(newPage: number) {
380
+ if (isLoading || newPage < 0) return;
381
+ page = newPage;
382
+ if (!pageCache.has(page)) {
383
+ isLoading = true;
384
+ tui.requestRender();
385
+ try {
386
+ const fetched = await fetchCommitsPage(ctx.cwd, page);
387
+ pageCache.set(page, fetched);
388
+ } finally {
389
+ isLoading = false;
390
+ }
391
+ }
392
+ cursor = { kind: "commit", index: 0 };
393
+ lastCommitIndex = 0;
394
+ tui.requestRender();
395
+ }
396
+
397
+ function getCurrentPageData() {
398
+ return pageCache.get(page) ?? { commits: [], groups: [], hasMore: false };
399
+ }
400
+
401
+ function getVisibleCommits(): CommitInfo[] {
402
+ const { commits } = getCurrentPageData();
403
+ if (!filter) return commits;
404
+ const f = filter.toLowerCase();
405
+ return commits.filter(
406
+ (c) => c.subject.toLowerCase().includes(f) || c.shortHash.toLowerCase().includes(f),
407
+ );
408
+ }
409
+
410
+ function render(width: number): string[] {
411
+ const currentData = getCurrentPageData();
412
+ const visibleCommits = getVisibleCommits();
413
+ const lines: string[] = [];
414
+
415
+ // Clamp cursor index if visible list changed
416
+ if (cursor.kind === "commit") {
417
+ if (visibleCommits.length === 0) {
418
+ cursor = { kind: "run" };
419
+ } else if (cursor.index >= visibleCommits.length) {
420
+ cursor = { kind: "commit", index: visibleCommits.length - 1 };
421
+ lastCommitIndex = cursor.index;
422
+ }
423
+ }
424
+
425
+ // Header
426
+ const startNum = page * COMMIT_BROWSER_MAX + 1;
427
+ const endNum = page * COMMIT_BROWSER_MAX + (visibleCommits.length || 0);
428
+ const pageLabel = currentData.hasMore
429
+ ? `Page ${page + 1} (${startNum}–${endNum}+)`
430
+ : `Page ${page + 1} (${startNum}–${endNum})`;
431
+
432
+ lines.push(
433
+ theme.fg(
434
+ "muted",
435
+ truncateToWidth(
436
+ ` # DATE HASH FILES ORDER SUBJECT [${pageLabel}]`,
437
+ width - 2,
438
+ "",
439
+ ),
440
+ ),
441
+ );
442
+
443
+ if (isLoading) {
444
+ lines.push(theme.fg("accent", " Loading commits from git…"));
445
+ } else if (visibleCommits.length === 0) {
446
+ lines.push(
447
+ filter
448
+ ? theme.fg("warning", ` No commits matching "${filter}" on this page`)
449
+ : theme.fg("muted", " No commits found"),
450
+ );
451
+ } else {
452
+ const anchor = cursor.kind === "commit" ? cursor.index : lastCommitIndex;
453
+ const winStart = visibleCommits.length > maxVisible
454
+ ? Math.max(0, Math.min(anchor + 1 - maxVisible, visibleCommits.length - maxVisible))
455
+ : 0;
456
+ const winEnd = Math.min(winStart + maxVisible, visibleCommits.length);
457
+
458
+ for (let i = winStart; i < winEnd; i++) {
459
+ const c = visibleCommits[i];
460
+ // Show date divider if it's the first commit in window or date changed
461
+ if (i === winStart || c.date !== visibleCommits[i - 1].date) {
462
+ lines.push(theme.fg("dim", ` ── ${c.date} ──`));
463
+ }
464
+
465
+ const isCursor = cursor.kind === "commit" && cursor.index === i;
466
+ const isSelected = selectedMap.has(c.hash);
467
+ const order = isSelected ? [...selectedMap.keys()].indexOf(c.hash) + 1 : 0;
468
+ const cursorMark = isCursor ? theme.fg("accent", "►") : " ";
469
+ const globalIdx = page * COMMIT_BROWSER_MAX + i + 1;
470
+ const num = `${cursorMark} ${String(globalIdx).padStart(2)}`;
471
+ const marker = isSelected ? `${theme.fg("accent", "◉")} [${order}]` : " · ";
472
+ const hash = theme.fg("dim", c.shortHash.slice(0, 7));
473
+ const statStr = c.filesChanged > 0 ? `${c.filesChanged}f` : "";
474
+ const statCell = statStr.padStart(3);
475
+ const dateShort = c.date.slice(5); // MM-DD
476
+ const subject = truncateToWidth(c.subject, Math.max(10, width - 36), "…");
477
+
478
+ const rowText = `${num} ${dateShort} ${hash} ${statCell} ${marker} ${subject}`;
479
+ if (isCursor) {
480
+ lines.push(theme.bg("selectedBg", theme.bold(rowText)));
481
+ } else {
482
+ lines.push(rowText);
483
+ }
484
+ }
485
+
486
+ if (visibleCommits.length > maxVisible) {
487
+ lines.push(
488
+ theme.fg(
489
+ "dim",
490
+ truncateToWidth(
491
+ ` (${cursor.kind === "commit" ? cursor.index + 1 : lastCommitIndex + 1}/${visibleCommits.length} on page ${page + 1})`,
492
+ width - 2,
493
+ "",
494
+ ),
495
+ ),
496
+ );
497
+ }
498
+ }
499
+
500
+ // Run row
501
+ lines.push("");
502
+ lines.push(theme.fg("dim", " " + "─".repeat(Math.max(1, width - 4))));
503
+ const runLabel = selectedMap.size === 0
504
+ ? "do on the commits (no commits selected)"
505
+ : `do on the commits (${selectedMap.size} commit${selectedMap.size !== 1 ? "s" : ""})`;
506
+ if (cursor.kind === "run") {
507
+ lines.push(theme.bg("selectedBg", theme.bold(`${theme.fg("accent", "►")} ${runLabel}`)));
508
+ } else if (selectedMap.size === 0) {
509
+ lines.push(theme.fg("dim", ` ${runLabel}`));
510
+ } else {
511
+ lines.push(theme.fg("accent", ` ${runLabel}`));
512
+ }
513
+
514
+ // Footer
515
+ const pageNavHints: string[] = [];
516
+ if (page > 0) pageNavHints.push("← prev page");
517
+ if (currentData.hasMore) pageNavHints.push("→ next page");
518
+ const pageHintStr = pageNavHints.length > 0 ? ` • ${pageNavHints.join(" • ")}` : "";
519
+
520
+ let footer = ` space/⏎ select • ↑/↓ move${pageHintStr} • esc`;
521
+ if (selectedMap.size > 0) {
522
+ footer += " • ctrl+u clear";
523
+ }
524
+ if (filter) {
525
+ footer += ` • filter: "${filter}"`;
526
+ }
527
+ lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
528
+
529
+ return lines;
530
+ }
531
+
532
+ async function handleInput(data: string) {
533
+ if (settled) return;
534
+ const currentData = getCurrentPageData();
535
+ const visibleCommits = getVisibleCommits();
536
+
537
+ // Space: toggle selection on current commit
538
+ if (matchesKey(data, "space")) {
539
+ if (cursor.kind === "commit" && visibleCommits[cursor.index]) {
540
+ const c = visibleCommits[cursor.index];
541
+ if (selectedMap.has(c.hash)) {
542
+ selectedMap.delete(c.hash);
543
+ let i = 1;
544
+ for (const sc of selectedMap.values()) {
545
+ sc.selectionOrder = i++;
546
+ }
547
+ } else if (selectedMap.size < COMMIT_SELECT_MAX) {
548
+ selectedMap.set(c.hash, { ...c, selectionOrder: selectedMap.size + 1 });
549
+ } else {
550
+ ctx.ui.notify(`do-always: chain is full (${COMMIT_SELECT_MAX}) — remove a commit first`, "error");
551
+ }
552
+ tui.requestRender();
553
+ }
554
+ return;
555
+ }
556
+
557
+ // Confirm (Enter): run the chain if on run row, or toggle selection if on a commit
558
+ if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
559
+ if (cursor.kind === "run") {
560
+ if (selectedMap.size === 0) {
561
+ ctx.ui.notify("do-always: no commits selected", "info");
562
+ } else {
563
+ finish([...selectedMap.values()]);
564
+ }
565
+ return;
566
+ }
567
+ if (cursor.kind === "commit" && visibleCommits[cursor.index]) {
568
+ const c = visibleCommits[cursor.index];
569
+ if (selectedMap.has(c.hash)) {
570
+ selectedMap.delete(c.hash);
571
+ let i = 1;
572
+ for (const sc of selectedMap.values()) {
573
+ sc.selectionOrder = i++;
574
+ }
575
+ } else if (selectedMap.size < COMMIT_SELECT_MAX) {
576
+ selectedMap.set(c.hash, { ...c, selectionOrder: selectedMap.size + 1 });
577
+ } else {
578
+ ctx.ui.notify(`do-always: chain is full (${COMMIT_SELECT_MAX}) — remove a commit first`, "error");
579
+ }
580
+ tui.requestRender();
581
+ }
582
+ return;
583
+ }
584
+
585
+ // Cancel (Esc / Ctrl+C)
586
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
587
+ finishCancel();
588
+ return;
589
+ }
590
+
591
+ // Ctrl+U: clear selections
592
+ if (matchesKey(data, "ctrl+u")) {
593
+ if (selectedMap.size > 0) {
594
+ selectedMap.clear();
595
+ tui.requestRender();
596
+ }
597
+ return;
598
+ }
599
+
600
+ // Backspace: delete character from filter, or undo last selection
601
+ if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "backspace")) {
602
+ if (filter.length > 0) {
603
+ filter = filter.slice(0, -1);
604
+ cursor = { kind: "commit", index: 0 };
605
+ lastCommitIndex = 0;
606
+ } else if (selectedMap.size > 0) {
607
+ const lastHash = [...selectedMap.keys()].pop()!;
608
+ selectedMap.delete(lastHash);
609
+ }
610
+ tui.requestRender();
611
+ return;
612
+ }
613
+
614
+ // Page navigation: Next page (Right arrow, or PageDown when at the end)
615
+ if (matchesKey(data, "right")) {
616
+ if (currentData.hasMore) {
617
+ await loadPage(page + 1);
618
+ }
619
+ return;
620
+ }
621
+ if (kb.matches(data, "tui.select.pageDown") || matchesKey(data, "pageDown")) {
622
+ if (cursor.kind === "commit") {
623
+ const next = cursor.index + maxVisible;
624
+ if (next < visibleCommits.length) {
625
+ cursor = { kind: "commit", index: next };
626
+ lastCommitIndex = next;
627
+ tui.requestRender();
628
+ } else if (currentData.hasMore) {
629
+ await loadPage(page + 1);
630
+ } else {
631
+ cursor = { kind: "run" };
632
+ tui.requestRender();
633
+ }
634
+ } else if (currentData.hasMore) {
635
+ await loadPage(page + 1);
636
+ }
637
+ return;
638
+ }
639
+
640
+ // Page navigation: Previous page (Left arrow, or PageUp when at the top)
641
+ if (matchesKey(data, "left")) {
642
+ if (page > 0) {
643
+ await loadPage(page - 1);
644
+ }
645
+ return;
646
+ }
647
+ if (kb.matches(data, "tui.select.pageUp") || matchesKey(data, "pageUp")) {
648
+ if (cursor.kind === "commit") {
649
+ const prev = cursor.index - maxVisible;
650
+ if (prev >= 0) {
651
+ cursor = { kind: "commit", index: prev };
652
+ lastCommitIndex = prev;
653
+ tui.requestRender();
654
+ } else if (page > 0) {
655
+ await loadPage(page - 1);
656
+ } else {
657
+ cursor = { kind: "commit", index: 0 };
658
+ lastCommitIndex = 0;
659
+ tui.requestRender();
660
+ }
661
+ } else if (visibleCommits.length > 0) {
662
+ cursor = { kind: "commit", index: Math.max(0, visibleCommits.length - maxVisible) };
663
+ lastCommitIndex = cursor.index;
664
+ tui.requestRender();
665
+ }
666
+ return;
667
+ }
668
+
669
+ // Up arrow
670
+ if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
671
+ if (cursor.kind === "run") {
672
+ if (visibleCommits.length > 0) {
673
+ cursor = { kind: "commit", index: visibleCommits.length - 1 };
674
+ lastCommitIndex = visibleCommits.length - 1;
675
+ }
676
+ } else if (cursor.index > 0) {
677
+ cursor = { kind: "commit", index: cursor.index - 1 };
678
+ lastCommitIndex = cursor.index;
679
+ } else {
680
+ cursor = { kind: "run" };
681
+ }
682
+ tui.requestRender();
683
+ return;
684
+ }
685
+
686
+ // Down arrow
687
+ if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
688
+ if (cursor.kind === "run") {
689
+ if (visibleCommits.length > 0) {
690
+ cursor = { kind: "commit", index: 0 };
691
+ lastCommitIndex = 0;
692
+ }
693
+ } else if (cursor.index < visibleCommits.length - 1) {
694
+ cursor = { kind: "commit", index: cursor.index + 1 };
695
+ lastCommitIndex = cursor.index;
696
+ } else {
697
+ cursor = { kind: "run" };
698
+ }
699
+ tui.requestRender();
700
+ return;
701
+ }
702
+
703
+ // Home / End
704
+ if (matchesKey(data, "home")) {
705
+ if (visibleCommits.length > 0) {
706
+ cursor = { kind: "commit", index: 0 };
707
+ lastCommitIndex = 0;
708
+ tui.requestRender();
709
+ }
710
+ return;
711
+ }
712
+ if (matchesKey(data, "end")) {
713
+ cursor = { kind: "run" };
714
+ tui.requestRender();
715
+ return;
716
+ }
717
+
718
+ // Filter typing (printable chars, excluding Space which toggles)
719
+ if (isPrintable(data) && data !== " ") {
720
+ filter += data;
721
+ cursor = { kind: "commit", index: 0 };
722
+ lastCommitIndex = 0;
723
+ tui.requestRender();
724
+ return;
725
+ }
726
+ }
727
+
728
+ return {
729
+ render,
730
+ handleInput,
731
+ invalidate: () => {},
732
+ };
733
+ });
734
+ });
735
+ }
736
+
259
737
  /** True for a single printable ASCII character (used for filter typing). */
260
738
  function isPrintable(data: string): boolean {
261
739
  return data.length === 1 && data >= " " && data <= "~";
262
740
  }
263
741
 
742
+ /**
743
+ * Task picker shown after a commit selection: proposes the Plan tasks (hidden
744
+ * ones included — e.g. "Review commits" exists to be picked here) to run on
745
+ * the selected commits. Type to filter, ↑/↓ move, Enter runs, Esc cancels.
746
+ * Returns the chosen task, or null on cancel.
747
+ */
748
+ function pickPlanTaskForCommits(
749
+ ctx: ExtensionContext,
750
+ candidates: DoAlwaysTask[],
751
+ commitCount: number,
752
+ ): Promise<DoAlwaysTask | null> {
753
+ return new Promise<DoAlwaysTask | null>((resolve) => {
754
+ ctx.ui.custom<DoAlwaysTask | null>((tui, theme, _kb, done) => {
755
+ const kb = getKeybindings();
756
+ let settled = false;
757
+ let filter = "";
758
+ let cursor = 0;
759
+
760
+ const visible = () => {
761
+ if (!filter) return candidates;
762
+ const f = filter.toLowerCase();
763
+ return candidates.filter(
764
+ (t) =>
765
+ t.name.toLowerCase().includes(f) ||
766
+ (t.description ?? "").toLowerCase().includes(f),
767
+ );
768
+ };
769
+
770
+ function finish(task: DoAlwaysTask | null) {
771
+ if (settled) return;
772
+ settled = true;
773
+ done(task);
774
+ resolve(task);
775
+ }
776
+
777
+ function render(width: number): string[] {
778
+ const items = visible();
779
+ if (items.length === 0) cursor = 0;
780
+ else if (cursor >= items.length) cursor = items.length - 1;
781
+ const lines: string[] = [];
782
+ lines.push(
783
+ theme.fg(
784
+ "accent",
785
+ theme.bold(
786
+ truncateToWidth(
787
+ ` do-always — run on ${commitCount} selected commit${commitCount !== 1 ? "s" : ""}`,
788
+ width - 2,
789
+ "",
790
+ ),
791
+ ),
792
+ ),
793
+ );
794
+ lines.push(theme.fg("muted", " # TASK"));
795
+ if (items.length === 0) {
796
+ lines.push(theme.fg("warning", ` No Plan tasks matching "${filter}"`));
797
+ } else {
798
+ for (let i = 0; i < items.length; i++) {
799
+ const t = items[i];
800
+ const auto = shouldAutoRun(t);
801
+ const name = truncateToWidth(t.name, Math.max(10, width - 8 - (auto ? 3 : 0)), "…", true);
802
+ const rowText = ` ${String(i + 1).padStart(2)} ${auto ? "⚡ " : ""}${name}`;
803
+ lines.push(i === cursor ? theme.bg("selectedBg", theme.bold(rowText)) : rowText);
804
+ }
805
+ }
806
+ lines.push(
807
+ theme.fg(
808
+ "dim",
809
+ truncateToWidth(
810
+ ` ↑/↓ move • ⏎ run • esc${filter ? ` • filter: "${filter}"` : ""}`,
811
+ width - 2,
812
+ "",
813
+ ),
814
+ ),
815
+ );
816
+ return lines;
817
+ }
818
+
819
+ function handleInput(data: string) {
820
+ if (settled) return;
821
+ const items = visible();
822
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
823
+ finish(null);
824
+ return;
825
+ }
826
+ if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
827
+ finish(items[cursor] ?? null);
828
+ return;
829
+ }
830
+ if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
831
+ if (items.length > 0) cursor = cursor === 0 ? items.length - 1 : cursor - 1;
832
+ tui.requestRender();
833
+ return;
834
+ }
835
+ if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
836
+ if (items.length > 0) cursor = cursor === items.length - 1 ? 0 : cursor + 1;
837
+ tui.requestRender();
838
+ return;
839
+ }
840
+ if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "backspace")) {
841
+ filter = filter.slice(0, -1);
842
+ cursor = 0;
843
+ tui.requestRender();
844
+ return;
845
+ }
846
+ if (isPrintable(data)) {
847
+ filter += data;
848
+ cursor = 0;
849
+ tui.requestRender();
850
+ }
851
+ }
852
+
853
+ return { render, handleInput, invalidate: () => {} };
854
+ });
855
+ });
856
+ }
857
+
264
858
  /** Delay before the selector reveals the selected task's prompt preview. */
265
859
  const PREVIEW_DELAY_MS = 2000;
266
860
  /** Max lines of the prompt shown in the selector preview. */
267
861
  const PREVIEW_MAX_LINES = 3;
268
862
 
269
863
  /** Outcome of one chain step's run (see sendAndWait). */
270
- type ChainStepOutcome = "completed" | "aborted" | "error" | "failed-to-start";
271
-
272
864
  /**
273
865
  * Status of one chain step for the below-prompt status widget: pending
274
866
  * (not reached yet), running (its turn is in flight), waiting (fill-first:
@@ -319,6 +911,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
319
911
  resolve: (outcome: ChainStepOutcome) => void;
320
912
  /** Which chain step (0-based) this waiter belongs to — for the report. */
321
913
  stepIndex: number;
914
+ startTime?: number;
322
915
  } | null = null;
323
916
 
324
917
  function settleChainWaiter(outcome: ChainStepOutcome) {
@@ -326,6 +919,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
326
919
  const waiter = chainWaiter;
327
920
  chainWaiter = null;
328
921
  if (waiter.timer) clearTimeout(waiter.timer);
922
+ if (waiter.startTime !== undefined) {
923
+ chainDurations[waiter.stepIndex] = performance.now() - waiter.startTime;
924
+ }
329
925
  waiter.resolve(outcome);
330
926
  }
331
927
 
@@ -343,18 +939,49 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
343
939
  // their event waiters (the old chain's sendAndWait would resolve on the
344
940
  // new chain's step), so starting one is refused until the first ends.
345
941
  let chainActive = false;
942
+ // Per-step durations (ms) for the chain-end summary. Indexed by step
943
+ // position; 0 means the step was not timed (e.g. blocked before running).
944
+ let chainDurations: number[] = [];
945
+ // For single auto-run tasks: the task name whose summary we post after
946
+ // agent_end. Only set when a Plan task (auto-run) is sent via
947
+ // pi.sendUserMessage (which does NOT arm a chainWaiter). The grace timer
948
+ // mirrors the chain's failed-to-start handling: a send that fails before
949
+ // the run starts emits no agent events at all, so without it the stale
950
+ // flag would post a spurious summary for the next unrelated turn.
951
+ let pendingSummaryTask: string | null = null;
952
+ let pendingSummaryTimer: NodeJS.Timeout | null = null;
953
+
954
+ /** Clear the auto-run summary flag and its grace timer (session start). */
955
+ function resetPendingSummary(): void {
956
+ pendingSummaryTask = null;
957
+ if (pendingSummaryTimer) {
958
+ clearTimeout(pendingSummaryTimer);
959
+ pendingSummaryTimer = null;
960
+ }
961
+ }
346
962
  // Whether chain runs write a Markdown report file (config `report`,
347
963
  // default true). Refreshed whenever the config is (re)loaded.
348
964
  let reportEnabled = true;
349
965
  // The in-flight chain's report file: its path (absolute + relative for
350
- // display), when the current step's run actually started (agent_start;
351
- // null until then and for failed-to-start steps), whether the footer has
352
- // been appended, the index of the last step section written (dedupes
353
- // retried runs), and whether any step section carried result text (the
354
- // file is worth keeping).
966
+ // display), the precomputed header (deferred — written together with the
967
+ // first step section, so a chain that dies before that leaves no
968
+ // header-only file behind), whether the file exists on disk yet, when the
969
+ // current step's run actually started (agent_start; null until then and
970
+ // for failed-to-start steps), whether the footer has been appended, the
971
+ // index of the last step section written (dedupes retried runs), and
972
+ // whether any step section carried result text (the file is worth
973
+ // keeping).
974
+ // Max in-memory sections kept for inline display; the file on disk retains
975
+ // all steps. Bounded to avoid holding 400KB–2MB of assistant text per chain.
976
+ const MAX_INLINE_SECTIONS = 3;
977
+
355
978
  let chainReport: {
356
979
  path: string;
357
980
  display: string;
981
+ /** Precomputed header; written together with the first section. */
982
+ header: string;
983
+ /** True once the file exists on disk (header written). */
984
+ written: boolean;
358
985
  stepStartedAt: Date | null;
359
986
  /** True once the summary (or abandoned) footer has been appended. */
360
987
  footerWritten: boolean;
@@ -362,8 +989,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
362
989
  lastStepSection: number;
363
990
  /** True once a step section with result text was appended. */
364
991
  hasContent: boolean;
365
- /** In-memory section data for inline display (populated in agent_end). */
366
- sections: Array<{ name: string; outcome: string; text: string }>;
992
+ /**
993
+ * In-memory section data for inline display (populated in agent_end,
994
+ * bounded to the last MAX_INLINE_SECTIONS). `index` is the step's
995
+ * position in the chain (retries share it); `markdown` is the formatted section.
996
+ */
997
+ sections: Array<{ index: number; markdown: string }>;
367
998
  } | null = null;
368
999
 
369
1000
  /** Status marker glyph (all one column wide) with its color. */
@@ -425,7 +1056,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
425
1056
  * removes the status widget so nothing lingers below the prompt; otherwise
426
1057
  * keeps it as a trace with the report path in the note.
427
1058
  */
428
- function finishReport(ctx: ExtensionContext): void {
1059
+ /**
1060
+ * Write the deferred report header so the file exists before the first
1061
+ * section/footer append. Returns false when there is no report or the
1062
+ * write failed (a warning was shown); the chain runs on without a file.
1063
+ */
1064
+ function ensureReportFile(ctx: ExtensionContext | null): boolean {
1065
+ const report = chainReport;
1066
+ if (!report || report.written) return report !== null;
1067
+ try {
1068
+ writeFileSync(report.path, report.header);
1069
+ report.written = true;
1070
+ return true;
1071
+ } catch (err) {
1072
+ ctx?.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
1073
+ return false;
1074
+ }
1075
+ }
1076
+
1077
+ async function finishReport(ctx: ExtensionContext): Promise<void> {
429
1078
  if (!chainReport || !chainStatus) return;
430
1079
  const statuses = chainStatus.steps.map((s) => s.status);
431
1080
  // Nothing worth keeping — remove the (mostly) empty file so a quick
@@ -435,19 +1084,21 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
435
1084
  chainReport = null;
436
1085
  return;
437
1086
  }
438
- try {
439
- appendFileSync(chainReport.path, reportFooter(statuses, new Date()));
440
- chainReport.footerWritten = true;
441
- } catch (err) {
442
- ctx.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
1087
+ if (ensureReportFile(ctx)) {
1088
+ try {
1089
+ appendFileSync(chainReport.path, reportFooter(statuses, new Date()));
1090
+ chainReport.footerWritten = true;
1091
+ } catch (err) {
1092
+ ctx.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
1093
+ }
443
1094
  }
444
1095
  const prev = chainStatus.note ? `${chainStatus.note} • ` : "";
445
1096
  chainStatus.note = `${prev}📄 ${chainReport.display}`;
446
- // Chain fully done → show inline report above editor, then clear the
1097
+ // Chain fully done → show the inline report, then clear the
447
1098
  // trace widget. Chain stopped early → keep the trace widget.
448
1099
  const allDone = statuses.every((s) => s === "completed");
449
1100
  if (allDone) {
450
- showInlineReport();
1101
+ await showInlineReport(ctx);
451
1102
  clearChainWidget(ctx);
452
1103
  } else {
453
1104
  updateChainWidget(ctx);
@@ -455,63 +1106,69 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
455
1106
  }
456
1107
 
457
1108
  /**
458
- * Send the full chain report as a markdown message in the chat when a chain
459
- * completes. Built from in-memory section data (no file read needed).
460
- * Auto-cleared on the next agent_start / session_start.
1109
+ * Show the full chain report in an ephemeral editor view when a chain
1110
+ * completes (TUI only). Built from in-memory section data (no file read
1111
+ * needed). Nothing is persisted to the session — the view closes with
1112
+ * Esc and the report file on disk is the permanent artifact. In non-TUI
1113
+ * modes the editor is a no-op; the final notification carries the file
1114
+ * path.
461
1115
  */
462
- function showInlineReport(): void {
1116
+ async function showInlineReport(ctx: ExtensionContext): Promise<void> {
1117
+ if (ctx.mode !== "tui") return;
463
1118
  if (!chainReport || !chainStatus || chainReport.sections.length === 0) return;
464
- // Reconstruct the full markdown report from in-memory sections.
1119
+ // Reconstruct the report from in-memory sections. Only the last
1120
+ // MAX_INLINE_SECTIONS are kept (the file on disk has all of them), so
1121
+ // flag the omission when a step has no section in the window.
1122
+ const shownSteps = new Set(chainReport.sections.map((s) => s.index));
1123
+ const omitted = chainStatus.steps.length - shownSteps.size;
465
1124
  const lines: string[] = [];
466
1125
  lines.push(`# do-always chain report — ${new Date().toISOString().slice(0, 10)}`);
467
1126
  lines.push("");
468
1127
  lines.push(`- Project: ${chainReport.display}`);
469
1128
  lines.push(`- Steps: ${chainStatus.steps.map((s) => s.name).join(" → ")}`);
470
1129
  lines.push("");
471
- for (const sec of chainReport.sections) {
472
- const section = reportStepSection(
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);
1130
+ if (omitted > 0) {
1131
+ lines.push(`> … ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted — see ${chainReport.display}`);
481
1132
  lines.push("");
482
1133
  }
1134
+ for (const sec of chainReport.sections) {
1135
+ lines.push(sec.markdown);
1136
+ }
483
1137
  // Footer.
484
1138
  const statuses = chainStatus.steps.map((s) => s.status);
485
1139
  lines.push(reportFooter(statuses, new Date()));
486
1140
 
487
- // Send as a markdown message in the chat (renders as markdown, not plain text).
488
- pi.sendMessage({
489
- customType: "do-always-report",
490
- content: lines.join("\n"),
491
- display: true,
492
- details: chainReport.display,
493
- });
1141
+ // Ephemeral editor view: read the report, Esc to dismiss. Unlike
1142
+ // pi.sendMessage this persists nothing to the session.
1143
+ await ctx.ui.editor("do-always — chain report", lines.join("\n"));
494
1144
  }
495
1145
 
496
1146
  /**
497
1147
  * Close an in-flight report that never reached a terminal path (e.g.,
498
1148
  * the session ended mid-chain): delete the file when the run produced
499
- * nothing worth keeping, otherwise append an "abandoned" footer so it
500
- * does not stay header-only on disk.
1149
+ * nothing worth keeping (a no-op when the deferred header was never
1150
+ * written), otherwise append an "abandoned" footer so it does not stay
1151
+ * header-only on disk.
501
1152
  */
502
1153
  function closeAbandonedReport(statuses: string[]): void {
503
1154
  if (!chainReport || chainReport.footerWritten) return;
504
1155
  if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
505
- // Nothing worth keeping — remove the (mostly) empty file.
506
- try {
507
- unlinkSync(chainReport.path);
508
- } catch {
509
- // Best effort — the file stays on disk.
1156
+ // Nothing worth keeping — remove the file if it was written.
1157
+ if (chainReport.written) {
1158
+ try {
1159
+ unlinkSync(chainReport.path);
1160
+ } catch {
1161
+ // Best effort — the file stays on disk.
1162
+ }
510
1163
  }
511
1164
  return;
512
1165
  }
513
1166
  try {
514
- appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
1167
+ if (chainReport.written) {
1168
+ appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
1169
+ } else {
1170
+ writeFileSync(chainReport.path, chainReport.header + reportAbandonedFooter(statuses, new Date()));
1171
+ }
515
1172
  } catch {
516
1173
  // Best effort — the report file stays as-is on disk.
517
1174
  }
@@ -565,7 +1222,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
565
1222
  }
566
1223
 
567
1224
  pi.on("agent_start", () => {
568
- if (chainWaiter) chainWaiter.started = true;
1225
+ if (chainWaiter) {
1226
+ chainWaiter.started = true;
1227
+ chainWaiter.startTime = performance.now();
1228
+ }
1229
+ // The run actually began — cancel the auto-run task's failed-to-start
1230
+ // grace timer (its summary posts at agent_end).
1231
+ if (pendingSummaryTimer) {
1232
+ clearTimeout(pendingSummaryTimer);
1233
+ pendingSummaryTimer = null;
1234
+ }
569
1235
  // The run actually began — time the step for the report.
570
1236
  if (chainReport) chainReport.stepStartedAt = new Date();
571
1237
  // Fill-first: step 1 left the editor and is running — update the
@@ -579,12 +1245,47 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
579
1245
  clearChainWidget(lastCtx);
580
1246
  }
581
1247
  });
1248
+ /** The turn's last assistant message (backward scan — no copy/reverse). */
1249
+ function lastAssistantMessage(messages: AgentEndEvent["messages"]) {
1250
+ for (let i = messages.length - 1; i >= 0; i--) {
1251
+ const m = messages[i];
1252
+ if (m.role === "assistant") return m;
1253
+ }
1254
+ return null;
1255
+ }
1256
+
1257
+ /** Map an assistant message's stopReason to a chain step outcome. */
1258
+ function outcomeFromStopReason(stopReason: string | undefined): "completed" | "aborted" | "error" {
1259
+ if (stopReason === "aborted") return "aborted";
1260
+ if (stopReason === "error") return "error";
1261
+ return "completed";
1262
+ }
1263
+
582
1264
  pi.on("agent_end", (event) => {
1265
+ // The agent may have changed the repo — drop the TTL context cache so
1266
+ // the next action sees the new tree (a running chain keeps its own
1267
+ // per-action snapshot and is unaffected).
1268
+ contextCache = null;
1269
+ // Single auto-run task summary (no chainWaiter: pi.sendUserMessage is
1270
+ // fire-and-forget, so no armWaiter is called).
1271
+ if (!chainWaiter && pendingSummaryTask) {
1272
+ if (pendingSummaryTimer) {
1273
+ clearTimeout(pendingSummaryTimer);
1274
+ pendingSummaryTimer = null;
1275
+ }
1276
+ const lastAssistant = lastAssistantMessage(event.messages);
1277
+ if (lastAssistant) {
1278
+ const outcome = outcomeFromStopReason(lastAssistant.stopReason);
1279
+ const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
1280
+ lastCtx?.ui.notify(`do-always: ${summary}`, "info");
1281
+ }
1282
+ pendingSummaryTask = null;
1283
+ return;
1284
+ }
583
1285
  if (!chainWaiter) return;
584
- const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant");
1286
+ const lastAssistant = lastAssistantMessage(event.messages);
585
1287
  if (lastAssistant) {
586
- const stopReason = lastAssistant.stopReason;
587
- const outcome = stopReason === "aborted" ? "aborted" : stopReason === "error" ? "error" : "completed";
1288
+ const outcome = outcomeFromStopReason(lastAssistant.stopReason);
588
1289
  chainWaiter.outcome = outcome;
589
1290
  // Append this step's result to the report while the transcript is
590
1291
  // fresh (the step's final assistant message is its result).
@@ -595,23 +1296,30 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
595
1296
  // report shows both attempts without a duplicate heading.
596
1297
  const isRetry = chainReport.lastStepSection === idx;
597
1298
  const name = chainStatus.steps[idx]?.name ?? `step ${idx + 1}`;
1299
+ const displayName = isRetry ? `${name} (retry)` : name;
598
1300
  const text = assistantText(lastAssistant.content);
599
1301
  const section = reportStepSection(
600
1302
  idx,
601
- isRetry ? `${name} (retry)` : name,
1303
+ displayName,
602
1304
  outcome,
603
1305
  chainReport.stepStartedAt,
604
1306
  new Date(),
605
1307
  text,
606
1308
  );
607
- try {
608
- appendFileSync(chainReport.path, section);
609
- chainReport.lastStepSection = idx;
610
- if (text.trim() !== "") chainReport.hasContent = true;
611
- // Keep in-memory section data for inline display.
612
- chainReport.sections.push({ name, outcome, text });
613
- } catch (err) {
614
- lastCtx?.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
1309
+ if (ensureReportFile(lastCtx)) {
1310
+ try {
1311
+ appendFileSync(chainReport.path, section);
1312
+ chainReport.lastStepSection = idx;
1313
+ if (text.trim() !== "") chainReport.hasContent = true;
1314
+ // Keep in-memory section data for inline display (bounded);
1315
+ // index lets it number sections like the file.
1316
+ chainReport.sections.push({ index: idx, markdown: section });
1317
+ if (chainReport.sections.length > MAX_INLINE_SECTIONS) {
1318
+ chainReport.sections.splice(0, chainReport.sections.length - MAX_INLINE_SECTIONS);
1319
+ }
1320
+ } catch (err) {
1321
+ lastCtx?.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
1322
+ }
615
1323
  }
616
1324
  chainReport.stepStartedAt = null;
617
1325
  }
@@ -651,9 +1359,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
651
1359
  return done;
652
1360
  }
653
1361
 
654
- /** Filter tasks by their `when` condition and refresh the completion cache. */
1362
+ /** Filter tasks by visibility (`hidden` flag + `when` condition) and refresh the completion cache. */
655
1363
  function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
656
- const visible = tasks.filter((t) => evaluateWhen(t, context));
1364
+ const visible = tasks.filter((t) => isTaskVisible(t, context));
657
1365
  visibleCache = { cwd, visible };
658
1366
  return visible;
659
1367
  }
@@ -677,7 +1385,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
677
1385
  });
678
1386
  }
679
1387
 
680
- pi.on("session_start", (_event, ctx) => {
1388
+ pi.on("session_start", async (_event, ctx) => {
681
1389
  lastCtx = ctx;
682
1390
  // Surface config validation problems (the README promises warnings);
683
1391
  // in non-TUI modes there is no UI, so fall back to the console.
@@ -688,16 +1396,157 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
688
1396
  // A failed chain's status widget is a trace of the previous session;
689
1397
  // start each session clean.
690
1398
  clearChainWidget(ctx);
1399
+ // Likewise a stale auto-run summary flag (e.g., a send that never
1400
+ // started) must not post a spurious summary for this session's turns.
1401
+ resetPendingSummary();
691
1402
  loadedCwd = ctx.cwd;
692
1403
  const config = loadConfig(ctx.cwd, onError);
693
1404
  tasks = config.tasks;
694
1405
  reportEnabled = config.report;
695
- refreshVisible(ctx.cwd, buildContext(ctx.cwd));
1406
+ refreshVisible(ctx.cwd, await getContext(ctx.cwd));
696
1407
  registerShortcut(config.shortcut, onError);
697
1408
  });
698
1409
 
1410
+ // Extension-level git context cache, keyed by cwd with a short TTL:
1411
+ // buildContext spawns git, and the repo rarely changes between actions,
1412
+ // so the session_start precompute feeds the first /do-always and
1413
+ // repeated invocations within the window reuse the same context.
1414
+ // Invalidated on agent_end (the agent may have changed the repo); a
1415
+ // running chain keeps its own per-action snapshot (createContextCache),
1416
+ // so its steps reuse one context across the run.
1417
+ let contextCache: { cwd: string; at: number; ctx: TaskContext } | null = null;
1418
+ const CONTEXT_TTL_MS = 5_000;
1419
+
1420
+ async function getContext(cwd: string): Promise<TaskContext> {
1421
+ if (contextCache && contextCache.cwd === cwd && Date.now() - contextCache.at < CONTEXT_TTL_MS) {
1422
+ return contextCache.ctx;
1423
+ }
1424
+ const ctx = await buildContext(cwd);
1425
+ contextCache = { cwd, at: Date.now(), ctx };
1426
+ return ctx;
1427
+ }
1428
+
1429
+ /**
1430
+ * Per-action context cache: the first build consults the extension-level
1431
+ * TTL cache (getContext), and later builds within the same action reuse
1432
+ * that snapshot — what the user saw in the preview is what gets injected.
1433
+ * Created fresh at each action entry point (runDoAlways, runChain).
1434
+ */
1435
+ function createContextCache(): {
1436
+ get(cwd: string): Promise<TaskContext>;
1437
+ } {
1438
+ let cached: { cwd: string; ctx: TaskContext } | null = null;
1439
+ return {
1440
+ async get(cwd: string): Promise<TaskContext> {
1441
+ if (cached && cached.cwd === cwd) return cached.ctx;
1442
+ cached = { cwd, ctx: await getContext(cwd) };
1443
+ return cached.ctx;
1444
+ },
1445
+ };
1446
+ }
1447
+
699
1448
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
700
1449
  async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
1450
+ // Commit browser: any task with `browser: "commits"` opens the
1451
+ // date-grouped browser first. The name check keeps configs that
1452
+ // predate the field working. After the selection: a task whose prompt
1453
+ // references {{selected_commits}} runs itself on the selection; a
1454
+ // generic entry (e.g. "Browse commits") proposes the Plan tasks to
1455
+ // run on it — hidden tasks included, since they exist to be picked
1456
+ // here (non-TUI: the first one whose guards pass).
1457
+ if (task.browser === "commits" || task.name === "Review commits") {
1458
+ let selected: SelectedCommit[] = [];
1459
+ if (ctx.mode !== "tui") {
1460
+ const initialData = await fetchCommitsPage(ctx.cwd, 0, 1);
1461
+ if (initialData.commits.length === 0) {
1462
+ ctx.ui.notify("do-always: no commits found", "info");
1463
+ return;
1464
+ }
1465
+ selected = [{ ...initialData.commits[0], selectionOrder: 1 }];
1466
+ } else {
1467
+ const initialData = await fetchCommitsPage(ctx.cwd, 0);
1468
+ if (initialData.commits.length === 0) {
1469
+ ctx.ui.notify("do-always: no commits found", "info");
1470
+ return;
1471
+ }
1472
+ const result = await browseCommits(ctx, initialData);
1473
+ if (!result) {
1474
+ ctx.ui.notify("do-always: commit browser cancelled", "info");
1475
+ return;
1476
+ }
1477
+ if (result.length === 0) {
1478
+ ctx.ui.notify("do-always: no commits selected", "info");
1479
+ return;
1480
+ }
1481
+ selected = result;
1482
+ }
1483
+
1484
+ // Which task runs on the selection: the originating task when its
1485
+ // prompt consumes {{selected_commits}}; otherwise a Plan task
1486
+ // picked by the user (TUI) or the first whose guards pass (non-TUI).
1487
+ const planTasks = tasks.filter(
1488
+ (t) =>
1489
+ !t.notForCommits &&
1490
+ evaluateWhen(t, context) &&
1491
+ (t.category ?? "").trim().toLowerCase() === "plan",
1492
+ );
1493
+ if (planTasks.length === 0) {
1494
+ ctx.ui.notify("do-always: no Plan task available to run on the selected commits", "info");
1495
+ return;
1496
+ }
1497
+ let chosen: DoAlwaysTask | undefined;
1498
+ if (/\{\{\s*selected_commits\s*\}\}/.test(task.prompt)) {
1499
+ chosen = task;
1500
+ } else if (ctx.mode === "tui") {
1501
+ const picked = await pickPlanTaskForCommits(ctx, planTasks, selected.length);
1502
+ if (!picked) {
1503
+ ctx.ui.notify("do-always: no task chosen — commits not used", "info");
1504
+ return;
1505
+ }
1506
+ chosen = picked;
1507
+ } else {
1508
+ // Non-TUI: no picker — the first Plan task whose guards pass.
1509
+ chosen = planTasks.find((t) => evaluateGuards(t, context) === null);
1510
+ }
1511
+ if (!chosen) {
1512
+ ctx.ui.notify("do-always: no Plan task available to run on the selected commits (guards unmet)", "info");
1513
+ return;
1514
+ }
1515
+
1516
+ // Guards apply to the commit run too: a picked (or originating)
1517
+ // task blocked for the current tree is not sent — same as the
1518
+ // non-browser path below.
1519
+ const blocked = evaluateGuards(chosen, context);
1520
+ if (blocked) {
1521
+ ctx.ui.notify(`do-always: ${blocked}`, "info");
1522
+ return;
1523
+ }
1524
+
1525
+ // Inject the selection: substitute {{selected_commits}} when the
1526
+ // prompt references it, otherwise append the detail block. The run
1527
+ // is a single turn (not a chain): sendUserMessage + summary.
1528
+ const details = formatSelectedCommits(selected);
1529
+ const strings = toPromptContext(context);
1530
+ const prompt = /\{\{\s*selected_commits\s*\}\}/.test(chosen.prompt)
1531
+ ? renderPrompt(chosen.prompt, { ...strings, selected_commits: details })
1532
+ : `${renderPrompt(chosen.prompt, strings)}\n\nSelected commits:\n${details}`;
1533
+
1534
+ pendingSummaryTask = chosen.name;
1535
+ if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
1536
+ pendingSummaryTimer = setTimeout(() => {
1537
+ pendingSummaryTimer = null;
1538
+ if (!pendingSummaryTask) return;
1539
+ const name = pendingSummaryTask;
1540
+ pendingSummaryTask = null;
1541
+ lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
1542
+ }, 10_000);
1543
+ pi.sendUserMessage(prompt);
1544
+ ctx.ui.notify(
1545
+ `do-always: ${selected.length} commit${selected.length !== 1 ? "s" : ""} → ${chosen.name}`,
1546
+ "info",
1547
+ );
1548
+ return;
1549
+ }
701
1550
  const blocked = evaluateGuards(task, context);
702
1551
  if (blocked) {
703
1552
  ctx.ui.notify(`do-always: ${blocked}`, "info");
@@ -709,6 +1558,19 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
709
1558
  if (shouldAutoRun(task)) {
710
1559
  // Fire-and-forget: sendUserMessage returns void; the run proceeds
711
1560
  // independently (see the chain control notes for why).
1561
+ // Track the task so we can post a summary after agent_end.
1562
+ pendingSummaryTask = task.name;
1563
+ // If the run never starts (no agent events at all — same failure
1564
+ // mode the chain's grace timer handles), drop the flag so a later
1565
+ // unrelated turn can't post a spurious summary for this task.
1566
+ if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
1567
+ pendingSummaryTimer = setTimeout(() => {
1568
+ pendingSummaryTimer = null;
1569
+ if (!pendingSummaryTask) return;
1570
+ const name = pendingSummaryTask;
1571
+ pendingSummaryTask = null;
1572
+ lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
1573
+ }, 10_000);
712
1574
  pi.sendUserMessage(prompt);
713
1575
  ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
714
1576
  return;
@@ -747,33 +1609,37 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
747
1609
  return;
748
1610
  }
749
1611
  // Fail fast: report the first blocked step before sending anything.
750
- const blocked = validateChain(tasks, { items: names, history: [] }, cache.get(ctx.cwd));
1612
+ const blocked = validateChain(tasks, { items: names, history: [] }, await cache.get(ctx.cwd));
751
1613
  if (blocked) {
752
1614
  ctx.ui.notify(`do-always: chain blocked at step ${blocked.step} (${blocked.task.name}): ${blocked.message}`, "warning");
753
1615
  return;
754
1616
  }
755
1617
  chainActive = true;
756
- // Report file: one per run, in the project root, appended as each step
757
- // finishes (see the report section in tasks.ts). A write failure is not
1618
+ // Per-step durations for the chain-end summary. Re-initialized each
1619
+ // chain run so stale durations from a previous run don't leak in.
1620
+ // Pre-filled with 0 so the fill-first step (index 0, timed outside
1621
+ // runChainSteps) and any unrun steps don't leave `undefined` holes
1622
+ // that would poison the reduce with NaN.
1623
+ chainDurations = Array.from({ length: steps.length }, () => 0);
1624
+ // Report file: one per run, in the project root. The header is
1625
+ // deferred until the first step section (a chain that dies before
1626
+ // that leaves no file), and each step is appended as it finishes
1627
+ // (see the report section in tasks.ts). A write failure is not
758
1628
  // fatal — the chain still runs, just without a report.
759
1629
  if (reportEnabled) {
760
1630
  const now = new Date();
761
1631
  const path = resolveReportPath(ctx.cwd, now);
762
- try {
763
- writeFileSync(path, reportHeader(ctx.cwd, steps.map((t) => t.name), now));
764
- chainReport = {
765
- path,
766
- display: relative(ctx.cwd, path),
767
- stepStartedAt: null,
768
- footerWritten: false,
769
- lastStepSection: -1,
770
- hasContent: false,
771
- sections: [],
772
- };
773
- } catch (err) {
774
- ctx.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
775
- chainReport = null;
776
- }
1632
+ chainReport = {
1633
+ path,
1634
+ display: relative(ctx.cwd, path),
1635
+ header: reportHeader(ctx.cwd, steps.map((t) => t.name), now),
1636
+ written: false,
1637
+ stepStartedAt: null,
1638
+ footerWritten: false,
1639
+ lastStepSection: -1,
1640
+ hasContent: false,
1641
+ sections: [],
1642
+ };
777
1643
  }
778
1644
  const first = steps[0];
779
1645
  if (shouldAutoRun(first) || ctx.mode !== "tui") {
@@ -782,7 +1648,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
782
1648
  ctx,
783
1649
  steps.map((t, i) => ({ name: t.name, status: i === 0 ? "running" : "pending" })),
784
1650
  );
785
- await runChainSteps(steps, ctx, 0, cache);
1651
+ await runChainSteps(steps, ctx, 0);
786
1652
  } finally {
787
1653
  chainActive = false;
788
1654
  }
@@ -798,7 +1664,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
798
1664
  steps.map((t, i) => ({ name: t.name, status: i === 0 ? "waiting" : "pending" })),
799
1665
  "step 1 is in the editor — press Enter to start",
800
1666
  );
801
- ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(cache.get(ctx.cwd))));
1667
+ const firstContext = await cache.get(ctx.cwd);
1668
+ ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(firstContext)));
802
1669
  ctx.ui.notify(
803
1670
  `do-always: step 1 of ${steps.length} in the editor — press Enter to run; steps 2–${steps.length} follow automatically`,
804
1671
  "info",
@@ -807,12 +1674,26 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
807
1674
  try {
808
1675
  if (outcome !== "completed") {
809
1676
  markChainStopped(ctx, 0, outcome);
810
- finishReport(ctx);
1677
+ await finishReport(ctx);
811
1678
  ctx.ui.notify(`do-always: step 1 — ${outcome}; chain stopped`, "error");
812
1679
  return;
813
1680
  }
814
1681
  setChainStep(ctx, 0, "completed");
815
- await runChainSteps(steps, ctx, 1, cache);
1682
+ // Post-step summary for step 1 (fill-first). When more steps
1683
+ // follow, the fresh full build doubles as step 2's pre-context
1684
+ // (the tree can only change via agent runs, which have settled);
1685
+ // a single-task chain only needs the file count.
1686
+ let nextContext: TaskContext | undefined;
1687
+ let fileCount: number;
1688
+ if (steps.length === 1) {
1689
+ fileCount = await changedFileCount(ctx.cwd);
1690
+ } else {
1691
+ nextContext = await getContext(ctx.cwd);
1692
+ fileCount = nextContext.files.length;
1693
+ }
1694
+ const summary = stepSummary(outcome, first.name, chainDurations[0], fileCount);
1695
+ ctx.ui.notify(`do-always: step 1/${steps.length} — ${summary}`, "info");
1696
+ await runChainSteps(steps, ctx, 1, nextContext);
816
1697
  } finally {
817
1698
  chainActive = false;
818
1699
  }
@@ -820,24 +1701,31 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
820
1701
  }
821
1702
 
822
1703
  /**
823
- * Send chain steps `startAt..end` sequentially. Each step gets a fresh
824
- * context (so its guards see the tree as it is now) and is awaited until
825
- * its run has fully settled; an aborted/errored step (or a send that
826
- * failed to start) stops the chain.
1704
+ * Send chain steps `startAt..end` sequentially. Each step evaluates guards
1705
+ * and renders prompts against fresh repository context, and each step is
1706
+ * awaited until its run has fully settled; an aborted/errored step (or a
1707
+ * send that failed to start) stops the chain.
1708
+ *
1709
+ * Context builds are shared across steps: after each completed step the
1710
+ * fresh full build (needed for the file-count summary) is reused as the
1711
+ * next step's pre-context — the tree can only change via agent runs, and
1712
+ * the step has fully settled before the build runs. `initialContext` lets
1713
+ * the fill-first path hand over its post-step-1 build. The last step's
1714
+ * summary uses a single-spawn file count instead of a full build.
827
1715
  */
828
1716
  async function runChainSteps(
829
1717
  steps: DoAlwaysTask[],
830
1718
  ctx: ExtensionContext,
831
1719
  startAt: number,
832
- cache: ReturnType<typeof createContextCache>,
1720
+ initialContext?: TaskContext,
833
1721
  ): Promise<void> {
1722
+ let context = initialContext ?? (await getContext(ctx.cwd));
834
1723
  for (let i = startAt; i < steps.length; i++) {
835
1724
  const step = steps[i];
836
- const context = cache.get(ctx.cwd);
837
1725
  const blocked = evaluateGuards(step, context);
838
1726
  if (blocked) {
839
1727
  markChainStopped(ctx, i, "skipped", blocked);
840
- finishReport(ctx);
1728
+ await finishReport(ctx);
841
1729
  ctx.ui.notify(`do-always: chain stopped at step ${i + 1} (${step.name}): ${blocked}`, "warning");
842
1730
  return;
843
1731
  }
@@ -845,19 +1733,35 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
845
1733
  const label = `do-always: step ${i + 1}/${steps.length} — ${step.name}`;
846
1734
  setChainStep(ctx, i, "running");
847
1735
  ctx.ui.notify(`${label} — starting`, "info");
1736
+ const stepStart = performance.now();
848
1737
  const outcome = await sendAndWait(prompt, 10_000, i);
1738
+ const stepDuration = chainDurations[i] || performance.now() - stepStart;
1739
+ chainDurations[i] = stepDuration;
849
1740
  if (outcome === "completed") {
850
1741
  setChainStep(ctx, i, "completed");
1742
+ // Post-step summary: files changed + duration. All but the last
1743
+ // step get a fresh full build that doubles as the next step's
1744
+ // pre-context; the last step only needs the count (one spawn).
1745
+ let fileCount: number;
1746
+ if (i === steps.length - 1) {
1747
+ fileCount = await changedFileCount(ctx.cwd);
1748
+ } else {
1749
+ context = await getContext(ctx.cwd);
1750
+ fileCount = context.files.length;
1751
+ }
1752
+ const summary = stepSummary(outcome, step.name, stepDuration, fileCount);
1753
+ ctx.ui.notify(`do-always: step ${i + 1}/${steps.length} — ${summary}`, "info");
851
1754
  continue;
852
1755
  }
853
1756
  markChainStopped(ctx, i, outcome);
854
- finishReport(ctx);
1757
+ await finishReport(ctx);
1758
+ const summary = stepSummary(outcome, step.name, stepDuration, 0);
855
1759
  if (outcome === "failed-to-start") {
856
- ctx.ui.notify(`${label} — failed to start (check model/API key); chain stopped`, "error");
1760
+ ctx.ui.notify(`${label} — ${summary} (check model/API key); chain stopped`, "error");
857
1761
  } else if (outcome === "aborted") {
858
- ctx.ui.notify(`${label} — aborted; chain stopped`, "error");
1762
+ ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
859
1763
  } else {
860
- ctx.ui.notify(`${label} — run errored; chain stopped`, "error");
1764
+ ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
861
1765
  }
862
1766
  return;
863
1767
  }
@@ -866,12 +1770,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
866
1770
  // the prompt. Capture the display path first — finishReport clears
867
1771
  // `chainReport` when the chain is fully done.
868
1772
  const reportDisplay = chainReport?.display;
1773
+ // Chain-end summary.
1774
+ const completed = chainDurations.filter((d) => d > 0).length;
1775
+ const totalMs = chainDurations.reduce((a, b) => a + b, 0);
1776
+ const chainSum = chainSummary(completed, steps.length, totalMs);
869
1777
  if (chainReport) {
870
- finishReport(ctx);
871
- ctx.ui.notify(`do-always: chain complete (${steps.length} steps) — report: ${reportDisplay}`, "info");
1778
+ await finishReport(ctx);
1779
+ ctx.ui.notify(`do-always: ${chainSum} — report: ${reportDisplay}`, "info");
872
1780
  } else {
873
1781
  clearChainWidget(ctx);
874
- ctx.ui.notify(`do-always: chain complete (${steps.length} steps)`, "info");
1782
+ ctx.ui.notify(`do-always: ${chainSum}`, "info");
875
1783
  }
876
1784
  }
877
1785
 
@@ -897,9 +1805,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
897
1805
  context: TaskContext,
898
1806
  cache: ReturnType<typeof createContextCache>,
899
1807
  ): Promise<void> {
900
- // Filter by the `when` condition once per session, so hidden tasks never
901
- // appear, are never numbered, and can't be picked.
902
- const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
1808
+ // Filter by the `hidden` flag and the `when` condition once per session,
1809
+ // so hidden tasks never appear, are never numbered, and can't be picked
1810
+ // (same predicate as refreshVisible, which numbers `/do-always <n>`).
1811
+ const visibleTasks = tasks.filter((t) => isTaskVisible(t, context));
903
1812
  // String view for prompt rendering (derived once, used by the preview).
904
1813
  const strings = toPromptContext(context);
905
1814
  const result = await ctx.ui.custom<SelectorResult>((tui, theme, _kb, done) => {
@@ -1004,12 +1913,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1004
1913
  );
1005
1914
  return { bodyRows, itemRows, winStart, visibleHeaderNames };
1006
1915
  }
1916
+ // One pass's visible state, computed once per input event / render
1917
+ // and shared by clampCursor, buildTable, and the navigation logic.
1918
+ type Visible = ReturnType<typeof getVisible>;
1007
1919
 
1008
1920
  // Keep the cursor valid after the rows or the chain change. (The
1009
1921
  // ORDER cell of a non-chained row is a valid cursor position: it is
1010
1922
  // the "add" state.)
1011
- function clampCursor() {
1012
- const { itemRows } = getVisible();
1923
+ function clampCursor(visible: Visible) {
1924
+ const { itemRows } = visible;
1013
1925
  if (itemRows.length === 0) {
1014
1926
  cursor = { kind: "cell", row: 0, col: "task" };
1015
1927
  return;
@@ -1044,15 +1956,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1044
1956
  }
1045
1957
 
1046
1958
  // Cached last buildTable result (avoids double-work on mouse hit-test).
1047
- // Invalidated whenever chain/filter changes (handledInput calls
1048
- // requestRender, which re-renders before any subsequent mouse event).
1049
- let lastTable: ReturnType<typeof buildTable> | null = null;
1959
+ // The table depends on cursor and preview state, so render() must
1960
+ // rebuild it on every pass; handleMouse only reuses the last render's
1961
+ // table (or builds one if no render has happened yet, e.g. the very
1962
+ // first mouse event).
1963
+ type Table = { lines: string[]; itemLine: Map<number, DoAlwaysTask>; runLine: number; orderColX: number | null };
1964
+ let lastTable: Table | null = null;
1050
1965
 
1051
1966
  // Build the full selector output for a width, plus the line map for
1052
1967
  // mouse handling (itemLine: line -> task, runLine: the Run row,
1053
1968
  // orderColX: where the ORDER cell starts, or null in the narrow tier).
1054
- function buildTable(width: number) {
1055
- const { bodyRows, itemRows, winStart, visibleHeaderNames } = getVisible();
1969
+ function buildTable(width: number, visible: Visible = getVisible()): Table {
1970
+ const { bodyRows, itemRows, winStart, visibleHeaderNames } = visible;
1056
1971
  const { tier, taskCol, descCol, orderColX } = tableGeometry(width);
1057
1972
  const lines: string[] = [];
1058
1973
  const itemLine = new Map<number, DoAlwaysTask>();
@@ -1114,7 +2029,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1114
2029
  cursor.kind === "cell" && cursor.row === idx && cursor.col === "order";
1115
2030
  const num = inTaskCol
1116
2031
  ? `${cursorMark} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
1117
- : ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
2032
+ : inOrderCol && tier === "narrow"
2033
+ ? `${theme.fg("warning", "►")} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
2034
+ : ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
1118
2035
  // ⚡ is 2 columns wide, so "⚡ " takes 3 — reserve it so
1119
2036
  // auto-run rows align with the others (ORDER cell is
1120
2037
  // hit-tested at a fixed x).
@@ -1213,10 +2130,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1213
2130
 
1214
2131
  return {
1215
2132
  render(width: number) {
1216
- lastTable = buildTable(width);
1217
- return lastTable!.lines;
2133
+ lastTable = buildTable(width, getVisible());
2134
+ return lastTable.lines;
2135
+ },
2136
+ invalidate() {
2137
+ clearPreviewTimer();
1218
2138
  },
1219
- invalidate() {},
1220
2139
  handleInput(data: string) {
1221
2140
  // Direct pick by number (1-9) — runs the task immediately (the
1222
2141
  // classic fast path), closing the selector and discarding the
@@ -1227,16 +2146,17 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1227
2146
  return;
1228
2147
  }
1229
2148
  // Filter typing (Backspace edits the filter; with an empty
1230
- // filter it undoes the last chain add).
2149
+ // filter it undoes the last chain add). The filter change
2150
+ // invalidates the visible set, so the clamp gets a fresh pass.
1231
2151
  if (kb.matches(data, "tui.editor.deleteCharBackward")) {
1232
2152
  if (filter.length > 0) {
1233
2153
  filter = filter.slice(0, -1);
1234
- clampCursor();
2154
+ clampCursor(getVisible());
1235
2155
  } else {
1236
2156
  const { state, removed } = chainUndo(chain);
1237
2157
  if (removed) {
1238
2158
  chain = state;
1239
- clampCursor();
2159
+ clampCursor(getVisible());
1240
2160
  }
1241
2161
  }
1242
2162
  resetPreview();
@@ -1245,12 +2165,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1245
2165
  }
1246
2166
  if (isPrintable(data)) {
1247
2167
  filter += data;
1248
- clampCursor();
2168
+ clampCursor(getVisible());
1249
2169
  resetPreview();
1250
2170
  tui.requestRender();
1251
2171
  return;
1252
2172
  }
1253
- const { itemRows } = getVisible();
2173
+ // One pass for the rest of the event: navigation, confirm,
2174
+ // and clear below all share this result (chain edits don't
2175
+ // change itemRows, so it stays valid).
2176
+ const visible = getVisible();
2177
+ const { itemRows } = visible;
1254
2178
  if (itemRows.length === 0) {
1255
2179
  // Nothing to navigate; only Esc is useful here.
1256
2180
  if (kb.matches(data, "tui.select.cancel")) finishCancel();
@@ -1317,6 +2241,51 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1317
2241
  tui.requestRender();
1318
2242
  return;
1319
2243
  }
2244
+ // Extended navigation: home, end, pageup, pagedown.
2245
+ if (matchesKey(data, "home")) {
2246
+ cursor = { kind: "cell", row: 0, col: cursor.kind === "cell" ? cursor.col : "task" };
2247
+ lastCellRow = 0;
2248
+ resetPreview();
2249
+ tui.requestRender();
2250
+ return;
2251
+ }
2252
+ if (matchesKey(data, "end")) {
2253
+ cursor = { kind: "run" };
2254
+ lastCellRow = itemRows.length - 1;
2255
+ resetPreview();
2256
+ tui.requestRender();
2257
+ return;
2258
+ }
2259
+ if (matchesKey(data, "pageUp")) {
2260
+ if (cursor.kind === "run") {
2261
+ cursor = { kind: "cell", row: Math.max(0, itemRows.length - maxVisible), col: "task" };
2262
+ } else {
2263
+ cursor = {
2264
+ kind: "cell",
2265
+ row: Math.max(0, cursor.row - maxVisible),
2266
+ col: cursor.col,
2267
+ };
2268
+ }
2269
+ lastCellRow = cursor.row;
2270
+ resetPreview();
2271
+ tui.requestRender();
2272
+ return;
2273
+ }
2274
+ if (matchesKey(data, "pageDown")) {
2275
+ if (cursor.kind === "cell") {
2276
+ const next = cursor.row + maxVisible;
2277
+ if (next >= itemRows.length) {
2278
+ cursor = { kind: "run" };
2279
+ lastCellRow = itemRows.length - 1;
2280
+ } else {
2281
+ cursor = { kind: "cell", row: next, col: cursor.col };
2282
+ lastCellRow = next;
2283
+ }
2284
+ }
2285
+ resetPreview();
2286
+ tui.requestRender();
2287
+ return;
2288
+ }
1320
2289
  // Confirm: context-dependent.
1321
2290
  if (kb.matches(data, "tui.select.confirm")) {
1322
2291
  if (cursor.kind === "run") {
@@ -1336,6 +2305,13 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1336
2305
  return;
1337
2306
  }
1338
2307
  // ORDER column: toggle this task's chain membership.
2308
+ // Browser tasks can't be chained: the chain runner sends
2309
+ // prompts directly, so the browser (and
2310
+ // {{selected_commits}}) never run — run them on their own.
2311
+ if (row.task.browser) {
2312
+ ctx.ui.notify(`do-always: "${row.task.name}" opens a browser — it can't be chained, run it on its own`, "info");
2313
+ return;
2314
+ }
1339
2315
  if (chain.items.includes(row.task.name)) {
1340
2316
  chain = chainRemove(chain, row.task.name);
1341
2317
  } else {
@@ -1354,7 +2330,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1354
2330
  if (matchesKey(data, "ctrl+u")) {
1355
2331
  if (chain.items.length > 0) {
1356
2332
  chain = chainClear();
1357
- clampCursor();
2333
+ clampCursor(visible);
1358
2334
  resetPreview();
1359
2335
  tui.requestRender();
1360
2336
  }
@@ -1378,11 +2354,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1378
2354
  return { handled: true, render: true };
1379
2355
  }
1380
2356
  if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
1381
- // Reuse the last render's table for hit-testing (avoids
1382
- // rebuilding the table twice per mouse event). The table is
1383
- // always fresh because handleInput calls requestRender before
1384
- // the next mouse event can arrive.
1385
- const { itemLine, runLine, orderColX } = lastTable ?? buildTable(event.width);
2357
+ // Reuse the last render's table for hit-testing (avoids rebuilding
2358
+ // the table twice per mouse event). The table is always fresh
2359
+ // because handleInput calls requestRender before the next mouse
2360
+ // event can arrive; build one if no render has happened yet.
2361
+ // One getVisible() per event, shared by the (possibly fresh)
2362
+ // table and the itemRows lookup below.
2363
+ const visible = getVisible();
2364
+ const { itemLine, runLine, orderColX } = lastTable ?? buildTable(event.width, visible);
1386
2365
  // Pinned Run row: press runs the chain.
1387
2366
  if (runLine >= 0 && event.y === runLine) {
1388
2367
  if (event.type === "press" && chain.items.length > 0) {
@@ -1392,19 +2371,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1392
2371
  }
1393
2372
  const task = itemLine.get(event.y);
1394
2373
  if (!task) return undefined;
1395
- const { itemRows } = getVisible();
2374
+ const { itemRows } = visible;
1396
2375
  const idx = itemRows.findIndex((r) => r.task === task);
1397
2376
  if (idx < 0) return undefined;
1398
- // ORDER cell: press toggles chain membership.
1399
- if (orderColX !== null && event.x >= orderColX) {
1400
- if (event.type === "press") {
1401
- chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
1402
- clampCursor();
1403
- resetPreview();
1404
- return { handled: true, render: true };
2377
+ // ORDER cell: press toggles chain membership.
2378
+ if (orderColX !== null && event.x >= orderColX) {
2379
+ if (event.type === "press") {
2380
+ if (task.browser) {
2381
+ // Same rule as the keyboard toggle: browser tasks
2382
+ // can't be chained (see ORDER column).
2383
+ ctx.ui.notify(`do-always: "${task.name}" opens a browser — it can't be chained, run it on its own`, "info");
2384
+ return { handled: true };
2385
+ }
2386
+ chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
2387
+ clampCursor(visible);
2388
+ resetPreview();
2389
+ return { handled: true, render: true };
2390
+ }
2391
+ return { handled: true }; // swallow the click after the press action
1405
2392
  }
1406
- return { handled: true }; // swallow the click after the press action
1407
- }
1408
2393
  // Task area: press selects, click runs (the classic fast path).
1409
2394
  if (event.type === "press") {
1410
2395
  mousePressedIndex = idx;
@@ -1471,7 +2456,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1471
2456
  // One context per command run: shared by visibility filtering, rendering,
1472
2457
  // and the completion cache (never inside a render loop).
1473
2458
  const cache = createContextCache();
1474
- const context = cache.get(ctx.cwd);
2459
+ const context = await cache.get(ctx.cwd);
1475
2460
  const visible = refreshVisible(ctx.cwd, context);
1476
2461
 
1477
2462
  const arg = args.trim();