@fastagent-sh/fastagent 0.21.1 → 0.22.1

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 +233 -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 +166 -229
  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,36 +1,13 @@
1
- /**
2
- * Session persistence for the `AgentSession` L0 — open-or-create a durable record by the Caller's
3
- * opaque session id, on pi-coding-agent's `SessionManager` (the v3 jsonl every pi surface reads).
4
- */
1
+ /** Session persistence for the `AgentSession` L0. */
5
2
  import { existsSync, mkdirSync, readdirSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
3
  import { basename, dirname, join, resolve } from "node:path";
4
+ import { contentText } from "@earendil-works/pi-ai";
7
5
  import { SessionManager } from "@earendil-works/pi-coding-agent";
8
6
  import { log } from "../../log.js";
9
- import { LEAF_ANCHOR, publishedLeaf, stampProvenance } from "./session-markers.js";
7
+ import { LEAF_ANCHOR, isConversationMessage, publishedLeaf, stampProvenance } from "./session-markers.js";
10
8
  import { activePath } from "./session-settings.js";
11
9
  import { copyBranchForInheritance, copyBranchInto, inheritanceCut, } from "./session-inheritance.js";
12
- /**
13
- * A Caller's session id, as a name pi will accept.
14
- *
15
- * `SessionManager` enforces `^[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?$` — which no built-in
16
- * channel satisfies: telegram's `-1001234567890` leads with a dash, feishu and slack keys carry
17
- * `:` and `/`, and any custom `route()` may mint anything at all.
18
- *
19
- * The mapping is INJECTIVE, because two conversations resolving to one record is two rooms sharing a
20
- * memory. `s` prefix (the pattern demands an alphanumeric head, and prefixing unconditionally is what
21
- * keeps it injective — a conditional one would map `-a` and `s-a` alike), then each character
22
- * outside `[A-Za-z0-9.-]` becomes `_XX` / `_uXXXX`, self-describing widths so no two inputs can
23
- * produce one output. `_` escapes itself for the same reason. A trailing `.` or `-` is legal
24
- * mid-name but not at the end, so it escapes too.
25
- *
26
- * Injective within this encoding — which is only sufficient because this store's records live in
27
- * their own directory. A name this store did not write draws from the same character set and can
28
- * still decode: a file called `s42` is also this encoding of `42`, so one directory would make some
29
- * names ambiguous no matter how this side spells them.
30
- *
31
- * Readability is deliberate: `-1001234567890` becomes `s-1001234567890`, so an operator can still
32
- * tell which room a file belongs to.
33
- */
10
+ /** A Caller's session id, as a name pi will accept. */
34
11
  export function piSessionId(sessionId) {
35
12
  const hex = (c) => {
36
13
  const code = c.charCodeAt(0);
@@ -42,11 +19,8 @@ export function piSessionId(sessionId) {
42
19
  return `s${body}`;
43
20
  }
44
21
  /**
45
- * {@link piSessionId} backwards — what `list()` needs, because a session id belongs to the CALLER and
46
- * a record name is storage detail. The encoding is self-describing (fixed widths, `_` escapes
47
- * itself), so this is a decode rather than a guess; a name this store did not write (no `s` head, a
48
- * truncated escape) answers undefined and is left out of the listing rather than reported under a
49
- * name nobody can dial.
22
+ * {@link piSessionId} backwards — what `list()` needs, because a session id belongs to the CALLER and a record name is
23
+ * storage detail.
50
24
  */
51
25
  export function callerSessionId(recordId) {
52
26
  if (!recordId.startsWith("s"))
@@ -69,61 +43,27 @@ export function callerSessionId(recordId) {
69
43
  }
70
44
  return out;
71
45
  }
72
- /**
73
- * Disk-backed store under `dir`: restart the process, conversations continue.
74
- *
75
- * Lookup is a directory scan of THIS store's own directory, reading ids out of the filenames pi
76
- * writes (`<timestamp>_<id>.jsonl`) — not `SessionManager.list`, which filters by the cwd recorded in
77
- * each header and would make a renamed agent directory look like an empty store (see
78
- * {@link recordFiles}).
79
- *
80
- * Records live in a subdirectory of their own, because a name this store did not write can still
81
- * decode to a Caller id: a file called `s42` beside them is also this encoding of `42`, so a shared
82
- * directory would answer `42` with a record it does not own. Only this directory is scanned, which
83
- * makes this encoding's own injectivity sufficient — a record written before this store existed
84
- * lies outside it and is never read.
85
- *
86
- * SCOPE OF "open-or-create": idempotent against a store that is serialized per session, which is what
87
- * the serving path provides — the single-writer lease is taken before any store call, so no two
88
- * turns of one conversation reach this at once. What it does NOT do is arbitrate a FIRST open racing
89
- * across processes: two instances that scan before either writes will both create, and the
90
- * conversation forks into two records. A horizontally-scaled deployment that wants more owes a lease
91
- * that spans its instances — an in-process one cannot arbitrate between them, and a file lock here
92
- * would only look like it could.
93
- */
46
+ /** Disk-backed store under `dir`: restart the process, conversations continue. */
94
47
  export function piSessionRecordStore(options) {
95
48
  const cwd = options.cwd ?? process.cwd();
96
- // Resolved against the workspace this store serves, not against wherever the process happens to
97
- // have been started: a relative `dir` means "inside this agent", and a serving process may chdir.
49
+ // Resolved against the workspace this store serves, not against wherever the process happens to have been started.
98
50
  const root = resolve(cwd, options.dir);
99
51
  const own = join(root, OWN_RECORDS_DIR);
100
- /** Where a forked record is finished before it becomes discoverable. A SUBDIRECTORY of the store,
101
- * so `list()` (one level, `*.jsonl`) never sees a record that is still being prepared. */
52
+ /** Where a forked record is finished before it becomes discoverable. */
102
53
  const staging = join(own, ".staging");
103
- /** An empty record under `id`, ON DISK (see {@link materialize}) and not yet discoverable. Copying
104
- * into it appends immediately, so a crash leaves a partial record in staging rather than a
105
- * complete-looking one under the id. */
54
+ /** An empty record under `id`, ON DISK (see {@link materialize}) and not yet discoverable. */
106
55
  const stage = (id) => {
107
56
  mkdirSync(staging, { recursive: true });
108
57
  return materialize(SessionManager.create(cwd, staging, { id }), staging);
109
58
  };
110
- /** Fill a staged record and move it in — or leave nothing behind. The partial file is deleted on
111
- * the way out and the failure is rethrown untouched: `fork` turns it into a coded result and
112
- * inheritance falls back to an empty session, but neither can see `.staging`, which would
113
- * otherwise accumulate a file per failed attempt (ENOSPC being the realistic repeat offender) in
114
- * a directory nothing ever reads. */
59
+ /** Fill a staged record and move it in — or leave nothing behind. */
115
60
  const fillStaged = (id, fill) => {
116
61
  const staged = stage(id);
117
- // WHERE the record is, so cleanup deletes the file that exists rather than the path it used to
118
- // have: after the rename, removing the staging path succeeds against nothing while the record it
119
- // was supposed to undo sits in the store.
62
+ // WHERE the record is, so cleanup deletes the file that exists rather than the path it used to have.
120
63
  let published;
121
64
  try {
122
65
  fill(staged);
123
- // Publishing is a mkdir + rename, and both can fail (EACCES, a store root on another
124
- // filesystem) — a finished record stranded in staging is the same accumulation as a partial
125
- // one. NOT covered by a test: the tests reach a fill failure by making a copy throw, and there
126
- // is no equivalent injection point for rename without mocking node:fs for the whole file.
66
+ // Publishing is a mkdir + rename, and both can fail (EACCES, a store root on another filesystem).
127
67
  published = publishStaged(staged);
128
68
  return SessionManager.open(published, own);
129
69
  }
@@ -132,8 +72,10 @@ export function piSessionRecordStore(options) {
132
72
  throw error;
133
73
  }
134
74
  };
135
- /** Move a finished record into the store: one same-filesystem rename, so a reader sees the whole
136
- * thing or nothing at all. Answers WHERE it landed — the caller opens it, and can undo it. */
75
+ /**
76
+ * Move a finished record into the store: one same-filesystem rename, so a reader sees the whole thing or nothing at
77
+ * all.
78
+ */
137
79
  const publishStaged = (staged) => {
138
80
  const file = staged.getSessionFile();
139
81
  if (!file)
@@ -143,8 +85,7 @@ export function piSessionRecordStore(options) {
143
85
  renameSync(file, target);
144
86
  return target;
145
87
  };
146
- /** Fork the named parent into `id`, or answer undefined so the caller starts empty. Every failure
147
- * is a warn: a thread must not lose its first turn to an inheritance edge. */
88
+ /** Fork the named parent into `id`, or answer undefined so the caller starts empty. */
148
89
  const inheritInto = async (sessionId, inherit) => {
149
90
  const found = locate(inherit.parentSession);
150
91
  if (!found) {
@@ -152,9 +93,7 @@ export function piSessionRecordStore(options) {
152
93
  return undefined;
153
94
  }
154
95
  try {
155
- // A parent that crashed mid tool-execution would otherwise pass its dangling tool_use down
156
- // to the child, whose very first request the provider then rejects.
157
- const parent = reconcileInterruptedToolCalls(SessionManager.open(found.path, found.dir));
96
+ const parent = SessionManager.open(found.path, found.dir);
158
97
  const cut = inheritanceCut(parent, inherit.branchHints);
159
98
  if (!cut)
160
99
  return undefined;
@@ -166,17 +105,20 @@ export function piSessionRecordStore(options) {
166
105
  return undefined;
167
106
  }
168
107
  };
169
- /** The unreadable records the last listing reported, so a polled endpoint states the condition
170
- * once rather than once a second. */
108
+ /**
109
+ * The unreadable records the last listing reported, so a polled endpoint states the condition once rather than once
110
+ * a second.
111
+ */
171
112
  let lastUnreadable = "";
172
- /** WHERE a session's record is — the one lookup every caller shares, so a fix to it (this store's
173
- * own directory rather than pi's cwd-filtered listing) cannot reach three of the four. */
113
+ /**
114
+ * WHERE a session's record is — the one lookup every caller shares, so a fix to it (this store's own directory
115
+ * rather than pi's cwd-filtered listing) cannot reach three of the four.
116
+ */
174
117
  const locate = (sessionId) => {
175
- const mine = recordFiles(own).find((f) => f.id === piSessionId(sessionId));
176
- return mine ? { path: mine.path, dir: own } : undefined;
118
+ const path = recordPathIn(own, sessionId);
119
+ return path ? { path, dir: own } : undefined;
177
120
  };
178
- /** Open an existing record, or undefined. A closure rather than a method call, so `fork` cannot be
179
- * broken by a caller that spreads this object into another one. */
121
+ /** Open an existing record, or undefined. */
180
122
  const openExisting = async (sessionId) => {
181
123
  const found = locate(sessionId);
182
124
  return found ? SessionManager.open(found.path, found.dir) : undefined;
@@ -185,10 +127,10 @@ export function piSessionRecordStore(options) {
185
127
  async openOrCreate(sessionId, inherit) {
186
128
  const found = locate(sessionId);
187
129
  if (found)
188
- return reconcileInterruptedToolCalls(SessionManager.open(found.path, found.dir));
130
+ return SessionManager.open(found.path, found.dir);
189
131
  mkdirSync(own, { recursive: true });
190
- // Inheritance is a CREATE-path decision: an existing session above ignores it entirely, which
191
- // is what makes it one-time by construction.
132
+ // Inheritance is a CREATE-path decision: an existing session above ignores it entirely, which is what makes it
133
+ // one-time by construction.
192
134
  if (inherit) {
193
135
  const inherited = await inheritInto(sessionId, inherit);
194
136
  if (inherited)
@@ -204,8 +146,8 @@ export function piSessionRecordStore(options) {
204
146
  const rows = [];
205
147
  const unreadable = [];
206
148
  for (const file of files) {
207
- // A name this store did not write cannot be decoded back to a Caller id, and a row nobody
208
- // can dial is worse than a row that is missing.
149
+ // A name this store did not write cannot be decoded back to a Caller id, and a row nobody can dial is worse
150
+ // than a row that is missing.
209
151
  const session = callerSessionId(file.id);
210
152
  if (!session)
211
153
  continue;
@@ -213,26 +155,17 @@ export function piSessionRecordStore(options) {
213
155
  rows.push(summarize(session, SessionManager.open(file.path, own)));
214
156
  }
215
157
  catch (error) {
216
- // ONE unreadable record must not take the listing down — the other conversations are fine,
217
- // and a GUI that shows nothing is worse than one missing a row. But it must not vanish
218
- // silently either: that is the same conflation `sessions_unavailable` prevents one level
219
- // up, just per record. Named, with its reason, every time it is polled: a record that
220
- // cannot be read is a condition someone has to act on.
158
+ // ONE unreadable record must not take the listing down — the other conversations are fine, and a GUI that
159
+ // shows nothing is worse than one missing a row.
221
160
  unreadable.push(`${basename(file.path)} (${String(error)})`);
222
161
  }
223
- // A TURN for everything else. Reading a record is synchronous (pi parses the whole file and
224
- // builds its index), and this endpoint is POLLED — so without a yield the process stops for
225
- // the length of the whole listing, once a second, while SSE heartbeats and in-flight turns
226
- // wait. pi's own listing streamed and interleaved; this reads faster and must not take that
227
- // away.
162
+ // A TURN for everything else.
228
163
  await new Promise((resolve) => setImmediate(resolve));
229
164
  }
230
- // NEWEST ACTIVITY first. The filenames sort by CREATION, so an old conversation that just
231
- // received a message would otherwise sit below a newer idle one — the wrong answer for the
232
- // column a conversation list is read by.
165
+ // NEWEST ACTIVITY first.
233
166
  rows.sort((a, b) => b.updatedAt - a.updatedAt);
234
- // Said when the SET changes, not on every poll: this endpoint is polled once a second or so,
235
- // and an unreadable record stays unreadable until someone acts on it.
167
+ // Said when the SET changes, not on every poll: this endpoint is polled once a second or so, and an unreadable
168
+ // record stays unreadable until someone acts on it.
236
169
  const reported = unreadable.join("; ");
237
170
  if (reported !== lastUnreadable) {
238
171
  lastUnreadable = reported;
@@ -246,25 +179,18 @@ export function piSessionRecordStore(options) {
246
179
  const parent = await openExisting(from);
247
180
  if (!parent)
248
181
  throw new Error(`session "${from}" has no record`);
249
- // The port promises a NEW record, so the guarantee belongs here rather than in the one caller
250
- // that happens to check: two records under one id makes which one a lookup finds a matter of
251
- // directory order.
182
+ // The port promises a NEW record, so the guarantee belongs here rather than in the one caller that happens to
183
+ // check.
252
184
  if (await openExisting(into))
253
185
  throw new Error(`session "${into}" already exists`);
254
186
  fillStaged(piSessionId(into), (staged) => {
255
- // METADATA FIRST, history last. pi has one leaf pointer and every append advances it, so
256
- // whatever is written last becomes the fork's `leafEntryId` — and a client opening a fresh
257
- // fork would find its head on an empty `custom` record instead of on the exchange it forked
258
- // at. The name and the provenance describe the record; the history is what the session IS.
187
+ // METADATA FIRST, history last. pi has one leaf pointer and every append advances it, so whatever is written
188
+ // last becomes the fork's `leafEntryId`.
259
189
  const name = parent.getSessionName();
260
- // The name travels: a fork of "Deploy notes" that lists as untitled is a row a user cannot
261
- // place. A client that wants "(copy)" calls `update({ name })`.
190
+ // The name travels: a fork of "Deploy notes" that lists as untitled is a row a user cannot place.
262
191
  if (name)
263
192
  staged.appendSessionInfo(name);
264
193
  stampProvenance(staged, provenance);
265
- // NOT reconciled: the repair appends at the parent's LEAF, which a copy stopping at `at` can
266
- // never reach — it would only write to the record being copied FROM. The child is reconciled
267
- // on its own first open, like every other record.
268
194
  copyBranchInto(parent, staged, at);
269
195
  });
270
196
  },
@@ -272,23 +198,14 @@ export function piSessionRecordStore(options) {
272
198
  const found = locate(sessionId);
273
199
  if (!found)
274
200
  return false;
275
- // A record that cannot be deleted must not report success — the caller turns the throw into a
276
- // coded failure, and the session is still there for the next attempt.
201
+ // A record that cannot be deleted must not report success — the caller turns the throw into a coded failure,
202
+ // and the session is still there for the next attempt.
277
203
  rmSync(found.path);
278
204
  return true;
279
205
  },
280
206
  };
281
207
  }
282
- /**
283
- * The property write, for both backends: the ONE place that knows how pi records a property.
284
- *
285
- * ORDER IS THE POINT. pi has a single leaf pointer and every append advances it, so the leaf ends up
286
- * wherever the last write landed — which is why the move goes FIRST (the properties below it must
287
- * hang off the branch the caller asked for, not the one it left) and why a fork writes its metadata
288
- * before its history (session-store's `fork`). The handle is opened HERE, inside the caller's lease:
289
- * one taken earlier could be a snapshot of a run that finished in the window, and appending to it
290
- * would hang the property off a stale leaf.
291
- */
208
+ /** The property write, for both backends: the ONE place that knows how pi records a property. */
292
209
  async function applyProperties(open, writes) {
293
210
  const record = await open();
294
211
  if (!record)
@@ -298,9 +215,8 @@ async function applyProperties(open, writes) {
298
215
  let failure;
299
216
  try {
300
217
  if (writes.leafEntryId !== undefined) {
301
- // A move to where the head ALREADY is asks for nothing: a client retry, or a UI firing on
302
- // every selection, must not grow the session. Compared against the PUBLISHED head, because
303
- // pi's leaf may be the anchor a previous move left there.
218
+ // A move to where the head ALREADY is asks for nothing: a client retry, or a UI firing on every selection, must
219
+ // not grow the session.
304
220
  if (publishedLeaf(record) !== writes.leafEntryId) {
305
221
  record.branch(writes.leafEntryId);
306
222
  moved = true;
@@ -321,41 +237,34 @@ async function applyProperties(open, writes) {
321
237
  }
322
238
  }
323
239
  catch (error) {
324
- // Held, not rethrown: what already landed still has to be reported, and the report is the only
325
- // way a caller learns the record moved.
240
+ // Held, not rethrown: what already landed still has to be reported, and the report is the only way a caller
241
+ // learns the record moved.
326
242
  failure = error;
327
243
  }
328
- // THE ANCHOR, outside the try so a failed property write cannot skip it — that is precisely the
329
- // case reporting a MOVE as landed. pi's `branch()` writes nothing (the leaf is runtime state, and
330
- // `open()` puts it back on the file's last entry), so a move nothing followed is forgotten the
331
- // moment anything reopens the record. Appending anything pins it; this appends only when nothing
332
- // else in the patch already did.
244
+ // THE ANCHOR, outside the try so a failed property write cannot skip it.
333
245
  if (moved && record.getLeafId() === writes.leafEntryId) {
334
246
  try {
335
247
  record.appendCustomEntry(LEAF_ANCHOR, {});
336
248
  }
337
249
  catch (error) {
338
- // The move is runtime-only and will not survive the next open, so it did NOT land: saying it
339
- // did is the one thing a partial report cannot afford. A first failure keeps its place — it is
340
- // what stopped the patch — and this one is reported when nothing else went wrong.
250
+ // The move is runtime-only and will not survive the next open, so it did NOT land: saying it did is the one
251
+ // thing a partial report cannot afford.
341
252
  landed.splice(landed.indexOf("leafEntryId"), 1);
342
253
  failure ??= error;
343
254
  }
344
255
  }
345
- // READ BACK, never echo: pi rewrites a name (newlines collapse, ends trim), and the settings a
346
- // path resolves to can change under a moved leaf. The caller reports THIS, so `state()` and
347
- // `list()` cannot disagree with what it said.
256
+ // READ BACK, never echo: pi rewrites a name (newlines collapse, ends trim), and the settings a path resolves to can
257
+ // change under a moved leaf.
348
258
  const name = record.getSessionName();
349
- // The PUBLISHED head, not pi's: the anchor above is ours, and a client must be told the position
350
- // it asked for — the one it can find in `entries()`.
259
+ // The PUBLISHED head, not pi's: the anchor above is ours, and a client must be told the position it asked for — the
260
+ // one it can find in `entries()`.
351
261
  const leafEntryId = publishedLeaf(record);
352
262
  let path;
353
263
  try {
354
264
  path = activePath(record);
355
265
  }
356
266
  catch (error) {
357
- // An unreadable chain is the caller's decision (design §7), not a silent short path. The writes
358
- // are already durable either way.
267
+ // An unreadable chain is the caller's decision (design §7), not a silent short path.
359
268
  log.warn(`[fastagent] session ${record.getSessionId()}: written, active path unreadable: ${String(error)}`);
360
269
  }
361
270
  return {
@@ -367,19 +276,13 @@ async function applyProperties(open, writes) {
367
276
  };
368
277
  }
369
278
  /**
370
- * The record files in a directory, newest first — pi names them `<ISO timestamp>_<id>.jsonl`, so the
371
- * name sorts by time and carries the id without opening anything.
372
- *
373
- * OUR readdir, not pi's `SessionManager.list`, and the difference is the reason this exists: that one
374
- * filters by the cwd recorded in each header — right for a TUI showing "this project's sessions",
375
- * wrong for a repository, where renaming the agent directory made every conversation vanish from the
376
- * listing AND from lookup, so the next turn started an empty session on top of the old one. It also
377
- * swallows per-file faults, which a listing needs to see.
378
- *
379
- * ENOENT is the one condition that is not a fault: a store nobody has written to holds no records.
380
- * Anything else — an unreadable ancestor, a file where the directory should be — travels with its
381
- * code, because "this deployment has no conversations" is not an answer for a store we cannot read.
279
+ * The record files in a directory, newest first — pi names them `<ISO timestamp>_<id>.jsonl`, so the name sorts by
280
+ * time and carries the id without opening anything.
382
281
  */
282
+ /** WHERE a session's record file is, WITHOUT opening it. */
283
+ function recordPathIn(own, sessionId) {
284
+ return recordFiles(own).find((f) => f.id === piSessionId(sessionId))?.path;
285
+ }
383
286
  function recordFiles(dir) {
384
287
  let names;
385
288
  try {
@@ -390,29 +293,25 @@ function recordFiles(dir) {
390
293
  throw error;
391
294
  return [];
392
295
  }
393
- // UNORDERED. Lookup does not care, and `list()` sorts its rows by activity — a filename order kept
394
- // here would be one nobody consumes and the next reader has to prove is unused.
296
+ // UNORDERED.
395
297
  return names
396
298
  .filter((name) => name.endsWith(RECORD_SUFFIX))
397
299
  .flatMap((name) => {
398
- // The FIRST underscore: the timestamp holds none, and an encoded id may hold several
399
- // (`piSessionId` escapes with `_`).
300
+ // The FIRST underscore: the timestamp holds none, and an encoded id may hold several (`piSessionId` escapes
301
+ // with `_`).
400
302
  const cut = name.indexOf("_");
401
303
  return cut < 0 ? [] : [{ path: join(dir, name), id: name.slice(cut + 1, -RECORD_SUFFIX.length) }];
402
304
  });
403
305
  }
404
- /** One record as a conversation-list row. Read out of the record itself rather than from pi's
405
- * listing, which is not asked for one — so the fields mean exactly what they say: no sentinel
406
- * standing in for "no first message", and `updatedAt` floored at the record's own creation (a
407
- * fork's entries carry the SOURCE's timestamps, so a branch made today would otherwise sort into
408
- * whenever the original was written — the one column a conversation list orders by). */
306
+ /** One record as a conversation-list row. */
409
307
  function summarize(session, record) {
410
308
  const entries = record.getEntries();
411
309
  const createdAt = Date.parse(record.getHeader()?.timestamp ?? "") || 0;
412
- // `|| 0` on both: an unparseable timestamp is NaN, and NaN in `updatedAt` serializes to `null` —
413
- // which the contract types as a number and a client sorts by.
310
+ // `|| 0` on both: an unparseable timestamp is NaN, and NaN in `updatedAt` serializes to `null` — which the contract
311
+ // types as a number and a client sorts by.
414
312
  const lastAt = Date.parse(entries.at(-1)?.timestamp ?? "") || 0;
415
- const messages = entries.filter((e) => e.type === "message");
313
+ // A list row counts turns in the conversation, not the engine's system bookkeeping (`isConversationMessage`).
314
+ const messages = entries.filter(isConversationMessage);
416
315
  const name = record.getSessionName();
417
316
  const preview = firstUserText(messages);
418
317
  return {
@@ -424,26 +323,20 @@ function summarize(session, record) {
424
323
  ...(preview ? { preview } : {}),
425
324
  };
426
325
  }
427
- /** The first user message with text in it, truncated by CODE POINT (a cut through a surrogate pair
428
- * would put a lone half in the row, which renders as U+FFFD). A session opened with a caption-less
429
- * photo has none, and answers undefined — a row without a preview, not a row claiming one. */
326
+ /**
327
+ * The first user message with text in it, truncated by CODE POINT (a cut through a surrogate pair would put a lone
328
+ * half in the row, which renders as U+FFFD).
329
+ */
430
330
  function firstUserText(messages) {
431
331
  for (const entry of messages) {
432
332
  const message = entry.message;
433
333
  if (message?.role !== "user")
434
334
  continue;
435
- const content = message.content;
436
- const text = typeof content === "string"
437
- ? content
438
- : Array.isArray(content)
439
- ? content
440
- .filter((block) => block.type === "text")
441
- .map((block) => block.text ?? "")
442
- .join(" ")
443
- : "";
444
- // Cut to UTF-16 units FIRST: a pasted megabyte would otherwise become a million-element array
445
- // on the way to keeping 200 of them. Two units per code point is the ceiling, so this cannot
446
- // take fewer characters than the slice below wants.
335
+ // The engine's own reading of message text, on a SPACE: this is a one-line preview, where adjacent
336
+ // blocks running together would read as one word.
337
+ const text = contentText(message.content, " ");
338
+ // Cut to UTF-16 units FIRST: a pasted megabyte would otherwise become a million-element array on the way to
339
+ // keeping 200 of them.
447
340
  if (text.trim())
448
341
  return [...text.slice(0, PREVIEW_CHARS * 2)].slice(0, PREVIEW_CHARS).join("");
449
342
  }
@@ -451,103 +344,11 @@ function firstUserText(messages) {
451
344
  }
452
345
  /** What pi names a record file. */
453
346
  const RECORD_SUFFIX = ".jsonl";
454
- /** How much of the first message a list row carries. A row, not a transcript. */
347
+ /** How much of the first message a list row carries. */
455
348
  const PREVIEW_CHARS = 200;
456
- /**
457
- * Crash-safety reconciliation, run on every OPEN of an existing record.
458
- *
459
- * A turn that dies mid tool-execution leaves an assistant `tool_use` with no matching result (the
460
- * assistant message is persisted before the tool runs). The next turn would then hand the provider
461
- * an `assistant(tool_use) -> user` sequence that Anthropic and OpenAI reject — the session is
462
- * poisoned. An honest "interrupted" error result is appended for each dangling call, restoring a
463
- * valid transcript. Tool side-effect idempotency stays the tool's responsibility (SPEC §6); this
464
- * restores transcript validity, not exactly-once execution.
465
- *
466
- * Pairing is TURN-LOCAL: a tool_use is paired only by a toolResult that immediately follows it (up
467
- * to the next non-toolResult). Tool-call ids are not unique across turns — a local model may restart
468
- * them each response — so matching against the whole transcript could falsely settle a leaf call
469
- * against an earlier turn's identical id. An append-only log can only repair a gap AT THE LEAF; an
470
- * earlier one is surfaced via log.warn rather than "fixed" with an orphaned result.
471
- *
472
- * The synthetic result splits its audiences: `content` (read by the model, may reach the end user)
473
- * stays neutral — it must NOT say "aborted" (pi's word for a user cancellation) or leak infra
474
- * detail; `details` carries the operational marker for developers and never reaches the provider.
475
- */
476
- function reconcileInterruptedToolCalls(record) {
477
- const messages = record.getBranch().flatMap((entry) => {
478
- const message = entry.message;
479
- return entry.type === "message" && message ? [message] : [];
480
- });
481
- let leafIdx = -1;
482
- for (let i = messages.length - 1; i >= 0; i--) {
483
- if (messages[i]?.role === "assistant") {
484
- leafIdx = i;
485
- break;
486
- }
487
- }
488
- if (leafIdx === -1)
489
- return record; // no assistant turn yet
490
- const leafReparable = messages.slice(leafIdx + 1).every((m) => m.role === "toolResult");
491
- const toRepair = [];
492
- const orphaned = [];
493
- messages.forEach((m, idx) => {
494
- if (m.role !== "assistant")
495
- return;
496
- const paired = new Set();
497
- for (let j = idx + 1; j < messages.length; j++) {
498
- const next = messages[j];
499
- if (next?.role !== "toolResult")
500
- break;
501
- paired.add(next.toolCallId);
502
- }
503
- for (const block of m.content) {
504
- if (block.type !== "toolCall" || paired.has(block.id))
505
- continue;
506
- if (idx === leafIdx && leafReparable)
507
- toRepair.push({ id: block.id, name: block.name });
508
- else
509
- orphaned.push(block.id);
510
- }
511
- });
512
- if (orphaned.length > 0) {
513
- log.warn(`[fastagent] unmatched tool_use is not at the session leaf; leaving it unreconciled ` +
514
- `(an append-only log cannot repair a mid-history gap): toolCallIds=${orphaned.join(",")}`);
515
- }
516
- for (const { id, name } of toRepair) {
517
- record.appendMessage({
518
- role: "toolResult",
519
- toolCallId: id,
520
- toolName: name,
521
- content: [
522
- {
523
- type: "text",
524
- text: "This tool call did not complete and its result is unavailable. Re-run it if the result is still needed.",
525
- },
526
- ],
527
- details: { fastagent: "interrupted-tool-call" },
528
- isError: true,
529
- timestamp: Date.now(),
530
- });
531
- }
532
- return record;
533
- }
534
349
  /** Where the records live, under the sessions directory the store is pointed at. */
535
350
  const OWN_RECORDS_DIR = "agent-session";
536
- /**
537
- * Make a NEW record exist on disk before anyone can act on it.
538
- *
539
- * `SessionManager` buffers a new session in memory and writes nothing until the first ASSISTANT
540
- * message arrives (`_persist` returns early while no assistant entry exists). Two consequences, and
541
- * the second is why this cannot be left to the engine:
542
- *
543
- * - a crash between "the user asked" and "the model answered" loses the question, while the record
544
- * pi-agent-core's storage wrote it immediately (conformance-levels.md §5 named this gap);
545
- * - **open-or-create stops being idempotent**: the second call cannot find the first call's record,
546
- * so one conversation forks into two files, each with half the history.
547
- *
548
- * Writing pi's OWN header (`getHeader()`, not a hand-built literal) and reopening puts the manager
549
- * on its normal "file exists" path, where every append lands immediately.
550
- */
351
+ /** Make a NEW record exist on disk before anyone can act on it. */
551
352
  function materialize(session, dir) {
552
353
  const file = session.getSessionFile();
553
354
  if (!file || existsSync(file))
@@ -565,19 +366,14 @@ export function piInMemorySessionRecordStore(options = {}) {
565
366
  const live = new Map();
566
367
  return {
567
368
  async openOrCreate(sessionId, inherit) {
568
- // Keyed by the CALLER's id: the encoding exists to satisfy pi's filename rule, and in memory
569
- // there are no filenames — two rooms whose encodings collide must still not share a map slot.
369
+ // Keyed by the CALLER's id: the encoding exists to satisfy pi's filename rule, and in memory there are no
370
+ // filenames.
570
371
  const existing = live.get(sessionId);
571
372
  if (existing)
572
- return reconcileInterruptedToolCalls(existing);
373
+ return existing;
573
374
  const fresh = () => SessionManager.inMemory(cwd, { id: piSessionId(sessionId) });
574
- // Same semantics as the durable store, different mechanism: with no file to fork, the parent's
575
- // path is copied entry by entry. Inheritance is a property of the CONTRACT, not of the medium —
576
- // a caller must not get a thread that forgot its room because the store is in memory.
577
- //
578
- // And the same atomicity: the session is REGISTERED only once it is complete. Registering
579
- // first and copying after would leave a half-inherited thread in place on any failure, while
580
- // the log claimed it started empty — the disk path stages a fork for exactly this reason.
375
+ // Same semantics as the durable store, different mechanism: with no file to fork, the parent's path is copied
376
+ // entry by entry.
581
377
  const parent = inherit ? live.get(inherit.parentSession) : undefined;
582
378
  if (inherit && !parent) {
583
379
  log.warn(`[fastagent] session "${sessionId}" names parent "${inherit.parentSession}", which has no record — starting empty`);
@@ -586,7 +382,7 @@ export function piInMemorySessionRecordStore(options = {}) {
586
382
  if (inherit && parent) {
587
383
  try {
588
384
  const staged = fresh();
589
- const cut = inheritanceCut(reconcileInterruptedToolCalls(parent), inherit.branchHints);
385
+ const cut = inheritanceCut(parent, inherit.branchHints);
590
386
  if (cut)
591
387
  copyBranchForInheritance(parent, staged, cut.at);
592
388
  created = staged;
@@ -607,29 +403,19 @@ export function piInMemorySessionRecordStore(options = {}) {
607
403
  },
608
404
  applyProperties: (sessionId, writes) => applyProperties(async () => live.get(sessionId), writes),
609
405
  async list() {
610
- // The SAME row builder the disk store uses, ordered the same way: a backend difference here is
611
- // one an embedder discovers as a missing field (this one had no `preview` for exactly that
612
- // reason). Nothing to read from disk, so nothing can be unreadable.
406
+ // The SAME row builder the disk store uses, ordered the same way.
613
407
  return [...live].map(([session, record]) => summarize(session, record)).sort((a, b) => b.updatedAt - a.updatedAt);
614
408
  },
615
409
  async fork(from, at, into, provenance) {
616
410
  const parent = live.get(from);
617
411
  if (!parent)
618
412
  throw new Error(`session "${from}" has no record`);
619
- // The port's promise, not the caller's: registering over a live session would replace its
620
- // history outright.
413
+ // The port's promise, not the caller's: registering over a live session would replace its history outright.
621
414
  if (live.has(into))
622
415
  throw new Error(`session "${into}" already exists`);
623
- // Entry-by-entry, like the in-memory inheritance path: there is no file to fork. Registered
624
- // only once complete, so a failure leaves no half-copied session behind. NO inheritance
625
- // window: this is the same user keeping their own history, not a new thread bounded from a
626
- // parent's.
416
+ // Entry-by-entry, like the in-memory inheritance path: there is no file to fork.
627
417
  const staged = SessionManager.inMemory(cwd, { id: piSessionId(into) });
628
- // METADATA FIRST, history last — the same order the disk store writes in, and for the same
629
- // reason: pi has one leaf pointer, so whatever is appended last is where a client opening this
630
- // fork finds its head. That should be the exchange it was forked at, not a metadata record.
631
- // The name travels so a fork of "Deploy notes" is not an untitled row a user cannot place; a
632
- // client that wants "(copy)" calls `update({ name })`.
418
+ // METADATA FIRST, history last — the same order the disk store writes in, and for the same reason.
633
419
  const name = parent.getSessionName();
634
420
  if (name)
635
421
  staged.appendSessionInfo(name);