@agentwhy/cli 0.0.0-dev → 0.2.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 (273) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +57 -5
  3. package/dist/adapter/claude-code/contract/assumptions.js +16 -4
  4. package/dist/adapter/claude-code/contract/denials.js +16 -1
  5. package/dist/adapter/claude-code/contract/desktop-sessions.js +28 -0
  6. package/dist/adapter/claude-code/contract/entry-points.js +6 -1
  7. package/dist/adapter/claude-code/contract/fields.js +2 -0
  8. package/dist/adapter/claude-code/contract/hooks.js +8 -0
  9. package/dist/adapter/claude-code/contract/identifiers.js +2 -0
  10. package/dist/adapter/claude-code/contract/layout.js +2 -0
  11. package/dist/adapter/claude-code/contract/line-types.js +2 -0
  12. package/dist/adapter/claude-code/contract/projects.js +2 -0
  13. package/dist/adapter/claude-code/contract/recognition.js +2 -0
  14. package/dist/adapter/claude-code/contract/settings.js +2 -0
  15. package/dist/adapter/claude-code/contract/task-notifications.js +2 -0
  16. package/dist/adapter/claude-code/contract/version.js +14 -1
  17. package/dist/adapter/claude-code/discovery/claude-code-project-catalogue.js +12 -12
  18. package/dist/adapter/claude-code/discovery/claude-code-recognition.js +2 -0
  19. package/dist/adapter/claude-code/discovery/claude-code-session-catalogue.js +28 -7
  20. package/dist/adapter/claude-code/discovery/claude-code-session-discovery.js +2 -0
  21. package/dist/adapter/claude-code/discovery/claude-code-session-titles.js +30 -6
  22. package/dist/adapter/claude-code/discovery/claude-desktop-titles.js +102 -0
  23. package/dist/adapter/claude-code/discovery/session-entries.js +2 -0
  24. package/dist/adapter/claude-code/events/delegation-index.js +2 -0
  25. package/dist/adapter/claude-code/events/transcript-scan.js +6 -5
  26. package/dist/adapter/claude-code/hooks/hook-output.js +2 -0
  27. package/dist/adapter/claude-code/hooks/pre-tool-use-input.js +2 -0
  28. package/dist/adapter/claude-code/hooks/stop-input.js +2 -0
  29. package/dist/adapter/claude-code/hooks/subagent-stop-input.js +4 -0
  30. package/dist/adapter/claude-code/probe/claude-code-probe.js +2 -0
  31. package/dist/adapter/claude-code/probe/collectors/agent-tool-input-key-collector.js +2 -0
  32. package/dist/adapter/claude-code/probe/collectors/denial-kind-collector.js +2 -0
  33. package/dist/adapter/claude-code/probe/collectors/denied-call-collector.js +2 -0
  34. package/dist/adapter/claude-code/probe/collectors/identifier-collector.js +2 -0
  35. package/dist/adapter/claude-code/probe/collectors/line-type-collector.js +2 -0
  36. package/dist/adapter/claude-code/probe/collectors/sidechain-collector.js +2 -0
  37. package/dist/adapter/claude-code/probe/collectors/task-notification-collector.js +2 -0
  38. package/dist/adapter/claude-code/probe/collectors/tool-result-reference-collector.js +2 -0
  39. package/dist/adapter/claude-code/probe/collectors/tool-use-collector.js +2 -0
  40. package/dist/adapter/claude-code/probe/collectors/tool-version-collector.js +2 -0
  41. package/dist/adapter/claude-code/probe/collectors/top-level-key-collector.js +2 -0
  42. package/dist/adapter/claude-code/probe/collectors/unknown-line-type-key-collector.js +2 -0
  43. package/dist/adapter/claude-code/probe/collectors/working-directory-collector.js +2 -0
  44. package/dist/adapter/claude-code/probe/meta-tally.js +2 -0
  45. package/dist/adapter/claude-code/probe/transcript-line.js +2 -0
  46. package/dist/adapter/claude-code/probe/transcript-tally.js +2 -0
  47. package/dist/adapter/claude-code/settings/deny-entries.js +2 -0
  48. package/dist/adapter/claude-code/settings/hook-entries.js +9 -0
  49. package/dist/adapter/claude-code/settings/refuse-project.js +99 -0
  50. package/dist/adapter/codex/contract/actions.js +2 -0
  51. package/dist/adapter/codex/contract/assumptions.js +2 -0
  52. package/dist/adapter/codex/contract/chat-folders.js +19 -0
  53. package/dist/adapter/codex/contract/delegations.js +2 -0
  54. package/dist/adapter/codex/contract/deliveries.js +2 -0
  55. package/dist/adapter/codex/contract/envelope.js +2 -0
  56. package/dist/adapter/codex/contract/hook-refusals.js +2 -0
  57. package/dist/adapter/codex/contract/hooks.js +51 -1
  58. package/dist/adapter/codex/contract/messages.js +2 -0
  59. package/dist/adapter/codex/contract/permissions.js +2 -0
  60. package/dist/adapter/codex/contract/reviews.js +2 -0
  61. package/dist/adapter/codex/contract/session.js +29 -1
  62. package/dist/adapter/codex/contract/turns.js +2 -0
  63. package/dist/adapter/codex/contract/version.js +2 -0
  64. package/dist/adapter/codex/discovery/codex-on-this-computer.js +13 -0
  65. package/dist/adapter/codex/discovery/codex-project-catalogue.js +2 -12
  66. package/dist/adapter/codex/discovery/codex-session-catalogue.js +2 -1
  67. package/dist/adapter/codex/discovery/codex-session-discovery.js +2 -0
  68. package/dist/adapter/codex/discovery/session-owners.js +2 -0
  69. package/dist/adapter/codex/events/action-items.js +2 -0
  70. package/dist/adapter/codex/events/codex-session-source.js +2 -0
  71. package/dist/adapter/codex/events/hook-refusals.js +2 -0
  72. package/dist/adapter/codex/events/rollout-scan.js +1 -1
  73. package/dist/adapter/codex/hooks/codex-conversation.js +54 -0
  74. package/dist/adapter/codex/hooks/pre-tool-use-input.js +2 -0
  75. package/dist/adapter/codex/hooks/reader.js +38 -0
  76. package/dist/adapter/codex/hooks/stop-input.js +32 -0
  77. package/dist/adapter/codex/hooks/stop-refusals.js +3 -6
  78. package/dist/adapter/codex/probe/codex-probe.js +2 -0
  79. package/dist/adapter/codex/settings/codex-check.js +38 -0
  80. package/dist/adapter/codex/settings/codex-hooks.js +168 -1
  81. package/dist/adapter/codex/settings/hook-approval.js +374 -0
  82. package/dist/cli/commands/check-cli-command.js +2 -0
  83. package/dist/cli/commands/codex-stop-cli-command.js +15 -12
  84. package/dist/cli/commands/doctor-arguments.js +2 -0
  85. package/dist/cli/commands/doctor-usage.js +2 -0
  86. package/dist/cli/commands/init-cli-command.js +10 -5
  87. package/dist/cli/commands/notify-cli-command.js +3 -1
  88. package/dist/cli/commands/refuse-cli-command.js +9 -4
  89. package/dist/cli/commands/report-arguments.js +2 -0
  90. package/dist/cli/commands/report-usage.js +2 -0
  91. package/dist/cli/commands/sessions-cli-command.js +9 -1
  92. package/dist/cli/commands/start-cli-command.js +29 -2
  93. package/dist/cli/commands/watch-cli-command.js +4 -2
  94. package/dist/cli/exit-codes.js +2 -0
  95. package/dist/cli.js +10 -0
  96. package/dist/composition-root.js +80 -15
  97. package/dist/core/access/command-line.js +48 -2
  98. package/dist/core/access/listing.js +2 -0
  99. package/dist/core/access/path-tokens.js +2 -0
  100. package/dist/core/access/protected-access.js +36 -3
  101. package/dist/core/access/protected-values.js +17 -0
  102. package/dist/core/access/search-output.js +2 -0
  103. package/dist/core/access/search-reach.js +2 -0
  104. package/dist/core/combined-project-catalogue.js +7 -1
  105. package/dist/core/correlation/correlate.js +13 -1
  106. package/dist/core/detector/prompt-signals.js +2 -0
  107. package/dist/core/guarded-places.js +42 -0
  108. package/dist/core/nearest-project.js +5 -2
  109. package/dist/core/policy/glob.js +2 -0
  110. package/dist/core/policy/parse-policy.js +2 -0
  111. package/dist/core/policy/policy.js +14 -2
  112. package/dist/core/policy/resolve-policy.js +2 -0
  113. package/dist/core/redaction/entropy.js +2 -0
  114. package/dist/core/redaction/redactor.js +2 -0
  115. package/dist/core/redaction/value-shapes.js +2 -0
  116. package/dist/core/redaction/value-trace.js +2 -0
  117. package/dist/core/session-catalogue.js +7 -0
  118. package/dist/core/session-filter.js +2 -0
  119. package/dist/infrastructure/clack-asker.js +2 -0
  120. package/dist/infrastructure/clack-chooser.js +2 -0
  121. package/dist/infrastructure/clack-multi-chooser.js +2 -0
  122. package/dist/infrastructure/file-alert-store.js +10 -3
  123. package/dist/infrastructure/file-checked-store.js +2 -0
  124. package/dist/infrastructure/file-mark-store.js +2 -0
  125. package/dist/infrastructure/file-onboarding-store.js +2 -0
  126. package/dist/infrastructure/file-page-servers.js +51 -0
  127. package/dist/infrastructure/folder-window.js +2 -0
  128. package/dist/infrastructure/node-agentwhy-invocation.js +2 -0
  129. package/dist/infrastructure/node-background-run.js +45 -0
  130. package/dist/infrastructure/node-browser.js +2 -0
  131. package/dist/infrastructure/node-file-system.js +42 -1
  132. package/dist/infrastructure/node-local-server.js +2 -0
  133. package/dist/infrastructure/node-page-probe.js +40 -0
  134. package/dist/infrastructure/os-notifier.js +2 -0
  135. package/dist/infrastructure/real-directory.js +2 -0
  136. package/dist/infrastructure/shell-command-trial.js +60 -0
  137. package/dist/infrastructure/terminal-banner.js +2 -0
  138. package/dist/ports/command-trial.js +1 -0
  139. package/dist/ports/directory-reader.js +14 -1
  140. package/dist/ports/file-replacer.js +1 -0
  141. package/dist/ports/mark-store.js +2 -0
  142. package/dist/ports/page-server.js +1 -0
  143. package/dist/refuse/command-reach.js +2 -0
  144. package/dist/refuse/command-refusal.js +19 -30
  145. package/dist/refuse/render/refusal-words.js +3 -1
  146. package/dist/report/build-report.js +59 -11
  147. package/dist/report/check/actions-digest.js +3 -0
  148. package/dist/report/check/check-lines.js +2 -1
  149. package/dist/report/check/render/text-digest-renderer.js +31 -9
  150. package/dist/report/check/session-actions.js +4 -6
  151. package/dist/report/check/session-check.js +8 -0
  152. package/dist/report/choose-policy.js +7 -1
  153. package/dist/report/nothing-saved-here.js +16 -0
  154. package/dist/report/private-files/tell-lists.js +2 -0
  155. package/dist/report/project-rules.js +2 -0
  156. package/dist/report/refusals.js +15 -0
  157. package/dist/report/render/home-relative.js +2 -0
  158. package/dist/report/render/html-head.js +2 -0
  159. package/dist/report/render/html-report-styles.js +2 -0
  160. package/dist/report/render/html-sidebar.js +2 -0
  161. package/dist/report/render/path-tail.js +2 -0
  162. package/dist/report/render/report-copy.js +2 -0
  163. package/dist/report/render/report-page/advanced-view.js +2 -0
  164. package/dist/report/render/report-page/files-view.js +2 -0
  165. package/dist/report/render/report-page/files.js +2 -0
  166. package/dist/report/render/report-page/fix-wizard-script.js +2 -0
  167. package/dist/report/render/report-page/helpers-view.js +2 -0
  168. package/dist/report/render/report-page/item-names.js +2 -0
  169. package/dist/report/render/report-page/protect-patterns.js +2 -0
  170. package/dist/report/render/report-page/report-views.js +2 -0
  171. package/dist/report/render/report-page/to-do-view.js +3 -2
  172. package/dist/report/render/text-report-renderer.js +2 -0
  173. package/dist/report/render/ui/add-file-popup.js +2 -0
  174. package/dist/report/render/ui/app-sidebar.js +2 -0
  175. package/dist/report/render/ui/ask-panel.js +2 -0
  176. package/dist/report/render/ui/avatar.js +2 -0
  177. package/dist/report/render/ui/backdrop.js +2 -0
  178. package/dist/report/render/ui/brand-mark.js +2 -0
  179. package/dist/report/render/ui/checklist.js +2 -0
  180. package/dist/report/render/ui/confirm-dialog.js +2 -0
  181. package/dist/report/render/ui/drawer.js +2 -0
  182. package/dist/report/render/ui/file-chip.js +2 -0
  183. package/dist/report/render/ui/fold-line.js +2 -0
  184. package/dist/report/render/ui/guide-card.js +2 -0
  185. package/dist/report/render/ui/live-script.js +2 -0
  186. package/dist/report/render/ui/page-shell.js +2 -0
  187. package/dist/report/render/ui/popup.js +2 -0
  188. package/dist/report/render/ui/progress.js +2 -0
  189. package/dist/report/render/ui/project-list.js +9 -9
  190. package/dist/report/render/ui/stats.js +2 -0
  191. package/dist/report/render/ui/status-icon.js +2 -0
  192. package/dist/report/render/ui/status-look.js +2 -0
  193. package/dist/report/render/ui/task-list.js +2 -0
  194. package/dist/report/render/ui/tokens.js +2 -0
  195. package/dist/report/render/ui/update-notice.js +2 -0
  196. package/dist/report/render/ui/words/app-words.js +3 -3
  197. package/dist/report/render/ui/words/onboarding-words.js +9 -9
  198. package/dist/report/render/ui/words/projects-words.js +3 -3
  199. package/dist/report/render/ui/words/report-words.js +11 -11
  200. package/dist/report/render/ui/words/settings-words.js +30 -6
  201. package/dist/report/rule-names.js +2 -0
  202. package/dist/report/session-report.js +2 -0
  203. package/dist/report/start/conversations/calendar-view.js +2 -0
  204. package/dist/report/start/conversations/conversation-columns.js +2 -0
  205. package/dist/report/start/conversations/empty-week.js +2 -0
  206. package/dist/report/start/conversations/files-window.js +2 -0
  207. package/dist/report/start/conversations/period-section.js +2 -0
  208. package/dist/report/start/conversations/periods-script.js +2 -0
  209. package/dist/report/start/conversations/periods.js +2 -0
  210. package/dist/report/start/conversations/week-view.js +2 -0
  211. package/dist/report/start/conversations/weeks.js +2 -0
  212. package/dist/report/start/detached-start.js +75 -0
  213. package/dist/report/start/month/month-view.js +2 -0
  214. package/dist/report/start/onboarding/done.js +9 -3
  215. package/dist/report/start/onboarding/finish-onboarding.js +6 -4
  216. package/dist/report/start/onboarding/intro.js +2 -0
  217. package/dist/report/start/onboarding/onboarding-renderer.js +4 -4
  218. package/dist/report/start/onboarding/onboarding-script.js +2 -0
  219. package/dist/report/start/onboarding/onboarding-view.js +1 -1
  220. package/dist/report/start/onboarding/project-step.js +1 -1
  221. package/dist/report/start/onboarding/steps.js +2 -0
  222. package/dist/report/start/onboarding/welcome.js +2 -0
  223. package/dist/report/start/page-notice.js +2 -0
  224. package/dist/report/start/projects/index-projects.js +5 -2
  225. package/dist/report/start/projects/moved-page.js +2 -0
  226. package/dist/report/start/projects/project-switch.js +2 -0
  227. package/dist/report/start/projects/projects-window.js +2 -0
  228. package/dist/report/start/projects/temporary-space.js +2 -0
  229. package/dist/report/start/render/start-words.js +15 -1
  230. package/dist/report/start/repository.js +2 -0
  231. package/dist/report/start/serve/index-handler.js +4 -1
  232. package/dist/report/start/serve/settings-setup.js +10 -0
  233. package/dist/report/start/session-start.js +69 -29
  234. package/dist/report/start/settings/alerts-tab.js +4 -1
  235. package/dist/report/start/settings/files-tab.js +18 -12
  236. package/dist/report/start/settings/general-tab.js +33 -1
  237. package/dist/report/start/settings/settings-renderer.js +2 -0
  238. package/dist/report/start/settings/settings-script.js +8 -1
  239. package/dist/report/start/settings/settings-view.js +29 -2
  240. package/dist/report/start/settings/settings-windows.js +16 -5
  241. package/dist/report/start/settings-files.js +2 -0
  242. package/dist/report/start/to-fix/done-list.js +2 -0
  243. package/dist/report/start/to-fix/file-window.js +2 -0
  244. package/dist/report/start/to-fix/to-fix-list.js +2 -0
  245. package/dist/report/start/to-fix/to-fix-renderer.js +2 -0
  246. package/dist/report/start/to-fix/to-fix-script.js +2 -0
  247. package/dist/report/watch/agent-alert.js +3 -0
  248. package/dist/report/watch/codex-turn-format.js +69 -0
  249. package/dist/report/watch/notice-choices.js +2 -0
  250. package/dist/report/watch/notice-settings.js +2 -0
  251. package/dist/report/watch/preferences.js +2 -0
  252. package/dist/report/watch/render/codex-request-words.js +106 -0
  253. package/dist/report/watch/render/instruction-words.js +114 -12
  254. package/dist/report/watch/render/notice-words.js +74 -4
  255. package/dist/report/watch/subagent-watch.js +270 -45
  256. package/dist/setup/behind.js +2 -0
  257. package/dist/setup/codex-mirror.js +311 -104
  258. package/dist/setup/not-a-project.js +2 -0
  259. package/dist/setup/project-setup.js +2 -0
  260. package/dist/shared/compare.js +2 -0
  261. package/dist/shared/content-version.js +2 -0
  262. package/dist/shared/counter.js +2 -0
  263. package/dist/shared/file-stamp.js +2 -0
  264. package/dist/shared/label.js +2 -0
  265. package/dist/shared/package-name.js +2 -0
  266. package/dist/shared/plain-invocation.js +2 -0
  267. package/dist/shared/printable.js +2 -0
  268. package/dist/shared/sentence.js +2 -0
  269. package/dist/shared/terminal-logo.js +2 -0
  270. package/dist/shared/text-table.js +2 -0
  271. package/dist/shared/wrap.js +2 -0
  272. package/package.json +2 -1
  273. package/dist/refuse/render/codex-stop-words.js +0 -21
