@edspencer/paddock 0.0.1 → 0.57.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 (296) hide show
  1. package/README.md +69 -0
  2. package/install-notice.mjs +29 -0
  3. package/package.json +58 -5
  4. package/packages/server/dist/adoptable.d.ts +127 -0
  5. package/packages/server/dist/adoptable.js +315 -0
  6. package/packages/server/dist/app.d.ts +67 -0
  7. package/packages/server/dist/app.js +325 -0
  8. package/packages/server/dist/archive.d.ts +42 -0
  9. package/packages/server/dist/archive.js +125 -0
  10. package/packages/server/dist/attachments-config.d.ts +114 -0
  11. package/packages/server/dist/attachments-config.js +165 -0
  12. package/packages/server/dist/attachments-hint.d.ts +53 -0
  13. package/packages/server/dist/attachments-hint.js +140 -0
  14. package/packages/server/dist/attachments.d.ts +33 -0
  15. package/packages/server/dist/attachments.js +117 -0
  16. package/packages/server/dist/auth.d.ts +84 -0
  17. package/packages/server/dist/auth.js +247 -0
  18. package/packages/server/dist/background.d.ts +36 -0
  19. package/packages/server/dist/background.js +137 -0
  20. package/packages/server/dist/bind-safety.d.ts +56 -0
  21. package/packages/server/dist/bind-safety.js +51 -0
  22. package/packages/server/dist/brand.d.ts +44 -0
  23. package/packages/server/dist/brand.js +88 -0
  24. package/packages/server/dist/chat-dto.d.ts +210 -0
  25. package/packages/server/dist/chat-dto.js +308 -0
  26. package/packages/server/dist/cli/import-chats.d.ts +21 -0
  27. package/packages/server/dist/cli/import-chats.js +197 -0
  28. package/packages/server/dist/cli/paddock.d.ts +2 -0
  29. package/packages/server/dist/cli/paddock.js +247 -0
  30. package/packages/server/dist/concurrency.d.ts +57 -0
  31. package/packages/server/dist/concurrency.js +71 -0
  32. package/packages/server/dist/config.d.ts +538 -0
  33. package/packages/server/dist/config.js +625 -0
  34. package/packages/server/dist/curation-config.d.ts +55 -0
  35. package/packages/server/dist/curation-config.js +64 -0
  36. package/packages/server/dist/environment-prompt.d.ts +52 -0
  37. package/packages/server/dist/environment-prompt.js +61 -0
  38. package/packages/server/dist/event-bus.d.ts +87 -0
  39. package/packages/server/dist/event-bus.js +48 -0
  40. package/packages/server/dist/git.d.ts +128 -0
  41. package/packages/server/dist/git.js +446 -0
  42. package/packages/server/dist/github-auth.d.ts +44 -0
  43. package/packages/server/dist/github-auth.js +169 -0
  44. package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
  45. package/packages/server/dist/herdctl-agent-config.js +331 -0
  46. package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
  47. package/packages/server/dist/herdctl-agent-names.js +228 -0
  48. package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
  49. package/packages/server/dist/herdctl-jobs-index.js +225 -0
  50. package/packages/server/dist/herdctl-jobs.d.ts +98 -0
  51. package/packages/server/dist/herdctl-jobs.js +225 -0
  52. package/packages/server/dist/herdctl.d.ts +623 -0
  53. package/packages/server/dist/herdctl.js +1279 -0
  54. package/packages/server/dist/hook-config.d.ts +217 -0
  55. package/packages/server/dist/hook-config.js +224 -0
  56. package/packages/server/dist/http-bytes.d.ts +24 -0
  57. package/packages/server/dist/http-bytes.js +52 -0
  58. package/packages/server/dist/index.d.ts +1 -0
  59. package/packages/server/dist/index.js +14 -0
  60. package/packages/server/dist/instance-config.d.ts +143 -0
  61. package/packages/server/dist/instance-config.js +385 -0
  62. package/packages/server/dist/localcommand.d.ts +23 -0
  63. package/packages/server/dist/localcommand.js +140 -0
  64. package/packages/server/dist/management-auth.d.ts +99 -0
  65. package/packages/server/dist/management-auth.js +165 -0
  66. package/packages/server/dist/management-config.d.ts +164 -0
  67. package/packages/server/dist/management-config.js +286 -0
  68. package/packages/server/dist/management-mcp-server.d.ts +20 -0
  69. package/packages/server/dist/management-mcp-server.js +95 -0
  70. package/packages/server/dist/management-metadata.d.ts +67 -0
  71. package/packages/server/dist/management-metadata.js +56 -0
  72. package/packages/server/dist/management-ops.d.ts +110 -0
  73. package/packages/server/dist/management-ops.js +449 -0
  74. package/packages/server/dist/management-policy.d.ts +172 -0
  75. package/packages/server/dist/management-policy.js +259 -0
  76. package/packages/server/dist/message-provenance.d.ts +112 -0
  77. package/packages/server/dist/message-provenance.js +260 -0
  78. package/packages/server/dist/models.d.ts +164 -0
  79. package/packages/server/dist/models.js +208 -0
  80. package/packages/server/dist/openapi.d.ts +66 -0
  81. package/packages/server/dist/openapi.js +183 -0
  82. package/packages/server/dist/parent-detach.d.ts +30 -0
  83. package/packages/server/dist/parent-detach.js +109 -0
  84. package/packages/server/dist/preload.d.ts +43 -0
  85. package/packages/server/dist/preload.js +55 -0
  86. package/packages/server/dist/project-files.d.ts +76 -0
  87. package/packages/server/dist/project-files.js +191 -0
  88. package/packages/server/dist/project-mime.d.ts +35 -0
  89. package/packages/server/dist/project-mime.js +55 -0
  90. package/packages/server/dist/project-paths.d.ts +88 -0
  91. package/packages/server/dist/project-paths.js +126 -0
  92. package/packages/server/dist/project-types.d.ts +316 -0
  93. package/packages/server/dist/project-types.js +48 -0
  94. package/packages/server/dist/projects.d.ts +202 -0
  95. package/packages/server/dist/projects.js +894 -0
  96. package/packages/server/dist/queued-message.d.ts +35 -0
  97. package/packages/server/dist/queued-message.js +116 -0
  98. package/packages/server/dist/read-state.d.ts +46 -0
  99. package/packages/server/dist/read-state.js +139 -0
  100. package/packages/server/dist/recovery-config.d.ts +111 -0
  101. package/packages/server/dist/recovery-config.js +105 -0
  102. package/packages/server/dist/recovery.d.ts +262 -0
  103. package/packages/server/dist/recovery.js +490 -0
  104. package/packages/server/dist/resume-drain.d.ts +122 -0
  105. package/packages/server/dist/resume-drain.js +158 -0
  106. package/packages/server/dist/route-context.d.ts +168 -0
  107. package/packages/server/dist/route-context.js +39 -0
  108. package/packages/server/dist/route-errors.d.ts +8 -0
  109. package/packages/server/dist/route-errors.js +13 -0
  110. package/packages/server/dist/routes/chats.d.ts +12 -0
  111. package/packages/server/dist/routes/chats.js +1406 -0
  112. package/packages/server/dist/routes/git.d.ts +13 -0
  113. package/packages/server/dist/routes/git.js +236 -0
  114. package/packages/server/dist/routes/mcp.d.ts +83 -0
  115. package/packages/server/dist/routes/mcp.js +250 -0
  116. package/packages/server/dist/routes/meta.d.ts +15 -0
  117. package/packages/server/dist/routes/meta.js +447 -0
  118. package/packages/server/dist/routes/projects.d.ts +25 -0
  119. package/packages/server/dist/routes/projects.js +736 -0
  120. package/packages/server/dist/routes/triggers.d.ts +21 -0
  121. package/packages/server/dist/routes/triggers.js +324 -0
  122. package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
  123. package/packages/server/dist/routes/workspace-mount.js +32 -0
  124. package/packages/server/dist/routes.d.ts +26 -0
  125. package/packages/server/dist/routes.js +29 -0
  126. package/packages/server/dist/run-provenance.d.ts +109 -0
  127. package/packages/server/dist/run-provenance.js +206 -0
  128. package/packages/server/dist/runs.d.ts +92 -0
  129. package/packages/server/dist/runs.js +72 -0
  130. package/packages/server/dist/schedule-config.d.ts +68 -0
  131. package/packages/server/dist/schedule-config.js +75 -0
  132. package/packages/server/dist/schedule-session.d.ts +34 -0
  133. package/packages/server/dist/schedule-session.js +120 -0
  134. package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
  135. package/packages/server/dist/self-mcp-descriptions.js +145 -0
  136. package/packages/server/dist/self-mcp-read.d.ts +15 -0
  137. package/packages/server/dist/self-mcp-read.js +97 -0
  138. package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
  139. package/packages/server/dist/self-mcp-triggers.js +144 -0
  140. package/packages/server/dist/self-mcp-types.d.ts +248 -0
  141. package/packages/server/dist/self-mcp-types.js +10 -0
  142. package/packages/server/dist/self-mcp-util.d.ts +73 -0
  143. package/packages/server/dist/self-mcp-util.js +157 -0
  144. package/packages/server/dist/self-mcp-write.d.ts +35 -0
  145. package/packages/server/dist/self-mcp-write.js +215 -0
  146. package/packages/server/dist/self-mcp.d.ts +95 -0
  147. package/packages/server/dist/self-mcp.js +448 -0
  148. package/packages/server/dist/send-file-mcp.d.ts +46 -0
  149. package/packages/server/dist/send-file-mcp.js +281 -0
  150. package/packages/server/dist/session-hub.d.ts +186 -0
  151. package/packages/server/dist/session-hub.js +291 -0
  152. package/packages/server/dist/spawn-capability.d.ts +99 -0
  153. package/packages/server/dist/spawn-capability.js +106 -0
  154. package/packages/server/dist/star.d.ts +26 -0
  155. package/packages/server/dist/star.js +94 -0
  156. package/packages/server/dist/start.d.ts +1 -0
  157. package/packages/server/dist/start.js +21 -0
  158. package/packages/server/dist/subagents.d.ts +337 -0
  159. package/packages/server/dist/subagents.js +626 -0
  160. package/packages/server/dist/sweep.d.ts +122 -0
  161. package/packages/server/dist/sweep.js +696 -0
  162. package/packages/server/dist/tooldetails.d.ts +33 -0
  163. package/packages/server/dist/tooldetails.js +466 -0
  164. package/packages/server/dist/transcribe.d.ts +69 -0
  165. package/packages/server/dist/transcribe.js +119 -0
  166. package/packages/server/dist/transcripts.d.ts +42 -0
  167. package/packages/server/dist/transcripts.js +164 -0
  168. package/packages/server/dist/trigger-config.d.ts +278 -0
  169. package/packages/server/dist/trigger-config.js +417 -0
  170. package/packages/server/dist/trigger-runtime.d.ts +94 -0
  171. package/packages/server/dist/trigger-runtime.js +82 -0
  172. package/packages/server/dist/trigger-session.d.ts +34 -0
  173. package/packages/server/dist/trigger-session.js +122 -0
  174. package/packages/server/dist/triggers.d.ts +83 -0
  175. package/packages/server/dist/triggers.js +124 -0
  176. package/packages/server/dist/trusted-proxy.d.ts +57 -0
  177. package/packages/server/dist/trusted-proxy.js +157 -0
  178. package/packages/server/dist/turn-notice.d.ts +154 -0
  179. package/packages/server/dist/turn-notice.js +271 -0
  180. package/packages/server/dist/unread.d.ts +36 -0
  181. package/packages/server/dist/unread.js +123 -0
  182. package/packages/server/dist/usage.d.ts +66 -0
  183. package/packages/server/dist/usage.js +243 -0
  184. package/packages/server/dist/wake-injection.d.ts +156 -0
  185. package/packages/server/dist/wake-injection.js +136 -0
  186. package/packages/server/dist/whisper-local.d.ts +9 -0
  187. package/packages/server/dist/whisper-local.js +122 -0
  188. package/packages/server/dist/ws-context.d.ts +158 -0
  189. package/packages/server/dist/ws-context.js +1 -0
  190. package/packages/server/dist/ws-protocol.d.ts +423 -0
  191. package/packages/server/dist/ws-protocol.js +251 -0
  192. package/packages/server/dist/ws-self-mcp.d.ts +52 -0
  193. package/packages/server/dist/ws-self-mcp.js +28 -0
  194. package/packages/server/dist/ws-triggers.d.ts +29 -0
  195. package/packages/server/dist/ws-triggers.js +261 -0
  196. package/packages/server/dist/ws-turn.d.ts +86 -0
  197. package/packages/server/dist/ws-turn.js +751 -0
  198. package/packages/server/dist/ws.d.ts +62 -0
  199. package/packages/server/dist/ws.js +1024 -0
  200. package/packages/server/package.json +6 -0
  201. package/packages/web/dist/assets/InstanceConfigPage-Bypd_quL.js +2 -0
  202. package/packages/web/dist/assets/MarkdownRenderer-Co8w9iuX.js +30 -0
  203. package/packages/web/dist/assets/ProjectRedirect-DXrA24Cy.js +2 -0
  204. package/packages/web/dist/assets/ProjectView-B9opP7Sv.js +13 -0
  205. package/packages/web/dist/assets/ProjectsGrid-D2cwb2jY.js +2 -0
  206. package/packages/web/dist/assets/arc-B--FIRB8.js +2 -0
  207. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-B6dRWZZ6.js +37 -0
  208. package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
  209. package/packages/web/dist/assets/blockDiagram-GPEHLZMM-DLxFAvvf.js +133 -0
  210. package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
  211. package/packages/web/dist/assets/c4Diagram-AAUBKEIU-CNee5ks4.js +11 -0
  212. package/packages/web/dist/assets/channel-Bw0A52CB.js +2 -0
  213. package/packages/web/dist/assets/chunk-2J33WTMH-DObw03Xl.js +2 -0
  214. package/packages/web/dist/assets/chunk-4BX2VUAB-CH2SR6nN.js +2 -0
  215. package/packages/web/dist/assets/chunk-55IACEB6-WlVpegT3.js +2 -0
  216. package/packages/web/dist/assets/chunk-727SXJPM-g8GT9Rgd.js +207 -0
  217. package/packages/web/dist/assets/chunk-AQP2D5EJ-DbN4PsDF.js +232 -0
  218. package/packages/web/dist/assets/chunk-FMBD7UC4-DSN3BjFG.js +16 -0
  219. package/packages/web/dist/assets/chunk-ND2GUHAM-D9R4le0z.js +2 -0
  220. package/packages/web/dist/assets/chunk-QZHKN3VN-D4YgRaKH.js +2 -0
  221. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-itf_kt-q.js +2 -0
  222. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-itf_kt-q.js +2 -0
  223. package/packages/web/dist/assets/core-CysJ2gLj.js +4 -0
  224. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-D1PB0KNu.js +2 -0
  225. package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
  226. package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
  227. package/packages/web/dist/assets/css-DazXZka4.js +2 -0
  228. package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
  229. package/packages/web/dist/assets/dagre-BM42HDAG-C6Uuq-dq.js +5 -0
  230. package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
  231. package/packages/web/dist/assets/diagram-2AECGRRQ-C9JvQ425.js +44 -0
  232. package/packages/web/dist/assets/diagram-5GNKFQAL-Cu6pdh2d.js +11 -0
  233. package/packages/web/dist/assets/diagram-KO2AKTUF-PmXcUQhY.js +4 -0
  234. package/packages/web/dist/assets/diagram-LMA3HP47-9ww6_9Iq.js +25 -0
  235. package/packages/web/dist/assets/diagram-OG6HWLK6-DWQVjv_n.js +25 -0
  236. package/packages/web/dist/assets/erDiagram-TEJ5UH35-6F7xTuff.js +86 -0
  237. package/packages/web/dist/assets/flowDiagram-I6XJVG4X-SCiGu7m6.js +163 -0
  238. package/packages/web/dist/assets/format-yf7Vf9kR.js +2 -0
  239. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-BNpPwxkL.js +293 -0
  240. package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-C0gyAJjW.js +107 -0
  241. package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
  242. package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
  243. package/packages/web/dist/assets/index-B-YuzkF6.js +71 -0
  244. package/packages/web/dist/assets/index-CvGiQddO.css +1 -0
  245. package/packages/web/dist/assets/infoDiagram-5YYISTIA-CJ2CFPfy.js +3 -0
  246. package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
  247. package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
  248. package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-Cs0D32Ry.js +71 -0
  249. package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
  250. package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
  251. package/packages/web/dist/assets/journeyDiagram-JHISSGLW-Bc9fuI2i.js +140 -0
  252. package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
  253. package/packages/web/dist/assets/kanban-definition-UN3LZRKU-D_tC7QfC.js +90 -0
  254. package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
  255. package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
  256. package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
  257. package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
  258. package/packages/web/dist/assets/linear-BzGHkF9K.js +2 -0
  259. package/packages/web/dist/assets/mermaid.core-DlbsYzE-.js +310 -0
  260. package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bxeh-ToD.js +97 -0
  261. package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
  262. package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
  263. package/packages/web/dist/assets/pieDiagram-4H26LBE5-CDmilo8S.js +31 -0
  264. package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
  265. package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-D14-JyE9.js +8 -0
  266. package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
  267. package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-CJ1PUGu-.js +85 -0
  268. package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
  269. package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
  270. package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-BkUK73W0.js +41 -0
  271. package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
  272. package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-sbcvsuLp.js +163 -0
  273. package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
  274. package/packages/web/dist/assets/stateDiagram-AJRCARHV-DV-7BdHK.js +2 -0
  275. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-BlkGdXKW.js +2 -0
  276. package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
  277. package/packages/web/dist/assets/timeline-definition-PNZ67QCA-C5su21G_.js +121 -0
  278. package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
  279. package/packages/web/dist/assets/vennDiagram-CIIHVFJN-JNkIJAaQ.js +35 -0
  280. package/packages/web/dist/assets/wardley-L42UT6IY-qcz6t2vR.js +174 -0
  281. package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-CMrOIZ-c.js +79 -0
  282. package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
  283. package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-C9q6Oy9u.js +8 -0
  284. package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
  285. package/packages/web/dist/favicon.ico +0 -0
  286. package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
  287. package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  288. package/packages/web/dist/icons/apple-touch-icon.png +0 -0
  289. package/packages/web/dist/icons/favicon-16.png +0 -0
  290. package/packages/web/dist/icons/favicon-32.png +0 -0
  291. package/packages/web/dist/icons/icon-192.png +0 -0
  292. package/packages/web/dist/icons/icon-512.png +0 -0
  293. package/packages/web/dist/icons/maskable-512.png +0 -0
  294. package/packages/web/dist/index.html +56 -0
  295. package/packages/web/dist/manifest.webmanifest +32 -0
  296. package/packages/web/dist/sw.js +139 -0
