pi-extensions 0.1.33 → 0.1.35

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 CHANGED
@@ -13,6 +13,7 @@ Personal extensions for the [Pi coding agent](https://github.com/badlogic/pi-mon
13
13
  | [/usage](usage-extension/) | 📊 Usage statistics dashboard. See cost, tokens, and messages by provider/model across Today, This Week, Last Week, and All Time — with a compact view for narrow terminals |
14
14
  | [/paste](raw-paste/) | Paste editable text, not [paste #1 +21 lines]. Running `/paste` with optional keybinding |
15
15
  | [/code](code-actions/) | Pick code blocks or inline snippets from assistant messages to copy, insert, or run with `/code` |
16
+ | [session-recap](session-recap/) | One-line recap above the editor when you refocus the terminal (or after idle). Keeps you in flow while multi-clauding |
16
17
  | [arcade](arcade/) | Play minigames while your tests run: 👾 sPIce-invaders, 👻 picman, 🏓 ping, 🧩 tetris, 🍄 mario-not |
17
18
 
18
19
  ## Skills
@@ -60,6 +61,7 @@ If you keep a local clone, add extensions to your `~/.pi/agent/settings.json`:
60
61
  "~/pi-extensions/agent-guidance/agent-guidance.ts",
61
62
  "~/pi-extensions/raw-paste",
62
63
  "~/pi-extensions/code-actions",
64
+ "~/pi-extensions/session-recap",
63
65
  "~/pi-extensions/usage-extension"
64
66
  ]
65
67
  }