package/LICENSE CHANGED
@@ -187,7 +187,7 @@
187
187
  same "printed page" as the copyright notice for easier
188
188
  identification within third-party archives.
189
189
 
190
- Copyright [yyyy] [name of copyright owner]
190
+ Copyright 2026 Nessprim Karol Kozer
191
191
 
192
192
  Licensed under the Apache License, Version 2.0 (the "License");
193
193
  you may not use this file except in compliance with the License.
package/README.md CHANGED
@@ -17,12 +17,47 @@
17
17
  One command · runs on your computer · sends nothing anywhere · <a href="LICENSE">Apache 2.0</a>
18
18
  </p>
19
19
 
20
+ <p align="center">
21
+ <img src="docs/screenshots/landing.png" alt="agentwhy: Your AI opens your files. Now you'll know which ones." width="820">
22
+ </p>
23
+
20
24
  ---
21
25
 
22
26
  ## What you see
23
27
 
24
28
  You run one command in your project. A page opens in your browser. Most days it says there is nothing to do.
25
- When there is, it looks like this:
29
+ When there is, it tells you what to fix. The pictures below are of a demo project.
30
+
31
+ **Conversations.** Every time your AI worked for you this week, and which of those times it read something private.
32
+
33
+ ![The Conversations page: nine conversations this week, three of them marked as needing attention](docs/screenshots/conversations.png)
34
+
35
+ **Your to-do list.** One conversation, opened: the private file your AI read, and a **Fix it** button that walks you
36
+ through making it safe.
37
+
38
+ ![The to-do list of one conversation: a private .env file was opened, with a Fix it button](docs/screenshots/to-do-list.png)
39
+
40
+ **What happened.** The story of one file, step by step: who read it, where it was repeated, and where it ended up.
41
+
42
+ ![What happened to this file, as a story: a helper read the keys, repeated them in a message and passed them back](docs/screenshots/what-happened-story.png)
43
+
44
+ The same story as a diagram, from your request to the file:
45
+
46
+ ![What happened to this file, as a diagram: you, your AI, a helper, the .env file, and the point where it was exposed](docs/screenshots/what-happened-diagram.png)
47
+
48
+ And as the full record, one line for every time the file was opened:
49
+
50
+ ![What happened to this file, as the full record: a table of each event with its time, tool and result](docs/screenshots/what-happened-full-record.png)
51
+
52
+ **Files.** Everything your AI opened, with the private files on top and whether each one is blocked.
53
+
54
+ ![The Files page: four files opened, one of them private](docs/screenshots/files.png)
55
+
56
+ **How it went.** The helpers your AI brought in, and which of them saw your private files.
57
+
58
+ ![The How it went page: your AI gave work to one helper, which read the .env file](docs/screenshots/helpers.png)
59
+
60
+ In the terminal, the same kind of finding looks like this:
26
61
 
