@saifymatteo/pi-extensions 0.0.0-stage → 0.1.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.
Files changed (183) hide show
  1. package/LICENSE +661 -0
  2. package/NOTICE.md +20 -0
  3. package/README.md +151 -2
  4. package/inventory.json +73 -0
  5. package/package.json +74 -4
  6. package/packages/pi-bg-bash/LICENSE +661 -0
  7. package/packages/pi-bg-bash/NOTICE.md +3 -0
  8. package/packages/pi-bg-bash/README.md +41 -0
  9. package/packages/pi-bg-bash/examples/settings.example.json +6 -0
  10. package/packages/pi-bg-bash/package.json +38 -0
  11. package/packages/pi-bg-bash/src/index.ts +969 -0
  12. package/packages/pi-bg-bash/src/lib/async-work.ts +27 -0
  13. package/packages/pi-bg-bash/test/bg-bash.test.mjs +34 -0
  14. package/packages/pi-claude-usage/LICENSE +661 -0
  15. package/packages/pi-claude-usage/NOTICE.md +3 -0
  16. package/packages/pi-claude-usage/README.md +27 -0
  17. package/packages/pi-claude-usage/package.json +35 -0
  18. package/packages/pi-claude-usage/src/index.ts +130 -0
  19. package/packages/pi-clipboard-image/LICENSE +661 -0
  20. package/packages/pi-clipboard-image/NOTICE.md +3 -0
  21. package/packages/pi-clipboard-image/README.md +27 -0
  22. package/packages/pi-clipboard-image/package.json +36 -0
  23. package/packages/pi-clipboard-image/src/index.ts +527 -0
  24. package/packages/pi-codeblock-borders/LICENSE +661 -0
  25. package/packages/pi-codeblock-borders/NOTICE.md +3 -0
  26. package/packages/pi-codeblock-borders/README.md +30 -0
  27. package/packages/pi-codeblock-borders/package.json +35 -0
  28. package/packages/pi-codeblock-borders/src/index.ts +302 -0
  29. package/packages/pi-codegraph/LICENSE +661 -0
  30. package/packages/pi-codegraph/NOTICE.md +5 -0
  31. package/packages/pi-codegraph/README.md +35 -0
  32. package/packages/pi-codegraph/examples/codegraph.example.json +4 -0
  33. package/packages/pi-codegraph/package.json +37 -0
  34. package/packages/pi-codegraph/src/index.ts +573 -0
  35. package/packages/pi-codegraph/test/codegraph.test.mjs +21 -0
  36. package/packages/pi-compact-tools/LICENSE +661 -0
  37. package/packages/pi-compact-tools/NOTICE.md +3 -0
  38. package/packages/pi-compact-tools/README.md +27 -0
  39. package/packages/pi-compact-tools/package.json +36 -0
  40. package/packages/pi-compact-tools/src/index.ts +1161 -0
  41. package/packages/pi-decisions/LICENSE +661 -0
  42. package/packages/pi-decisions/NOTICE.md +3 -0
  43. package/packages/pi-decisions/README.md +37 -0
  44. package/packages/pi-decisions/examples/service-models.example.json +27 -0
  45. package/packages/pi-decisions/package.json +37 -0
  46. package/packages/pi-decisions/src/index.ts +364 -0
  47. package/packages/pi-decisions/src/lib/service-models.ts +95 -0
  48. package/packages/pi-decisions/test/service-models.test.mjs +71 -0
  49. package/packages/pi-load-pi-dotenv/LICENSE +661 -0
  50. package/packages/pi-load-pi-dotenv/NOTICE.md +3 -0
  51. package/packages/pi-load-pi-dotenv/README.md +27 -0
  52. package/packages/pi-load-pi-dotenv/package.json +32 -0
  53. package/packages/pi-load-pi-dotenv/src/index.ts +66 -0
  54. package/packages/pi-model-go/LICENSE +661 -0
  55. package/packages/pi-model-go/NOTICE.md +5 -0
  56. package/packages/pi-model-go/README.md +33 -0
  57. package/packages/pi-model-go/package.json +36 -0
  58. package/packages/pi-model-go/src/index.ts +171 -0
  59. package/packages/pi-model-go/src/lib/dialog.ts +31 -0
  60. package/packages/pi-model-go/src/lib/model-go.ts +239 -0
  61. package/packages/pi-model-go/test/behavior.test.mjs +17 -0
  62. package/packages/pi-prestart-working/LICENSE +661 -0
  63. package/packages/pi-prestart-working/NOTICE.md +3 -0
  64. package/packages/pi-prestart-working/README.md +25 -0
  65. package/packages/pi-prestart-working/package.json +35 -0
  66. package/packages/pi-prestart-working/src/index.ts +86 -0
  67. package/packages/pi-prestart-working/test/behavior.test.mjs +56 -0
  68. package/packages/pi-root-guard/LICENSE +661 -0
  69. package/packages/pi-root-guard/NOTICE.md +3 -0
  70. package/packages/pi-root-guard/README.md +27 -0
  71. package/packages/pi-root-guard/package.json +32 -0
  72. package/packages/pi-root-guard/src/index.ts +202 -0
  73. package/packages/pi-session-finder/LICENSE +661 -0
  74. package/packages/pi-session-finder/NOTICE.md +3 -0
  75. package/packages/pi-session-finder/README.md +33 -0
  76. package/packages/pi-session-finder/package.json +36 -0
  77. package/packages/pi-session-finder/src/index.ts +306 -0
  78. package/packages/pi-session-finder/src/lib/dialog.ts +31 -0
  79. package/packages/pi-session-finder/test/behavior.test.mjs +18 -0
  80. package/packages/pi-session-id/LICENSE +661 -0
  81. package/packages/pi-session-id/NOTICE.md +3 -0
  82. package/packages/pi-session-id/README.md +31 -0
  83. package/packages/pi-session-id/package.json +35 -0
  84. package/packages/pi-session-id/src/index.ts +20 -0
  85. package/packages/pi-session-id/test/behavior.test.mjs +25 -0
  86. package/packages/pi-session-nav/LICENSE +661 -0
  87. package/packages/pi-session-nav/NOTICE.md +3 -0
  88. package/packages/pi-session-nav/README.md +32 -0
  89. package/packages/pi-session-nav/package.json +36 -0
  90. package/packages/pi-session-nav/src/index.ts +203 -0
  91. package/packages/pi-session-nav/src/lib/dialog.ts +31 -0
  92. package/packages/pi-session-nav/test/behavior.test.mjs +36 -0
  93. package/packages/pi-session-rename/LICENSE +661 -0
  94. package/packages/pi-session-rename/NOTICE.md +3 -0
  95. package/packages/pi-session-rename/README.md +31 -0
  96. package/packages/pi-session-rename/package.json +35 -0
  97. package/packages/pi-session-rename/src/index.ts +456 -0
  98. package/packages/pi-session-rename/test/behavior.test.mjs +10 -0
  99. package/packages/pi-session-retention/LICENSE +661 -0
  100. package/packages/pi-session-retention/NOTICE.md +3 -0
  101. package/packages/pi-session-retention/README.md +62 -0
  102. package/packages/pi-session-retention/package.json +36 -0
  103. package/packages/pi-session-retention/src/README.md +42 -0
  104. package/packages/pi-session-retention/src/core.ts +333 -0
  105. package/packages/pi-session-retention/src/dialog.ts +118 -0
  106. package/packages/pi-session-retention/src/index.ts +311 -0
  107. package/packages/pi-session-retention/src/lib/dialog.ts +31 -0
  108. package/packages/pi-session-retention/src/safety.ts +50 -0
  109. package/packages/pi-session-retention/test/behavior.test.mjs +42 -0
  110. package/packages/pi-skill-usage-log/LICENSE +661 -0
  111. package/packages/pi-skill-usage-log/NOTICE.md +3 -0
  112. package/packages/pi-skill-usage-log/README.md +32 -0
  113. package/packages/pi-skill-usage-log/package.json +36 -0
  114. package/packages/pi-skill-usage-log/src/index.ts +316 -0
  115. package/packages/pi-skill-usage-log/src/lib/dialog.ts +31 -0
  116. package/packages/pi-skill-usage-log/src/lib/usage-log.ts +62 -0
  117. package/packages/pi-skill-usage-log/test/behavior.test.mjs +34 -0
  118. package/packages/pi-tool-usage-log/LICENSE +661 -0
  119. package/packages/pi-tool-usage-log/NOTICE.md +5 -0
  120. package/packages/pi-tool-usage-log/README.md +32 -0
  121. package/packages/pi-tool-usage-log/package.json +36 -0
  122. package/packages/pi-tool-usage-log/src/index.ts +233 -0
  123. package/packages/pi-tool-usage-log/src/lib/dialog.ts +31 -0
  124. package/packages/pi-tool-usage-log/src/lib/usage-log.ts +62 -0
  125. package/packages/pi-tool-usage-log/src/skill-usage-log.ts +316 -0
  126. package/packages/pi-tool-usage-log/test/behavior.test.mjs +23 -0
  127. package/packages/pi-tts/LICENSE +661 -0
  128. package/packages/pi-tts/NOTICE.md +3 -0
  129. package/packages/pi-tts/README.md +37 -0
  130. package/packages/pi-tts/examples/service-models.example.json +27 -0
  131. package/packages/pi-tts/package.json +37 -0
  132. package/packages/pi-tts/src/index.ts +135 -0
  133. package/packages/pi-tts/src/lib/service-models.ts +95 -0
  134. package/packages/pi-tts/test/tts-output.test.mjs +47 -0
  135. package/packages/pi-tts/test/tts.test.mjs +57 -0
  136. package/packages/pi-user-image-previews/LICENSE +661 -0
  137. package/packages/pi-user-image-previews/NOTICE.md +3 -0
  138. package/packages/pi-user-image-previews/README.md +27 -0
  139. package/packages/pi-user-image-previews/package.json +36 -0
  140. package/packages/pi-user-image-previews/src/index.ts +168 -0
  141. package/packages/pi-vision/LICENSE +661 -0
  142. package/packages/pi-vision/NOTICE.md +5 -0
  143. package/packages/pi-vision/README.md +43 -0
  144. package/packages/pi-vision/examples/vision-toolkit.example.json +12 -0
  145. package/packages/pi-vision/package.json +38 -0
  146. package/packages/pi-vision/skills/vision-tools/LICENSE +21 -0
  147. package/packages/pi-vision/skills/vision-tools/SKILL.md +134 -0
  148. package/packages/pi-vision/skills/vision-tools/agents/openai.yaml +4 -0
  149. package/packages/pi-vision/skills/vision-tools/bin/crop +94 -0
  150. package/packages/pi-vision/skills/vision-tools/bin/detect +18 -0
  151. package/packages/pi-vision/skills/vision-tools/bin/glance +98 -0
  152. package/packages/pi-vision/skills/vision-tools/bin/ground +18 -0
  153. package/packages/pi-vision/skills/vision-tools/bin/trace +134 -0
  154. package/packages/pi-vision/skills/vision-tools/bin/vision-doctor +168 -0
  155. package/packages/pi-vision/skills/vision-tools/detect.py +56 -0
  156. package/packages/pi-vision/skills/vision-tools/ground.py +216 -0
  157. package/packages/pi-vision/skills/vision-tools/references/gui.md +93 -0
  158. package/packages/pi-vision/skills/vision-tools/references/long-screenshot-ocr.md +103 -0
  159. package/packages/pi-vision/skills/vision-tools/references/restore-graphic.md +92 -0
  160. package/packages/pi-vision/skills/vision-tools/references/restore-structure.md +47 -0
  161. package/packages/pi-vision/skills/vision-tools/references/restore-ui.md +200 -0
  162. package/packages/pi-vision/skills/vision-tools/scripts/dominant_colors.py +224 -0
  163. package/packages/pi-vision/skills/vision-tools/scripts/extract_fg.py +278 -0
  164. package/packages/pi-vision/skills/vision-tools/scripts/html_shot.py +111 -0
  165. package/packages/pi-vision/skills/vision-tools/scripts/long_screenshot_ocr.py +1245 -0
  166. package/packages/pi-vision/skills/vision-tools/scripts/pixel_diff.py +88 -0
  167. package/packages/pi-vision/skills/vision-tools/vision_client.py +581 -0
  168. package/packages/pi-vision/src/index.ts +728 -0
  169. package/packages/pi-vision/test/vision.test.mjs +30 -0
  170. package/packages/pi-wait/LICENSE +661 -0
  171. package/packages/pi-wait/NOTICE.md +3 -0
  172. package/packages/pi-wait/README.md +37 -0
  173. package/packages/pi-wait/package.json +37 -0
  174. package/packages/pi-wait/src/index.ts +88 -0
  175. package/packages/pi-wait/src/lib/async-work.ts +27 -0
  176. package/packages/pi-wait/test/wait.test.mjs +43 -0
  177. package/packages/pi-zvec/LICENSE +661 -0
  178. package/packages/pi-zvec/NOTICE.md +3 -0
  179. package/packages/pi-zvec/README.md +37 -0
  180. package/packages/pi-zvec/examples/zvec.example.json +5 -0
  181. package/packages/pi-zvec/package.json +37 -0
  182. package/packages/pi-zvec/src/index.ts +754 -0
  183. package/packages/pi-zvec/test/zvec.test.mjs +19 -0