@@ -0,0 +1,154 @@
1
+ /** Sentinel model the CLI stamps on its synthetic placeholder turns. Mirrors
2
+ * `@herdctl/chat`'s `SYNTHETIC_MODEL` (kept local to avoid a value import churn). */
3
+ export declare const SYNTHETIC_MODEL = "<synthetic>";
4
+ /** The three notice families a dead-ended turn resolves to. */
5
+ export type TurnNoticeKind = "usage_limit" | "error" | "max_turns";
6
+ /**
7
+ * A surfaced turn-ending condition. `message` is the one-line human summary the
8
+ * banner shows; `resetTime` (usage-limit only) is the parsed "resets …" clause;
9
+ * `retryable` says whether offering a Continue/Retry affordance is safe (a usage
10
+ * limit is NOT retryable — it only clears when the quota resets).
11
+ */
12
+ export interface TurnNotice {
13
+ kind: TurnNoticeKind;
14
+ message: string;
15
+ /** Usage-limit only: the reset clause, e.g. `"7:10pm (America/New_York)"`. */
16
+ resetTime?: string;
17
+ /** Optional secondary detail (e.g. the raw SDK error subtype / message). */
18
+ detail?: string;
19
+ /** Whether a Continue/Retry affordance is safe to offer for this notice. */
20
+ retryable: boolean;
21
+ }
22
+ /** Loosely-typed shape of the fields we read off an SDK message / JSONL entry. */
23
+ interface RawMessage {
24
+ type?: string;
25
+ subtype?: string;
26
+ is_error?: boolean;
27
+ success?: boolean;
28
+ message?: {
29
+ role?: string;
30
+ model?: string;
31
+ content?: unknown;
32
+ stop_reason?: string;
33
+ };
34
+ }
35
+ /**
36
+ * Classify a synthetic assistant message's text.
37
+ *
38
+ * Returns a `usage_limit` {@link TurnNotice} for a "session/usage limit" message
39
+ * (with the reset clause parsed out), the sentinel `"declined"` for the
40
+ * runtime's own `"No response requested."` (and any other non-limit synthetic —
41
+ * a placeholder the UI should simply suppress, never render), or `null` for
42
+ * empty text.
43
+ */
44
+ export declare function classifySyntheticText(text: string): TurnNotice | "declined" | null;
45
+ /**
46
+ * Classify a terminal `type:"result"` SDK message. Returns an `error`/`max_turns`
47
+ * {@link TurnNotice} when the result marks a genuine failure, else `null` for a
48
+ * successful turn.
49
+ *
50
+ * The authoritative failure signal is the **subtype** — the SDK's error results
51
+ * always carry an `error_*` subtype (`error_during_execution`, `error_max_turns`,
52
+ * …) — or an explicit `success === false`. This mirrors `@herdctl/core`'s own
53
+ * success computation (`chatSession`: `subtype.startsWith("error") || success ===
54
+ * false`), so Paddock's banner never disagrees with herdctl about whether a turn
55
+ * failed.
56
+ *
57
+ * We DELIBERATELY do NOT treat `is_error === true` as a failure on its own
58
+ * (issue #329 regression): `SDKResultSuccess` is typed `is_error: boolean`, and
59
+ * the runtime stamps `is_error:true` (with `api_error_status`) on a
60
+ * `subtype:"success"` result when the turn RECOVERED from a transient mid-turn
61
+ * API error (e.g. "Connection closed mid-response") and still produced a normal
62
+ * reply. Reading that as a turn failure rendered a false "turn failed" banner
63
+ * directly beneath a perfectly good reply on essentially every session-mode turn.
64
+ * A genuine error always carries an `error_*` subtype, so nothing is lost.
65
+ */
66
+ export declare function classifyResult(result: {
67
+ subtype?: string;
68
+ is_error?: boolean;
69
+ success?: boolean;
70
+ }): TurnNotice | null;
71
+ /** Build a plain `error` notice from a free-text turn-failure message (a thrown
72
+ * error / a failed `chat:complete` that never reached a `result`). */
73
+ export declare function errorNotice(message: string | undefined): TurnNotice;
74
+ /**
75
+ * Classify one raw SDK/JSONL message. Surfaces a {@link TurnNotice} for a
76
+ * synthetic usage-limit assistant message or a terminal error result; returns
77
+ * `null` for everything else — ordinary output, a successful result, and every
78
+ * suppressed synthetic placeholder (`"No response requested."`).
79
+ */
80
+ export declare function noticeFromMessage(m: RawMessage): TurnNotice | null;
81
+ /**
82
+ * Did this streamed message emit REAL assistant prose — a non-synthetic assistant
83
+ * message carrying non-empty **text** content, regardless of `stop_reason`?
84
+ *
85
+ * The live paths (`ws.ts`) OR-accumulate this across every message of a turn (a
86
+ * per-turn `producedReply` flag) so a terminal error/`max_turns` result that
87
+ * arrives AFTER the turn already showed the user real prose does not paint a false
88
+ * "turn failed" banner beneath the answer (issues #380, #394). In session mode the
89
+ * SDK's terminal `result` frame — streamed live but never persisted — can carry an
90
+ * `error_*` subtype / `success:false` even though the turn produced a perfectly
91
+ * good reply; on reload the history path ({@link scanTranscriptNotice}) suppresses
92
+ * the dead-end via its "any real assistant text supersedes the notice" rule, and
93
+ * this predicate lets the live path apply the SAME rule.
94
+ *
95
+ * #394 — why this is TEXT-ANY-STOP_REASON, not text+`end_turn`. The original #380
96
+ * predicate required a SINGLE message with non-empty text AND `stop_reason:
97
+ * "end_turn"`. Long tool-driven turns break both halves: (a) the visible prose
98
+ * usually rides on a message that ALSO makes a tool call → `stop_reason:"tool_use"`
99
+ * (excluded by the old gate), and (b) the terminal `end_turn` message is frequently
100
+ * **thinking-only** (zero text). So `producedReply` never flipped true and the
101
+ * benign terminal error result was surfaced. Keying off ANY non-synthetic assistant
102
+ * text (across all of the turn's messages, no `stop_reason` gate) makes a
103
+ * text+`tool_use` message count and exactly matches `scanTranscriptNotice`, killing
104
+ * the live/history asymmetry.
105
+ *
106
+ * DELIBERATELY text-only (a bare `thinking` block does NOT count): this mirrors the
107
+ * history path — which clears only on real text — so a genuinely empty turn (no
108
+ * assistant text ANYWHERE, e.g. an `error_max_turns` that wrote nothing renderable)
109
+ * still surfaces its error notice.
110
+ */
111
+ export declare function messageProducedReply(m: RawMessage): boolean;
112
+ /**
113
+ * Should an already-classified {@link TurnNotice} be dropped because a complete
114
+ * reply already streamed this turn (issue #380)? An `error` / `max_turns`
115
+ * dead-end beneath a good answer is a false alarm — suppress it. A `usage_limit`
116
+ * is NOT suppressed: a session-limit stop is a real dead-end still worth showing
117
+ * even alongside text.
118
+ */
119
+ export declare function suppressNoticeAfterReply(notice: TurnNotice, producedReply: boolean): boolean;
120
+ /**
121
+ * Did this turn effectively succeed for the purposes of its post-turn side
122
+ * effects — the queue drain, the after-turn curation sweep, and the recovery
123
+ * watch (issue #404)?
124
+ *
125
+ * The raw `result.success` handed back by the drive is computed purely from the
126
+ * terminal `result` frame (`!error_* subtype && !success:false`). In session
127
+ * mode that frame routinely carries an `error_*` subtype / `success:false` even
128
+ * when a complete reply already streamed — the very asymmetry the #380/#394
129
+ * banner fix exists for. Gating side effects on the raw signal silently strands
130
+ * a queued follow-up (the flush that {@link drainQueue} owns never runs), skips
131
+ * the sweep, and drops the recovery arm.
132
+ *
133
+ * This is the side-effect twin of {@link suppressNoticeAfterReply}: both trust
134
+ * the same `producedReply` signal (a real assistant reply streamed this turn) to
135
+ * override a benign trailing failure. A turn is effectively successful when the
136
+ * raw signal says so OR a real reply was produced. A genuinely dead turn (no
137
+ * reply — a Stop, an up-front API error) keeps `false`, so its queue is held.
138
+ */
139
+ export declare function turnEffectivelySucceeded(rawSuccess: boolean, producedReply: boolean): boolean;
140
+ /**
141
+ * Scan a transcript JSONL file for a TRAILING dead-end notice to surface on
142
+ * reload (the history-hydration path). Only the synthetic usage-limit case is
143
+ * recoverable from history: `@herdctl/core` drops synthetic messages when it
144
+ * parses the transcript, and an `error_max_turns` turn writes NOTHING at all —
145
+ * so those are live-only.
146
+ *
147
+ * A usage-limit notice is surfaced only when it is the LAST thing that happened
148
+ * on the transcript: a later real assistant reply (a non-synthetic assistant
149
+ * message with text) means the chat recovered, so any earlier limit is cleared.
150
+ * Returns the trailing notice, or `null`. Never throws (a missing/unreadable
151
+ * file → `null`).
152
+ */
153
+ export declare function scanTranscriptNotice(filePath: string): Promise<TurnNotice | null>;
154
+ export {};
@@ -0,0 +1,271 @@
1
+ /**
2
+ * Turn-notice classification (issue #329)
3
+ *
4
+ * A keeper turn can end without producing a normal assistant reply, and today
5
+ * those endings are INVISIBLE in the UI — the chat just looks dead. Two families:
6
+ *
7
+ * 1. A **synthetic** runtime message. The Claude Code CLI answers a turn it
8
+ * cannot really run with an assistant message tagged `model:"<synthetic>"`
9
+ * and `stop_reason:"stop_sequence"`. The most important one on this box is
10
+ * the shared Max-plan quota: `"You've hit your session limit · resets …"`.
11
+ * The interleaved `"No response requested."` placeholders are the runtime
12
+ * declining its own follow-up turns under the limit. `@herdctl/chat`'s
13
+ * translator DROPS every synthetic message (so no text ever streams), and
14
+ * `@herdctl/core`'s history parser drops them too — hence the silent stop.
15
+ *
16
+ * 2. A terminal **error result**. The Claude Agent SDK ends a turn with a
17
+ * `type:"result"` message whose `subtype`/`is_error` mark a failure:
18
+ * `error_max_turns` (the turn hit its max-turns cap and wrote NOTHING
19
+ * renderable to the transcript), `error_during_execution`, an API/overload
20
+ * error, etc.
21
+ *
22
+ * This module turns either signal into a small {@link TurnNotice} DTO that the
23
+ * live path (`ws.ts` per-message inspection) and the history-hydration path
24
+ * (`scanTranscriptNotice`) both surface to the client as a distinct notice turn.
25
+ * Pure + unit-tested; no I/O except {@link scanTranscriptNotice}.
26
+ */
27
+ import { createReadStream } from "node:fs";
28
+ import { createInterface } from "node:readline";
29
+ /** Sentinel model the CLI stamps on its synthetic placeholder turns. Mirrors
30
+ * `@herdctl/chat`'s `SYNTHETIC_MODEL` (kept local to avoid a value import churn). */
31
+ export const SYNTHETIC_MODEL = "<synthetic>";
32
+ /** Flatten an assistant `content` (string, or an array of text blocks) to text. */
33
+ function contentText(content) {
34
+ if (typeof content === "string")
35
+ return content;
36
+ if (Array.isArray(content)) {
37
+ return content
38
+ .map((b) => (b && typeof b === "object" && typeof b.text === "string"
39
+ ? b.text
40
+ : ""))
41
+ .join("");
42
+ }
43
+ return "";
44
+ }
45
+ /**
46
+ * Classify a synthetic assistant message's text.
47
+ *
48
+ * Returns a `usage_limit` {@link TurnNotice} for a "session/usage limit" message
49
+ * (with the reset clause parsed out), the sentinel `"declined"` for the
50
+ * runtime's own `"No response requested."` (and any other non-limit synthetic —
51
+ * a placeholder the UI should simply suppress, never render), or `null` for
52
+ * empty text.
53
+ */
54
+ export function classifySyntheticText(text) {
55
+ const trimmed = text.trim();
56
+ if (!trimmed)
57
+ return null;
58
+ // The shared Max-plan quota. Match on "session limit" / "usage limit" rather
59
+ // than the exact string so a reworded CLI message still surfaces.
60
+ if (/\b(session|usage) limit\b/i.test(trimmed) || /hit your .*limit/i.test(trimmed)) {
61
+ const resetTime = /resets?\s+(.+?)\s*$/i.exec(trimmed)?.[1]?.trim() || undefined;
62
+ return {
63
+ kind: "usage_limit",
64
+ message: trimmed,
65
+ ...(resetTime ? { resetTime } : {}),
66
+ retryable: false,
67
+ };
68
+ }
69
+ // "No response requested." and any other synthetic placeholder: not a limit,
70
+ // carries no real output — suppress it (never a chat bubble).
71
+ return "declined";
72
+ }
73
+ /** Human-readable message for a terminal error `result`'s subtype. */
74
+ function errorMessageForSubtype(subtype) {
75
+ if (subtype === "error_max_turns") {
76
+ return {
77
+ kind: "max_turns",
78
+ message: "Claude reached its turn limit before finishing this turn.",
79
+ };
80
+ }
81
+ return {
82
+ kind: "error",
83
+ message: "Claude's turn failed before producing a reply.",
84
+ };
85
+ }
86
+ /**
87
+ * Classify a terminal `type:"result"` SDK message. Returns an `error`/`max_turns`
88
+ * {@link TurnNotice} when the result marks a genuine failure, else `null` for a
89
+ * successful turn.
90
+ *
91
+ * The authoritative failure signal is the **subtype** — the SDK's error results
92
+ * always carry an `error_*` subtype (`error_during_execution`, `error_max_turns`,
93
+ * …) — or an explicit `success === false`. This mirrors `@herdctl/core`'s own
94
+ * success computation (`chatSession`: `subtype.startsWith("error") || success ===
95
+ * false`), so Paddock's banner never disagrees with herdctl about whether a turn
96
+ * failed.
97
+ *
98
+ * We DELIBERATELY do NOT treat `is_error === true` as a failure on its own
99
+ * (issue #329 regression): `SDKResultSuccess` is typed `is_error: boolean`, and
100
+ * the runtime stamps `is_error:true` (with `api_error_status`) on a
101
+ * `subtype:"success"` result when the turn RECOVERED from a transient mid-turn
102
+ * API error (e.g. "Connection closed mid-response") and still produced a normal
103
+ * reply. Reading that as a turn failure rendered a false "turn failed" banner
104
+ * directly beneath a perfectly good reply on essentially every session-mode turn.
105
+ * A genuine error always carries an `error_*` subtype, so nothing is lost.
106
+ */
107
+ export function classifyResult(result) {
108
+ const subtype = typeof result.subtype === "string" ? result.subtype : undefined;
109
+ // A `subtype:"success"` result is a completed turn, full stop. `is_error` only
110
+ // counts as a failure when the runtime gave us NO subtype at all (defensive:
111
+ // some other runtime shape that reports is_error without one).
112
+ const failed = (subtype !== undefined && subtype.startsWith("error")) ||
113
+ result.success === false ||
114
+ (subtype === undefined && result.is_error === true);
115
+ if (!failed)
116
+ return null;
117
+ const { kind, message } = errorMessageForSubtype(subtype);
118
+ return {
119
+ kind,
120
+ message,
121
+ ...(subtype ? { detail: subtype } : {}),
122
+ retryable: true,
123
+ };
124
+ }
125
+ /** Build a plain `error` notice from a free-text turn-failure message (a thrown
126
+ * error / a failed `chat:complete` that never reached a `result`). */
127
+ export function errorNotice(message) {
128
+ const detail = message?.trim();
129
+ return {
130
+ kind: "error",
131
+ message: "Claude's turn failed before producing a reply.",
132
+ ...(detail ? { detail } : {}),
133
+ retryable: true,
134
+ };
135
+ }
136
+ /**
137
+ * Classify one raw SDK/JSONL message. Surfaces a {@link TurnNotice} for a
138
+ * synthetic usage-limit assistant message or a terminal error result; returns
139
+ * `null` for everything else — ordinary output, a successful result, and every
140
+ * suppressed synthetic placeholder (`"No response requested."`).
141
+ */
142
+ export function noticeFromMessage(m) {
143
+ if (m.type === "assistant" && m.message?.model === SYNTHETIC_MODEL) {
144
+ const r = classifySyntheticText(contentText(m.message?.content));
145
+ return r && r !== "declined" ? r : null;
146
+ }
147
+ if (m.type === "result") {
148
+ return classifyResult(m);
149
+ }
150
+ return null;
151
+ }
152
+ /**
153
+ * Did this streamed message emit REAL assistant prose — a non-synthetic assistant
154
+ * message carrying non-empty **text** content, regardless of `stop_reason`?
155
+ *
156
+ * The live paths (`ws.ts`) OR-accumulate this across every message of a turn (a
157
+ * per-turn `producedReply` flag) so a terminal error/`max_turns` result that
158
+ * arrives AFTER the turn already showed the user real prose does not paint a false
159
+ * "turn failed" banner beneath the answer (issues #380, #394). In session mode the
160
+ * SDK's terminal `result` frame — streamed live but never persisted — can carry an
161
+ * `error_*` subtype / `success:false` even though the turn produced a perfectly
162
+ * good reply; on reload the history path ({@link scanTranscriptNotice}) suppresses
163
+ * the dead-end via its "any real assistant text supersedes the notice" rule, and
164
+ * this predicate lets the live path apply the SAME rule.
165
+ *
166
+ * #394 — why this is TEXT-ANY-STOP_REASON, not text+`end_turn`. The original #380
167
+ * predicate required a SINGLE message with non-empty text AND `stop_reason:
168
+ * "end_turn"`. Long tool-driven turns break both halves: (a) the visible prose
169
+ * usually rides on a message that ALSO makes a tool call → `stop_reason:"tool_use"`
170
+ * (excluded by the old gate), and (b) the terminal `end_turn` message is frequently
171
+ * **thinking-only** (zero text). So `producedReply` never flipped true and the
172
+ * benign terminal error result was surfaced. Keying off ANY non-synthetic assistant
173
+ * text (across all of the turn's messages, no `stop_reason` gate) makes a
174
+ * text+`tool_use` message count and exactly matches `scanTranscriptNotice`, killing
175
+ * the live/history asymmetry.
176
+ *
177
+ * DELIBERATELY text-only (a bare `thinking` block does NOT count): this mirrors the
178
+ * history path — which clears only on real text — so a genuinely empty turn (no
179
+ * assistant text ANYWHERE, e.g. an `error_max_turns` that wrote nothing renderable)
180
+ * still surfaces its error notice.
181
+ */
182
+ export function messageProducedReply(m) {
183
+ if (m.type !== "assistant")
184
+ return false;
185
+ if (m.message?.model === SYNTHETIC_MODEL)
186
+ return false;
187
+ return contentText(m.message?.content).trim().length > 0;
188
+ }
189
+ /**
190
+ * Should an already-classified {@link TurnNotice} be dropped because a complete
191
+ * reply already streamed this turn (issue #380)? An `error` / `max_turns`
192
+ * dead-end beneath a good answer is a false alarm — suppress it. A `usage_limit`
193
+ * is NOT suppressed: a session-limit stop is a real dead-end still worth showing
194
+ * even alongside text.
195
+ */
196
+ export function suppressNoticeAfterReply(notice, producedReply) {
197
+ return producedReply && (notice.kind === "error" || notice.kind === "max_turns");
198
+ }
199
+ /**
200
+ * Did this turn effectively succeed for the purposes of its post-turn side
201
+ * effects — the queue drain, the after-turn curation sweep, and the recovery
202
+ * watch (issue #404)?
203
+ *
204
+ * The raw `result.success` handed back by the drive is computed purely from the
205
+ * terminal `result` frame (`!error_* subtype && !success:false`). In session
206
+ * mode that frame routinely carries an `error_*` subtype / `success:false` even
207
+ * when a complete reply already streamed — the very asymmetry the #380/#394
208
+ * banner fix exists for. Gating side effects on the raw signal silently strands
209
+ * a queued follow-up (the flush that {@link drainQueue} owns never runs), skips
210
+ * the sweep, and drops the recovery arm.
211
+ *
212
+ * This is the side-effect twin of {@link suppressNoticeAfterReply}: both trust
213
+ * the same `producedReply` signal (a real assistant reply streamed this turn) to
214
+ * override a benign trailing failure. A turn is effectively successful when the
215
+ * raw signal says so OR a real reply was produced. A genuinely dead turn (no
216
+ * reply — a Stop, an up-front API error) keeps `false`, so its queue is held.
217
+ */
218
+ export function turnEffectivelySucceeded(rawSuccess, producedReply) {
219
+ return rawSuccess || producedReply;
220
+ }
221
+ /**
222
+ * Scan a transcript JSONL file for a TRAILING dead-end notice to surface on
223
+ * reload (the history-hydration path). Only the synthetic usage-limit case is
224
+ * recoverable from history: `@herdctl/core` drops synthetic messages when it
225
+ * parses the transcript, and an `error_max_turns` turn writes NOTHING at all —
226
+ * so those are live-only.
227
+ *
228
+ * A usage-limit notice is surfaced only when it is the LAST thing that happened
229
+ * on the transcript: a later real assistant reply (a non-synthetic assistant
230
+ * message with text) means the chat recovered, so any earlier limit is cleared.
231
+ * Returns the trailing notice, or `null`. Never throws (a missing/unreadable
232
+ * file → `null`).
233
+ */
234
+ export async function scanTranscriptNotice(filePath) {
235
+ let pending = null;
236
+ try {
237
+ const rl = createInterface({
238
+ input: createReadStream(filePath, { encoding: "utf8" }),
239
+ crlfDelay: Infinity,
240
+ });
241
+ for await (const line of rl) {
242
+ const trimmed = line.trim();
243
+ if (!trimmed)
244
+ continue;
245
+ let entry;
246
+ try {
247
+ entry = JSON.parse(trimmed);
248
+ }
249
+ catch {
250
+ continue;
251
+ }
252
+ if (entry.type !== "assistant")
253
+ continue;
254
+ const model = entry.message?.model;
255
+ if (model === SYNTHETIC_MODEL) {
256
+ const r = classifySyntheticText(contentText(entry.message?.content));
257
+ if (r && r !== "declined")
258
+ pending = r; // a usage-limit dead-end
259
+ // a "declined" placeholder neither sets nor clears the pending notice
260
+ }
261
+ else if (contentText(entry.message?.content).trim().length > 0) {
262
+ // A real assistant reply: the turn was actually answered → clear.
263
+ pending = null;
264
+ }
265
+ }
266
+ }
267
+ catch {
268
+ return null;
269
+ }
270
+ return pending;
271
+ }
@@ -0,0 +1,36 @@
1
+ export declare class UnreadStore {
2
+ private readonly stateFile;
3
+ /** In-memory set of manually-unread keys (loaded once, written through on change). */
4
+ private marked;
5
+ /**
6
+ * The in-flight load, cached so concurrent first-callers share ONE read — same
7
+ * lost-update guard as {@link StarStore} / {@link ReadStateStore}.
8
+ */
9
+ private loadPromise;
10
+ /** Serialises concurrent writes so the file never interleaves. */
11
+ private writing;
12
+ constructor(dataDir: string);
13
+ /** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
14
+ private ensureLoaded;
15
+ /**
16
+ * Whether this user has manually flagged this chat unread. Non-throwing: any
17
+ * load error reads as false. Pass `null` for `username` to read the shared bucket.
18
+ */
19
+ isUnread(username: string | null, agent: string, sessionId: string): Promise<boolean>;
20
+ /**
21
+ * Set (or clear) this user's manual-unread flag for this chat, persisting the
22
+ * change. Idempotent. Returns whether the flag actually CHANGED, mirroring
23
+ * `StarStore.setStarred`. Pass `null` for `username` to write the shared bucket.
24
+ */
25
+ setUnread(username: string | null, agent: string, sessionId: string, unread: boolean): Promise<boolean>;
26
+ /**
27
+ * Set (or clear) this user's manual-unread flag for MANY chats in ONE write
28
+ * (#508) — the subtree "mark read / mark unread" action. Same single-persist
29
+ * rationale as `ArchiveStore.setManyArchived`: a client-side loop over a
30
+ * 21-chat family can half-succeed and leave the family in two different states.
31
+ * Returns the ids whose flag actually changed.
32
+ */
33
+ setManyUnread(username: string | null, agent: string, sessionIds: readonly string[], unread: boolean): Promise<string[]>;
34
+ /** Write-through, serialised so overlapping toggles can't corrupt the file. */
35
+ private persist;
36
+ }
@@ -0,0 +1,123 @@
1
+ /**
2
+ * UnreadStore — a Paddock-side sidecar for the per-chat, per-user MANUAL "unread"
3
+ * override (#458).
4
+ *
5
+ * The unread affordance (#160) is normally DERIVED: a chat reads as unread when
6
+ * the agent finished a turn (`lastTurnCompletedAt`) more recently than the user
7
+ * last viewed it (`ReadStateStore` `lastSeen`). This store adds an explicit,
8
+ * user-driven override on top of that derivation: "mark this chat unread" so it
9
+ * resurfaces its cue the next time you come back to the list — the email-client
10
+ * pattern. It cannot be expressed by pushing `lastSeen` backwards, because that
11
+ * value is deliberately MONOTONIC (see {@link ReadStateStore}); it needs its own
12
+ * flag.
13
+ *
14
+ * Shape/pattern is a near-verbatim copy of {@link StarStore} — a tiny JSON
15
+ * sidecar in the data dir, write-through, serialised, corruption-tolerant, only
16
+ * storing the SET keys (an unread=false clears the key) so the file stays compact.
17
+ * The one difference is KEYING: like read-state (and unlike star/archive, which
18
+ * are shared) the override is PER USER — "I haven't dealt with this yet" is
19
+ * personal — so it reuses {@link keyOf} from read-state:
20
+ * - Real identity (trusted-header / jwt): `username \0 agent \0 sessionId`
21
+ * - No user (`none` mode / anonymous): `agent \0 sessionId` (shared bucket)
22
+ *
23
+ * Written `0o600` (like read-state) — low-sensitivity personal metadata (which
24
+ * chats you flagged to revisit), but no reason for it to be world-readable.
25
+ */
26
+ import { promises as fs } from "node:fs";
27
+ import path from "node:path";
28
+ import { keyOf } from "./read-state.js";
29
+ const STATE_FILE = "unread-state.json";
30
+ export class UnreadStore {
31
+ stateFile;
32
+ /** In-memory set of manually-unread keys (loaded once, written through on change). */
33
+ marked = null;
34
+ /**
35
+ * The in-flight load, cached so concurrent first-callers share ONE read — same
36
+ * lost-update guard as {@link StarStore} / {@link ReadStateStore}.
37
+ */
38
+ loadPromise = null;
39
+ /** Serialises concurrent writes so the file never interleaves. */
40
+ writing = Promise.resolve();
41
+ constructor(dataDir) {
42
+ this.stateFile = path.join(dataDir, STATE_FILE);
43
+ }
44
+ /** Load the persisted set (lazily, deduped; tolerant of a missing/corrupt file). */
45
+ ensureLoaded() {
46
+ if (this.marked)
47
+ return Promise.resolve(this.marked);
48
+ if (!this.loadPromise) {
49
+ this.loadPromise = (async () => {
50
+ let keys = [];
51
+ try {
52
+ const raw = await fs.readFile(this.stateFile, "utf8");
53
+ const parsed = JSON.parse(raw);
54
+ if (Array.isArray(parsed))
55
+ keys = parsed.filter((k) => typeof k === "string");
56
+ }
57
+ catch {
58
+ /* missing or unreadable — start empty */
59
+ }
60
+ this.marked = new Set(keys);
61
+ return this.marked;
62
+ })();
63
+ }
64
+ return this.loadPromise;
65
+ }
66
+ /**
67
+ * Whether this user has manually flagged this chat unread. Non-throwing: any
68
+ * load error reads as false. Pass `null` for `username` to read the shared bucket.
69
+ */
70
+ async isUnread(username, agent, sessionId) {
71
+ const set = await this.ensureLoaded().catch(() => new Set());
72
+ return set.has(keyOf(username, agent, sessionId));
73
+ }
74
+ /**
75
+ * Set (or clear) this user's manual-unread flag for this chat, persisting the
76
+ * change. Idempotent. Returns whether the flag actually CHANGED, mirroring
77
+ * `StarStore.setStarred`. Pass `null` for `username` to write the shared bucket.
78
+ */
79
+ async setUnread(username, agent, sessionId, unread) {
80
+ const set = await this.ensureLoaded();
81
+ const key = keyOf(username, agent, sessionId);
82
+ if (unread === set.has(key))
83
+ return false; // no-op — avoid a needless write
84
+ if (unread)
85
+ set.add(key);
86
+ else
87
+ set.delete(key);
88
+ await this.persist(set);
89
+ return true;
90
+ }
91
+ /**
92
+ * Set (or clear) this user's manual-unread flag for MANY chats in ONE write
93
+ * (#508) — the subtree "mark read / mark unread" action. Same single-persist
94
+ * rationale as `ArchiveStore.setManyArchived`: a client-side loop over a
95
+ * 21-chat family can half-succeed and leave the family in two different states.
96
+ * Returns the ids whose flag actually changed.
97
+ */
98
+ async setManyUnread(username, agent, sessionIds, unread) {
99
+ const set = await this.ensureLoaded();
100
+ const changed = [];
101
+ for (const sessionId of sessionIds) {
102
+ const key = keyOf(username, agent, sessionId);
103
+ if (unread === set.has(key))
104
+ continue; // already in the target state
105
+ if (unread)
106
+ set.add(key);
107
+ else
108
+ set.delete(key);
109
+ changed.push(sessionId);
110
+ }
111
+ if (changed.length)
112
+ await this.persist(set);
113
+ return changed;
114
+ }
115
+ /** Write-through, serialised so overlapping toggles can't corrupt the file. */
116
+ persist(set) {
117
+ this.writing = this.writing.then(async () => {
118
+ const json = JSON.stringify([...set], null, 2);
119
+ await fs.writeFile(this.stateFile, json, { encoding: "utf8", mode: 0o600 }).catch(() => undefined);
120
+ });
121
+ return this.writing;
122
+ }
123
+ }
@@ -0,0 +1,66 @@
1
+ import type { TokenTotals } from "./models.js";
2
+ /** A chat's context fill (last turn) plus its cumulative lifetime token totals. */
3
+ export interface SessionTokenUsage {
4
+ /** True once at least one assistant turn carried a usage block. */
5
+ hasData: boolean;
6
+ /** Number of assistant turns (unique message ids) with usage. */
7
+ turnCount: number;
8
+ /**
9
+ * Context-window fill as of the last completed turn:
10
+ * `input + cache_read + cache_creation` of the final assistant message. This
11
+ * matches core's `SessionUsage.inputTokens` so the ring/meter math is unchanged.
12
+ */
13
+ contextTokens: number;
14
+ /** Cumulative uncached input tokens across every turn. */
15
+ inputTotal: number;
16
+ /** Cumulative output tokens across every turn. */
17
+ outputTotal: number;
18
+ /** Cumulative cache-read input tokens across every turn. */
19
+ cacheReadTotal: number;
20
+ /** Cumulative cache-creation (write) input tokens across every turn. */
21
+ cacheCreationTotal: number;
22
+ /**
23
+ * The same token totals split by the model that produced them (keyed by the
24
+ * `message.model` on each assistant turn). A chat can span models — the
25
+ * composer switches model per turn, and the running model may differ from the
26
+ * project default — so cost must be priced per-model, not at one blended rate.
27
+ * The flat `*Total` fields above stay model-agnostic for the token headline.
28
+ */
29
+ byModel: Record<string, TokenTotals>;
30
+ }
31
+ /**
32
+ * Stream a transcript file and accumulate cumulative token usage. Deduplicates
33
+ * assistant messages by `message.id` (keep-first) so repeated stream frames for
34
+ * one message are counted once. Returns {@link EMPTY} for a missing/unreadable
35
+ * file (parity with core, which returns a zeroed summary).
36
+ */
37
+ export declare function readSessionTokenUsageFile(file: string): Promise<SessionTokenUsage>;
38
+ /**
39
+ * Cumulative token usage for a chat, resolved from `<projectDir>/.chats/`.
40
+ * Memoized on the transcript's mtime. Returns {@link EMPTY} for an unsafe
41
+ * session id or a session with no transcript yet.
42
+ */
43
+ export declare function readSessionTokenUsage(projectDir: string, sessionId: string): Promise<SessionTokenUsage>;
44
+ /**
45
+ * Per-message context-window fill, keyed by the transcript record `uuid` that the
46
+ * messages DTO carries (issue #451). For every `type:"assistant"` record we store
47
+ * `input + cache_read + cache_creation` — the context-window fill at that turn.
48
+ *
49
+ * A single assistant message is written across several JSONL records (one per
50
+ * content block: text, tool_use, …), each with its own `uuid` but the SAME
51
+ * message-level `usage`, so keying by record `uuid` resolves any of a message's
52
+ * turn ids to the identical fill. Callers forward-fill the value across the
53
+ * non-assistant records between turns (user prompts, tool results) to answer
54
+ * "how full was the context as of this point" for every message.
55
+ *
56
+ * This is a SEPARATE pass from {@link readSessionTokenUsageFile} on purpose: it
57
+ * only runs on the open-chat messages path, never the chat-list rings, so the
58
+ * list DTO stays lean. It rides its own mtime cache below.
59
+ */
60
+ export declare function readContextSeriesFile(file: string): Promise<Map<string, number>>;
61
+ /**
62
+ * Per-message context series for a chat, resolved from `<projectDir>/.chats/`.
63
+ * Memoized on the transcript's mtime (parity with {@link readSessionTokenUsage}).
64
+ * Returns an empty map for an unsafe session id or a session with no transcript.
65
+ */
66
+ export declare function readContextSeries(projectDir: string, sessionId: string): Promise<Map<string, number>>;