27
62
  ```
28
63
  the session itself · 4 actions · 1 file reached · wrote the value into files
@@ -103,8 +138,13 @@ In Claude Code, it stops the obvious routes: `init` adds rules for Claude Code's
103
138
  refuses shell commands like `cat .env` or `grep -r` over a private file. It is not a sandbox. A path in a variable or a
104
139
  script can still get through, and that is why the main job is telling you what actually happened. In Codex the same
105
140
  check runs as a Codex hook, with the same list of files. When it blocks a command, a second hook asks Codex to explain
106
- the block in its reply in the terminal or code editor, without asking you to paste a secret. Codex runs new or changed
107
- hooks only after you approve them there: until then, it skips them without a word.
141
+ the block in its reply in the terminal or code editor, without asking you to paste a secret. The check is written into
142
+ your own `~/.codex/hooks.json` and approved by agentwhy itself in `~/.codex/config.toml` - its own two entries and
143
+ nothing else, and no folder is marked trusted - because Codex skips an unapproved hook without a word, and in VS Code
144
+ the first conversation starts before anything can be approved (measured on Codex 0.159). Before writing it, `init`
145
+ runs the check once the way Codex will, and writes nothing if that fails. So it blocks from the first message, in the
146
+ terminal and in VS Code, and it acts only in projects whose rules block files. If Codex changes how it checks
147
+ approvals, agentwhy's Settings page says the block may ask again instead of claiming it holds.
108
148
 
109
149
  **Which agents does it work with?**
110
150
  Claude Code, in the terminal and in code editors, and Codex. Not Cursor's own AI, Windsurf or chats on claude.ai. Each
@@ -122,8 +162,8 @@ Codex writes down less than Claude Code does, so a Codex report can answer fewer
122
162
  | Which helper was asked to do what, and what it gave back | yes | yes |
123
163
  | Which files the AI changed | yes | yes; a change that failed is not counted |
124
164
  | What Codex was allowed to do at the time | - | yes, shown apart from your own rules |
125
- | Blocking a file | yes | yes, the same files, once you approve agentwhy's hook in Codex (measured on 0.159.2) |
126
- | A message in the chat after agentwhy blocks a command | yes | yes, in the terminal and code editor once both hooks are approved |
165
+ | Blocking a file | yes | yes, the same files, from the first message - agentwhy approves its own check in your `~/.codex` files (measured on 0.159) |
166
+ | A message in the chat after agentwhy blocks a command | yes | yes, in the terminal and code editor |
127
167
  | A warning when a value reached the chat, and `check` | yes | no |
128
168
 
129
169
  Older Codex files keep the commands only inside the code that ran them, so their reports show no commands and say so.
@@ -168,8 +208,20 @@ npx @agentwhy/cli notify # what you are told when a turn ends, and whe
168
208
 
169
209
  Every command, flag and hook, and what each word on the page means: [docs/reference.md](docs/reference.md).
170
210
 
211
+ ## Author
212
+
213
+ agentwhy is made by **Karol Kozer** ([LinkedIn](https://www.linkedin.com/feed/update/urn:li:activity:7507715323744206849/)).
214
+ Before it, Karol made [Planby](https://planby.app/), which
215
+ [won first place at WaysAwards 2024](https://www.linkedin.com/posts/waysawards2024-digitalinnovation-techawards-ugcPost-7252302039907504129-uszQ/),
216
+ hosted by WaysConf, and has over [1,700 stars on GitHub](https://github.com/karolkozer/planby).
217
+
218
+ Something else in mind? Write to [karol@agentwhy.dev](mailto:karol@agentwhy.dev). Every message is read, and most get
219
+ an answer within a day or two.
220
+
171
221
  ## License
172
222
 
223
+ Copyright 2026 Nessprim Karol Kozer.
224
+
173
225
  Open source, under the [Apache License 2.0](LICENSE). Use it, change it and share it for any purpose, commercial
174
226
  use included. When you pass on a copy, keep the license, the copyright and [NOTICE](NOTICE) with it. The license
175
227
  does not cover the name agentwhy. The full terms are in [LICENSE](LICENSE); this summary does not replace them.
@@ -123,9 +123,15 @@ export const ASSUMPTIONS = [
123
123
  },
124
124
  {
125
125
  id: 'entry-point-per-session',
126
- statement: 'The lines that hold a conversation carry entrypoint, which way into Claude Code the session was started by, and one session carries one value: cli, claude-vscode or sdk-cli. Any other value is unknown, never the nearest of these.',
126
+ statement: 'The lines that hold a conversation carry entrypoint, which way into Claude Code the session was started by, and one session carries one value: cli, claude-vscode, sdk-cli or claude-desktop. Any other value is unknown, never the nearest of these.',
127
127
  status: 'verified',
128
- evidence: 'which-project VB5 (2026-09-28): the last megabyte of each of 244 transcripts carries it - claude-vscode 180, cli 62, sdk-cli 2 - and no transcript, tail or whole, carries two values. The same values the hooks are given as CLAUDE_CODE_ENTRYPOINT (the-agent-tells-you B9e2). The committed corpus holds a canary marker in its place, which reads as unknown',
128
+ evidence: 'which-project VB5 (2026-09-28): the last megabyte of each of 244 transcripts carries it - claude-vscode 180, cli 62, sdk-cli 2 - and no transcript, tail or whole, carries two values. The same values the hooks are given as CLAUDE_CODE_ENTRYPOINT (the-agent-tells-you B9e2). claude-desktop on every entrypoint-carrying line of 3 of 3 Claude desktop app conversations, one value each (claude-desktop-conversations CDB1, 2026-10-01). The committed corpus holds a canary marker in its place, which reads as unknown',
129
+ },
130
+ {
131
+ id: 'desktop-session-file',
132
+ statement: 'The Claude desktop app writes no ai-title line. It keeps one file per conversation on macOS, local_<uuid>.json two directory levels under ~/Library/Application Support/Claude/claude-code-sessions, one JSON object whose cliSessionId is the transcript session id and whose title is the name the app sidebar shows. Only those two fields are read; where the app keeps this on Windows is not measured.',
133
+ status: 'verified',
134
+ evidence: 'claude-desktop-conversations CDB1 (2026-10-01, app 2.16120.0 running Claude Code 2.1.284, macOS): 3 of 3 desktop conversations - no ai-title in any transcript read whole, against 1-2 in each of 4 terminal transcripts; 3 files, each one JSON object of 31 keys with a string cliSessionId equal to the transcript file name and the sessionId on its lines, and a non-empty string title; measured by scripts printing key names, counts, lengths and equalities only',
129
135
  },
130
136
  {
131
137
  id: 'sidechain-discriminator',
@@ -135,9 +141,9 @@ export const ASSUMPTIONS = [
135
141
  },
136
142
  {
137
143
  id: 'denial-marker',
138
- statement: 'A blocked call is marked by toolDenialKind on its result line; the only observed value is permission-rule.',
144
+ statement: 'A call that did not run is marked by toolDenialKind on its result line; permission-rule says a rule refused it, automode-blocked that auto mode did. user-rejected has been seen and is not classified.',
139
145
  status: 'verified',
140
- evidence: '5 of 5 denials',
146
+ evidence: '5 of 5 denials, permission-rule (2026-09-12); 5 of 5 markers of automode-blocked on a user line with one result (2026-10-01, Claude Code 2.1.236 and 2.1.284); user-rejected, 1 marker, its effect not measured',
141
147
  },
142
148
  {
143
149
  id: 'meta-keys',
@@ -193,6 +199,12 @@ export const ASSUMPTIONS = [
193
199
  status: 'verified',
194
200
  evidence: '5 of 5 Stop inputs (2026-09-17, the-agent-nobody-watches B8b) and every SubagentStop input of B4 (2026-09-16). Read by `watch` to decide which project a person\'s notification choices were made for, before any of the session has been read',
195
201
  },
202
+ {
203
+ id: 'hook-subagent-stop-agent-file',
204
+ statement: "A SubagentStop input names the finished agent's own file in agent_transcript_path, and a delegated agent's file is on disk when the hook runs. A named file that is not there is an agent that kept no record.",
205
+ status: 'verified',
206
+ evidence: "On disk on 4 of 4 delegated agents (2026-09-16, when-an-agent-finishes B4c) and on 2 more runs (B4f). Named and not on disk on 2 of 2 SubagentStop runs in the Claude desktop app on Claude Code 2.1.284 (2026-10-01, B4g), each about 3 seconds after the turn's Stop, with an empty agent_type, no file under subagents/ and the agent's id nowhere in the session. Read by `watch` to say such an agent once a session instead of after every turn",
207
+ },
196
208
  {
197
209
  id: 'session-title-latest-near-end',
198
210
  statement: 'The title a session has now is its last ai-title line, and that line lies within the last megabyte of the transcript.',
@@ -1,4 +1,19 @@
1
- export const KNOWN_DENIAL_KINDS = ['permission-rule'];
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ export const KNOWN_DENIAL_KINDS = ['permission-rule', 'automode-blocked'];
4
+ /**
5
+ * Who each known value says refused the call (`specs/2026-10-01-who-stopped-it.md` WS1): a permission rule, or the
6
+ * reviewer - auto mode's classifier. Measured 2026-10-01 (spec §4.0): `automode-blocked` on 5 result lines of Claude
7
+ * Code 2.1.236 and 2.1.284, each a `user` line holding one result marked as an error; two were commands that would have
8
+ * started a server, and none started. A Record over the union: a value added above does not compile until it says who.
9
+ *
10
+ * `user-rejected` - a call the person turned down at a prompt - was seen once and is **not** listed: that such a call
11
+ * does not run has not been measured (WSB1), and a value read as "did not run" without that would be a guess.
12
+ */
13
+ export const DENIAL_SOURCE = {
14
+ 'permission-rule': 'rule',
15
+ 'automode-blocked': 'reviewer',
16
+ };
2
17
  // An unrecognised denial kind must surface as unknown, never be folded into a known one.
3
18
  export function isKnownDenialKind(value) {
4
19
  return typeof value === 'string' && KNOWN_DENIAL_KINDS.includes(value);
@@ -0,0 +1,28 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ /**
4
+ * Where the Claude desktop app keeps what it knows about a conversation, and the two fields agentwhy reads from it
5
+ * (`.ai/specs/2026-10-01-claude-desktop-conversations.md` CD1). The app runs Claude Code but writes no `ai-title` into
6
+ * the transcript: the name its sidebar shows is in a file of the app's own. Measured 2026-10-01 on macOS alone (CDB1,
7
+ * app 2.16120.0 running Claude Code 2.1.284, 3 of 3 desktop conversations): one JSON object per conversation, two
8
+ * directory levels under the folder, whose `cliSessionId` is the transcript's session id and whose `title` is the name
9
+ * the sidebar shows. The file also holds what must never be read - MCP server configuration, permissions, prompt
10
+ * snapshots - which is why the fields read are named here and nothing else is (CD4). Where the app keeps this on
11
+ * Windows is not measured (CDB5), so no path is guessed there (CD6).
12
+ */
13
+ export const DESKTOP_SESSIONS = {
14
+ /** Under the home directory, on macOS. */
15
+ folder: ['Library', 'Application Support', 'Claude', 'claude-code-sessions'],
16
+ /** The file sits exactly this many directory levels under the folder - two ids whose meaning is not measured. */
17
+ depth: 2,
18
+ filePrefix: 'local_',
19
+ fileSuffix: '.json',
20
+ /** The transcript's session id - the join to `~/.claude/projects/<project>/<session id>.jsonl`. */
21
+ sessionIdField: 'cliSessionId',
22
+ /** The name the app's sidebar shows. A model wrote it from what the person typed: content, never structure (L009). */
23
+ titleField: 'title',
24
+ };
25
+ /** Whether a directory entry's name is a desktop session file. Anything else in those folders is not read. */
26
+ export function isDesktopSessionFileName(name) {
27
+ return name.startsWith(DESKTOP_SESSIONS.filePrefix) && name.endsWith(DESKTOP_SESSIONS.fileSuffix);
28
+ }
@@ -1,10 +1,14 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * Which way into Claude Code a session was started by, as its transcript records it in `entrypoint` - by the words a
3
5
  * person knows it by (`.ai/specs/2026-09-27-which-project.md` V4). These are the values Claude Code also gives its hooks
4
6
  * as `CLAUDE_CODE_ENTRYPOINT` (`the-agent-tells-you.md` B9e2: terminal `claude`, the editor extension, `claude -p`).
5
7
  *
6
8
  * Measured, not guessed (which-project VB5, 2026-09-28): the last megabyte of each of 244 transcripts carries the field,
7
- * `claude-vscode` in 180, `cli` in 62, `sdk-cli` in 2, and no transcript carries two values. No other value has been
9
+ * `claude-vscode` in 180, `cli` in 62, `sdk-cli` in 2, and no transcript carries two values. A fourth value, measured
10
+ * 2026-10-01 (`claude-desktop-conversations.md` CDB1, CD7): the Claude desktop app writes `claude-desktop`, on every
11
+ * line that carries the field in 3 of 3 desktop transcripts, one value each. No other value has been
8
12
  * seen: one that is not listed here is said to be unknown, never read as the nearest of these. What the extension writes
9
13
  * inside Cursor, and the plugin inside JetBrains, is not measured (VB4), so `editor` is a code editor and not "VS Code".
10
14
  */
@@ -12,4 +16,5 @@ export const ENTRY_POINT_VALUES = {
12
16
  terminal: 'cli',
13
17
  editor: 'claude-vscode',
14
18
  script: 'sdk-cli',
19
+ desktop: 'claude-desktop',
15
20
  };
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  export const FIELDS = {
2
4
  lineType: 'type',
3
5
  sidechain: 'isSidechain',
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * What a Claude Code hook is handed and what it may hand back (`specs/2026-09-16-when-an-agent-finishes.md` §2).
3
5
  *
@@ -25,6 +27,12 @@ export const SUBAGENT_STOP = {
25
27
  lastMessage: 'last_assistant_message',
26
28
  /** The session the agent belongs to: what an alert is remembered under until its turn ends. */
27
29
  sessionId: 'session_id',
30
+ /**
31
+ * The agent's own file, `subagents/agent-<id>.jsonl` beside the main transcript (D2). On disk when the hook ran for
32
+ * every delegated agent measured (B4c, B4f); named and **not** on disk on 2 of 2 runs in the Claude desktop app
33
+ * where nothing was delegated (B4g) - an agent that kept no record at all.
34
+ */
35
+ agentTranscriptPath: 'agent_transcript_path',
28
36
  },
29
37
  };
30
38
  /**
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  const TOOL_USE_ID = /toolu_[A-Za-z0-9]{20,}/g;
2
4
  const UUID = /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g;
3
5
  const AGENT_ID = /^(agent-)?a[0-9a-f]{16}$/;
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  export const LAYOUT = {
2
4
  transcriptSuffix: '.jsonl',
3
5
  subagentsDir: 'subagents',
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  export const CONVERSATION_LINE_TYPES = ['user', 'assistant'];
2
4
  export const SKIPPED_LINE_TYPES = [
3
5
  'attachment',
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * Where a project's sessions live: `~/.claude/projects/<encoded working directory>/`.
3
5
  *
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * What makes a file a Claude Code transcript by its content (`2026-09-27-what-codex-wrote.md` X2): its first non-blank
3
5
  * line is a JSON object with a string `type`, and either a string `sessionId` or a `type` the contract lists.
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * Where a project keeps Claude Code settings, and how hooks are written in them (`specs/2026-09-16-worth-running-every-day.md`
3
5
  * R4-R8). **Documented, not measured** against this project's oracle: read from the settings and hooks references.
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  /**
2
4
  * A report delivered late (`specs/2026-09-15-where-the-value-went.md` R1, R5). An agent started in the background
3
5
  * answers its delegating call with a launch notice, and its report reaches the calling agent afterwards, in a
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  // The format contract: the only code that knows the Claude Code transcript format, one subject per file in this
2
4
  // directory. Everything here was measured against a real session (spec §4.0, tests/fixtures/oracle.json). When
3
5
  // `agentwhy doctor` reports drift, follow .ai/skills/update-format-contract/SKILL.md instead of patching callers.
@@ -43,7 +45,18 @@
43
45
  // v13 (2026-09-29): recognition by content (`recognition.ts`), so a Codex rollout is never read as a transcript
44
46
  // (`.ai/specs/2026-09-27-what-codex-wrote.md` X2, XD7). Measured over the first lines of 257 main transcripts: a string
45
47
  // `type` and a string `sessionId` equal to the file's name in all 257; a listed `type` in 193.
46
- export const CONTRACT_VERSION = 13;
48
+ // v14 (2026-10-01): a second value of `toolDenialKind`, `automode-blocked`, and who each value says refused the call
49
+ // (`denials.ts`, `.ai/specs/2026-10-01-who-stopped-it.md`). Measured on the 7 transcripts of one project, Claude Code
50
+ // 2.1.236 and 2.1.284: 5 markers, each on a `user` line holding exactly one result marked as an error, with
51
+ // `toolUseResult` and `sourceToolAssistantUUID` beside it - where `permission-rule` sits. A third value seen there,
52
+ // `user-rejected`, stays unknown until it is measured that its call does not run.
53
+ // v15 (2026-10-04): the Claude desktop app's session file (`desktop-sessions.ts`) - where the app keeps a
54
+ // conversation's title, since it writes no `ai-title` line - and `claude-desktop` as a fourth `entrypoint` value
55
+ // (`entry-points.ts`). Measured 2026-10-01 on macOS (`.ai/specs/2026-10-01-claude-desktop-conversations.md` CDB1,
56
+ // app 2.16120.0 running Claude Code 2.1.284): 3 of 3 desktop conversations, no `ai-title` in any, one
57
+ // `local_<uuid>.json` each whose `cliSessionId` is the transcript's session id and whose `title` is the sidebar's name.
58
+ // Only those two fields are read (CD4); Windows is not measured (CDB5), so no path is read there.
59
+ export const CONTRACT_VERSION = 15;
47
60
  export const VERIFIED_AGAINST = {
48
61
  claudeCode: '2.1.268',
49
62
  session: '951c4f76-9d2a-40d2-9c76-1e57cf47ae4e',
@@ -1,4 +1,7 @@
1
- import { join } from 'node:path';
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { basename, join } from 'node:path';
4
+ import { isDirectory } from '../../../ports/directory-reader.js';
2
5
  import { FileAccessError } from '../../../ports/file-access-error.js';
3
6
  import { LAYOUT } from '../contract/layout.js';
4
7
  import { PROJECTS_DIRECTORY, matchesProject } from '../contract/projects.js';
@@ -68,10 +71,17 @@ export class ClaudeCodeProjectCatalogue {
68
71
  }
69
72
  if (path === undefined)
70
73
  return 'unreadable';
74
+ // CD2: a conversation the Claude desktop app held has no `ai-title`; its row is titled by the app's own name,
75
+ // through this list's redactor, as the transcript's title would be.
76
+ if (recognised.title === undefined && this.#dependencies.desktop !== undefined) {
77
+ const named = await this.#dependencies.desktop.titleOf(basename(newest.path, LAYOUT.transcriptSuffix));
78
+ if (named !== undefined)
79
+ recognised = { ...recognised, title: this.#dependencies.redactor.scan(named) };
80
+ }
71
81
  return {
72
82
  id,
73
83
  path,
74
- exists: await this.#isDirectory(path),
84
+ folder: this.#dependencies.leaveAlone?.(path) === true ? 'not-looked' : (await isDirectory(this.#dependencies.directories, path)) ? 'there' : 'gone',
75
85
  conversations: transcripts.length,
76
86
  newest: { modifiedAt: newest.modifiedAt, ...recognised },
77
87
  };
@@ -116,14 +126,4 @@ export class ClaudeCodeProjectCatalogue {
116
126
  return 0;
117
127
  }
118
128
  }
119
- async #isDirectory(path) {
120
- try {
121
- return (await this.#dependencies.directories.kindOf(path)) === 'directory';
122
- }
123
- catch (error) {
124
- if (!(error instanceof FileAccessError))
125
- throw error;
126
- return false;
127
- }
128
- }
129
129
  }
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import { resolve } from 'node:path';
2
4
  import { FileAccessError } from '../../../ports/file-access-error.js';
3
5
  import { parseJsonObject } from '../../../shared/json.js';
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import { join } from 'node:path';
2
4
  import { FileAccessError } from '../../../ports/file-access-error.js';
3
5
  import { LAYOUT, parseSubagentFileName } from '../contract/layout.js';
@@ -15,9 +17,25 @@ export class ClaudeCodeSessionCatalogue {
15
17
  this.#directories = directories;
16
18
  this.#home = home;
17
19
  }
20
+ /**
21
+ * Whether a folder is there and can be read: listed, not only seen, since a sandbox may show a folder it will not let
22
+ * an app list - and from inside it that is the same as no folder. An error the port names is "no", and nothing else
23
+ * is swallowed.
24
+ */
25
+ async #canList(path) {
26
+ try {
27
+ await this.#directories.list(path);
28
+ return true;
29
+ }
30
+ catch (error) {
31
+ if (!(error instanceof FileAccessError))
32
+ throw error;
33
+ return false;
34
+ }
35
+ }
18
36
  async list(workingDirectory) {
19
37
  const root = join(this.#home, ...PROJECTS_DIRECTORY);
20
- const directory = await this.#directoryFor(root, workingDirectory);
38
+ const { directory, storeListed } = await this.#directoryFor(root, workingDirectory);
21
39
  let entries;
22
40
  try {
23
41
  entries = await this.#directories.list(directory);
@@ -25,8 +43,10 @@ export class ClaudeCodeSessionCatalogue {
25
43
  catch (error) {
26
44
  if (!(error instanceof FileAccessError))
27
45
  throw error;
28
- // Not found is an answer, not a failure: this project may simply never have been worked on here.
29
- return { directory, found: false, searched: [{ provider: 'claude-code', directory, found: false }], sessions: [] };
46
+ // Not found is an answer, not a failure: this project may simply never have been worked on here. Whether Claude
47
+ // Code keeps any conversations here at all is the other half of the answer (R28, amended 2026-10-01).
48
+ const store = (storeListed ?? (await this.#canList(root))) ? {} : { store: 'missing' };
49
+ return { directory, found: false, searched: [{ provider: 'claude-code', directory, found: false, ...store }], sessions: [] };
30
50
  }
31
51
  const sessions = await Promise.all(entries
32
52
  .filter((entry) => entry.kind === 'file' && entry.name.endsWith(LAYOUT.transcriptSuffix))
@@ -41,13 +61,14 @@ export class ClaudeCodeSessionCatalogue {
41
61
  /**
42
62
  * The encoded name first, because it costs nothing when it is right. When it is not - and the rule is
43
63
  * inferred from examples, so it will not always be - the stored directories are compared against the working
44
- * directory instead of trusting the guess.
64
+ * directory instead of trusting the guess. Where the store was listed for that, whether it could be is said too, so
65
+ * it is not asked again.
45
66
  */
46
67
  async #directoryFor(root, workingDirectory) {
47
68
  const derived = join(root, projectDirectoryName(workingDirectory));
48
69
  try {
49
70
  if ((await this.#directories.kindOf(derived)) === 'directory')
50
- return derived;
71
+ return { directory: derived };
51
72
  }
52
73
  catch (error) {
53
74
  if (!(error instanceof FileAccessError))
@@ -56,12 +77,12 @@ export class ClaudeCodeSessionCatalogue {
56
77
  try {
57
78
  const stored = await this.#directories.list(root);
58
79
  const match = stored.find((entry) => entry.kind === 'directory' && matchesProject(entry.name, workingDirectory));
59
- return match === undefined ? derived : join(root, match.name);
80
+ return { directory: match === undefined ? derived : join(root, match.name), storeListed: true };
60
81
  }
61
82
  catch (error) {
62
83
  if (!(error instanceof FileAccessError))
63
84
  throw error;
64
- return derived;
85
+ return { directory: derived, storeListed: false };
65
86
  }
66
87
  }
67
88
  async #summarise(directory, fileName) {
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import { basename, join, resolve } from 'node:path';
2
4
  import { FileAccessError } from '../../../ports/file-access-error.js';
3
5
  import { byString } from '../../../shared/compare.js';
@@ -5,24 +5,48 @@ import { recognitionIn } from './transcript-tail.js';
5
5
  /**
6
6
  * Reads what a person recognises a session by from the end of its transcript, in one read: the title Claude Code gave
7
7
  * it, handed over only once it has passed the redactor, and which way into Claude Code it was held
8
- * (`.ai/specs/2026-09-27-which-project.md` V4). Nothing else in the transcript is looked at, and nothing is kept.
8
+ * (`.ai/specs/2026-09-27-which-project.md` V4). Nothing else in the transcript is looked at, and nothing but the tail's
9
+ * answer is kept. A session the desktop app held has no `ai-title`: its title is the app's own, read the same way a
10
+ * Codex thread's name is (CD2) - so a row with no title can be asked again at the cost of a look at the app's folder,
11
+ * never another read of an unchanged transcript (CD5).
9
12
  */
10
13
  export class ClaudeCodeSessionTitles {
11
14
  #dependencies;
15
+ /** By the session's path: what its tail said when it was last this old. */
16
+ #known = new Map();
12
17
  constructor(dependencies) {
13
18
  this.#dependencies = dependencies;
14
19
  }
15
20
  async recognise(session) {
16
- const { transcripts, redactor } = this.#dependencies;
17
- let tail;
21
+ const { redactor, desktop } = this.#dependencies;
22
+ const kept = this.#known.get(session.path);
23
+ let fromTail;
24
+ if (kept !== undefined && kept.modifiedAt === session.modifiedAt) {
25
+ fromTail = kept.recognition;
26
+ }
27
+ else {
28
+ const tail = await this.#tail(session.path + LAYOUT.transcriptSuffix);
29
+ // A transcript that is missing or unreadable is recognised by nothing, and nothing is kept: the next ask tries it.
30
+ if (tail === undefined)
31
+ return {};
32
+ fromTail = recognitionIn(tail, redactor);
33
+ this.#known.set(session.path, { modifiedAt: session.modifiedAt, recognition: fromTail });
34
+ }
35
+ if (fromTail.title !== undefined || desktop === undefined)
36
+ return fromTail;
37
+ const named = await desktop.titleOf(session.id);
38
+ if (named === undefined)
39
+ return fromTail;
40
+ return { ...fromTail, title: redactor.scan(named) };
41
+ }
42
+ async #tail(path) {
18
43
  try {
19
- tail = await transcripts.readTail(session.path + LAYOUT.transcriptSuffix, SESSION_TITLE.tailBytes);
44
+ return await this.#dependencies.transcripts.readTail(path, SESSION_TITLE.tailBytes);
20
45
  }
21
46
  catch (error) {
22
47
  if (!(error instanceof FileAccessError))
23
48
  throw error;
24
- return {};
49
+ return undefined;
25
50
  }
26
- return recognitionIn(tail, redactor);
27
51
  }
28
52
  }
@@ -0,0 +1,102 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
3
+ import { join } from 'node:path';
4
+ import { FileAccessError } from '../../../ports/file-access-error.js';
5
+ import { parseJsonObject } from '../../../shared/json.js';
6
+ import { oneLine } from '../../../shared/printable.js';
7
+ import { DESKTOP_SESSIONS, isDesktopSessionFileName } from '../contract/desktop-sessions.js';
8
+ /**
9
+ * The names the Claude desktop app gave its conversations, for the ones whose transcripts hold no `ai-title`
10
+ * (`.ai/specs/2026-10-01-claude-desktop-conversations.md` CD2). Each file is read for `cliSessionId` and `title` and
11
+ * nothing else is kept, counted or handed out (CD4); the whole walk answers nothing where the folder is missing or
12
+ * unreadable, which on a computer without the app is the ordinary case. The app names a conversation at its first turn,
13
+ * which can be after a list first showed it, so every ask walks the folders again - a look at their listings - while a
14
+ * file is read again only where it changed (CD5). A title leaves here raw: every caller passes it through its own
15
+ * redactor before anything shows it (§13.2 pitfall 7), exactly as `ai-title` is handled.
16
+ */
17
+ export class ClaudeDesktopTitles {
18
+ #dependencies;
19
+ /** By file path. A file that could not be read is not kept, so the next ask tries it again. */
20
+ #read = new Map();
21
+ /** The walk in flight, shared by the asks of one batch so eight rows cost one walk. */
22
+ #names;
23
+ constructor(dependencies) {
24
+ this.#dependencies = dependencies;
25
+ }
26
+ /** The app's name for this session, raw, or nothing - which a caller never tells apart from "no app here". */
27
+ async titleOf(sessionId) {
28
+ if (this.#dependencies.folder === undefined)
29
+ return undefined;
30
+ if (this.#names === undefined) {
31
+ this.#names = this.#walk(this.#dependencies.folder).finally(() => {
32
+ this.#names = undefined;
33
+ });
34
+ }
35
+ return (await this.#names).get(sessionId);
36
+ }
37
+ /**
38
+ * Every session file at exactly `DESKTOP_SESSIONS.depth` levels under the folder - one that sits shallower or deeper
39
+ * is not the measured shape and is not read. Two files naming one id with different titles name none (CD2): the
40
+ * contract has no way to pick, and a guess would show a person the wrong conversation's name.
41
+ */
42
+ async #walk(folder) {
43
+ const names = new Map();
44
+ const conflicted = new Set();
45
+ for (const first of await this.#list(folder)) {
46
+ if (first.kind !== 'directory')
47
+ continue;
48
+ for (const second of await this.#list(join(folder, first.name))) {
49
+ if (second.kind !== 'directory')
50
+ continue;
51
+ for (const entry of await this.#list(join(folder, first.name, second.name))) {
52
+ if (entry.kind !== 'file' || !isDesktopSessionFileName(entry.name))
53
+ continue;
54
+ const file = await this.#file(join(folder, first.name, second.name, entry.name));
55
+ if (file?.id === undefined || file.title === undefined)
56
+ continue;
57
+ const known = names.get(file.id);
58
+ if (known !== undefined && known !== file.title) {
59
+ conflicted.add(file.id);
60
+ continue;
61
+ }
62
+ names.set(file.id, file.title);
63
+ }
64
+ }
65
+ }
66
+ for (const id of conflicted)
67
+ names.delete(id);
68
+ return names;
69
+ }
70
+ async #list(path) {
71
+ try {
72
+ return await this.#dependencies.directories.list(path);
73
+ }
74
+ catch (error) {
75
+ if (!(error instanceof FileAccessError))
76
+ throw error;
77
+ return [];
78
+ }
79
+ }
80
+ /** One file's two fields, from the cache where it has not changed. A shape that is not CDB1's says nothing. */
81
+ async #file(path) {
82
+ const { directories, files } = this.#dependencies;
83
+ try {
84
+ const modifiedAt = await directories.modifiedAt(path);
85
+ const kept = this.#read.get(path);
86
+ if (kept !== undefined && kept.modifiedAt === modifiedAt)
87
+ return kept;
88
+ const record = parseJsonObject(await files.readText(path));
89
+ const id = record?.[DESKTOP_SESSIONS.sessionIdField];
90
+ const rawTitle = record?.[DESKTOP_SESSIONS.titleField];
91
+ const title = typeof rawTitle === 'string' ? oneLine(rawTitle) : '';
92
+ const read = { modifiedAt, ...(typeof id === 'string' && id !== '' && title !== '' ? { id, title } : {}) };
93
+ this.#read.set(path, read);
94
+ return read;
95
+ }
96
+ catch (error) {
97
+ if (!(error instanceof FileAccessError))
98
+ throw error;
99
+ return undefined;
100
+ }
101
+ }
102
+ }
@@ -1,3 +1,5 @@
1
+ // Copyright 2026 Nessprim Karol Kozer
2
+ // SPDX-License-Identifier: Apache-2.0
1
3
  import { join } from 'node:path';
2
4
  import { byString } from '../../../shared/compare.js';
3
5
  import { LAYOUT, isToolResultFileName, parseSubagentFileName, subagentFileName } from '../contract/layout.js';