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