@fastagent-sh/fastagent 0.21.1 → 0.22.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 (388) hide show
  1. package/README.md +9 -12
  2. package/dist/agent.d.ts +24 -37
  3. package/dist/agent.js +7 -15
  4. package/dist/atomic-write.d.ts +13 -17
  5. package/dist/atomic-write.js +18 -29
  6. package/dist/bind.d.ts +12 -22
  7. package/dist/bind.js +18 -27
  8. package/dist/channel.d.ts +11 -33
  9. package/dist/channels/agentcore-limits.d.ts +2 -13
  10. package/dist/channels/agentcore-limits.js +2 -13
  11. package/dist/channels/agentcore-protocol.d.ts +67 -58
  12. package/dist/channels/agentcore-protocol.js +23 -15
  13. package/dist/channels/agentcore-service.d.ts +19 -33
  14. package/dist/channels/agentcore-service.js +176 -59
  15. package/dist/channels/agentcore.d.ts +39 -33
  16. package/dist/channels/agentcore.js +90 -224
  17. package/dist/channels/body.d.ts +15 -2
  18. package/dist/channels/body.js +24 -2
  19. package/dist/channels/busy.d.ts +1 -21
  20. package/dist/channels/busy.js +2 -39
  21. package/dist/channels/control.d.ts +17 -62
  22. package/dist/channels/control.js +74 -182
  23. package/dist/channels/define-channel.d.ts +39 -0
  24. package/dist/channels/define-channel.js +9 -0
  25. package/dist/channels/discover.d.ts +10 -31
  26. package/dist/channels/discover.js +37 -31
  27. package/dist/channels/feishu/bootstrap-token.d.ts +1 -8
  28. package/dist/channels/feishu/bootstrap-token.js +7 -23
  29. package/dist/channels/feishu/card.d.ts +14 -28
  30. package/dist/channels/feishu/card.js +16 -30
  31. package/dist/channels/feishu/cloud.d.ts +1 -5
  32. package/dist/channels/feishu/context-buffer.d.ts +4 -23
  33. package/dist/channels/feishu/context-buffer.js +8 -31
  34. package/dist/channels/feishu/crypto.d.ts +10 -5
  35. package/dist/channels/feishu/crypto.js +12 -19
  36. package/dist/channels/feishu/feishu-api.d.ts +2 -1
  37. package/dist/channels/feishu/feishu-api.js +5 -4
  38. package/dist/channels/feishu/feishu.d.ts +4 -10
  39. package/dist/channels/feishu/feishu.js +113 -227
  40. package/dist/channels/feishu/invoke-turn.d.ts +5 -4
  41. package/dist/channels/feishu/invoke-turn.js +36 -54
  42. package/dist/channels/feishu/model.d.ts +4 -21
  43. package/dist/channels/feishu/model.js +1 -8
  44. package/dist/channels/feishu/normalize.d.ts +2 -10
  45. package/dist/channels/feishu/normalize.js +7 -42
  46. package/dist/channels/feishu/parse.d.ts +9 -29
  47. package/dist/channels/feishu/parse.js +8 -26
  48. package/dist/channels/feishu/preview.d.ts +12 -14
  49. package/dist/channels/feishu/preview.js +108 -207
  50. package/dist/channels/feishu/register-app.d.ts +8 -23
  51. package/dist/channels/feishu/register-app.js +7 -20
  52. package/dist/channels/feishu/register-webhook.d.ts +2 -9
  53. package/dist/channels/feishu/register-webhook.js +9 -33
  54. package/dist/channels/feishu/scaffold/channel.ts +27 -19
  55. package/dist/channels/feishu/scaffold/channel.websocket.ts +31 -0
  56. package/dist/channels/feishu/setup-mode.d.ts +11 -36
  57. package/dist/channels/feishu/setup-mode.js +7 -26
  58. package/dist/channels/feishu/ws-ingress.d.ts +1 -2
  59. package/dist/channels/feishu/ws-ingress.js +6 -8
  60. package/dist/channels/http.d.ts +15 -15
  61. package/dist/channels/http.js +19 -9
  62. package/dist/channels/kit/attachment-path.d.ts +4 -5
  63. package/dist/channels/kit/attachment-path.js +10 -34
  64. package/dist/channels/kit/context-buffer.d.ts +14 -21
  65. package/dist/channels/kit/context-buffer.js +25 -38
  66. package/dist/channels/kit/delivery.d.ts +35 -0
  67. package/dist/channels/kit/delivery.js +129 -0
  68. package/dist/channels/kit/event-stream.d.ts +5 -0
  69. package/dist/channels/kit/event-stream.js +29 -0
  70. package/dist/channels/kit/invoke-turn-kit.d.ts +53 -26
  71. package/dist/channels/kit/invoke-turn-kit.js +89 -32
  72. package/dist/channels/kit/preview-kit.d.ts +18 -72
  73. package/dist/channels/kit/preview-kit.js +22 -88
  74. package/dist/channels/kit/signature.d.ts +3 -9
  75. package/dist/channels/kit/signature.js +3 -9
  76. package/dist/channels/kit/state.d.ts +1 -2
  77. package/dist/channels/kit/state.js +5 -14
  78. package/dist/channels/kit/stop-command.d.ts +1 -2
  79. package/dist/channels/kit/stop-command.js +6 -9
  80. package/dist/channels/kit/tasks.d.ts +1 -1
  81. package/dist/channels/kit/tasks.js +8 -21
  82. package/dist/channels/kit/text.d.ts +2 -16
  83. package/dist/channels/kit/text.js +23 -31
  84. package/dist/channels/kit/thread-participants.d.ts +4 -17
  85. package/dist/channels/kit/thread-participants.js +8 -55
  86. package/dist/channels/kit/transport.d.ts +26 -0
  87. package/dist/channels/kit/transport.js +20 -0
  88. package/dist/channels/kit/turn-queue.d.ts +7 -11
  89. package/dist/channels/kit/turn-queue.js +21 -39
  90. package/dist/channels/kit/turn-runner.d.ts +32 -18
  91. package/dist/channels/kit/turn-runner.js +98 -53
  92. package/dist/channels/kit/turn-store.d.ts +32 -32
  93. package/dist/channels/kit/turn-store.js +45 -88
  94. package/dist/channels/lark/lark.d.ts +1 -5
  95. package/dist/channels/lark/onboard.d.ts +3 -12
  96. package/dist/channels/lark/onboard.js +3 -13
  97. package/dist/channels/lark/scaffold/channel.ts +27 -19
  98. package/dist/channels/lark/scaffold/channel.websocket.ts +31 -0
  99. package/dist/channels/registration.d.ts +9 -35
  100. package/dist/channels/registration.js +6 -37
  101. package/dist/channels/secret.js +2 -5
  102. package/dist/channels/serve.d.ts +47 -44
  103. package/dist/channels/serve.js +165 -101
  104. package/dist/channels/slack/config-api.d.ts +4 -27
  105. package/dist/channels/slack/config-api.js +5 -34
  106. package/dist/channels/slack/context-buffer.d.ts +2 -3
  107. package/dist/channels/slack/context-buffer.js +5 -5
  108. package/dist/channels/slack/invoke-turn.d.ts +3 -1
  109. package/dist/channels/slack/invoke-turn.js +25 -34
  110. package/dist/channels/slack/manifest.js +1 -3
  111. package/dist/channels/slack/model.d.ts +1 -2
  112. package/dist/channels/slack/model.js +1 -1
  113. package/dist/channels/slack/onboard.d.ts +4 -3
  114. package/dist/channels/slack/onboard.js +8 -11
  115. package/dist/channels/slack/onboarding-state.d.ts +3 -5
  116. package/dist/channels/slack/onboarding-state.js +9 -6
  117. package/dist/channels/slack/parse.d.ts +4 -13
  118. package/dist/channels/slack/parse.js +7 -28
  119. package/dist/channels/slack/preview.d.ts +12 -2
  120. package/dist/channels/slack/preview.js +292 -269
  121. package/dist/channels/slack/reaction.d.ts +11 -3
  122. package/dist/channels/slack/reaction.js +19 -8
  123. package/dist/channels/slack/register-webhook.js +4 -7
  124. package/dist/channels/slack/scaffold/channel.ts +20 -13
  125. package/dist/channels/slack/scaffold/slack-send.ts +2 -2
  126. package/dist/channels/slack/setup-server.d.ts +1 -5
  127. package/dist/channels/slack/setup-server.js +1 -5
  128. package/dist/channels/slack/shared-api.d.ts +1 -4
  129. package/dist/channels/slack/shared-api.js +2 -13
  130. package/dist/channels/slack/slack-api.d.ts +2 -1
  131. package/dist/channels/slack/slack-api.js +6 -4
  132. package/dist/channels/slack/slack.d.ts +11 -14
  133. package/dist/channels/slack/slack.js +121 -122
  134. package/dist/channels/sse.d.ts +1 -1
  135. package/dist/channels/sse.js +1 -1
  136. package/dist/channels/telegram/context-buffer.d.ts +7 -22
  137. package/dist/channels/telegram/context-buffer.js +6 -23
  138. package/dist/channels/telegram/invoke-turn.d.ts +7 -7
  139. package/dist/channels/telegram/invoke-turn.js +24 -43
  140. package/dist/channels/telegram/parse.d.ts +24 -51
  141. package/dist/channels/telegram/parse.js +32 -84
  142. package/dist/channels/telegram/preview.d.ts +10 -1
  143. package/dist/channels/telegram/preview.js +79 -100
  144. package/dist/channels/telegram/register-webhook.js +5 -23
  145. package/dist/channels/telegram/scaffold/channel.ts +25 -18
  146. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -4
  147. package/dist/channels/telegram/telegram-api.d.ts +4 -3
  148. package/dist/channels/telegram/telegram-api.js +9 -7
  149. package/dist/channels/telegram/telegram.d.ts +4 -18
  150. package/dist/channels/telegram/telegram.js +38 -94
  151. package/dist/channels/telegram/turn-store.d.ts +11 -14
  152. package/dist/channels/telegram/turn-store.js +8 -6
  153. package/dist/channels/wait-health.d.ts +4 -6
  154. package/dist/channels/wait-health.js +10 -14
  155. package/dist/cli/add-feishu.d.ts +2 -12
  156. package/dist/cli/add-feishu.js +24 -71
  157. package/dist/cli/add-slack.d.ts +5 -3
  158. package/dist/cli/add-slack.js +53 -76
  159. package/dist/cli/auth-view.d.ts +2 -16
  160. package/dist/cli/auth-view.js +2 -16
  161. package/dist/cli/commands/add.js +25 -40
  162. package/dist/cli/commands/chat.d.ts +0 -1
  163. package/dist/cli/commands/chat.js +5 -6
  164. package/dist/cli/commands/deploy/agentcore.js +60 -65
  165. package/dist/cli/commands/deploy/docker.js +50 -23
  166. package/dist/cli/commands/deploy/fly.js +23 -61
  167. package/dist/cli/commands/deploy/railway.js +23 -18
  168. package/dist/cli/commands/deploy/shared.d.ts +38 -57
  169. package/dist/cli/commands/deploy/shared.js +40 -59
  170. package/dist/cli/commands/deploy.d.ts +10 -27
  171. package/dist/cli/commands/deploy.js +38 -60
  172. package/dist/cli/commands/destroy.d.ts +4 -0
  173. package/dist/cli/commands/destroy.js +56 -0
  174. package/dist/cli/commands/dev.d.ts +2 -1
  175. package/dist/cli/commands/dev.js +20 -23
  176. package/dist/cli/commands/info.d.ts +0 -2
  177. package/dist/cli/commands/info.js +83 -49
  178. package/dist/cli/commands/init.d.ts +1 -2
  179. package/dist/cli/commands/init.js +16 -55
  180. package/dist/cli/commands/invoke.d.ts +0 -1
  181. package/dist/cli/commands/invoke.js +5 -7
  182. package/dist/cli/commands/login.d.ts +2 -1
  183. package/dist/cli/commands/login.js +33 -38
  184. package/dist/cli/commands/logs.js +3 -4
  185. package/dist/cli/commands/models.js +1 -5
  186. package/dist/cli/commands/routine-run.d.ts +6 -0
  187. package/dist/cli/commands/routine-run.js +47 -0
  188. package/dist/cli/commands/routine.d.ts +25 -0
  189. package/dist/cli/commands/routine.js +122 -0
  190. package/dist/cli/commands/start.d.ts +31 -3
  191. package/dist/cli/commands/start.js +131 -81
  192. package/dist/cli/commands/tool.js +26 -12
  193. package/dist/cli/fail.d.ts +13 -23
  194. package/dist/cli/fail.js +20 -23
  195. package/dist/cli/invoke-stream.d.ts +1 -8
  196. package/dist/cli/kernel.d.ts +6 -38
  197. package/dist/cli/kernel.js +14 -77
  198. package/dist/cli/models-view.d.ts +1 -9
  199. package/dist/cli/models-view.js +11 -22
  200. package/dist/cli/program.d.ts +5 -6
  201. package/dist/cli/program.js +120 -157
  202. package/dist/cli/serve.d.ts +57 -41
  203. package/dist/cli/serve.js +94 -121
  204. package/dist/cli/shared.d.ts +15 -61
  205. package/dist/cli/shared.js +55 -181
  206. package/dist/cli.js +1 -8
  207. package/dist/collect.d.ts +6 -0
  208. package/dist/collect.js +6 -0
  209. package/dist/core.d.ts +4 -1
  210. package/dist/core.js +9 -7
  211. package/dist/declared-secrets.d.ts +55 -0
  212. package/dist/declared-secrets.js +88 -0
  213. package/dist/deploy/agentcore/aws-cli.d.ts +91 -0
  214. package/dist/deploy/agentcore/aws-cli.js +64 -0
  215. package/dist/deploy/agentcore/destroy.d.ts +51 -0
  216. package/dist/deploy/agentcore/destroy.js +273 -0
  217. package/dist/deploy/agentcore/forwarder.js +28 -79
  218. package/dist/deploy/agentcore/logs.d.ts +2 -18
  219. package/dist/deploy/agentcore/logs.js +27 -51
  220. package/dist/deploy/agentcore/plan.d.ts +85 -105
  221. package/dist/deploy/agentcore/plan.js +152 -314
  222. package/dist/deploy/agentcore/run.d.ts +28 -28
  223. package/dist/deploy/agentcore/run.js +227 -306
  224. package/dist/deploy/agentcore/zip.d.ts +2 -15
  225. package/dist/deploy/agentcore/zip.js +3 -17
  226. package/dist/deploy/build-context.d.ts +21 -0
  227. package/dist/deploy/build-context.js +137 -0
  228. package/dist/deploy/channel-ingress.d.ts +26 -39
  229. package/dist/deploy/channel-ingress.js +57 -28
  230. package/dist/deploy/container.d.ts +20 -34
  231. package/dist/deploy/container.js +49 -100
  232. package/dist/deploy/docker/plan.d.ts +12 -11
  233. package/dist/deploy/docker/plan.js +43 -24
  234. package/dist/deploy/docker/run.d.ts +22 -25
  235. package/dist/deploy/docker/run.js +39 -37
  236. package/dist/deploy/fly/plan.d.ts +15 -44
  237. package/dist/deploy/fly/plan.js +41 -73
  238. package/dist/deploy/fly/run.d.ts +16 -51
  239. package/dist/deploy/fly/run.js +42 -95
  240. package/dist/deploy/hosts.d.ts +24 -3
  241. package/dist/deploy/hosts.js +24 -3
  242. package/dist/deploy/preflight.d.ts +47 -41
  243. package/dist/deploy/preflight.js +282 -383
  244. package/dist/deploy/railway/plan.d.ts +16 -42
  245. package/dist/deploy/railway/plan.js +42 -60
  246. package/dist/deploy/railway/run.d.ts +30 -68
  247. package/dist/deploy/railway/run.js +86 -100
  248. package/dist/deploy/registration-gate.d.ts +6 -13
  249. package/dist/deploy/residency.d.ts +37 -0
  250. package/dist/deploy/residency.js +24 -0
  251. package/dist/deploy/runner.d.ts +10 -9
  252. package/dist/deploy/runner.js +7 -18
  253. package/dist/deploy/secrets.d.ts +40 -41
  254. package/dist/deploy/secrets.js +117 -103
  255. package/dist/deploy/workspace.d.ts +50 -0
  256. package/dist/deploy/workspace.js +197 -0
  257. package/dist/dev-supervisor.d.ts +1 -9
  258. package/dist/dev-supervisor.js +21 -64
  259. package/dist/effect-port.d.ts +50 -0
  260. package/dist/effect-port.js +77 -0
  261. package/dist/engines/pi/agent-session-factory.d.ts +50 -69
  262. package/dist/engines/pi/agent-session-factory.js +249 -146
  263. package/dist/engines/pi/auth.d.ts +15 -11
  264. package/dist/engines/pi/auth.js +96 -99
  265. package/dist/engines/pi/chat.d.ts +1 -5
  266. package/dist/engines/pi/chat.js +2 -14
  267. package/dist/engines/pi/config.d.ts +53 -51
  268. package/dist/engines/pi/config.js +83 -92
  269. package/dist/engines/pi/create.d.ts +60 -75
  270. package/dist/engines/pi/create.js +108 -141
  271. package/dist/engines/pi/definition.d.ts +14 -58
  272. package/dist/engines/pi/definition.js +21 -85
  273. package/dist/engines/pi/invoke-session.d.ts +5 -25
  274. package/dist/engines/pi/invoke-session.js +209 -244
  275. package/dist/engines/pi/login.d.ts +95 -35
  276. package/dist/engines/pi/login.js +259 -107
  277. package/dist/engines/pi/machine.d.ts +31 -0
  278. package/dist/engines/pi/machine.js +106 -0
  279. package/dist/engines/pi/models.d.ts +70 -89
  280. package/dist/engines/pi/models.js +188 -81
  281. package/dist/engines/pi/open.d.ts +65 -53
  282. package/dist/engines/pi/open.js +129 -69
  283. package/dist/engines/pi/report.d.ts +3 -12
  284. package/dist/engines/pi/report.js +2 -16
  285. package/dist/engines/pi/search-tools.d.ts +1 -9
  286. package/dist/engines/pi/search-tools.js +26 -57
  287. package/dist/engines/pi/service.d.ts +2 -16
  288. package/dist/engines/pi/service.js +2 -16
  289. package/dist/engines/pi/session-builder.d.ts +5 -9
  290. package/dist/engines/pi/session-builder.js +44 -82
  291. package/dist/engines/pi/session-control.js +286 -269
  292. package/dist/engines/pi/session-effects.d.ts +14 -0
  293. package/dist/engines/pi/session-effects.js +30 -0
  294. package/dist/engines/pi/session-inheritance.d.ts +9 -18
  295. package/dist/engines/pi/session-inheritance.js +103 -160
  296. package/dist/engines/pi/session-markers.d.ts +31 -30
  297. package/dist/engines/pi/session-markers.js +26 -25
  298. package/dist/engines/pi/session-settings.d.ts +6 -22
  299. package/dist/engines/pi/session-settings.js +10 -21
  300. package/dist/engines/pi/session-store.d.ts +26 -92
  301. package/dist/engines/pi/session-store.js +96 -310
  302. package/dist/engines/pi/tool-context.d.ts +17 -45
  303. package/dist/engines/pi/tool-context.js +7 -26
  304. package/dist/engines/pi/tool.d.ts +45 -36
  305. package/dist/engines/pi/tool.js +43 -57
  306. package/dist/engines/pi/turn-kit.d.ts +14 -52
  307. package/dist/engines/pi/turn-kit.js +18 -67
  308. package/dist/engines/pi/wake-tool.d.ts +2 -10
  309. package/dist/engines/pi/wake-tool.js +7 -27
  310. package/dist/env.d.ts +27 -28
  311. package/dist/env.js +45 -50
  312. package/dist/index.js +0 -2
  313. package/dist/lark.d.ts +1 -2
  314. package/dist/lark.js +1 -2
  315. package/dist/loader.d.ts +14 -55
  316. package/dist/loader.js +24 -43
  317. package/dist/log.d.ts +2 -12
  318. package/dist/log.js +3 -24
  319. package/dist/node.d.ts +2 -15
  320. package/dist/node.js +2 -18
  321. package/dist/observe.d.ts +2 -8
  322. package/dist/open-url.d.ts +1 -1
  323. package/dist/open-url.js +1 -1
  324. package/dist/paths.d.ts +70 -134
  325. package/dist/paths.js +165 -232
  326. package/dist/pi.d.ts +4 -6
  327. package/dist/pi.js +3 -3
  328. package/dist/proxy.d.ts +16 -6
  329. package/dist/proxy.js +52 -16
  330. package/dist/runtime.d.ts +18 -12
  331. package/dist/runtime.js +18 -11
  332. package/dist/scaffold/add-channel.d.ts +14 -31
  333. package/dist/scaffold/add-channel.js +48 -112
  334. package/dist/scaffold/init.d.ts +9 -31
  335. package/dist/scaffold/init.js +45 -138
  336. package/dist/scaffold/templates/env.example +6 -4
  337. package/dist/scaffold/templates/fastagent.config.ts +36 -0
  338. package/dist/scaffold/templates.d.ts +3 -3
  339. package/dist/scaffold/templates.js +5 -8
  340. package/dist/scaffold/vendor-skill.d.ts +2 -5
  341. package/dist/scaffold/vendor-skill.js +19 -29
  342. package/dist/schedule/cron.d.ts +9 -5
  343. package/dist/schedule/cron.js +9 -11
  344. package/dist/schedule/discover.d.ts +12 -12
  345. package/dist/schedule/discover.js +43 -34
  346. package/dist/schedule/routine.d.ts +43 -0
  347. package/dist/schedule/routine.js +32 -0
  348. package/dist/schedule/run.d.ts +55 -0
  349. package/dist/schedule/run.js +146 -0
  350. package/dist/schedule/scheduler.d.ts +44 -55
  351. package/dist/schedule/scheduler.js +291 -265
  352. package/dist/schedule/state.d.ts +105 -10
  353. package/dist/schedule/state.js +293 -19
  354. package/dist/schedule/wake-alarm.d.ts +10 -23
  355. package/dist/schedule/wake-alarm.js +91 -121
  356. package/dist/schedule/wakeups.d.ts +23 -39
  357. package/dist/schedule/wakeups.js +33 -75
  358. package/dist/secrets-gate.d.ts +32 -0
  359. package/dist/secrets-gate.js +33 -0
  360. package/dist/service.d.ts +137 -98
  361. package/dist/service.js +250 -252
  362. package/dist/session-remote.d.ts +33 -44
  363. package/dist/session-remote.js +203 -192
  364. package/dist/session.d.ts +153 -228
  365. package/dist/session.js +33 -55
  366. package/dist/tunnel.d.ts +8 -36
  367. package/dist/tunnel.js +24 -65
  368. package/dist/version.d.ts +2 -2
  369. package/dist/version.js +2 -2
  370. package/package.json +10 -16
  371. package/dist/channels/agentcore-state.d.ts +0 -77
  372. package/dist/channels/agentcore-state.js +0 -276
  373. package/dist/channels/github/github.d.ts +0 -31
  374. package/dist/channels/github/github.js +0 -89
  375. package/dist/channels/github/scaffold/channel.ts +0 -25
  376. package/dist/cli/commands/attach.d.ts +0 -108
  377. package/dist/cli/commands/attach.js +0 -637
  378. package/dist/cli/commands/fire.d.ts +0 -7
  379. package/dist/cli/commands/fire.js +0 -38
  380. package/dist/cli/commands/schedule.d.ts +0 -12
  381. package/dist/cli/commands/schedule.js +0 -87
  382. package/dist/github.d.ts +0 -3
  383. package/dist/github.js +0 -2
  384. package/dist/scaffold/templates/fastagent.config.mjs +0 -23
  385. package/dist/schedule/audit.d.ts +0 -18
  386. package/dist/schedule/audit.js +0 -57
  387. package/dist/schedule/schedule.d.ts +0 -35
  388. package/dist/schedule/schedule.js +0 -23