@@ -0,0 +1,62 @@
1
+ # pi-session-retention
2
+
3
+ From the monorepo root:
4
+
5
+ ```sh
6
+ pi -e ./packages/pi-session-retention
7
+ ```
8
+
9
+ **Warning:** this uses existing personal Pi resources and settings unless isolated as described in the root README. Existing retention approval/state is reused: startup may automatically move sessions and permanently purge expired Trash without a new approval. Back up conversations before loading. Do not load two copies of this extension at once.
10
+
11
+ Node.js >=24 for development; Pi loads the TypeScript extension. Host coding-agent/TUI packages are peers. Store locations and destructive behavior are unchanged. `PI_CODING_AGENT_DIR` selects the agent home (default `~/.pi/agent`); `PI_CODING_AGENT_SESSION_DIR` and explicit session-directory overrides narrow the scope. See the operational notes below and [NOTICE.md](NOTICE.md) for provenance.
12
+
13
+ ## Packaged tests and limitations
14
+
15
+ Run `node --test packages/pi-session-retention/test/*.test.mjs` from the repository root after its dev dependencies are available. Tests use temporary fixtures or pure helpers, never the real session store. Interactive terminal behavior and OS process discovery still need manual exercise in an isolated agent home. Requires working `ps` on Unix or PowerShell/CIM on Windows and a filesystem supporting exclusive hard-link publication. No network/model requests are made by retention. Trash searches read stored conversation text; local Trash is not encryption or secure erasure.
16
+
17
+ The original operational README is included under `src/README.md`. Its `bun`-based check commands and deployment statement describe the private workspace this extension was developed in, not this repository; use the repository-root checks above for verification.
18
+
19
+ ## Original operational documentation
20
+
21
+ # Session retention
22
+
23
+ Moves inactive session families out of Pi's searchable session store. No search index, scheduler, model calls, or added dependencies.
24
+
25
+ ## Use
26
+
27
+ - `/session-cleanup preview` — show counts, scope and example candidate families without moving or deleting sessions.
28
+ - `/session-cleanup` — review the first cleanup and approve daily automatic maintenance. Later calls run maintenance immediately.
29
+ - `/session-cleanup pause` — pause automatic maintenance; run `/session-cleanup` to review and enable it again.
30
+ - `/session-trash [query]` — open a dialog styled like `session-finder.ts`, with a heavy border, padded rows, a `Search:` line and yellow selection, listing trashed families immediately. Type to filter by names, first user prompts, IDs or original paths; use ↑↓ and Enter to select, then confirm a whole-family restore. Esc cancels. An optional query prefills the filter; clearing it shows all families again.
31
+
32
+ `/session-cleanup` advertises `[preview|pause]` and offers both arguments through command completion.
33
+
34
+ The footer shows maintenance phases: scanning, approval, process checks, moving sessions and purging expired Trash. Trash loading and restoration also show status. The status clears on completion, cancellation or failure; it does not leave an idle indicator.
35
+
36
+ Interactive startup checks at most once per 24 hours, including after session switches/reloads. Print, JSON and RPC modes never run maintenance. There is no OS scheduler: expired Trash remains until the next successful cleanup.
37
+
38
+ ## Rules
39
+
40
+ A family consists of a session and every descendant linked by `parentSession`, including descendants in another project. Its activity clock is the newest filesystem modification time or header timestamp among its members. Only families strictly more than 30 days inactive qualify. Named sessions use the same rule. The current session protects its entire family.
41
+
42
+ Trashed families receive another 30 days before permanent deletion. Restoring a family preserves its JSONL contents and original paths, updates file modification times to give the whole family a fresh 30-day grace period, and never overwrites existing files.
43
+
44
+ Default scope is every project under the agent's normal `sessions` store. A custom session directory reported by the session manager is treated as its own store; the extension does not sweep unrelated custom locations. Trash is a sibling `.trash/<store-name>-<path-hash>` directory **outside** the live store, not `sessions/.trash`. Approval, pause state, daily timestamp and maintenance lock live there too, not in this repository.
45
+
46
+ Startup reads bounded session headers and file metadata, not every transcript. A cleanup batch revalidates the family graph once, rather than rescanning the entire store for each family. Only explicit Trash searches read conversation text from trashed files. Normal session discovery no longer needs to read those files. This does not erase copies exported elsewhere or memories already retained by external integrations.
47
+
48
+ ## Safety and recovery
49
+
50
+ - Maintenance is serialized by an exclusive per-store lock. Process discovery must succeed, and maintenance defers when its inventory finds another recognized Pi CLI process, including idle windows. Successful inventories are reused for at most one second within a maintenance transaction to avoid starting PowerShell for every file. Session switches and forks in the current window are blocked until maintenance ends. It does not assume that excluding this window protects other windows. Close other Pi windows and run `/session-cleanup` to retry. Unrecognized custom SDK hosts and programs with renamed executables are outside this process detector's guarantee; do not run maintenance alongside them.
51
+ - On `/reload`, session replacement or graceful shutdown, maintenance cancels pending approval/Trash dialogs, stops at the next safety boundary and waits for in-flight file work to finish before releasing its lock. The lock is never removed while its operation can still mutate files. Forced termination or a crash can still leave a stale lock; these are not automatically removed.
52
+ - Each family has a recovery manifest written before any file moves. Incomplete operations remain recoverable in `/session-trash` and are never automatically purged. Keep the manifest with its files. If the process crashes leaving `.maintenance.lock`, close all Pi processes before removing that stale lock and retrying. A corrupt lock fails closed rather than being guessed away.
53
+ - Symlink paths, invalid headers, unresolved parents, family cycles, changed files and corrupt/path-escaping manifests are rejected or protected. An unreadable session can defer cleanup because the complete family graph cannot be established safely. Restore collisions do not overwrite either copy.
54
+ - Moving session files does not retroactively ingest them into external memory integrations. Such integrations retain their live in-memory state and flush outstanding writes at shutdown; they do not depend on reopening historical JSONL files for those writes. Earlier failed ingestion is not repaired by this extension.
55
+
56
+ Keep other Pi windows closed during maintenance. Sampled OS process discovery and an extension-owned lock are conservative safeguards, not a native session lock honored by every external program. A new external process can race a cleanup; do not launch another Pi process while maintenance is running. Back up irreplaceable conversations before approving retention.
57
+
58
+ ## Development checks
59
+
60
+ This copy lives in the [pi-extensions](https://github.com/saifymatteo/pi-extensions) repository. Run its checks from the repository root: `node --test packages/pi-session-retention/test/*.test.mjs` (temporary fixtures and pure helpers; the real session store is never touched) and `npm run smoke` for an isolated host load. Interactive terminal behavior and OS process discovery still need manual exercise in an isolated agent home.
61
+
62
+ Requires working `ps` on Unix or PowerShell/CIM on Windows and a filesystem supporting exclusive hard-link publication. No network or model requests are made by retention. Trash searches read stored conversation text; local Trash is not encryption or secure erasure.
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@saifymatteo/pi-session-retention",
3
+ "version": "0.1.0",
4
+ "license": "AGPL-3.0-only",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package"
8
+ ],
9
+ "files": [
10
+ "src",
11
+ "README.md",
12
+ "NOTICE.md",
13
+ "LICENSE"
14
+ ],
15
+ "pi": {
16
+ "extensions": [
17
+ "./src/index.ts"
18
+ ]
19
+ },
20
+ "peerDependencies": {
21
+ "@earendil-works/pi-coding-agent": "*",
22
+ "@earendil-works/pi-tui": "*"
23
+ },
24
+ "engines": {
25
+ "node": ">=24"
26
+ },
27
+ "description": "Inspect and manage stored sessions; review deletion behavior before use.",
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "repository": {
32
+ "type": "git",
33
+ "url": "git+https://github.com/saifymatteo/pi-extensions.git",
34
+ "directory": "packages/pi-session-retention"
35
+ }
36
+ }
@@ -0,0 +1,42 @@
1
+ # Session retention
2
+
3
+ Moves inactive session families out of Pi's searchable session store. No search index, scheduler, model calls, or added dependencies.
4
+
5
+ ## Use
6
+
7
+ - `/session-cleanup preview` — show counts, scope and example candidate families without moving or deleting sessions.
8
+ - `/session-cleanup` — review the first cleanup and approve daily automatic maintenance. Later calls run maintenance immediately.
9
+ - `/session-cleanup pause` — pause automatic maintenance; run `/session-cleanup` to review and enable it again.
10
+ - `/session-trash [query]` — open a dialog styled like `session-finder.ts`, with a heavy border, padded rows, a `Search:` line and yellow selection, listing trashed families immediately. Type to filter by names, first user prompts, IDs or original paths; use ↑↓ and Enter to select, then confirm a whole-family restore. Esc cancels. An optional query prefills the filter; clearing it shows all families again.
11
+
12
+ `/session-cleanup` advertises `[preview|pause]` and offers both arguments through command completion.
13
+
14
+ The footer shows maintenance phases: scanning, approval, process checks, moving sessions and purging expired Trash. Trash loading and restoration also show status. The status clears on completion, cancellation or failure; it does not leave an idle indicator.
15
+
16
+ Interactive startup checks at most once per 24 hours, including after session switches/reloads. Print, JSON and RPC modes never run maintenance. There is no OS scheduler: expired Trash remains until the next successful cleanup.
17
+
18
+ ## Rules
19
+
20
+ A family consists of a session and every descendant linked by `parentSession`, including descendants in another project. Its activity clock is the newest filesystem modification time or header timestamp among its members. Only families strictly more than 30 days inactive qualify. Named sessions use the same rule. The current session protects its entire family.
21
+
22
+ Trashed families receive another 30 days before permanent deletion. Restoring a family preserves its JSONL contents and original paths, updates file modification times to give the whole family a fresh 30-day grace period, and never overwrites existing files.
23
+
24
+ Default scope is every project under the agent's normal `sessions` store. A custom session directory reported by the session manager is treated as its own store; the extension does not sweep unrelated custom locations. Trash is a sibling `.trash/<store-name>-<path-hash>` directory **outside** the live store, not `sessions/.trash`. Approval, pause state, daily timestamp and maintenance lock live there too, not in this repository.
25
+
26
+ Startup reads bounded session headers and file metadata, not every transcript. A cleanup batch revalidates the family graph once, rather than rescanning the entire store for each family. Only explicit Trash searches read conversation text from trashed files. Normal session discovery no longer needs to read those files. This does not erase copies exported elsewhere or memories already retained by external integrations.
27
+
28
+ ## Safety and recovery
29
+
30
+ - Maintenance is serialized by an exclusive per-store lock. Process discovery must succeed, and maintenance defers when its inventory finds another recognized Pi CLI process, including idle windows. Successful inventories are reused for at most one second within a maintenance transaction to avoid starting PowerShell for every file. Session switches and forks in the current window are blocked until maintenance ends. It does not assume that excluding this window protects other windows. Close other Pi windows and run `/session-cleanup` to retry. Unrecognized custom SDK hosts and programs with renamed executables are outside this process detector's guarantee; do not run maintenance alongside them.
31
+ - On `/reload`, session replacement or graceful shutdown, maintenance cancels pending approval/Trash dialogs, stops at the next safety boundary and waits for in-flight file work to finish before releasing its lock. The lock is never removed while its operation can still mutate files. Forced termination or a crash can still leave a stale lock; these are not automatically removed.
32
+ - Each family has a recovery manifest written before any file moves. Incomplete operations remain recoverable in `/session-trash` and are never automatically purged. Keep the manifest with its files. If the process crashes leaving `.maintenance.lock`, close all Pi processes before removing that stale lock and retrying. A corrupt lock fails closed rather than being guessed away.
33
+ - Symlink paths, invalid headers, unresolved parents, family cycles, changed files and corrupt/path-escaping manifests are rejected or protected. An unreadable session can defer cleanup because the complete family graph cannot be established safely. Restore collisions do not overwrite either copy.
34
+ - Moving session files does not retroactively ingest them into external memory integrations. Such integrations retain their live in-memory state and flush outstanding writes at shutdown; they do not depend on reopening historical JSONL files for those writes. Earlier failed ingestion is not repaired by this extension.
35
+
36
+ Keep other Pi windows closed during maintenance. Sampled OS process discovery and an extension-owned lock are conservative safeguards, not a native session lock honored by every external program. A new external process can race a cleanup; do not launch another Pi process while maintenance is running. Back up irreplaceable conversations before approving retention.
37
+
38
+ ## Development checks
39
+
40
+ This copy lives in the [pi-extensions](https://github.com/saifymatteo/pi-extensions) repository. Run its checks from the repository root: `node --test packages/pi-session-retention/test/*.test.mjs` (temporary fixtures and pure helpers; the real session store is never touched) and `npm run smoke` for an isolated host load. Interactive terminal behavior and OS process discovery still need manual exercise in an isolated agent home.
41
+
42
+ Requires working `ps` on Unix or PowerShell/CIM on Windows and a filesystem supporting exclusive hard-link publication. No network or model requests are made by retention. Trash searches read stored conversation text; local Trash is not encryption or secure erasure.
@@ -0,0 +1,333 @@
1
+ import { constants } from 'node:fs'
2
+ import { copyFile, link, lstat, mkdir, open, readdir, rename, rmdir, unlink, utimes } from 'node:fs/promises'
3
+ import { isAbsolute, join, parse, relative, resolve, sep } from 'node:path'
4
+ import { randomUUID } from 'node:crypto'
5
+
6
+ export const RETENTION_MS = 30 * 24 * 60 * 60 * 1000
7
+ const HEADER_BYTES = 64 * 1024
8
+ const MANIFEST_BYTES = 1024 * 1024
9
+ export type Family = { id: string; paths: string[]; latestActivity: number; protected: boolean; reasons: string[] }
10
+ export type ScanResult = { families: Family[]; candidates: Family[]; issues: string[]; aborted: boolean }
11
+ // Adapter must hold its cross-process lock for the entire awaited operation, and
12
+ // discover active sessions itself. A guard is required, and only true permits work.
13
+ export type Guard = (operation: 'trash' | 'restore' | 'purge', paths: readonly string[]) => Promise<boolean>
14
+ export type MutationOptions = { now?: number; guard: Guard }
15
+ export type Manifest = { version: 1; id: string; trashedAt: number; ready: boolean; files: { original: string; stored: string }[] }
16
+ export type TrashBatch = { manifest: Manifest; missing: string[] }
17
+ export const pathKey = (path: string) => process.platform === 'win32' ? resolve(path).toLowerCase() : resolve(path)
18
+ const inside = (root: string, path: string) => { const r = relative(root, path); return r !== '' && !isAbsolute(r) && r !== '..' && !r.startsWith(`..${sep}`) }
19
+ const time = (now = Date.now()) => { if (!Number.isFinite(now) || now < 0) throw new Error('Invalid clock'); return now }
20
+ const message = (error: unknown) => error instanceof Error ? error.message : String(error)
21
+ const absent = (error: unknown) => (error as NodeJS.ErrnoException).code === 'ENOENT'
22
+ const uuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
23
+
24
+ // Check every component, not just the leaf. Missing suffixes are allowed only
25
+ // for creation; existing symlinks and non-directory ancestors are always errors.
26
+ async function safePath(path: string, missing = false) {
27
+ const absolute = resolve(path)
28
+ const root = parse(absolute).root
29
+ let cursor = root
30
+ const parts = absolute.slice(root.length).split(sep).filter(Boolean)
31
+ for (let i = 0; i < parts.length; i++) {
32
+ cursor = join(cursor, parts[i])
33
+ try {
34
+ const s = await lstat(cursor)
35
+ if (s.isSymbolicLink() || (i < parts.length - 1 && !s.isDirectory())) throw new Error(`Unsafe path: ${cursor}`)
36
+ } catch (error) { if (missing && absent(error)) return; throw error }
37
+ }
38
+ }
39
+ async function exists(path: string) { try { await lstat(path); return true } catch (error) { if (absent(error)) return false; throw error } }
40
+ async function regular(path: string) {
41
+ await safePath(path)
42
+ const s = await lstat(path)
43
+ if (!s.isFile()) throw new Error(`Not a regular file: ${path}`)
44
+ return s
45
+ }
46
+ async function header(path: string) {
47
+ const before = await regular(path)
48
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
49
+ try {
50
+ const s = await handle.stat()
51
+ if (!s.isFile() || before.ino !== s.ino || before.dev !== s.dev) throw new Error(`Changed file: ${path}`)
52
+ const buffer = Buffer.alloc(HEADER_BYTES)
53
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, 0)
54
+ const end = buffer.subarray(0, bytesRead).indexOf(10)
55
+ if (end < 0 && (s.size >= HEADER_BYTES || bytesRead < s.size)) throw new Error('Session header exceeds limit')
56
+ const h = JSON.parse(buffer.subarray(0, end < 0 ? bytesRead : end).toString('utf8'))
57
+ const stamp = typeof h.timestamp === 'string' ? Date.parse(h.timestamp) : NaN
58
+ if (h.type !== 'session' || typeof h.id !== 'string' || !h.id.trim() || !Number.isFinite(stamp) ||
59
+ (h.parentSession != null && (typeof h.parentSession !== 'string' || !isAbsolute(h.parentSession)))) throw new Error(`Invalid session header: ${path}`)
60
+ return { parent: h.parentSession == null ? undefined : resolve(h.parentSession), activity: Math.max(s.mtimeMs, stamp) }
61
+ } finally { await handle.close() }
62
+ }
63
+
64
+ export class RetentionStore {
65
+ readonly root: string
66
+ readonly trashRoot: string
67
+ constructor(root: string, trashRoot: string) {
68
+ this.root = resolve(root)
69
+ this.trashRoot = resolve(trashRoot)
70
+ if (pathKey(this.root) === pathKey(this.trashRoot) || inside(this.root, this.trashRoot) || inside(this.trashRoot, this.root)) throw new Error('Session and trash roots must be disjoint')
71
+ }
72
+ private async roots() {
73
+ await safePath(this.root)
74
+ if (!(await lstat(this.root)).isDirectory()) throw new Error('Session root is not a directory')
75
+ await safePath(this.trashRoot, true)
76
+ if (await exists(this.trashRoot) && !(await lstat(this.trashRoot)).isDirectory()) throw new Error('Trash root is not a directory')
77
+ }
78
+ private async allow(options: MutationOptions, operation: Parameters<Guard>[0], paths: string[]) {
79
+ if (typeof options?.guard !== 'function' || await options.guard(operation, paths) !== true) throw new Error('Mutation guard refused')
80
+ await this.roots()
81
+ }
82
+
83
+ async scan(options: { now?: number; protectedPaths?: readonly string[] } = {}): Promise<ScanResult> {
84
+ const now = time(options.now)
85
+ await this.roots()
86
+ const nodes = new Map<string, Awaited<ReturnType<typeof header>>>()
87
+ const issues: string[] = []
88
+ const walk = async (dir: string) => {
89
+ try {
90
+ await safePath(dir)
91
+ for (const item of await readdir(dir, { withFileTypes: true })) {
92
+ const path = join(dir, item.name)
93
+ if (item.isSymbolicLink()) { issues.push(`Symlink skipped; completeness unknown: ${path}`); continue }
94
+ if (item.isDirectory()) await walk(path)
95
+ else if (item.name.endsWith('.jsonl')) {
96
+ try { nodes.set(path, await header(path)) } catch (error) { issues.push(`${path}: ${message(error)}`) }
97
+ }
98
+ }
99
+ } catch (error) { issues.push(`${dir}: ${message(error)}`) }
100
+ }
101
+ await walk(this.root)
102
+ const neighbors = new Map([...nodes.keys()].map(path => [path, new Set<string>()]))
103
+ const byKey = new Map([...nodes.keys()].map(path => [pathKey(path), path]))
104
+ const invalid = new Map<string, string>()
105
+ for (const [path, node] of nodes) {
106
+ if (!node.parent) continue
107
+ const parent = byKey.get(pathKey(node.parent))
108
+ if (!inside(this.root, node.parent) || !parent) invalid.set(path, 'Missing or outside-root parent')
109
+ else { neighbors.get(path)!.add(parent); neighbors.get(parent)!.add(path) }
110
+ }
111
+ const protectedPaths = new Set((options.protectedPaths ?? []).map(pathKey))
112
+ const seen = new Set<string>()
113
+ const families: Family[] = []
114
+ for (const start of nodes.keys()) {
115
+ if (seen.has(start)) continue
116
+ const paths: string[] = [], queue = [start]
117
+ while (queue.length) {
118
+ const path = queue.pop()!
119
+ if (seen.has(path)) continue
120
+ seen.add(path); paths.push(path)
121
+ queue.push(...neighbors.get(path)!)
122
+ }
123
+ paths.sort()
124
+ const reasons = new Set<string>()
125
+ for (const path of paths) {
126
+ if (invalid.has(path)) reasons.add(invalid.get(path)!)
127
+ if (protectedPaths.has(pathKey(path))) reasons.add('Active/protected session')
128
+ }
129
+ // A connected parent graph is a tree iff it has exactly one parentless root.
130
+ if (paths.filter(path => !nodes.get(path)!.parent).length !== 1) reasons.add('Cycle or incomplete tree')
131
+ families.push({ id: paths[0], paths, latestActivity: paths.reduce((latest, path) => Math.max(latest, nodes.get(path)!.activity), -Infinity), protected: reasons.size > 0, reasons: [...reasons] })
132
+ }
133
+ // An unreadable header could refer to ANY known family. Never guess completeness.
134
+ return { families, candidates: issues.length ? [] : families.filter(f => !f.protected && now - f.latestActivity > RETENTION_MS), issues, aborted: issues.length > 0 }
135
+ }
136
+
137
+ private batchPath(id: string) {
138
+ if (!uuid.test(id)) throw new Error('Invalid batch id')
139
+ return join(this.trashRoot, id)
140
+ }
141
+ private original(path: string) {
142
+ if (typeof path !== 'string' || !path || isAbsolute(path) || path.includes('\\') || path.split('/').some(p => !p || p === '.' || p === '..' || p.includes(':')) || !path.endsWith('.jsonl')) throw new Error('Invalid original path')
143
+ const absolute = resolve(this.root, ...path.split('/'))
144
+ if (!inside(this.root, absolute)) throw new Error('Original path escapes root')
145
+ return absolute
146
+ }
147
+ private async load(id: string): Promise<TrashBatch> {
148
+ await this.roots()
149
+ const dir = this.batchPath(id)
150
+ const path = join(dir, 'manifest.json')
151
+ const stat = await regular(path)
152
+ if (stat.size > MANIFEST_BYTES) throw new Error('Manifest too large')
153
+ const handle = await open(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0))
154
+ let m: Manifest
155
+ try { m = JSON.parse(await handle.readFile('utf8')) } finally { await handle.close() }
156
+ if (!m || m.version !== 1 || m.id !== id || !Number.isFinite(m.trashedAt) || m.trashedAt < 0 || typeof m.ready !== 'boolean' || !Array.isArray(m.files) || !m.files.length) throw new Error('Invalid manifest')
157
+ const originals = new Set<string>(), stored = new Set<string>(), missing: string[] = []
158
+ for (const [i, file] of m.files.entries()) {
159
+ if (!file || file.stored !== `${i}.jsonl`) throw new Error('Invalid stored path')
160
+ const original = this.original(file.original)
161
+ if (originals.has(pathKey(original))) throw new Error('Duplicate original path')
162
+ originals.add(pathKey(original)); stored.add(file.stored)
163
+ await safePath(original, true)
164
+ const target = join(dir, file.stored)
165
+ if (!await exists(target)) missing.push(file.stored)
166
+ else await regular(target)
167
+ }
168
+ // Never recursively remove a directory described by an untrusted manifest.
169
+ for (const name of await readdir(dir)) {
170
+ if (name === 'manifest.json' || stored.has(name)) continue
171
+ if (/^manifest\.[0-9a-f-]+\.tmp$/.test(name) || [...stored].some(file => name === `${file}.staging`)) {
172
+ await regular(join(dir, name))
173
+ missing.push(name) // Crash debris forbids automatic purge, but not recovery.
174
+ } else throw new Error(`Unexpected batch entry: ${name}`)
175
+ }
176
+ return { manifest: m, missing }
177
+ }
178
+ async listTrash(): Promise<{ batches: TrashBatch[]; issues: string[] }> {
179
+ await this.roots()
180
+ const batches: TrashBatch[] = [], issues: string[] = []
181
+ if (!await exists(this.trashRoot)) return { batches, issues }
182
+ for (const item of await readdir(this.trashRoot, { withFileTypes: true })) {
183
+ // Shared store metadata belongs to the adapter, not to a session family.
184
+ if (['settings.json', '.maintenance.lock'].includes(item.name) || /^settings-[0-9a-f-]+\.tmp$/.test(item.name)) continue
185
+ try {
186
+ if (!item.isDirectory() || item.isSymbolicLink()) throw new Error('Not a batch directory')
187
+ batches.push(await this.load(item.name))
188
+ } catch (error) { issues.push(`${item.name}: ${message(error)}`) }
189
+ }
190
+ return { batches, issues }
191
+ }
192
+ // Atomic metadata replacement keeps the previous recoverable journal on crash.
193
+ // Only metadata is renamed; session files are NEVER rename-overwritten.
194
+ private async journal(manifest: Manifest, create = false) {
195
+ const dir = this.batchPath(manifest.id), path = join(dir, 'manifest.json')
196
+ await safePath(path, create)
197
+ if (create) {
198
+ const handle = await open(path, 'wx')
199
+ try { await handle.writeFile(JSON.stringify(manifest)); await handle.sync() } finally { await handle.close() }
200
+ return
201
+ }
202
+ await regular(path)
203
+ const temp = join(dir, `manifest.${randomUUID()}.tmp`)
204
+ const handle = await open(temp, 'wx')
205
+ try { await handle.writeFile(JSON.stringify(manifest)); await handle.sync() } finally { await handle.close() }
206
+ await regular(path)
207
+ await rename(temp, path)
208
+ }
209
+ // Stage and sync the copy before exclusive hard-link publication on the target
210
+ // filesystem. Even a crash mid-copy cannot expose a truncated recoverable file.
211
+ // A crash after publication may leave BOTH copies: restore refuses that collision.
212
+ private async move(source: string, target: string) {
213
+ const before = await regular(source)
214
+ await safePath(target, true)
215
+ const staging = `${target}.staging`
216
+ await copyFile(source, staging, constants.COPYFILE_EXCL)
217
+ const handle = await open(staging, constants.O_RDWR | (constants.O_NOFOLLOW ?? 0))
218
+ try { await handle.sync() } finally { await handle.close() }
219
+ await link(staging, target) // fails rather than overwriting any existing entry
220
+ await unlink(staging)
221
+ const after = await regular(source)
222
+ if (before.ino !== after.ino || before.dev !== after.dev || before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs) throw new Error('Source changed during move; both copies retained')
223
+ await unlink(source)
224
+ }
225
+ async trashFamily(family: Family, options: MutationOptions): Promise<Manifest> {
226
+ return (await this.trashFamilies([family], options))[0]
227
+ }
228
+ async trashFamilies(families: Family[], options: MutationOptions): Promise<Manifest[]> {
229
+ const now = time(options?.now)
230
+ await this.allow(options, 'trash', families.flatMap(f => f.paths))
231
+ // Revalidate the entire plan once under the adapter's exclusive lock, not
232
+ // once per family (which would reread O(n²) headers during the first cleanup).
233
+ const scan = await this.scan({ now })
234
+ const candidates = new Map(scan.candidates.map(f => [f.id, f]))
235
+ const seen = new Set<string>()
236
+ for (const family of families) {
237
+ const current = candidates.get(family.id)
238
+ if (!current || family.protected || seen.has(family.id) || current.paths.length !== family.paths.length || !current.paths.every((p, i) => p === family.paths[i])) throw new Error('Family is no longer a safe candidate')
239
+ seen.add(family.id)
240
+ }
241
+ const manifests: Manifest[] = []
242
+ for (const family of families) manifests.push(await this.trashValidated(family, { ...options, now }))
243
+ return manifests
244
+ }
245
+ private async trashValidated(current: Family, options: MutationOptions): Promise<Manifest> {
246
+ for (const path of current.paths) {
247
+ if (time(options.now) - (await header(path)).activity <= RETENTION_MS) throw new Error('Family activity changed; no longer a safe candidate')
248
+ }
249
+ const manifest: Manifest = { version: 1, id: randomUUID(), trashedAt: time(options.now), ready: false, files: current.paths.map((path, i) => ({ original: relative(this.root, path).split(sep).join('/'), stored: `${i}.jsonl` })) }
250
+ for (const file of manifest.files) this.original(file.original)
251
+ if (Buffer.byteLength(JSON.stringify(manifest)) > MANIFEST_BYTES) throw new Error('Manifest too large')
252
+ await mkdir(this.trashRoot, { recursive: true })
253
+ await safePath(this.trashRoot)
254
+ await mkdir(this.batchPath(manifest.id))
255
+ await this.journal(manifest, true)
256
+ for (const file of manifest.files) {
257
+ await this.allow(options, 'trash', current.paths)
258
+ const source = this.original(file.original)
259
+ if (time(options.now) - (await header(source)).activity <= RETENTION_MS) throw new Error('Family activity changed; incomplete journal retained')
260
+ await this.move(source, join(this.batchPath(manifest.id), file.stored))
261
+ }
262
+ await this.allow(options, 'trash', current.paths)
263
+ manifest.ready = true
264
+ await this.journal(manifest)
265
+ return manifest
266
+ }
267
+ async restoreFamily(id: string, options: MutationOptions): Promise<{ restored: string[] }> {
268
+ const now = time(options?.now)
269
+ const { manifest } = await this.load(id)
270
+ const paths = manifest.files.flatMap(file => [this.original(file.original), join(this.batchPath(id), file.stored)])
271
+ await this.allow(options, 'restore', paths)
272
+ // Preflight the whole batch before moving anything, including incomplete ones.
273
+ for (const file of manifest.files) {
274
+ const source = join(this.batchPath(id), file.stored), target = this.original(file.original)
275
+ if (await exists(source) && await exists(target)) throw new Error(`Restore collision: ${target}`)
276
+ if (!await exists(source) && !await exists(target)) throw new Error(`Missing both copies: ${target}`)
277
+ if (await exists(target)) await regular(target)
278
+ }
279
+ manifest.ready = false
280
+ await this.journal(manifest)
281
+ const restored: string[] = []
282
+ for (const file of manifest.files) {
283
+ const source = join(this.batchPath(id), file.stored), target = this.original(file.original)
284
+ await this.allow(options, 'restore', paths)
285
+ await safePath(target, true)
286
+ if (await exists(source)) {
287
+ await mkdir(resolve(target, '..'), { recursive: true })
288
+ await this.move(source, target)
289
+ restored.push(target)
290
+ }
291
+ await regular(target)
292
+ await utimes(target, new Date(now), new Date(now))
293
+ }
294
+ await this.allow(options, 'restore', paths)
295
+ await this.removeJournal(id)
296
+ return { restored }
297
+ }
298
+ private async removeJournal(id: string) {
299
+ const dir = this.batchPath(id)
300
+ await regular(join(dir, 'manifest.json'))
301
+ const names = await readdir(dir)
302
+ if (names.length !== 1 || names[0] !== 'manifest.json') throw new Error('Batch still contains files or crash debris; journal retained')
303
+ await unlink(join(dir, 'manifest.json'))
304
+ await rmdir(dir)
305
+ }
306
+ async purgeExpired(options: MutationOptions): Promise<{ purged: string[]; issues: string[] }> {
307
+ const now = time(options?.now)
308
+ await this.allow(options, 'purge', [])
309
+ const { batches, issues } = await this.listTrash()
310
+ const purged: string[] = []
311
+ for (const batch of batches) {
312
+ if (!batch.manifest.ready || batch.missing.length || now - batch.manifest.trashedAt <= RETENTION_MS) continue
313
+ const id = batch.manifest.id
314
+ try {
315
+ const { manifest, missing } = await this.load(id)
316
+ if (!manifest.ready || missing.length || now - manifest.trashedAt <= RETENTION_MS) continue
317
+ const paths = manifest.files.flatMap(file => [this.original(file.original), join(this.batchPath(id), file.stored)])
318
+ await this.allow(options, 'purge', paths)
319
+ manifest.ready = false // A partial purge must never automatically continue.
320
+ await this.journal(manifest)
321
+ for (const file of manifest.files) {
322
+ await this.allow(options, 'purge', paths)
323
+ const path = join(this.batchPath(id), file.stored)
324
+ await regular(path)
325
+ await unlink(path)
326
+ }
327
+ await this.removeJournal(id)
328
+ purged.push(id)
329
+ } catch (error) { issues.push(`${id}: ${message(error)}`) }
330
+ }
331
+ return { purged, issues }
332
+ }
333
+ }
@@ -0,0 +1,118 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Input, Key, matchesKey, SelectList, truncateToWidth, visibleWidth, type Component, type Focusable, type KeybindingsManager, type TUI, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import { dialogRows, wheelAsArrows } from "./lib/dialog.ts";
4
+
5
+ export type TrashChoice = { value: string; label: string; description: string; search: string };
6
+
7
+ export class SessionTrashDialog implements Component, Focusable {
8
+ private input = new Input({ prompt: "" });
9
+ private list!: SelectList;
10
+ private filtered: TrashChoice[] = [];
11
+ private viewport = 5;
12
+
13
+ constructor(
14
+ private tui: TUI,
15
+ private theme: Theme,
16
+ private keybindings: KeybindingsManager,
17
+ private choices: TrashChoice[],
18
+ query: string,
19
+ private done: (id: string | undefined) => void,
20
+ ) {
21
+ this.input.handleInput(query); // Native text insertion leaves the cursor after the prefilled query.
22
+ this.filter();
23
+ }
24
+
25
+ get focused(): boolean { return this.input.focused; }
26
+ set focused(value: boolean) { this.input.focused = value; }
27
+
28
+ private filter(): void {
29
+ const query = this.input.getValue().toLowerCase();
30
+ this.filtered = this.choices.filter((choice) => choice.search.toLowerCase().includes(query));
31
+ this.buildList();
32
+ }
33
+
34
+ private buildList(selected?: string): void {
35
+ this.list = new SelectList(this.filtered, this.viewport, {
36
+ selectedPrefix: (text) => text,
37
+ selectedText: (text) => text.replace(/^→ /, "▶ "),
38
+ description: (text) => text,
39
+ scrollInfo: (text) => this.theme.fg("dim", text),
40
+ noMatch: () => this.theme.fg("muted", this.choices.length ? "No matching families" : "Trash empty"),
41
+ });
42
+ if (selected !== undefined) this.list.setSelectedIndex(Math.max(0, this.filtered.findIndex((choice) => choice.value === selected)));
43
+ }
44
+
45
+ handleMouse(event: TuiMouseEvent) { return wheelAsArrows(event, (key) => this.handleInput(key)); }
46
+
47
+ handleInput(data: string): void {
48
+ const kb = this.keybindings;
49
+ if (kb.matches(data, "tui.select.cancel") || matchesKey(data, Key.escape) || matchesKey(data, Key.ctrl("c"))) {
50
+ this.done(undefined);
51
+ return;
52
+ }
53
+ if (kb.matches(data, "tui.select.confirm")) {
54
+ const selected = this.list.getSelectedItem();
55
+ if (selected) this.done(selected.value);
56
+ return;
57
+ }
58
+ const up = kb.matches(data, "tui.select.up");
59
+ const down = kb.matches(data, "tui.select.down");
60
+ const pageUp = kb.matches(data, "tui.select.pageUp");
61
+ const pageDown = kb.matches(data, "tui.select.pageDown");
62
+ if (up || down || pageUp || pageDown) {
63
+ const index = this.filtered.findIndex((choice) => choice.value === this.list.getSelectedItem()?.value);
64
+ const count = this.filtered.length;
65
+ if (count) {
66
+ const next = pageUp || pageDown
67
+ ? Math.max(0, Math.min(count - 1, index + (pageUp ? -this.viewport : this.viewport)))
68
+ : (index + (up ? -1 : 1) + count) % count;
69
+ this.list.setSelectedIndex(next);
70
+ }
71
+ } else {
72
+ const previous = this.input.getValue();
73
+ this.input.handleInput(data);
74
+ if (previous !== this.input.getValue()) this.filter();
75
+ }
76
+ this.invalidate();
77
+ this.tui.requestRender();
78
+ }
79
+
80
+ render(width: number): string[] {
81
+ const height = Math.max(1, this.tui.terminal.rows);
82
+ // Fixed lines: 2 borders, search, footer, list scroll info.
83
+ const viewport = dialogRows(this.tui, 5);
84
+ if (viewport !== this.viewport) {
85
+ this.viewport = viewport;
86
+ this.buildList(this.list.getSelectedItem()?.value);
87
+ }
88
+ // Below the frame's minimum size, keep the native input/cursor usable.
89
+ if (width < 16 || height < 8) return [
90
+ ...this.input.render(Math.max(1, width)),
91
+ ...this.list.render(Math.max(4, width)).map((line) => this.theme.fg(line.startsWith("▶ ") ? "warning" : "text", line)),
92
+ ].slice(0, height).map((line) => truncateToWidth(line, Math.max(0, width), ""));
93
+
94
+ // Match session-finder.ts: heavy border, two-cell padding and warning selection.
95
+ const border = (value: string) => this.theme.fg("border", value);
96
+ const inner = width - 2;
97
+ const bodyWidth = inner - 4;
98
+ const title = truncateToWidth(` Session Trash · ${this.filtered.length} results `, inner - 1, "…");
99
+ const row = (value: string, color: "text" | "warning" | "dim" = "text") => {
100
+ const fitted = truncateToWidth(value, bodyWidth, "…");
101
+ const padded = ` ${fitted}${" ".repeat(Math.max(0, bodyWidth - visibleWidth(fitted)))} `;
102
+ return border("┃") + this.theme.fg(color, padded) + border("┃");
103
+ };
104
+ const lines = [border(`┏${title}${"━".repeat(Math.max(0, inner - visibleWidth(title)))}┓`)];
105
+ for (const line of this.input.render(bodyWidth - 8)) lines.push(row(`Search: ${line}`, "warning"));
106
+ const items = this.list.render(bodyWidth);
107
+ while (items.length < viewport + 1) items.push("");
108
+ for (const item of items) lines.push(row(item, item.startsWith("▶ ") ? "warning" : "text"));
109
+ lines.push(row("↑↓ / PgUp/PgDn navigate · type to search · Enter restore · Esc close", "dim"));
110
+ lines.push(border(`┗${"━".repeat(inner)}┛`));
111
+ return lines;
112
+ }
113
+
114
+ invalidate(): void {
115
+ this.input.invalidate();
116
+ this.list.invalidate();
117
+ }
118
+ }