@@ -4,6 +4,19 @@ All notable changes to this extension will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.1.18] - 2026-04-19
8
+
9
+ ### Changed
10
+ - Show symlinks with a `↗` marker in the `/readfiles` tree.
11
+
12
+ ### Fixed
13
+ - Let `/readfiles` navigate into directory symlinks in both non-git folders and git repos instead of rendering them as inert files or empty directories.
14
+ - Guard symlink directory scanning against ancestor cycles so links like `foo -> .` or `foo -> ..` don't recurse forever.
15
+ - Treat git-tracked and untracked directory symlinks as lazily scannable directories rather than plain files.
16
+
17
+ ### Thanks
18
+ - Thanks to @xapids for reporting the original macOS symlink navigation issue ([#9](https://github.com/tmustier/pi-extensions/issues/9)).
19
+
7
20
  ## [0.1.17] - 2026-04-19
8
21
 
9
22
  ### Changed
@@ -2,6 +2,8 @@
2
2
 
3
3
  In-terminal file browser and diff viewer widget for Pi. Navigate files, view diffs, select code, and send comments to the agent without leaving the terminal and without interrupting your agent.
4
4
 
5
+ Directory symlinks are shown with a `↗` marker and can be expanded like normal folders.
6
+
5
7
  <video controls autoplay loop muted playsinline>
6
8
  <source src="demo.mp4" type="video/mp4" />
7
9
  </video>
@@ -1,6 +1,7 @@
1
1
  import type { Theme } from "@mariozechner/pi-coding-agent";
2
2
  import { Key, matchesKey, truncateToWidth } from "@mariozechner/pi-tui";
3
- import { readdir, readFile, stat } from "node:fs/promises";
3
+ import { lstatSync, realpathSync, statSync } from "node:fs";
4
+ import { readdir, readFile, realpath, stat } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
5
6
  import { basename, join, relative, resolve, sep } from "node:path";
6
7
 
@@ -104,6 +105,51 @@ function getNodeDepth(node: FileNode, cwd: string): number {
104
105
  return rel.split(sep).length;
105
106
  }
106
107
 
108
+ function safeRealPathSync(path: string): string {
109
+ try {
110
+ return realpathSync(path);
111
+ } catch {
112
+ return resolve(path);
113
+ }
114
+ }
115
+
116
+ function getPathInfoSync(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
117
+ try {
118
+ const linkStat = lstatSync(path);
119
+ const isSymlink = linkStat.isSymbolicLink();
120
+ const targetStat = isSymlink ? statSync(path) : linkStat;
121
+ return {
122
+ isDirectory: targetStat.isDirectory(),
123
+ isSymlink,
124
+ realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
125
+ };
126
+ } catch {
127
+ return { isDirectory: false, isSymlink: false };
128
+ }
129
+ }
130
+
131
+ async function getPathInfo(path: string, isSymlink: boolean): Promise<{ isDirectory: boolean; isSymlink: boolean; realPath?: string }> {
132
+ try {
133
+ const targetStat = await stat(path);
134
+ return {
135
+ isDirectory: targetStat.isDirectory(),
136
+ isSymlink,
137
+ realPath: targetStat.isDirectory() ? await realpath(path).catch(() => resolve(path)) : undefined,
138
+ };
139
+ } catch {
140
+ return { isDirectory: false, isSymlink };
141
+ }
142
+ }
143
+
144
+ function hasAncestorRealPath(node: FileNode | undefined, realPath: string): boolean {
145
+ let current = node;
146
+ while (current) {
147
+ if (current.realPath === realPath) return true;
148
+ current = current.parent;
149
+ }
150
+ return false;
151
+ }
152
+
107
153
  function shouldSafeMode(cwd: string): boolean {
108
154
  const resolved = resolve(cwd);
109
155
  const home = resolve(homedir());
@@ -183,14 +229,19 @@ function formatNodeMeta(node: FileNode, theme: Theme): string {
183
229
  return parts.length > 0 ? ` ${parts.join(" ")}` : "";
184
230
  }
185
231
 
232
+ function withSymlinkMarker(label: string, node: FileNode, theme: Theme): string {
233
+ return node.isSymlink ? `${label}${theme.fg("dim", " ↗")}` : label;
234
+ }
235
+
186
236
  function formatNodeName(node: FileNode, theme: Theme): string {
187
- if (isIgnoredStatus(node.gitStatus)) return theme.fg("dim", node.name);
237
+ if (isIgnoredStatus(node.gitStatus)) return withSymlinkMarker(theme.fg("dim", node.name), node, theme);
188
238
  if (node.isDirectory) {
189
239
  const label = node.hasChangedChildren ? theme.fg("warning", node.name) : theme.fg("accent", node.name);
190
- return node.loading ? `${label}${theme.fg("dim", " ⏳")}` : label;
240
+ const rendered = withSymlinkMarker(label, node, theme);
241
+ return node.loading ? `${rendered}${theme.fg("dim", " ⏳")}` : rendered;
191
242
  }
192
- if (node.gitStatus) return theme.fg("warning", node.name);
193
- return node.name;
243
+ if (node.gitStatus) return withSymlinkMarker(theme.fg("warning", node.name), node, theme);
244
+ return withSymlinkMarker(node.name, node, theme);
194
245
  }
195
246
 
196
247
  function collapseAllExcept(node: FileNode, keep: Set<FileNode>): void {
@@ -227,6 +278,7 @@ export function createFileBrowser(
227
278
  name: ".",
228
279
  path: cwd,
229
280
  isDirectory: true,
281
+ realPath: safeRealPathSync(cwd),
230
282
  children: undefined,
231
283
  expanded: true,
232
284
  hasChangedChildren: false,
@@ -350,6 +402,14 @@ export function createFileBrowser(
350
402
  }
351
403
 
352
404
  function shouldAutoScan(depth: number): boolean {
405
+ // In git repos the main tree comes from git file lists, not from filesystem
406
+ // crawling. If the user expands a symlinked directory inside that tree, only
407
+ // scan one level on demand; nested directories stay lazy until explicitly
408
+ // expanded so links into large trees (iCloud/Drive/$HOME) don't trigger a
409
+ // broad recursive crawl.
410
+ if (repo) {
411
+ return false;
412
+ }
353
413
  if (browser.scanState.mode === "safe") {
354
414
  return depth <= 0;
355
415
  }
@@ -399,31 +459,74 @@ export function createFileBrowser(
399
459
  for (const entry of sorted) {
400
460
  if (ignored.has(entry.name) || entry.name.startsWith(".")) continue;
401
461
  const fullPath = join(node.path, entry.name);
462
+ const childDepth = depth + 1;
463
+
402
464
  if (entry.isDirectory()) {
403
465
  const dirNode: FileNode = {
404
466
  name: entry.name,
405
467
  path: fullPath,
406
468
  isDirectory: true,
469
+ realPath: await realpath(fullPath).catch(() => resolve(fullPath)),
470
+ parent: node,
407
471
  children: undefined,
408
- expanded: depth + 1 < 1,
472
+ expanded: childDepth < 1,
409
473
  hasChangedChildren: false,
410
474
  };
411
475
  dirs.push(dirNode);
412
476
  browser.nodeByPath.set(fullPath, dirNode);
413
- if (shouldAutoScan(depth + 1)) {
414
- enqueueScan(dirNode, depth + 1);
477
+ if (shouldAutoScan(childDepth)) {
478
+ enqueueScan(dirNode, childDepth);
415
479
  }
416
- } else {
417
- const fileNode: FileNode = {
480
+ continue;
481
+ }
482
+
483
+ if (entry.isSymbolicLink()) {
484
+ const pathInfo = await getPathInfo(fullPath, true);
485
+ if (pathInfo.isDirectory) {
486
+ const isCycle = pathInfo.realPath ? hasAncestorRealPath(node, pathInfo.realPath) : false;
487
+ const dirNode: FileNode = {
488
+ name: entry.name,
489
+ path: fullPath,
490
+ isDirectory: true,
491
+ isSymlink: true,
492
+ realPath: pathInfo.realPath,
493
+ parent: node,
494
+ children: isCycle ? [] : undefined,
495
+ expanded: childDepth < 1,
496
+ hasChangedChildren: false,
497
+ };
498
+ dirs.push(dirNode);
499
+ browser.nodeByPath.set(fullPath, dirNode);
500
+ if (!isCycle && shouldAutoScan(childDepth)) {
501
+ enqueueScan(dirNode, childDepth);
502
+ }
503
+ continue;
504
+ }
505
+
506
+ const symlinkFileNode: FileNode = {
418
507
  name: entry.name,
419
508
  path: fullPath,
420
509
  isDirectory: false,
510
+ isSymlink: true,
511
+ parent: node,
421
512
  agentModified: agentModifiedFiles.has(fullPath),
422
513
  };
423
- files.push(fileNode);
424
- browser.nodeByPath.set(fullPath, fileNode);
425
- queueLineCount(fileNode);
514
+ files.push(symlinkFileNode);
515
+ browser.nodeByPath.set(fullPath, symlinkFileNode);
516
+ queueLineCount(symlinkFileNode);
517
+ continue;
426
518
  }
519
+
520
+ const fileNode: FileNode = {
521
+ name: entry.name,
522
+ path: fullPath,
523
+ isDirectory: false,
524
+ parent: node,
525
+ agentModified: agentModifiedFiles.has(fullPath),
526
+ };
527
+ files.push(fileNode);
528
+ browser.nodeByPath.set(fullPath, fileNode);
529
+ queueLineCount(fileNode);
427
530
  }
428
531
 
429
532
  node.children = [...dirs, ...files];
@@ -483,14 +586,13 @@ export function createFileBrowser(
483
586
 
484
587
  function applyGitUpdates(): void {
485
588
  for (const node of browser.nodeByPath.values()) {
486
- if (node.isDirectory) continue;
487
589
  const relPath = normalizeGitPath(relative(cwd, node.path));
488
590
  node.gitStatus = gitStatus.get(relPath);
489
591
  node.diffStats = diffStats.get(relPath);
490
592
  }
491
593
  }
492
594
 
493
- function ensureFileNode(relPath: string): FileNode | null {
595
+ function ensureNode(relPath: string): FileNode | null {
494
596
  if (!browser.root) return null;
495
597
  let normalized = relPath.trim();
496
598
  if (!normalized) return null;
@@ -517,6 +619,8 @@ export function createFileBrowser(
517
619
  name: part,
518
620
  path: dirPath,
519
621
  isDirectory: true,
622
+ realPath: safeRealPathSync(dirPath),
623
+ parent: current,
520
624
  children: [],
521
625
  expanded: depth < 1,
522
626
  hasChangedChildren: false,
@@ -536,10 +640,36 @@ export function createFileBrowser(
536
640
  const existing = browser.nodeByPath.get(filePath);
537
641
  if (existing) return existing;
538
642
 
643
+ const pathInfo = getPathInfoSync(filePath);
644
+ if (pathInfo.isDirectory) {
645
+ const isCycle = pathInfo.realPath ? hasAncestorRealPath(current, pathInfo.realPath) : false;
646
+ const dirNode: FileNode = {
647
+ name: fileName,
648
+ path: filePath,
649
+ isDirectory: true,
650
+ isSymlink: pathInfo.isSymlink,
651
+ realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
652
+ parent: current,
653
+ children: pathInfo.isSymlink && !isCycle ? undefined : [],
654
+ expanded: false,
655
+ hasChangedChildren: false,
656
+ gitStatus: gitStatus.get(normalized),
657
+ diffStats: diffStats.get(normalized),
658
+ };
659
+
660
+ current.children ??= [];
661
+ current.children.push(dirNode);
662
+ sortChildren(current);
663
+ browser.nodeByPath.set(filePath, dirNode);
664
+ return dirNode;
665
+ }
666
+
539
667
  const fileNode: FileNode = {
540
668
  name: fileName,
541
669
  path: filePath,
542
670
  isDirectory: false,
671
+ isSymlink: pathInfo.isSymlink,
672
+ parent: current,
543
673
  gitStatus: gitStatus.get(normalized),
544
674
  agentModified: agentModifiedFiles.has(filePath),
545
675
  diffStats: diffStats.get(normalized),
@@ -555,11 +685,13 @@ export function createFileBrowser(
555
685
  function addUntrackedNodes(): void {
556
686
  for (const [relPath, status] of gitStatus.entries()) {
557
687
  if (!isUntrackedStatus(status)) continue;
558
- const node = ensureFileNode(relPath);
688
+ const node = ensureNode(relPath);
559
689
  if (node) {
560
690
  node.gitStatus = status;
561
691
  node.diffStats = diffStats.get(relPath);
562
- queueLineCount(node, true);
692
+ if (!node.isDirectory) {
693
+ queueLineCount(node, true);
694
+ }
563
695
  }
564
696
  }
565
697
  }
@@ -673,7 +805,7 @@ export function createFileBrowser(
673
805
  function toggleDir(node: FileNode): void {
674
806
  if (node.isDirectory) {
675
807
  node.expanded = !node.expanded;
676
- if (!repo && node.expanded && node.children === undefined) {
808
+ if (node.expanded && node.children === undefined) {
677
809
  enqueueScan(node, getNodeDepth(node, cwd), true);
678
810
  }
679
811
  refreshLists();
@@ -1,4 +1,4 @@
1
- import { statSync } from "node:fs";
1
+ import { lstatSync, realpathSync, statSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
 
4
4
  import { MAX_TREE_DEPTH } from "./constants";
@@ -6,11 +6,26 @@ import type { DiffStats, FileNode, FlatNode } from "./types";
6
6
 
7
7
  const collator = new Intl.Collator(undefined, { sensitivity: "base" });
8
8
 
9
- function isDirectoryPath(path: string): boolean {
9
+ function safeRealPathSync(path: string): string {
10
10
  try {
11
- return statSync(path).isDirectory();
11
+ return realpathSync(path);
12
12
  } catch {
13
- return false;
13
+ return path;
14
+ }
15
+ }
16
+
17
+ function getPathInfo(path: string): { isDirectory: boolean; isSymlink: boolean; realPath?: string } {
18
+ try {
19
+ const linkStat = lstatSync(path);
20
+ const isSymlink = linkStat.isSymbolicLink();
21
+ const targetStat = isSymlink ? statSync(path) : linkStat;
22
+ return {
23
+ isDirectory: targetStat.isDirectory(),
24
+ isSymlink,
25
+ realPath: targetStat.isDirectory() ? safeRealPathSync(path) : undefined,
26
+ };
27
+ } catch {
28
+ return { isDirectory: false, isSymlink: false };
14
29
  }
15
30
  }
16
31
 
@@ -105,6 +120,7 @@ export function buildFileTreeFromPaths(
105
120
  name: ".",
106
121
  path: cwd,
107
122
  isDirectory: true,
123
+ realPath: safeRealPathSync(cwd),
108
124
  children: [],
109
125
  expanded: true,
110
126
  hasChangedChildren: false,
@@ -145,6 +161,8 @@ export function buildFileTreeFromPaths(
145
161
  name: part,
146
162
  path: join(cwd, relPath),
147
163
  isDirectory: true,
164
+ realPath: safeRealPathSync(join(cwd, relPath)),
165
+ parent: current,
148
166
  children: [],
149
167
  expanded: depth < 1,
150
168
  hasChangedChildren: false,
@@ -178,14 +196,18 @@ export function buildFileTreeFromPaths(
178
196
  continue;
179
197
  }
180
198
 
181
- const isDirEntry = normalized.endsWith("/") || isDirectoryPath(filePath);
199
+ const pathInfo = getPathInfo(filePath);
200
+ const isDirEntry = normalized.endsWith("/") || pathInfo.isDirectory;
182
201
  if (isDirEntry) {
183
202
  const depth = parts.length;
184
203
  const dirNode: FileNode = {
185
204
  name: fileName,
186
205
  path: filePath,
187
206
  isDirectory: true,
188
- children: [],
207
+ isSymlink: pathInfo.isSymlink,
208
+ realPath: pathInfo.realPath ?? safeRealPathSync(filePath),
209
+ parent: current,
210
+ children: pathInfo.isSymlink ? undefined : [],
189
211
  expanded: depth < 1,
190
212
  hasChangedChildren: false,
191
213
  gitStatus: fileGitStatus,
@@ -202,6 +224,8 @@ export function buildFileTreeFromPaths(
202
224
  name: fileName,
203
225
  path: filePath,
204
226
  isDirectory: false,
227
+ isSymlink: pathInfo.isSymlink,
228
+ parent: current,
205
229
  gitStatus: fileGitStatus,
206
230
  agentModified: agentModified.has(filePath),
207
231
  diffStats: fileDiffStats,
@@ -70,7 +70,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
70
70
  },
71
71
  });
72
72
 
73
- pi.registerCommand("review", {
73
+ pi.registerCommand("readfiles-review", {
74
74
  description: "Open tuicr to review changes and send feedback to agent",
75
75
  handler: async (_args, ctx) => {
76
76
  if (!hasCommand("tuicr")) {
@@ -105,7 +105,7 @@ export default function editorExtension(pi: ExtensionAPI): void {
105
105
  },
106
106
  });
107
107
 
108
- pi.registerCommand("diff", {
108
+ pi.registerCommand("readfiles-diff", {
109
109
  description: "Open critique to view diffs",
110
110
  handler: async (args, ctx) => {
111
111
  if (!hasCommand("bun")) {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tmustier/pi-files-widget",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "In-terminal file browser and viewer for Pi.",
5
5
  "license": "MIT",
6
6
  "author": "Thomas Mustier",
@@ -7,6 +7,9 @@ export interface FileNode {
7
7
  name: string;
8
8
  path: string;
9
9
  isDirectory: boolean;
10
+ isSymlink?: boolean;
11
+ realPath?: string;
12
+ parent?: FileNode;
10
13
  children?: FileNode[];
11
14
  expanded?: boolean;
12
15
  gitStatus?: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-extensions",
3
- "version": "0.1.33",
3
+ "version": "0.1.35",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "keywords": [
@@ -18,6 +18,7 @@
18
18
  "./files-widget/index.ts",
19
19
  "./raw-paste/index.ts",
20
20
  "./pi-ralph-wiggum/index.ts",
21
+ "./session-recap/index.ts",
21
22
  "./tab-status/tab-status.ts",
22
23
  "./usage-extension/index.ts"
23
24
  ],
@@ -0,0 +1,15 @@
1
+ # Changelog
2
+
3
+ ## v0.1.0
4
+
5
+ - Initial release.
6
+ - Two triggers: DECSET `?1004` focus reporting + idle fallback on `turn_end`.
7
+ - Auto-recap on `/resume` and `/fork`.
8
+ - `/recap` command for manual generation.
9
+ - Defaults to the user's active model with `reasoning: "minimal"` when supported, for zero-auth-surprise behaviour across built-in and custom providers.
10
+ - Flags: `--recap-idle-seconds`, `--recap-focus-min-seconds`, `--recap-disable-focus`, `--recap-disable`, `--recap-model`.
11
+ - Draft stamping by branch-leaf id to avoid regenerating on focus-out/in churn without new session activity.
12
+ - Idle fallback armed on `turn_end` rather than `agent_end` so errored/aborted turns still get a recap.
13
+ - Robust focus-event parser that advances through its buffer so completed sequences never fire twice across chunk boundaries.
14
+ - Per-call `AbortController` ownership so late-completing aborted requests can't clear state for a newer in-flight request.
15
+ - Quick refocus (< `--recap-focus-min-seconds`) now also cancels any in-flight focus draft, preventing a slow model response from bypassing the suppression.
@@ -0,0 +1,173 @@
1
+ # session-recap — design & plan
2
+
3
+ > Status: **v0.1.0 — initial release** · Lives in `tmustier/pi-extensions/session-recap/`
4
+ > Mirrors the [Claude Code session recap feature](https://x.com/ClaudeDevs) for Pi.
5
+
6
+ ## Summary
7
+
8
+ When you switch focus away from a Pi session and come back, Pi drops a one-line
9
+ recap above the editor so you can re-enter flow without re-reading scrollback.
10
+ Targets the "multi-clauding / multi-pi" workflow where several agent sessions
11
+ run in parallel tabs.
12
+
13
+ ```
14
+ ✦ recap
15
+ recap: Migrated 4 of 7 billing tables to the v2 schema; invoices.ts still fails
16
+ its FK constraint. Next: fix the foreign key on line 142.
17
+ ```
18
+
19
+ ## Triggers
20
+
21
+ | Trigger | Detection | Behaviour |
22
+ |---|---|---|
23
+ | Terminal focus out → in | DECSET `?1004` → `ESC[O` / `ESC[I` on stdin | Draft a recap in background on focus-out; reveal on focus-in if the out-duration ≥ `--recap-focus-min-seconds` (default 3s). |
24
+ | Idle after turn ends | `setTimeout(idleMs)` armed on `turn_end` | Generate and immediately show after `--recap-idle-seconds` (default 45s) of no user input. Idle path is the fallback for terminals without focus reporting. |
25
+ | `/resume` (and `/fork`) | `session_start { reason: "resume" \| "fork" }` | Auto-recap the prior session so you know where you left off. |
26
+ | Manual | `/recap` command | Generate now, bypass the activity gate. |
27
+
28
+ All four cancel each other cleanly via an `AbortController`; the next `input`,
29
+ `agent_start`, or new turn clears the widget.
30
+
31
+ ## Display
32
+
33
+ - `ctx.ui.setWidget("session-recap", [...], { placement: "aboveEditor" })`
34
+ - Two lines: accent-bold `✦ recap` header + dim one-liner body.
35
+ - Cleared on: user input, new turn start, session reload, session shutdown.
36
+ - **No session persistence.** Recap lives only in the widget for the active session.
37
+
38
+ ## Model selection — decision
39
+
40
+ Default must not surprise users with auth/login issues.
41
+
42
+ **Decision:** default to the **currently active model** with **`reasoning: "minimal"`** where supported. Trust the user's model choice — no auto-fallback to a cheap tier. If they're on Opus 4-7 the recap uses Opus 4-7. It's the only way to guarantee reliable generation across built-in + custom providers.
43
+
44
+ - Primary: `ctx.model` (whatever the user is running right now).
45
+ - Reasoning: pass `reasoningEffort: "minimal"` when `model.reasoning === true`; omit entirely otherwise.
46
+ - Pi's own `setThinkingLevel` already clamps to model capabilities — we follow the same rule.
47
+ - Some custom providers may not honour `reasoningEffort`; that's fine, they'll ignore it.
48
+ - Auth: `ctx.modelRegistry.getApiKeyAndHeaders(ctx.model)` — same primitive as every other pi call, so any OAuth / env-var / custom-provider credential the user already set up just works.
49
+ - Custom / local models (via `pi.registerProvider`): same path. If the provider is registered and has a key, recap works. If not, we skip silently — never fail loudly.
50
+ - No active model / no API key → skip silently, log to `console.error` for debugging.
51
+
52
+ **Escape hatch flag**
53
+
54
+ - `--recap-model "<provider>/<id>"` — force a specific model (e.g. if the user wants Sonnet 4-6 for speed regardless of what they're chatting with).
55
+
56
+ **Trade-off we're accepting**
57
+
58
+ - If the user is on a heavy model (Opus 4-7, GPT-5.4), each recap uses that model for a small one-liner task. Still cheap in absolute terms because the prompt is capped at ~12k chars and the output is one line, but not the cheapest option. We prefer "no auth surprise" over "always-cheapest".
59
+
60
+ ## Context fed to the model
61
+
62
+ **Current (v0.1):** transcript of the branch since the last user prompt:
63
+ - User text (trimmed to 1200 chars)
64
+ - Assistant text (trimmed to 1200 chars)
65
+ - Tool calls as `- <name>(<JSON args, ≤280 chars>)`
66
+ - Tool results as `Result(<name>): <text, ≤400 chars>`
67
+ - Whole transcript capped at 12,000 chars.
68
+
69
+ **TODO (v0.2+):** smarter context extraction. Options to explore:
70
+ - [ ] User prompt + file diffs (from edit/write tool calls) only — compact, factual.
71
+ - [ ] Tool-call list + brief summaries (skip raw file content).
72
+ - [ ] Structured "files touched + what changed" block pre-built before the model call.
73
+ - [ ] Keep last N message entries instead of trimming each.
74
+
75
+ Trigger to revisit: recaps feel shallow, OR costs creep up on long sessions, OR we want to support much longer running tasks without a summariser pass.
76
+
77
+ ## Prompt
78
+
79
+ One user message, no system prompt, no tools. Verbatim:
80
+
81
+ ```
82
+ You produce a single-line recap of what the coding agent just did, so the user
83
+ can re-enter flow after switching focus back to this session.
84
+
85
+ Rules:
86
+ - Output ONE line, no preamble, no markdown.
87
+ - Format: `recap: <what happened, past tense, concrete>. Next: <one-line next step>.`
88
+ - If there is no meaningful next step, omit the `Next:` clause.
89
+ - Use file/function names where relevant. Be concrete, not vague.
90
+ - Max ~220 characters.
91
+
92
+ <transcript>
93
+
94
+ </transcript>
95
+ ```
96
+
97
+ Post-processing: keep only the first line of the response as a belt-and-braces
98
+ guard against multi-line outputs.
99
+
100
+ ## Edge cases
101
+
102
+ ### 1. Focus → defocus → focus again without user input
103
+
104
+ **Current behaviour:** `handleFocusOut` re-enters `generateAndShow` if there is no in-flight request and no `draftingForFocus` flag, even if `pendingRecap` is still a perfectly valid recap for the same session state. Wasteful, and may overwrite a good recap with an identical one.
105
+
106
+ **Fix (planned):**
107
+ - Stamp each drafted recap with the current branch leaf id via `ctx.sessionManager.getLeafId()`.
108
+ - On focus-out: if `pendingRecap` exists AND its stamp matches the current leaf, skip regen entirely.
109
+ - Any new `turn_end` (or `input` / `agent_start`) invalidates the stamp.
110
+
111
+ **Related:** also gate on "has any activity happened since the previous draft?" — if nothing, reuse; if yes, regenerate.
112
+
113
+ ### 2. Agent turn ends in error or abort
114
+
115
+ **Question:** does `agent_end` fire reliably on user-Escape abort and on model/transport errors? Need to verify against pi's current behaviour. `turn_end` is documented as per-turn and should fire even on partial completion.
116
+
117
+ **Fix (planned):**
118
+ - Switch the idle-timer arming from `agent_end` → **`turn_end`**. `turn_end` fires after every turn regardless of outcome and is overwritten/cleared by the next `turn_start` or by `input`. This makes the idle fallback robust to errors and aborts without needing a separate error signal.
119
+ - Focus-out path already works: `hasMeaningfulActivity` counts assistant words and tool calls, independent of success/failure. An aborted turn with partial work still qualifies.
120
+ - Add a note in the recap prompt encouraging the model to mention "aborted" / "failed" state explicitly when present in the transcript, so the one-liner is honest (e.g. `recap: Started refactor of auth.ts; aborted before tests ran. Next: resume from middleware split.`).
121
+
122
+ ### 3. Terminal doesn't support DECSET ?1004
123
+
124
+ Idle fallback covers it. `--recap-disable-focus` lets the user opt out explicitly (in case the escape sequences cause weird ghost characters in a less-compliant terminal).
125
+
126
+ ### 4. tmux without `focus-events on`
127
+
128
+ tmux swallows focus events unless `set -g focus-events on` is set. Document in README. Idle fallback still works.
129
+
130
+ ### 5. Aborted-in-flight recap request
131
+
132
+ Already handled: `AbortController` on every `complete()` call; cancelled on input / agent_start / session_shutdown / next trigger.
133
+
134
+ ### 6. Multiple pi sessions in the same terminal process
135
+
136
+ Not applicable — pi is one process per terminal tab. The stdin listener we add is scoped to the process and cleaned up on `session_shutdown`.
137
+
138
+ ## Non-goals
139
+
140
+ - Session persistence of recap history (not needed — the widget is transient by design).
141
+ - Multi-recap / rolling summary across many focus cycles.
142
+ - Recap UI beyond the widget (no modal, no notifications by default).
143
+
144
+ ## Release checklist — v0.1.0
145
+
146
+ ### Code
147
+ - [x] Extension lives at `session-recap/index.ts`.
148
+ - [x] Default model = `ctx.model` with `reasoning: "minimal"` via `completeSimple()` when the model advertises reasoning; `--recap-model` override.
149
+ - [x] Idle timer armed on `turn_end` (not `agent_end`) so error/abort turns still get a recap.
150
+ - [x] `pendingRecap` + `lastDraftedLeafId` stamping; skip regen on focus-out if branch leaf hasn't changed.
151
+ - [x] Prompt explicitly asks the model to mention aborted/errored turn state when present.
152
+ - [x] `--recap-disable-focus` escape hatch in case DECSET `?1004` misbehaves.
153
+ - [x] Cleanup: `\x1b[?1004l` + listener removal on `session_shutdown`.
154
+
155
+ ### Packaging
156
+ - [x] `session-recap/package.json` (`@tmustier/pi-session-recap` v0.1.0).
157
+ - [x] Added `./session-recap/index.ts` to root `package.json` → `pi.extensions`.
158
+ - [x] Root version bumped.
159
+
160
+ ### Docs
161
+ - [x] `session-recap/README.md` — features, install, flags, terminal compatibility table.
162
+ - [x] `session-recap/CHANGELOG.md`.
163
+ - [x] Row added to repo-root `README.md`.
164
+ - [x] `DESIGN.md` retained as design-of-record.
165
+
166
+ ### Release
167
+ - [ ] Follow `RELEASING.md`: commit, tag `session-recap/v0.1.0`, publish `@tmustier/pi-session-recap` + repo `pi-extensions`, push tag, GitHub release.
168
+
169
+ ## Follow-ups (v0.2+)
170
+
171
+ - [ ] **Smarter context feeding**: try feeding user prompt + file diffs (from `edit`/`write` tool-call args) only, instead of the full trimmed transcript. Simpler, factual, likely cheaper. Alternative: pre-build a structured "files touched + what changed" block.
172
+ - [ ] **Verify `turn_end` really fires on every abort path** (user Escape mid-stream, provider errors, transport failures). If there's a gap, add a belt-and-braces `session_before_compact` / `session_shutdown` fallback.
173
+ - [ ] Optional: small e2e test harness to trigger fake `turn_end` / focus-in/out sequences and assert widget state transitions.
@@ -0,0 +1,101 @@
1
+ # session-recap
2
+
3
+ Claude-Code-style session recap for Pi. When you switch focus away from a Pi session and come back, a one-line recap appears above the editor so you can re-enter flow without re-reading scrollback.
4
+
5
+ ![session-recap widget in a live Pi session](./assets/recap.png)
6
+
7
+ Built for multi-clauding / multi-pi workflows where several agent sessions run in parallel tabs.
8
+
9
+ ## How it triggers
10
+
11
+ Two complementary triggers. You get whichever fires first.
12
+
13
+ 1. **Terminal focus reporting (DECSET `?1004`).** The extension enables focus events on session start and listens for `ESC[O` (focus-out) and `ESC[I` (focus-in). On focus-out it drafts a recap in the background; on focus-in it reveals the recap above the editor, as long as you were away for at least `--recap-focus-min-seconds` (default 3s — suppresses quick glances).
14
+ 2. **Idle fallback.** After the last `turn_end`, if you don't type for `--recap-idle-seconds` (default 45s), the recap is generated and shown anyway. This covers terminals that don't report focus events.
15
+
16
+ Also fires automatically on `/resume` and `/fork` so you know where the prior session left off.
17
+
18
+ Clears cleanly on: next user input, new turn start, session reload, or session shutdown.
19
+
20
+ ## Terminal compatibility
21
+
22
+ | Terminal | Focus reporting | Notes |
23
+ |---|---|---|
24
+ | iTerm2, Ghostty, Alacritty, Kitty, WezTerm, xterm | ✅ | Works out of the box. |
25
+ | VS Code integrated terminal, Warp | ✅ | Works. |
26
+ | Apple Terminal | ⚠️ Partial | Idle fallback covers it. |
27
+ | tmux | ✅ (with config) | Add `set -g focus-events on` to `~/.tmux.conf`, then `tmux source-file ~/.tmux.conf`. |
28
+
29
+ If focus events cause any weirdness in your terminal, run with `--recap-disable-focus` and the idle fallback still works.
30
+
31
+ ## Model
32
+
33
+ Defaults to the **currently active model** in your Pi session with `reasoning: "minimal"` where the model supports it. This piggybacks on whatever auth you already have (including custom providers registered via `pi.registerProvider`), so there are no login surprises.
34
+
35
+ - Reasoning-capable model (Opus 4-7, GPT-5.4, etc.) → runs at minimal thinking for speed/cost.
36
+ - Non-reasoning model → no reasoning params passed.
37
+ - No active model or missing API key → the recap is skipped silently.
38
+
39
+ Override with `--recap-model "<provider>/<id>"` if you want a specific model regardless of the session's active one.
40
+
41
+ ## Install
42
+
43
+ ### Pi package manager
44
+
45
+ ```bash
46
+ pi install git:github.com/tmustier/pi-extensions
47
+ ```
48
+
49
+ Filter to just this extension in `~/.pi/agent/settings.json`:
50
+
51
+ ```json
52
+ {
53
+ "packages": [
54
+ {
55
+ "source": "git:github.com/tmustier/pi-extensions",
56
+ "extensions": ["session-recap/index.ts"]
57
+ }
58
+ ]
59
+ }
60
+ ```
61
+
62
+ ### Local clone
63
+
64
+ ```json
65
+ {
66
+ "extensions": [
67
+ "~/pi-extensions/session-recap/index.ts"
68
+ ]
69
+ }
70
+ ```
71
+
72
+ ## Flags
73
+
74
+ | Flag | Default | Description |
75
+ |---|---|---|
76
+ | `--recap-idle-seconds <n>` | `45` | Seconds after `turn_end` before the idle-fallback recap fires. |
77
+ | `--recap-focus-min-seconds <n>` | `3` | Minimum focus-out duration before a recap is revealed on refocus. |
78
+ | `--recap-disable-focus` | `false` | Disable DECSET `?1004` focus reporting. Idle fallback still runs. |
79
+ | `--recap-disable` | `false` | Disable the automatic recap entirely. `/recap` still works. |
80
+ | `--recap-model "<p/id>"` | (active model) | Override the default, e.g. `anthropic/claude-sonnet-4-6`. |
81
+
82
+ ## Command
83
+
84
+ | Command | Description |
85
+ |---|---|
86
+ | `/recap` | Force-generate a recap right now, bypassing the activity gate. |
87
+
88
+ ## Behaviour notes
89
+
90
+ - **Uses `turn_end`, not `agent_end`**, so a turn that errors or is aborted still gets recapped.
91
+ - **No duplicate drafts**: the last-drafted branch-leaf is stamped; if you focus out / in repeatedly without any new session activity, the recap is reused rather than regenerated.
92
+ - **Aborts on new input**: any in-flight recap request is cancelled when you start typing or a new turn begins.
93
+ - **No session persistence**: the recap lives only in the widget for the active session — nothing is stored.
94
+
95
+ ## Design
96
+
97
+ See [DESIGN.md](./DESIGN.md) for the design-of-record and open questions.
98
+
99
+ ## License
100
+
101
+ MIT
Binary file
@@ -0,0 +1,567 @@
1
+ /**
2
+ * session-recap
3
+ *
4
+ * Claude-Code-style session recap for pi. Two complementary triggers:
5
+ *
6
+ * 1) True terminal focus reporting via DECSET ?1004. When the terminal
7
+ * loses focus we start drafting a recap in the background; when it
8
+ * regains focus we reveal it in a widget above the editor. Mirrors
9
+ * Claude Code's "refocus the tab" moment.
10
+ *
11
+ * 2) Idle-return fallback: if the terminal doesn't support focus events,
12
+ * or the user stays in the same window, we still generate a recap N
13
+ * seconds after the last `turn_end` so something is waiting above the
14
+ * editor when they look back at the session. `turn_end` (not
15
+ * `agent_end`) is used so the fallback fires even when a turn ends
16
+ * in an error or is aborted by the user.
17
+ *
18
+ * Also fires on `/resume` (session_start reason="resume") to recap where
19
+ * the prior session left off.
20
+ *
21
+ * Model: defaults to the user's currently active model with
22
+ * `reasoning: "minimal"` when the model advertises reasoning support. This
23
+ * piggybacks on whatever auth the user already has configured (including
24
+ * custom providers) so there are no login surprises. Override explicitly
25
+ * with `--recap-model "<provider>/<id>"` if you want a specific model.
26
+ *
27
+ * Flags:
28
+ * --recap-idle-seconds <n> Seconds after turn_end for idle recap (default 45)
29
+ * --recap-focus-min-seconds <n> Min focus-out duration to show a recap (default 3)
30
+ * --recap-disable-focus Disable DECSET ?1004 focus reporting
31
+ * --recap-disable Disable the automatic recap entirely
32
+ * --recap-model <p/id> Override the default (active) model
33
+ *
34
+ * Command:
35
+ * /recap Force-generate a recap right now
36
+ */
37
+
38
+ import { completeSimple, getModel } from "@mariozechner/pi-ai";
39
+ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
40
+
41
+ type ContentBlock = {
42
+ type?: string;
43
+ text?: string;
44
+ name?: string;
45
+ arguments?: Record<string, unknown>;
46
+ };
47
+
48
+ type Entry = {
49
+ id?: string;
50
+ type: string;
51
+ message?: {
52
+ role?: string;
53
+ content?: unknown;
54
+ toolName?: string;
55
+ };
56
+ };
57
+
58
+ type Model = Parameters<typeof completeSimple>[0];
59
+
60
+ type RecapReason = "idle" | "manual" | "resume" | "focus";
61
+
62
+ const WIDGET_KEY = "session-recap";
63
+ const STATUS_KEY = "session-recap";
64
+
65
+ const DEFAULT_IDLE_SECONDS = 45;
66
+ const DEFAULT_FOCUS_MIN_SECONDS = 3;
67
+
68
+ // DECSET 1004 focus reporting — https://invisible-island.net/xterm/ctlseqs/ctlseqs.html
69
+ const FOCUS_ENABLE = "\x1b[?1004h";
70
+ const FOCUS_DISABLE = "\x1b[?1004l";
71
+ const FOCUS_IN_SEQ = "\x1b[I";
72
+ const FOCUS_OUT_SEQ = "\x1b[O";
73
+
74
+ // --- helpers -----------------------------------------------------------------
75
+
76
+ function splitModel(spec: string): { provider: string; id: string } | undefined {
77
+ const idx = spec.indexOf("/");
78
+ if (idx <= 0) return undefined;
79
+ return { provider: spec.slice(0, idx), id: spec.slice(idx + 1) };
80
+ }
81
+
82
+ function extractText(content: unknown): string {
83
+ if (typeof content === "string") return content;
84
+ if (!Array.isArray(content)) return "";
85
+ const parts: string[] = [];
86
+ for (const part of content) {
87
+ if (!part || typeof part !== "object") continue;
88
+ const b = part as ContentBlock;
89
+ if (b.type === "text" && typeof b.text === "string") parts.push(b.text);
90
+ }
91
+ return parts.join("\n");
92
+ }
93
+
94
+ function extractToolCalls(content: unknown): string[] {
95
+ if (!Array.isArray(content)) return [];
96
+ const out: string[] = [];
97
+ for (const part of content) {
98
+ if (!part || typeof part !== "object") continue;
99
+ const b = part as ContentBlock;
100
+ if (b.type !== "toolCall" || typeof b.name !== "string") continue;
101
+ const args = b.arguments ?? {};
102
+ const summary = JSON.stringify(args).slice(0, 280);
103
+ out.push(`- ${b.name}(${summary})`);
104
+ }
105
+ return out;
106
+ }
107
+
108
+ /**
109
+ * Compact transcript of the assistant's activity since the last user message.
110
+ * For `resume`, we pass the whole branch instead so the summariser has context.
111
+ */
112
+ function buildRecentTranscript(entries: Entry[], fromLastUser = true): string {
113
+ let slice = entries;
114
+ if (fromLastUser) {
115
+ let lastUserIdx = -1;
116
+ for (let i = entries.length - 1; i >= 0; i--) {
117
+ const e = entries[i];
118
+ if (e.type === "message" && e.message?.role === "user") {
119
+ lastUserIdx = i;
120
+ break;
121
+ }
122
+ }
123
+ if (lastUserIdx >= 0) slice = entries.slice(lastUserIdx);
124
+ }
125
+
126
+ const lines: string[] = [];
127
+ for (const e of slice) {
128
+ if (e.type !== "message" || !e.message?.role) continue;
129
+ const role = e.message.role;
130
+ if (role === "user") {
131
+ const t = extractText(e.message.content).trim();
132
+ if (t) lines.push(`User: ${t.slice(0, 1200)}`);
133
+ } else if (role === "assistant") {
134
+ const t = extractText(e.message.content).trim();
135
+ if (t) lines.push(`Assistant: ${t.slice(0, 1200)}`);
136
+ const calls = extractToolCalls(e.message.content);
137
+ if (calls.length) lines.push(...calls);
138
+ } else if (role === "toolResult") {
139
+ const t = extractText(e.message.content).trim();
140
+ const name = e.message.toolName ?? "tool";
141
+ if (t) lines.push(`Result(${name}): ${t.slice(0, 400)}`);
142
+ }
143
+ }
144
+ return lines.join("\n");
145
+ }
146
+
147
+ /**
148
+ * Only draft a recap if there has been real agent activity since the last user
149
+ * message: at least one tool call, or ~30+ words of assistant text.
150
+ */
151
+ function hasMeaningfulActivity(entries: Entry[]): boolean {
152
+ let lastUserIdx = -1;
153
+ for (let i = entries.length - 1; i >= 0; i--) {
154
+ const e = entries[i];
155
+ if (e.type === "message" && e.message?.role === "user") {
156
+ lastUserIdx = i;
157
+ break;
158
+ }
159
+ }
160
+ const tail = lastUserIdx >= 0 ? entries.slice(lastUserIdx + 1) : entries;
161
+ let assistantWords = 0;
162
+ let toolCalls = 0;
163
+ for (const e of tail) {
164
+ if (e.type !== "message") continue;
165
+ if (e.message?.role === "assistant") {
166
+ const t = extractText(e.message.content);
167
+ assistantWords += t.split(/\s+/).filter(Boolean).length;
168
+ toolCalls += extractToolCalls(e.message.content).length;
169
+ }
170
+ }
171
+ return toolCalls > 0 || assistantWords >= 30;
172
+ }
173
+
174
+ async function generateRecap(
175
+ transcript: string,
176
+ ctx: ExtensionContext,
177
+ overrideSpec: string | undefined,
178
+ signal: AbortSignal | undefined,
179
+ ): Promise<string | undefined> {
180
+ // Prefer explicit override flag; otherwise use the active model.
181
+ let model: Model | undefined = ctx.model;
182
+ if (overrideSpec) {
183
+ const parsed = splitModel(overrideSpec);
184
+ if (parsed) {
185
+ const found = (getModel as (provider: string, id: string) => Model | undefined)(
186
+ parsed.provider,
187
+ parsed.id,
188
+ );
189
+ if (found) model = found;
190
+ }
191
+ }
192
+ if (!model) return undefined;
193
+
194
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
195
+ if (!auth?.ok || !auth.apiKey) return undefined;
196
+
197
+ const prompt =
198
+ "You produce a single-line recap of what the coding agent just did, " +
199
+ "so the user can re-enter flow after switching focus back to this session.\n\n" +
200
+ "Rules:\n" +
201
+ "- Output ONE line, no preamble, no markdown.\n" +
202
+ "- Format: `recap: <what happened, past tense, concrete>. Next: <one-line next step>.`\n" +
203
+ "- If there is no meaningful next step, omit the `Next:` clause.\n" +
204
+ "- If the transcript shows the turn was aborted or errored, say so explicitly " +
205
+ '(e.g. "aborted during X", "errored at Y").\n' +
206
+ "- Use file/function names where relevant. Be concrete, not vague.\n" +
207
+ "- Max ~220 characters.\n\n" +
208
+ "<transcript>\n" +
209
+ transcript.slice(0, 12000) +
210
+ "\n</transcript>";
211
+
212
+ const response = await completeSimple(
213
+ model,
214
+ {
215
+ messages: [
216
+ {
217
+ role: "user",
218
+ content: [{ type: "text", text: prompt }],
219
+ timestamp: Date.now(),
220
+ },
221
+ ],
222
+ },
223
+ {
224
+ apiKey: auth.apiKey,
225
+ headers: auth.headers,
226
+ signal,
227
+ // Only request reasoning on reasoning-capable models. Non-reasoning
228
+ // models ignore unknown params but we keep this clean.
229
+ ...(model.reasoning ? { reasoning: "minimal" as const } : {}),
230
+ },
231
+ );
232
+
233
+ const text = response.content
234
+ .filter((c): c is { type: "text"; text: string } => c.type === "text")
235
+ .map((c) => c.text)
236
+ .join("\n")
237
+ .trim();
238
+
239
+ return text ? text.split(/\r?\n/, 1)[0].trim() : undefined;
240
+ }
241
+
242
+ function showRecap(ctx: ExtensionContext, recap: string) {
243
+ if (!ctx.hasUI) return;
244
+ const theme = ctx.ui.theme;
245
+ const header = theme.fg("accent", theme.bold("✦ recap"));
246
+ ctx.ui.setWidget(WIDGET_KEY, [header, theme.fg("dim", recap)], { placement: "aboveEditor" });
247
+ }
248
+
249
+ function clearRecap(ctx: ExtensionContext) {
250
+ if (!ctx.hasUI) return;
251
+ ctx.ui.setWidget(WIDGET_KEY, undefined);
252
+ ctx.ui.setStatus(STATUS_KEY, undefined);
253
+ }
254
+
255
+ // --- extension ---------------------------------------------------------------
256
+
257
+ export default function (pi: ExtensionAPI) {
258
+ pi.registerFlag("recap-idle-seconds", {
259
+ description: "Seconds after turn_end before the session recap is generated",
260
+ type: "string",
261
+ default: String(DEFAULT_IDLE_SECONDS),
262
+ });
263
+ pi.registerFlag("recap-focus-min-seconds", {
264
+ description: "Minimum focus-out duration (seconds) before showing a recap on refocus",
265
+ type: "string",
266
+ default: String(DEFAULT_FOCUS_MIN_SECONDS),
267
+ });
268
+ pi.registerFlag("recap-disable-focus", {
269
+ description: "Disable DECSET ?1004 focus reporting (idle fallback still runs)",
270
+ type: "boolean",
271
+ default: false,
272
+ });
273
+ pi.registerFlag("recap-disable", {
274
+ description: "Disable the automatic session recap",
275
+ type: "boolean",
276
+ default: false,
277
+ });
278
+ pi.registerFlag("recap-model", {
279
+ description: "Override the default (active) model, e.g. anthropic/claude-sonnet-4-6",
280
+ type: "string",
281
+ default: "",
282
+ });
283
+
284
+ let idleTimer: NodeJS.Timeout | undefined;
285
+
286
+ // Active recap request state. Only one request is ever in flight; starting
287
+ // a new one aborts the previous. We track both the controller and the
288
+ // reason so we can ask questions like "is there a focus draft running?"
289
+ // without a separate boolean that can go out of sync on late completions.
290
+ let activeController: AbortController | undefined;
291
+ let activeReason: RecapReason | undefined;
292
+
293
+ // Focus reporting state.
294
+ let focusListener: ((chunk: Buffer) => void) | undefined;
295
+ let focusEnabled = false;
296
+ let focusedOutAt: number | undefined;
297
+ let pendingRecap: string | undefined; // drafted while away, shown on refocus
298
+
299
+ // Leaf-id of the branch state we last drafted for. Lets us skip regen on
300
+ // refocus churn when nothing has happened in the session.
301
+ let lastDraftedLeafId: string | undefined;
302
+
303
+ const idleMs = (): number => {
304
+ const n = Number(pi.getFlag("--recap-idle-seconds") ?? DEFAULT_IDLE_SECONDS);
305
+ return Math.max(5, Number.isFinite(n) ? n : DEFAULT_IDLE_SECONDS) * 1000;
306
+ };
307
+ const focusMinMs = (): number => {
308
+ const n = Number(pi.getFlag("--recap-focus-min-seconds") ?? DEFAULT_FOCUS_MIN_SECONDS);
309
+ return Math.max(0, Number.isFinite(n) ? n : DEFAULT_FOCUS_MIN_SECONDS) * 1000;
310
+ };
311
+ const isDisabled = (): boolean => Boolean(pi.getFlag("--recap-disable"));
312
+ const isFocusDisabled = (): boolean => Boolean(pi.getFlag("--recap-disable-focus"));
313
+ const modelOverride = (): string | undefined => {
314
+ const v = String(pi.getFlag("--recap-model") ?? "").trim();
315
+ return v.length > 0 ? v : undefined;
316
+ };
317
+
318
+ const clearTimer = () => {
319
+ if (idleTimer) {
320
+ clearTimeout(idleTimer);
321
+ idleTimer = undefined;
322
+ }
323
+ };
324
+
325
+ const cancelActive = () => {
326
+ if (activeController) {
327
+ activeController.abort();
328
+ activeController = undefined;
329
+ activeReason = undefined;
330
+ }
331
+ };
332
+
333
+ const scheduleRecap = (ctx: ExtensionContext) => {
334
+ clearTimer();
335
+ if (isDisabled() || !ctx.hasUI) return;
336
+ idleTimer = setTimeout(() => {
337
+ idleTimer = undefined;
338
+ void generateAndShow(ctx, { reason: "idle" });
339
+ }, idleMs());
340
+ };
341
+
342
+ const getLeafId = (ctx: ExtensionContext): string | undefined => {
343
+ try {
344
+ return ctx.sessionManager.getLeafId();
345
+ } catch {
346
+ return undefined;
347
+ }
348
+ };
349
+
350
+ const generateAndShow = async (ctx: ExtensionContext, opts: { reason: RecapReason }) => {
351
+ const entries = ctx.sessionManager.getBranch() as Entry[];
352
+ if (!hasMeaningfulActivity(entries) && opts.reason !== "manual") return;
353
+
354
+ const transcript = buildRecentTranscript(entries, opts.reason !== "resume");
355
+ if (!transcript.trim()) return;
356
+
357
+ // Snapshot the leaf we're summarising BEFORE we await. If the branch
358
+ // advances while the model call is in flight, the recap reflects stale
359
+ // content — we must discard it rather than stamp the wrong leaf.
360
+ const startLeaf = getLeafId(ctx);
361
+
362
+ // Take ownership of the active-request slot. Any prior request is
363
+ // cancelled; we'll only clear shared state in the finally if we're
364
+ // still the current owner, so a late-completing aborted call can't
365
+ // stomp on a newer in-flight request.
366
+ cancelActive();
367
+ const controller = new AbortController();
368
+ activeController = controller;
369
+ activeReason = opts.reason;
370
+
371
+ const showStatus = opts.reason !== "resume" && opts.reason !== "focus";
372
+ if (showStatus && ctx.hasUI)
373
+ ctx.ui.setStatus(STATUS_KEY, ctx.ui.theme.fg("dim", "✦ drafting recap…"));
374
+
375
+ try {
376
+ const recap = await generateRecap(transcript, ctx, modelOverride(), controller.signal);
377
+ if (!recap || controller.signal.aborted) return;
378
+ // Discard the recap if the branch moved on while we were drafting.
379
+ if (getLeafId(ctx) !== startLeaf) return;
380
+
381
+ // Stamp with the leaf we actually summarised, not the live one.
382
+ lastDraftedLeafId = startLeaf;
383
+ // Another trigger has now produced a recap for this leaf — kill the
384
+ // idle fallback so we don't issue a second call 45s later.
385
+ clearTimer();
386
+
387
+ if (opts.reason === "focus") {
388
+ if (focusedOutAt === undefined) showRecap(ctx, recap);
389
+ else pendingRecap = recap;
390
+ } else {
391
+ showRecap(ctx, recap);
392
+ }
393
+ } catch (err) {
394
+ if (!controller.signal.aborted) console.error("[session-recap] failed:", err);
395
+ } finally {
396
+ if (activeController === controller) {
397
+ activeController = undefined;
398
+ activeReason = undefined;
399
+ if (showStatus && ctx.hasUI) ctx.ui.setStatus(STATUS_KEY, undefined);
400
+ }
401
+ }
402
+ };
403
+
404
+ // --- focus reporting wiring -------------------------------------------
405
+
406
+ const handleFocusOut = (ctx: ExtensionContext) => {
407
+ focusedOutAt = Date.now();
408
+ if (isDisabled() || activeController) return;
409
+
410
+ // Skip regen if we already have a fresh recap for the current session
411
+ // state — regardless of whether it's still parked in pendingRecap or
412
+ // already shown in the widget. The stamp is invalidated on any new
413
+ // turn_end / input / agent_start.
414
+ const leaf = getLeafId(ctx);
415
+ if (lastDraftedLeafId && leaf === lastDraftedLeafId) return;
416
+
417
+ const entries = ctx.sessionManager.getBranch() as Entry[];
418
+ if (!hasMeaningfulActivity(entries)) return;
419
+ void generateAndShow(ctx, { reason: "focus" });
420
+ };
421
+
422
+ const handleFocusIn = (ctx: ExtensionContext) => {
423
+ const outAt = focusedOutAt;
424
+ focusedOutAt = undefined;
425
+ if (outAt === undefined) return; // spurious focus-in before we saw focus-out
426
+ const duration = Date.now() - outAt;
427
+ if (duration < focusMinMs()) {
428
+ // Quick glance — discard any parked recap AND cancel an in-flight
429
+ // focus draft so a slow model response can't bypass min-seconds.
430
+ // Also clear the leaf stamp, otherwise a later real absence at the
431
+ // same leaf would skip regen and never surface a recap.
432
+ pendingRecap = undefined;
433
+ lastDraftedLeafId = undefined;
434
+ if (activeReason === "focus") cancelActive();
435
+ return;
436
+ }
437
+ if (pendingRecap) {
438
+ const recap = pendingRecap;
439
+ pendingRecap = undefined;
440
+ showRecap(ctx, recap);
441
+ }
442
+ // Still drafting? generateAndShow's success-path will reveal it when done.
443
+ };
444
+
445
+ const attachFocusReporting = (ctx: ExtensionContext) => {
446
+ if (focusEnabled || isFocusDisabled() || !ctx.hasUI) return;
447
+ if (!process.stdout.isTTY || !process.stdin.isTTY) return;
448
+
449
+ try {
450
+ process.stdout.write(FOCUS_ENABLE);
451
+ } catch {
452
+ return;
453
+ }
454
+
455
+ // Scan stdin for ESC[I / ESC[O. Sequences can straddle chunks, so we
456
+ // keep unconsumed trailing bytes in `buf` between calls. Consume each
457
+ // match by advancing `i`, so a completed sequence never fires twice.
458
+ // Adding a 'data' listener is safe: Node dispatches to all listeners
459
+ // and pi is already in flowing mode — we don't steal bytes from the
460
+ // TUI's input layer.
461
+ const MAX_SEQ = Math.max(FOCUS_IN_SEQ.length, FOCUS_OUT_SEQ.length);
462
+ let buf = "";
463
+ const listener = (chunk: Buffer) => {
464
+ try {
465
+ buf += chunk.toString("binary");
466
+ let i = 0;
467
+ while (i + MAX_SEQ <= buf.length) {
468
+ if (buf.startsWith(FOCUS_IN_SEQ, i)) {
469
+ handleFocusIn(ctx);
470
+ i += FOCUS_IN_SEQ.length;
471
+ } else if (buf.startsWith(FOCUS_OUT_SEQ, i)) {
472
+ handleFocusOut(ctx);
473
+ i += FOCUS_OUT_SEQ.length;
474
+ } else {
475
+ i++;
476
+ }
477
+ }
478
+ buf = buf.slice(i);
479
+ // Safety net — never let buf grow unbounded if we're reading a
480
+ // long non-escape stream on a terminal that streams ahead of us.
481
+ if (buf.length > 64) buf = buf.slice(-(MAX_SEQ - 1));
482
+ } catch {
483
+ /* best-effort */
484
+ }
485
+ };
486
+ process.stdin.on("data", listener);
487
+ focusListener = listener;
488
+ focusEnabled = true;
489
+ };
490
+
491
+ const detachFocusReporting = () => {
492
+ if (focusListener) {
493
+ try {
494
+ process.stdin.off("data", focusListener);
495
+ } catch {
496
+ /* noop */
497
+ }
498
+ focusListener = undefined;
499
+ }
500
+ if (focusEnabled) {
501
+ try {
502
+ process.stdout.write(FOCUS_DISABLE);
503
+ } catch {
504
+ /* noop */
505
+ }
506
+ focusEnabled = false;
507
+ }
508
+ focusedOutAt = undefined;
509
+ pendingRecap = undefined;
510
+ };
511
+
512
+ // Lifecycle: idle timer arms on turn_end (fires even on error/abort),
513
+ // and is cleared on anything that indicates new activity or input.
514
+
515
+ pi.on("turn_end", async (_event, ctx) => {
516
+ // A new turn (successful or not) invalidates any prior draft.
517
+ lastDraftedLeafId = undefined;
518
+ scheduleRecap(ctx);
519
+ });
520
+
521
+ pi.on("turn_start", async () => {
522
+ // Another turn is starting in the same agent loop — clear the idle timer
523
+ // we armed on the previous turn_end; it'll re-arm on the next turn_end.
524
+ clearTimer();
525
+ });
526
+
527
+ pi.on("input", async (_event, ctx) => {
528
+ clearTimer();
529
+ cancelActive();
530
+ pendingRecap = undefined;
531
+ lastDraftedLeafId = undefined;
532
+ clearRecap(ctx);
533
+ });
534
+
535
+ pi.on("agent_start", async (_event, ctx) => {
536
+ clearTimer();
537
+ cancelActive();
538
+ pendingRecap = undefined;
539
+ lastDraftedLeafId = undefined;
540
+ clearRecap(ctx);
541
+ });
542
+
543
+ pi.on("session_shutdown", async () => {
544
+ clearTimer();
545
+ cancelActive();
546
+ detachFocusReporting();
547
+ });
548
+
549
+ // Session start: wire up focus reporting; on resume, show a recap.
550
+ pi.on("session_start", async (event, ctx) => {
551
+ attachFocusReporting(ctx);
552
+ if (isDisabled()) return;
553
+ if (event.reason === "resume" || event.reason === "fork") {
554
+ setTimeout(() => {
555
+ void generateAndShow(ctx, { reason: "resume" });
556
+ }, 300);
557
+ }
558
+ });
559
+
560
+ // Manual command.
561
+ pi.registerCommand("recap", {
562
+ description: "Generate a one-line recap of recent session activity",
563
+ handler: async (_args, ctx) => {
564
+ await generateAndShow(ctx, { reason: "manual" });
565
+ },
566
+ });
567
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "@tmustier/pi-session-recap",
3
+ "version": "0.1.0",
4
+ "description": "One-line recap above the editor when you refocus a Pi session. Keeps you in flow when multi-agenting.",
5
+ "license": "MIT",
6
+ "author": "Thomas Mustier",
7
+ "keywords": [
8
+ "pi-package"
9
+ ],
10
+ "repository": {
11
+ "type": "git",
12
+ "url": "git+https://github.com/tmustier/pi-extensions.git",
13
+ "directory": "session-recap"
14
+ },
15
+ "bugs": "https://github.com/tmustier/pi-extensions/issues",
16
+ "homepage": "https://github.com/tmustier/pi-extensions/tree/main/session-recap",
17
+ "pi": {
18
+ "extensions": [
19
+ "index.ts"
20
+ ],
21
+ "image": "https://raw.githubusercontent.com/tmustier/pi-extensions/main/session-recap/assets/recap.png"
22
+ }
23
+ }