@@ -1,46 +1,75 @@
1
- import { findCutPoint, getLatestCompactionEntry, sessionEntryToContextMessages, } from "@earendil-works/pi-coding-agent";
1
+ // The L0 rendering payload reads message text the way the engine does — ONE reading, joined without a
2
+ // separator because this payload is a transcript, not a preview line.
3
+ import { contentText } from "@earendil-works/pi-ai";
4
+ import * as Deferred from "effect/Deferred";
5
+ import * as Effect from "effect/Effect";
6
+ import * as Queue from "effect/Queue";
7
+ import { PortFailure, port, portAbort, portCleanup, portError, portJoin } from "../../effect-port.js";
8
+ import { acquireSession, acquireSessionLease } from "./session-effects.js";
9
+ import { calculateContextTokens, estimateTokens, getLastAssistantUsage, } from "@earendil-works/pi-coding-agent";
2
10
  import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
3
11
  import { SESSION_BUSY_CODE } from "../../agent.js";
4
12
  import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, isAddressableSession, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, PARTIAL_UPDATE_CODE, UPDATE_FIELDS, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
5
13
  import { listModels } from "./config.js";
6
14
  import { forkProvenance, isNavigable, publishedLeaf } from "./session-markers.js";
15
+ import { startCompaction } from "./agent-session-factory.js";
7
16
  import { toRetryScheduledEvent } from "./retry-event.js";
8
17
  import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
9
18
  import { log } from "../../log.js";
10
- /** Admission uses coding-agent's cut point and context rules, including a split-turn prefix.
11
- * Its prepareCompaction is private; agent-core's namesake uses a different journal format. */
12
- function hasCompactableHistory(path, keepRecentTokens) {
13
- if (path.at(-1)?.type === "compaction")
14
- return false;
15
- const previous = getLatestCompactionEntry(path);
16
- let start = 0;
17
- if (previous) {
18
- const kept = path.findIndex((entry) => entry.id === previous.firstKeptEntryId);
19
- start = kept >= 0 ? kept : path.indexOf(previous) + 1;
19
+ // ── Usage (read from the record) ─────────────────────────────────────────────
20
+ /**
21
+ * What the newest answer cost and how full the context is, from the record rather than a live session (a served
22
+ * session exists only during its run). The numbers are the provider's own, on the newest assistant pi counts as
23
+ * carrying usage (`getLastAssistantUsage`). The context size adds pi's estimate of what followed that answer, which
24
+ * after a finished turn is nothing: then it is exactly pi's `getContextUsage()`.
25
+ *
26
+ * `contextTokens` is omitted, never guessed, once a compaction or a context edit follows that answer: the provider's
27
+ * count no longer describes the context (pi's own rule). pi then estimates from characters; this reports unknown
28
+ * until the next response.
29
+ */
30
+ function sessionUsage(path, record, contextWindow) {
31
+ const usage = getLastAssistantUsage(path);
32
+ if (!usage)
33
+ return undefined;
34
+ let at = path.length - 1;
35
+ while (at >= 0) {
36
+ const entry = path[at];
37
+ if (entry?.type === "message" && entry.message.usage === usage)
38
+ break;
39
+ at--;
20
40
  }
21
- const { firstKeptEntryIndex } = findCutPoint(path, start, path.length, keepRecentTokens);
22
- return path
23
- .slice(start, firstKeptEntryIndex)
24
- .some((entry) => entry.type !== "compaction" && sessionEntryToContextMessages(entry).length > 0);
41
+ const answer = path[at];
42
+ const stale = path.slice(at + 1).some((entry) => entry.type === "compaction" || entry.type === "context_edit");
43
+ let contextTokens;
44
+ if (answer && !stale) {
45
+ let trailing = 0;
46
+ let after = false;
47
+ for (const { sourceEntry, messages } of record.buildSessionProjection().entries) {
48
+ if (after)
49
+ for (const message of messages)
50
+ trailing += estimateTokens(message);
51
+ if (sourceEntry.id === answer.id)
52
+ after = true;
53
+ }
54
+ contextTokens = calculateContextTokens(usage) + trailing;
55
+ }
56
+ return {
57
+ inputTokens: usage.input,
58
+ outputTokens: usage.output,
59
+ cacheReadTokens: usage.cacheRead,
60
+ cacheWriteTokens: usage.cacheWrite,
61
+ cost: usage.cost.total,
62
+ ...(contextTokens !== undefined ? { contextTokens } : {}),
63
+ ...(contextWindow !== undefined && contextWindow > 0 ? { contextWindow } : {}),
64
+ };
25
65
  }
26
66
  // ── Entry normalization (durable plane) ──────────────────────────────────────
27
- /** Concatenated plain text of a message's content blocks (the L0 rendering payload). A custom
28
- * AgentMessage role may carry no `content` at all — that reads as empty, not a crash. */
29
- function textOf(content) {
30
- if (typeof content === "string")
31
- return content;
32
- if (!Array.isArray(content))
33
- return "";
34
- return content
35
- .filter((b) => b.type === "text" && typeof b.text === "string")
36
- .map((b) => b.text)
37
- .join("");
38
- }
39
67
  /**
40
68
  * pi `PiSessionEntry` → neutral {@link SessionEntry}. Message entries map onto the guaranteed
41
69
  * kind vocabulary (user/assistant/tool) with a minimal render payload; every other engine record
42
70
  * keeps its pi type as an open-set kind with an EMPTY payload — present so `parentId` chains and
43
71
  * cursors stay intact, skippable by contract, and no pi message class leaks through the adapter.
72
+ * One exception: `context_edit` carries `{ targetId, omitted }`, never the replacement content.
44
73
  */
45
74
  function toSessionEntry(entry, parentId) {
46
75
  const base = {
@@ -51,12 +80,12 @@ function toSessionEntry(entry, parentId) {
51
80
  if (entry.type === "message") {
52
81
  const m = entry.message;
53
82
  if (m.role === "user")
54
- return { ...base, kind: "user", data: { text: textOf(m.content) } };
83
+ return { ...base, kind: "user", data: { text: contentText(m.content, "") } };
55
84
  if (m.role === "assistant") {
56
85
  const toolCalls = m.content
57
86
  .filter((b) => b.type === "toolCall")
58
87
  .map((b) => ({ id: b.id ?? "", name: b.name ?? "" }));
59
- const data = { text: textOf(m.content) };
88
+ const data = { text: contentText(m.content, "") };
60
89
  if (toolCalls.length > 0)
61
90
  data.toolCalls = toolCalls;
62
91
  return { ...base, kind: "assistant", data };
@@ -69,13 +98,21 @@ function toSessionEntry(entry, parentId) {
69
98
  toolCallId: m.toolCallId,
70
99
  toolName: m.toolName,
71
100
  isError: m.isError ?? false,
72
- text: textOf(m.content),
101
+ text: contentText(m.content, ""),
73
102
  },
74
103
  };
75
104
  }
76
- // A custom AgentMessage role (channel/extension-defined): open-set kind, skippable.
105
+ // Not a conversation turn (`isConversationMessage`): the engine's own `system` bookkeeping, or a custom
106
+ // AgentMessage role a channel/extension defined. Open-set kind with an EMPTY payload, skippable by contract —
107
+ // which is also what keeps the assembled prompt out of everything this plane publishes.
77
108
  return { ...base, kind: `message:${m.role}`, data: {} };
78
109
  }
110
+ // Which transcript entry the model no longer sees as written: `omitted` when it left the model context (pi's own
111
+ // abandoned retry and overflow attempts, or an extension's edit), otherwise its content was replaced. The target
112
+ // stays in the transcript either way, and the edit applies only on a path that contains it.
113
+ if (entry.type === "context_edit") {
114
+ return { ...base, kind: entry.type, data: { targetId: entry.targetId, omitted: entry.replacement === null } };
115
+ }
79
116
  return { ...base, kind: entry.type, data: {} };
80
117
  }
81
118
  // ── Live fan-out (events plane) ──────────────────────────────────────────────
@@ -83,9 +120,22 @@ function toSessionEntry(entry, parentId) {
83
120
  * prefix; the reader drains it before seeing done, then reconnects and backfills via entries(). */
84
121
  export const SUBSCRIBER_BUFFER_CAP = 10_000;
85
122
  export const SUBSCRIBER_BUFFER_BYTES = 8 * 1024 * 1024;
86
- /** One subscriber's bounded push→pull queue. `close()` settles a
87
- * pending pull — an async generator suspended on a quiet stream cannot be ended by `return()`
88
- * alone (it queues behind the never-settling await), so teardown needs this explicit door. */
123
+ /**
124
+ * One subscriber's bounded push→pull queue.
125
+ *
126
+ * The mechanics are Effect's, because both halves are things this class hand-rolled and got wrong
127
+ * once each. `Queue.end` stops accepting offers, lets the reader drain what WAS accepted, and only
128
+ * then reports done — which is both the overflow policy above and the door teardown needs (an async
129
+ * generator suspended on a quiet stream cannot be ended by `return()` alone: that queues behind the
130
+ * never-settling await). And a queue has a SET of takers, so two concurrent `next()` calls — legal,
131
+ * any wrapper may poll twice — cannot overwrite each other's resolver.
132
+ *
133
+ * What stays here is the arithmetic Queue has no opinion on: the two backlog limits, and the JSON
134
+ * snapshot (taken at push, so a producer that later mutates its event or tool result cannot grow a
135
+ * queued one). Cost of the swap: one fiber per delivered event instead of an array shift, on a
136
+ * stream that carries per-token deltas — cheap next to a model call, and paid only while someone is
137
+ * attached.
138
+ */
89
139
  class Subscriber {
90
140
  /** For the overflow diagnostic only — a warn without the session is not actionable on a
91
141
  * multi-session serve. (Explicit assignment: TS parameter properties break Node's strip-only
@@ -94,52 +144,34 @@ class Subscriber {
94
144
  constructor(session) {
95
145
  this.session = session;
96
146
  }
97
- // Serialized snapshots cannot grow when a producer later mutates its event or tool result.
98
- buffer = [];
147
+ queue = Effect.runSync(Queue.unbounded());
148
+ buffered = 0;
99
149
  bufferedBytes = 0;
100
- // A QUEUE of waiters, not a single slot: concurrent next() calls are contract-legal (any wrapper
101
- // may poll twice), and a single `wake` field would let the second await overwrite the first's
102
- // resolver — hanging the first next() forever. Every wake flushes all waiters; each re-checks the
103
- // buffer and re-queues if another consumer won the event.
104
- wakes = [];
105
- closed = false;
106
- flush() {
107
- const wakes = this.wakes;
108
- this.wakes = [];
109
- for (const wake of wakes)
110
- wake();
111
- }
150
+ ended = false;
112
151
  push(event) {
113
- if (this.closed)
152
+ if (this.ended)
114
153
  return;
115
154
  const json = JSON.stringify(event);
116
155
  const bytes = Buffer.byteLength(json);
117
- if (this.buffer.length >= SUBSCRIBER_BUFFER_CAP || this.bufferedBytes + bytes > SUBSCRIBER_BUFFER_BYTES) {
156
+ if (this.buffered >= SUBSCRIBER_BUFFER_CAP || this.bufferedBytes + bytes > SUBSCRIBER_BUFFER_BYTES) {
118
157
  log.warn(`[fastagent] session-control subscriber for session "${this.session}" exceeded its buffer limit (${SUBSCRIBER_BUFFER_CAP} events / ${SUBSCRIBER_BUFFER_BYTES} bytes; ${this.bufferedBytes} bytes queued, ${bytes} incoming); its stream ends after draining the backlog, then the client resyncs via entries()`);
119
158
  this.close();
120
159
  return;
121
160
  }
122
- this.buffer.push({ json, bytes });
161
+ Queue.offerUnsafe(this.queue, { json, bytes });
162
+ this.buffered++;
123
163
  this.bufferedBytes += bytes;
124
- this.flush();
125
164
  }
126
165
  close() {
127
- this.closed = true;
128
- this.flush();
166
+ this.ended = true;
167
+ Queue.endUnsafe(this.queue);
129
168
  }
130
- async next() {
131
- while (true) {
132
- const item = this.buffer.shift();
133
- if (item) {
134
- this.bufferedBytes -= item.bytes;
135
- return { done: false, value: JSON.parse(item.json) };
136
- }
137
- if (this.closed)
138
- return { done: true, value: undefined };
139
- await new Promise((resolve) => {
140
- this.wakes.push(resolve);
141
- });
142
- }
169
+ next() {
170
+ return Effect.runPromise(Queue.take(this.queue).pipe(Effect.map((item) => {
171
+ this.buffered--;
172
+ this.bufferedBytes -= item.bytes;
173
+ return { done: false, value: JSON.parse(item.json) };
174
+ }), Effect.catchTag("Done", () => Effect.succeed({ done: true, value: undefined }))));
143
175
  }
144
176
  }
145
177
  /**
@@ -183,7 +215,7 @@ export function createPiSessionControl(options) {
183
215
  };
184
216
  const observer = (session, event, run) => {
185
217
  if (event.type === "run_started" && event.runId) {
186
- active.set(session, { runId: event.runId, controls: run, pending: { steering: 0, followUp: 0 } });
218
+ active.set(session, { runId: event.runId, controls: run, pending: { steering: [], followUp: [] } });
187
219
  }
188
220
  else if (event.type === "run_settled") {
189
221
  active.delete(session);
@@ -194,6 +226,22 @@ export function createPiSessionControl(options) {
194
226
  entry.pending = event.data;
195
227
  }
196
228
  fanOut(session, event);
229
+ if (event.type === "run_settled")
230
+ publishUsage(session);
231
+ };
232
+ /**
233
+ * Report `usage` as the record now reads it: after a run (a new answer) and after a compaction (the context it
234
+ * described is gone). Read after the fact, so it follows the event that caused it.
235
+ */
236
+ const publishUsage = (session) => {
237
+ // Every serve runs a hub, most with nobody attached (a chat channel): reading the whole record each turn for an
238
+ // empty audience would cost a full parse per turn, growing with the conversation.
239
+ if (!subscribers.get(session)?.size && !options.tap)
240
+ return;
241
+ reads.state(session).then(({ usage }) => {
242
+ if (usage)
243
+ emitOwn(session, { type: "state_changed", timestamp: Date.now(), data: { usage } });
244
+ }, (error) => log.warn(`[fastagent] session ${session}: usage not published: ${String(error)}`));
197
245
  };
198
246
  // The reads, plus the two sessionless declarations. Bound onto a handle below.
199
247
  const reads = {
@@ -219,7 +267,7 @@ export function createPiSessionControl(options) {
219
267
  // `state().availableThinkingLevels` — a list here could only answer for one model.
220
268
  ...(b ? { allowedModels: listModels(b.models) } : {}),
221
269
  toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
222
- usage: false,
270
+ usage: true, // state().usage, and state_changed{usage} after each run and compaction
223
271
  };
224
272
  },
225
273
  async state(session) {
@@ -235,12 +283,16 @@ export function createPiSessionControl(options) {
235
283
  // `boundary_command_failed`. Here it is a server-side warn.
236
284
  const b = boundary;
237
285
  let settings;
238
- if (opened && b) {
286
+ let usage;
287
+ if (opened) {
239
288
  try {
240
- settings = resolveSessionSettings(activePath(opened), b.models, b.defaults);
289
+ const path = activePath(opened);
290
+ if (b)
291
+ settings = resolveSessionSettings(path, b.models, b.defaults);
292
+ usage = sessionUsage(path, opened, settings?.model.contextWindow);
241
293
  }
242
294
  catch (error) {
243
- log.warn(`[fastagent] session ${session}: settings unreadable (entry chain): ${String(error)}`);
295
+ log.warn(`[fastagent] session ${session}: state unreadable (entry chain): ${String(error)}`);
244
296
  }
245
297
  }
246
298
  const name = opened?.getSessionName();
@@ -255,7 +307,10 @@ export function createPiSessionControl(options) {
255
307
  availableThinkingLevels: settings.availableThinkingLevels,
256
308
  }
257
309
  : {}),
258
- pending: run ? { ...run.pending } : { steering: 0, followUp: 0 },
310
+ pending: run
311
+ ? { steering: [...run.pending.steering], followUp: [...run.pending.followUp] }
312
+ : { steering: [], followUp: [] },
313
+ ...(usage ? { usage } : {}),
259
314
  ...(leafEntryId ? { leafEntryId } : {}),
260
315
  };
261
316
  },
@@ -300,16 +355,33 @@ export function createPiSessionControl(options) {
300
355
  return { entries, ...(leafEntryId ? { leafEntryId } : {}) };
301
356
  },
302
357
  events(session) {
303
- // EVERY ITERATION IS A FRESH SUBSCRIPTION — the per-subscription state lives inside
304
- // asyncIterator(), matching the remote client (one connection per iteration): two concurrent
305
- // iterations each get the full stream, and one iteration's end does not poison the next.
306
- // Registration happens on the FIRST next(), not at iterator creation: subscription semantics
307
- // = you are subscribed while you iterate; an iterator obtained but never driven must not
308
- // buffer. Teardown goes through Subscriber.close() so a `return()` on a QUIET stream
309
- // resolves promptly instead of queueing behind a never-settling pull — without it every
310
- // attach/detach against an idle session would leak a permanently registered subscriber.
358
+ // ONE CALL IS ONE SUBSCRIPTION, and `ready` is its boundary: the moment after which nothing emitted for this
359
+ // session can be missed (session.ts, SessionEventStream). A second iteration of the same stream would be a
360
+ // second subscription inheriting the first one's readiness — a guarantee it never earned — so it is refused
361
+ // rather than documented. Matching the remote client, which opens one connection per stream.
362
+ //
363
+ // Registration happens on the FIRST next(), not at iterator creation: subscription semantics = you are
364
+ // subscribed while you iterate; an iterator obtained but never driven must not buffer. Teardown goes through
365
+ // Subscriber.close() so a `return()` on a QUIET stream resolves promptly instead of queueing behind a
366
+ // never-settling pull — without it every attach/detach against an idle session would leak a permanently
367
+ // registered subscriber.
368
+ let registered;
369
+ let unestablished;
370
+ const ready = new Promise((resolve, reject) => {
371
+ registered = resolve;
372
+ unestablished = reject;
373
+ });
374
+ // An awaiter gets the rejection; a consumer that only iterates would otherwise take an unhandled one for a
375
+ // cancellation it performed itself.
376
+ ready.catch(() => { });
377
+ let iterated = false;
311
378
  return {
379
+ ready,
312
380
  [Symbol.asyncIterator]() {
381
+ if (iterated) {
382
+ throw new Error("session events: this stream is one subscription — call events() again for another");
383
+ }
384
+ iterated = true;
313
385
  let sub;
314
386
  // `finished` is its own state: `sub === undefined` alone would conflate "not yet
315
387
  // registered" with "terminated", and a post-done next() would silently REGISTER A FRESH
@@ -318,8 +390,12 @@ export function createPiSessionControl(options) {
318
390
  let finished = false;
319
391
  const cleanup = () => {
320
392
  finished = true;
321
- if (!sub)
393
+ if (!sub) {
394
+ // Cancelled before it registered: `ready` has a promise it cannot keep, and hanging on it is the one
395
+ // outcome a caller cannot diagnose. The remote client answers the same way.
396
+ unestablished(new Error("session events: cancelled before the subscription was established"));
322
397
  return;
398
+ }
323
399
  sub.close();
324
400
  const set = subscribers.get(session);
325
401
  if (set) {
@@ -341,6 +417,7 @@ export function createPiSessionControl(options) {
341
417
  subscribers.set(session, set);
342
418
  }
343
419
  set.add(sub);
420
+ registered();
344
421
  }
345
422
  const result = await sub.next();
346
423
  if (result.done)
@@ -386,9 +463,14 @@ export function createPiSessionControl(options) {
386
463
  ok: false,
387
464
  error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
388
465
  });
466
+ const runBoundary = (command) => Effect.runPromise(Effect.scoped(command).pipe(Effect.catchTag("SessionBusy", () => Effect.succeed(busy())), Effect.catchCause((cause) => Effect.succeed(failed(portError(cause))))));
389
467
  /** steer / follow_up / abort — the run actions. They reach the LIVE run through the controls
390
468
  * registered with `run_started`; nothing durable is written. */
391
- const runAction = async (session, action) => {
469
+ const runAction = (session, action) => Effect.runPromise(Effect.gen(function* () {
470
+ // pi dequeues a queued prompt by matching its text when it enters the conversation, and skips an empty one
471
+ // (`_handleAgentEvent`): an image-only prompt would sit in `pending` for the whole run and then read as dropped.
472
+ if ((action.type === "steer" || action.type === "follow_up") && action.prompt.text === "")
473
+ return invalid(`${action.type} needs non-empty text: a queued prompt is reported by its text`);
392
474
  const run = active.get(session);
393
475
  if (!run) {
394
476
  // Run/compaction symmetry: an in-flight compaction is a model call too, and `abort` is its
@@ -425,23 +507,17 @@ export function createPiSessionControl(options) {
425
507
  },
426
508
  };
427
509
  }
428
- try {
429
- if (action.type === "steer")
430
- await run.controls.steer(action.prompt);
431
- else if (action.type === "follow_up")
432
- await run.controls.followUp(action.prompt);
433
- else
434
- await run.controls.abort();
435
- }
436
- catch (error) {
437
- // The run raced us to settlement, failed setup, or the engine refused: still pre-acceptance
438
- // (nothing was queued), distinct from "no run existed". retryable: false for the same reason —
439
- // the run is gone; consult state() before calling again.
440
- return { ok: false, error: { code: RUN_COMMAND_FAILED_CODE, message: String(error), retryable: false } };
441
- }
442
- // Accepted: joined (or stopped) THIS run. The outcome arrives as run_settled.
510
+ const controls = run.controls;
511
+ yield* port(() => action.type === "steer"
512
+ ? controls.steer(action.prompt)
513
+ : action.type === "follow_up"
514
+ ? controls.followUp(action.prompt)
515
+ : controls.abort());
443
516
  return { ok: true, runId: run.runId };
444
- };
517
+ }).pipe(Effect.catchCause((cause) => Effect.succeed({
518
+ ok: false,
519
+ error: { code: RUN_COMMAND_FAILED_CODE, message: String(portError(cause)), retryable: false },
520
+ }))));
445
521
  /**
446
522
  * {@link Session.update} — validate the whole patch, take the lease once, hand the writes to the
447
523
  * store, report what landed.
@@ -450,7 +526,7 @@ export function createPiSessionControl(options) {
450
526
  * behind. What a value MEANS is decided here (a model spec against the registry, a level against
451
527
  * the model it lands on); how a record takes it is the store's.
452
528
  */
453
- const updateOf = async (session, patch) => {
529
+ const updateOf = (session, patch) => runBoundary(Effect.gen(function* () {
454
530
  // Keys, not values: a field this runtime does not know must not be silently skipped — that is a
455
531
  // client typo, or a newer client talking to an older serve, and both need to hear about it.
456
532
  const named = Object.keys(patch);
@@ -483,7 +559,7 @@ export function createPiSessionControl(options) {
483
559
  // Sessions are created by invoke or copied by fork, never minted by an update: an unknown id is
484
560
  // rejected, not turned into a ghost record. (Read-only handle — the WRITE one is opened under
485
561
  // the lease below, and this one is discarded.)
486
- const existing = await sessions.openIfExists(session);
562
+ const existing = yield* port(() => sessions.openIfExists(session));
487
563
  if (!existing)
488
564
  return noSuchSession(session);
489
565
  if (patch.leafEntryId !== undefined) {
@@ -520,30 +596,18 @@ export function createPiSessionControl(options) {
520
596
  return invalid(`thinking level "${patch.thinkingLevel}" is not supported by ${target.provider}/${target.id} (allowed: ${levels.join(", ")})`);
521
597
  }
522
598
  }
523
- // The control plane's writes take the same lease as every run — a write must never race one
524
- // (design §9).
525
- const release = b.lease.tryAcquire(session);
526
- if (!release)
527
- return busy();
528
- let applied;
529
- try {
530
- // HOW a record takes a property — order, the leaf pointer, the name pi rewrites — is the
531
- // store's to know. This asks for the writes and is told what landed.
532
- applied = await sessions.applyProperties(session, {
599
+ // Release before publishing state_changed: an observer may immediately issue another command.
600
+ const applied = yield* Effect.scoped(Effect.gen(function* () {
601
+ yield* acquireSessionLease(b.lease, session);
602
+ // portJoin, not port: the lease is held for the duration of this write, so an interrupted
603
+ // fiber must not release it while the store is still writing (effect-port.ts, rule 1).
604
+ return yield* portJoin(() => sessions.applyProperties(session, {
533
605
  ...(patch.name !== undefined ? { name: patch.name } : {}),
534
606
  ...(model ? { model: { provider: model.provider, id: model.id } } : {}),
535
607
  ...(patch.thinkingLevel !== undefined ? { thinkingLevel: patch.thinkingLevel } : {}),
536
608
  ...(patch.leafEntryId !== undefined ? { leafEntryId: patch.leafEntryId } : {}),
537
- });
538
- }
539
- catch (error) {
540
- // Opening the record failed — nothing was written; the same patch may succeed on retry. The
541
- // lease is freed by the `finally` on the way out, once.
542
- return failed(error);
543
- }
544
- finally {
545
- release();
546
- }
609
+ }));
610
+ }));
547
611
  if (!applied)
548
612
  return noSuchSession(session); // vanished in the window: same condition, same code
549
613
  if (applied.landed.length > 0) {
@@ -562,6 +626,15 @@ export function createPiSessionControl(options) {
562
626
  log.warn(`[fastagent] session ${session}: updated, settings unresolvable: ${String(error)}`);
563
627
  }
564
628
  }
629
+ // A moved head reads a different newest answer, and another model has another window: the event carries
630
+ // the usage the session now has, and carries none when it has none. Read back through `state()`, the one
631
+ // derivation; a read failure only costs the field, since the write has already landed.
632
+ const usage = applied.landed.some((f) => f === "leafEntryId" || f === "model")
633
+ ? yield* port(() => reads.state(session)).pipe(Effect.map((state) => state.usage), Effect.catchCause((cause) => Effect.sync(() => {
634
+ log.warn(`[fastagent] session ${session}: updated, usage unreadable: ${String(portError(cause))}`);
635
+ return undefined;
636
+ })))
637
+ : undefined;
565
638
  emitOwn(session, {
566
639
  type: "state_changed",
567
640
  timestamp: Date.now(),
@@ -571,6 +644,7 @@ export function createPiSessionControl(options) {
571
644
  ? { model: `${settings.model.provider}/${settings.model.id}`, thinkingLevel: settings.thinkingLevel }
572
645
  : {}),
573
646
  ...(applied.landed.includes("name") && applied.name ? { name: applied.name } : {}),
647
+ ...(usage ? { usage } : {}),
574
648
  },
575
649
  });
576
650
  }
@@ -589,57 +663,47 @@ export function createPiSessionControl(options) {
589
663
  };
590
664
  }
591
665
  return { ok: true };
592
- };
593
- /**
594
- * ACCEPT-FAST compaction: a full model call (tens of seconds is normal), so holding the call open
595
- * until it finishes would make acceptance = outcome — the one exception to §5.2, and what broke
596
- * remote clients whose request timeouts are sized for control calls. This answers once the work is
597
- * ADMITTED (lease held, session bound); the outcome travels as
598
- * `compaction_finished{summary|error|aborted}`.
599
- *
600
- * Admission is everything cheap and local: binding the session (the ONE canonical resolution of
601
- * overrides + auth) plus the compaction PREPARATION, a pure branch read. The boundary between
602
- * "reject" and "the outcome travels as an event" sits where the work becomes asynchronous and
603
- * expensive: the model call. "Nothing to compact" is therefore a pre-acceptance answer, never a
604
- * finished{error} dressed as a failure — pi reports it as a throw from compact(), too late.
605
- */
606
- const compactOf = async (session, instructions) => {
607
- const b = boundary;
608
- if (!b)
609
- return unsupported("compact()");
610
- const existing = await sessions.openIfExists(session);
611
- if (!existing)
612
- return noSuchSession(session);
613
- const release = b.lease.tryAcquire(session);
614
- if (!release)
615
- return busy();
616
- let bound;
617
- try {
618
- bound = await b.sessionFactory(session);
619
- }
620
- catch (error) {
621
- release();
622
- return failed(error);
623
- }
624
- const teardown = () => {
625
- try {
626
- bound.dispose();
627
- }
628
- catch (error) {
629
- log.warn(`[fastagent] compaction session teardown failed: ${String(error)}`);
630
- }
631
- };
632
- try {
633
- // The SAME settings pi will use inside compact(): asking with different thresholds would
634
- // either reject a compaction pi would have run, or admit one it refuses — and its refusal
635
- // arrives too late to be a pre-acceptance answer.
636
- const path = bound.sessionManager.getBranch();
637
- if (!hasCompactableHistory(path, bound.settingsManager.getCompactionSettings().keepRecentTokens)) {
638
- teardown();
639
- release();
640
- // A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
641
- // machine-distinguish "give up" from "call again once the session grows", and branching on
642
- // message prose is forbidden by contract.
666
+ }));
667
+ /** Admission waits for binding and pi's own admission (its cut point), not the model call.
668
+ * Its execution scope survives the control response; completion is published only after releasing the shared
669
+ * lease. */
670
+ const compactOf = (session, instructions) => {
671
+ const admitted = Deferred.makeUnsafe();
672
+ let accepted = false;
673
+ let aborted = false;
674
+ const work = Effect.scoped(Effect.gen(function* () {
675
+ const b = boundary;
676
+ if (!b)
677
+ return unsupported("compact()");
678
+ const existing = yield* port(() => sessions.openIfExists(session));
679
+ if (!existing)
680
+ return noSuchSession(session);
681
+ yield* acquireSessionLease(b.lease, session);
682
+ const bound = yield* acquireSession(b.sessionFactory, session);
683
+ yield* Effect.acquireRelease(Effect.try({
684
+ try: () => bound.subscribe((event) => {
685
+ if (event.type !== "summarization_retry_scheduled")
686
+ return;
687
+ log.warn(`[fastagent] compaction retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms (session ${session}): ${event.errorMessage}`);
688
+ emitOwn(session, toRetryScheduledEvent(event));
689
+ }),
690
+ catch: (error) => new PortFailure(error),
691
+ }), (unsubscribe) => portCleanup("unsubscribe", unsubscribe));
692
+ // Admission is pi's own decision, observed rather than predicted: a prediction has to track pi's private
693
+ // cut-point rules release by release. It settles before the model call.
694
+ const compaction = startCompaction(bound, instructions, () => {
695
+ accepted = true;
696
+ compacting.set(session, {
697
+ abort: () => {
698
+ aborted = true;
699
+ bound.abortCompaction();
700
+ },
701
+ });
702
+ emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
703
+ Deferred.doneUnsafe(admitted, Effect.succeed({ ok: true }));
704
+ });
705
+ const admission = yield* portAbort("compact admission", () => compaction.admission, () => bound.abortCompaction());
706
+ if (admission === "nothing_to_compact") {
643
707
  return {
644
708
  ok: false,
645
709
  error: {
@@ -649,54 +713,34 @@ export function createPiSessionControl(options) {
649
713
  },
650
714
  };
651
715
  }
652
- }
653
- catch (error) {
654
- teardown();
655
- release();
656
- return failed(error);
657
- }
658
- // Pi creates the abort controller after an await and emits compaction_start immediately after.
659
- // Retain early cancellation until that event; there is no time limit on session startup.
660
- let aborted = false;
661
- compacting.set(session, {
662
- abort: () => {
663
- aborted = true;
664
- bound.abortCompaction();
665
- },
666
- });
667
- emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
668
- void (async () => {
669
- let outcome;
670
- // Retries are otherwise invisible between compaction_started and _finished — surface each
671
- // backoff so a long gap is diagnosable (not confusable with a hang): as a session event for
672
- // attached observers, as a warn for server logs.
673
- const unsub = bound.subscribe((event) => {
674
- if (event.type === "compaction_start" && event.reason === "manual" && aborted)
675
- bound.abortCompaction();
676
- if (event.type !== "summarization_retry_scheduled")
677
- return;
678
- log.warn(`[fastagent] compaction retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms (session ${session}): ${event.errorMessage}`);
679
- emitOwn(session, toRetryScheduledEvent(event));
680
- });
681
- try {
682
- const done = await bound.compact(instructions);
683
- outcome = { summary: done.summary };
716
+ // Any other error pi raises before admission. A served session has no known trigger: it always binds a
717
+ // model, and pi swallows a credential failure here and meets it at the model call (compaction_finished).
718
+ // NOT retryable all the same: it is pi's decision on this session, not a transport or lease fault that
719
+ // clears on its own, so repeating the call would meet it again.
720
+ if (admission !== "admitted") {
721
+ return {
722
+ ok: false,
723
+ error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(admission.refused), retryable: false },
724
+ };
684
725
  }
685
- catch (error) {
686
- // A deliberate stop is not a failure — run/compaction symmetry with run_settled{aborted}:
687
- // the intent is the classification, same discipline as run abort attribution (a racing real
688
- // failure still reads as aborted).
689
- outcome = aborted ? { aborted: true } : { error: String(error) };
726
+ const done = yield* portAbort("compact", () => compaction.done, () => bound.abortCompaction());
727
+ return { summary: done.summary };
728
+ })).pipe(Effect.catchTag("SessionBusy", () => Effect.succeed(busy())), Effect.catchCause((cause) => Effect.succeed(failed(portError(cause)))), Effect.tap((result) => Effect.sync(() => {
729
+ if (accepted) {
730
+ compacting.delete(session);
731
+ emitOwn(session, {
732
+ type: "compaction_finished",
733
+ timestamp: Date.now(),
734
+ data: "summary" in result ? result : aborted ? { aborted: true } : { error: result.error.message },
735
+ });
736
+ if ("summary" in result)
737
+ publishUsage(session);
690
738
  }
691
- unsub();
692
- teardown();
693
- // Release BEFORE emitting finished: a watcher seeing finished may act next — "finished ⇒ the
694
- // lease is free and status is no longer compacting" must hold.
695
- compacting.delete(session);
696
- release();
697
- emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
698
- })();
699
- return { ok: true };
739
+ Deferred.doneUnsafe(admitted, Effect.succeed("summary" in result ? { ok: true } : result));
740
+ })));
741
+ // The admitted operation, rather than the request waiting on this Deferred, owns the scope.
742
+ Effect.runFork(work);
743
+ return Effect.runPromise(Deferred.await(admitted));
700
744
  };
701
745
  /**
702
746
  * Copy a history into a new session. IDEMPOTENT by construction: `into` is the caller's id, so a
@@ -705,7 +749,7 @@ export function createPiSessionControl(options) {
705
749
  * safe rather than merely quiet: the same `into` naming a session that came from somewhere else is
706
750
  * a rejection, not an overwrite.
707
751
  */
708
- const forkOf = async (options) => {
752
+ const forkOf = (options) => runBoundary(Effect.gen(function* () {
709
753
  const { from, at, into } = options;
710
754
  /** WHICH fork this is: source + branch point. Two forks of one session at different entries are
711
755
  * different requests, so a retry of one must not be answered by the other. */
@@ -719,7 +763,7 @@ export function createPiSessionControl(options) {
719
763
  if (!isAddressableSession(into)) {
720
764
  return invalid(`${JSON.stringify(into)} cannot be a session id — the control plane could not address it`);
721
765
  }
722
- const source = await sessions.openIfExists(from);
766
+ const source = yield* port(() => sessions.openIfExists(from));
723
767
  if (!source)
724
768
  return noSuchSession(from);
725
769
  // The entry predicate is the one `entries()` publishes by, so "everything published is forkable"
@@ -728,7 +772,7 @@ export function createPiSessionControl(options) {
728
772
  if (!entry || !isNavigable(entry)) {
729
773
  return invalid(`entry "${at}" is not a forkable position in session "${from}" — entries() lists the ids`);
730
774
  }
731
- const existingTarget = await sessions.openIfExists(into);
775
+ const existingTarget = yield* port(() => sessions.openIfExists(into));
732
776
  if (existingTarget) {
733
777
  // Already forked from HERE: the request already happened, so answering ok is the truth rather
734
778
  // than a convenience. Anything else under that id is a different history, and saying yes would
@@ -742,60 +786,33 @@ export function createPiSessionControl(options) {
742
786
  // `into`, or a second fork from a DIFFERENT source (whose source lease is another key entirely),
743
787
  // otherwise lands between that check and this write. tryAcquire never blocks, so taking two
744
788
  // cannot deadlock.
745
- const release = b.lease.tryAcquire(from);
746
- if (!release)
747
- return busy();
748
- const releaseInto = b.lease.tryAcquire(into);
749
- if (!releaseInto) {
750
- release();
751
- return busy();
752
- }
753
- try {
754
- // Holding the lease is not the same as having looked: re-asked under it, as the update path
755
- // re-opens its record, so an id taken inside the window is a payload error rather than a
756
- // store failure the client would read as retryable.
757
- const raced = await sessions.openIfExists(into);
758
- if (raced) {
759
- return forkProvenance(raced) === provenance
760
- ? { ok: true }
761
- : invalid(`session "${into}" already exists with a different history — fork mints nothing over it`);
762
- }
763
- await sessions.fork(from, at, into, provenance);
764
- }
765
- catch (error) {
766
- // Nothing durable landed: the copy is staged and published by rename.
767
- return failed(error);
768
- }
769
- finally {
770
- releaseInto();
771
- release();
789
+ yield* acquireSessionLease(b.lease, from);
790
+ yield* acquireSessionLease(b.lease, into);
791
+ // Recheck under both leases: another creator may have taken the target since validation.
792
+ const raced = yield* port(() => sessions.openIfExists(into));
793
+ if (raced) {
794
+ return forkProvenance(raced) === provenance
795
+ ? { ok: true }
796
+ : invalid(`session "${into}" already exists with a different history — fork mints nothing over it`);
772
797
  }
798
+ // portJoin: both leases are held for this write and must outlive it (effect-port.ts, rule 1).
799
+ yield* portJoin(() => sessions.fork(from, at, into, provenance));
773
800
  return { ok: true };
774
- };
775
- const deleteOf = async (session) => {
801
+ }));
802
+ const deleteOf = (session) => runBoundary(Effect.gen(function* () {
776
803
  const b = boundary;
777
804
  if (!b)
778
805
  return unsupported("delete()");
779
- const existing = await sessions.openIfExists(session);
806
+ const existing = yield* port(() => sessions.openIfExists(session));
780
807
  if (!existing)
781
808
  return noSuchSession(session);
782
- // The same lease as a run: a delete racing one would pull the record out from under it.
783
- const release = b.lease.tryAcquire(session);
784
- if (!release)
785
- return busy();
786
- try {
787
- // It was there before the lease and is gone now — the same real condition the check above
788
- // answers, so the same code.
789
- if (!(await sessions.delete(session)))
790
- return noSuchSession(session);
791
- }
792
- catch (error) {
793
- // A delete that throws left the record in place.
794
- return failed(error);
795
- }
796
- finally {
797
- release();
798
- }
809
+ const removed = yield* Effect.scoped(Effect.gen(function* () {
810
+ yield* acquireSessionLease(b.lease, session);
811
+ // portJoin: the lease must outlive the removal (effect-port.ts, rule 1).
812
+ return yield* portJoin(() => sessions.delete(session));
813
+ }));
814
+ if (!removed)
815
+ return noSuchSession(session);
799
816
  // The session is gone, so its live streams have nothing left to report: end them rather than
800
817
  // hold connections open on a record that no longer exists. A client's reconnect then reads an
801
818
  // empty `state()`, which is the truth.
@@ -803,7 +820,7 @@ export function createPiSessionControl(options) {
803
820
  sub.close();
804
821
  subscribers.delete(session);
805
822
  return { ok: true };
806
- };
823
+ }));
807
824
  const control = {
808
825
  capabilities: reads.capabilities,
809
826
  commands: reads.commands,