@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
@@ -1,25 +1,25 @@
1
1
  /**
2
- * The pi implementation of the session control plane: observation (`state`/`entries`/`events`,
3
- * design Phase 1) and run modulation (`dispatch`: steer/follow_up/abort, Phase 2a) over
4
- * invoke-driven runs. `createPiSessionControl` returns the neutral `SessionControl` plus the
5
- * {@link SessionObserver} to plug into the invoke pipeline (`createPiAgentFromHarness({ observer })`)
6
- * — the hub derives everything from the rich event stream (plus the {@link RunControls} the
7
- * run_started event carries), holds no durable state of its own, and never writes: durable truth
8
- * stays in the session repository (read via {@link PiSessionReader}), live truth in the events the
9
- * data plane emits, modulation in the controls the data plane registers.
2
+ * The pi implementation of the session control plane. `createPiSessionControl` returns the neutral
3
+ * `SessionControl` plus the {@link SessionObserver} to plug into the invoke pipeline
4
+ * (`createPiAgent({ observer })`).
10
5
  *
11
- * Boundary mutations (Phase 2b: compact/set_model/set_thinking/navigate) take the same lease as runs;
12
- * without boundary wiring they are rejected before acceptance with `unsupported_capability` a
13
- * client gating on `capabilities()` never sends them.
6
+ * It holds no durable state of its own: live truth comes from the event stream (plus the
7
+ * {@link RunControls} a `run_started` carries), durable truth from {@link PiSessionRecordStore}
8
+ * which is also what performs every write, so how a record takes a property is not knowledge this
9
+ * file has. What it owns is the vocabulary: capability gating, the lease, error codes, and the
10
+ * events its own writes emit.
11
+ *
12
+ * Writes take the same lease as runs. Without boundary wiring they reject before acceptance with
13
+ * `unsupported_capability` — a client gating on `capabilities()` never sends them.
14
14
  */
15
- import { DEFAULT_COMPACTION_SETTINGS, compact, prepareCompaction } from "@earendil-works/pi-agent-core";
15
+ import { prepareCompaction } from "@earendil-works/pi-agent-core";
16
+ import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
16
17
  import { SESSION_BUSY_CODE } from "../../agent.js";
17
- import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
18
+ import { BOUNDARY_COMMAND_FAILED_CODE, INVALID_COMMAND_CODE, isAddressableSession, NO_ACTIVE_RUN_CODE, NOTHING_TO_COMPACT_CODE, NO_SUCH_SESSION_CODE, PARTIAL_UPDATE_CODE, UPDATE_FIELDS, RUN_COMMAND_FAILED_CODE, UNSUPPORTED_CAPABILITY_CODE, } from "../../session.js";
18
19
  import { listModels } from "./config.js";
19
- import { SUMMARIZATION_RETRY_POLICY, harnessSession } from "./harness.js";
20
- import { THINKING_LEVELS, resolveSessionSettings } from "./session-settings.js";
20
+ import { forkProvenance, isNavigable, publishedLeaf } from "./session-markers.js";
21
+ import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
21
22
  import { log } from "../../log.js";
22
- import { activePathEntries } from "./sessions.js";
23
23
  // ── Entry normalization (durable plane) ──────────────────────────────────────
24
24
  /** Concatenated plain text of a message's content blocks (the L0 rendering payload). A custom
25
25
  * AgentMessage role may carry no `content` at all — that reads as empty, not a crash. */
@@ -34,15 +34,15 @@ function textOf(content) {
34
34
  .join("");
35
35
  }
36
36
  /**
37
- * pi `SessionTreeEntry` → neutral {@link SessionEntry}. Message entries map onto the guaranteed
37
+ * pi `PiSessionEntry` → neutral {@link SessionEntry}. Message entries map onto the guaranteed
38
38
  * kind vocabulary (user/assistant/tool) with a minimal render payload; every other engine record
39
39
  * keeps its pi type as an open-set kind with an EMPTY payload — present so `parentId` chains and
40
40
  * cursors stay intact, skippable by contract, and no pi message class leaks through the adapter.
41
41
  */
42
- function toSessionEntry(entry) {
42
+ function toSessionEntry(entry, parentId) {
43
43
  const base = {
44
44
  id: entry.id,
45
- parentId: entry.parentId ?? undefined,
45
+ parentId,
46
46
  timestamp: Date.parse(entry.timestamp),
47
47
  };
48
48
  if (entry.type === "message") {
@@ -75,16 +75,6 @@ function toSessionEntry(entry) {
75
75
  }
76
76
  return { ...base, kind: entry.type, data: {} };
77
77
  }
78
- /**
79
- * THE invariant the client's rule rests on: everything `entries()` publishes is a legal `navigate`
80
- * target. pi's `leaf` records are the exception — they journal a MOVE rather than mark a position
81
- * (their parentId is the OLD leaf, nothing is ever chained onto them), so navigating to one would
82
- * put the branch head off every conversation path. Withheld from the published plane and refused as
83
- * a target THROUGH THIS ONE PREDICATE, so a second exclusion cannot make the two disagree.
84
- */
85
- function isNavigable(entry) {
86
- return entry.type !== "leaf";
87
- }
88
78
  // ── Live fan-out (events plane) ──────────────────────────────────────────────
89
79
  /** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
90
80
  * connection — the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
@@ -199,7 +189,8 @@ export function createPiSessionControl(options) {
199
189
  }
200
190
  fanOut(session, event);
201
191
  };
202
- const control = {
192
+ // The reads, plus the two sessionless declarations. Bound onto a handle below.
193
+ const reads = {
203
194
  async commands() {
204
195
  return (await options.commands?.()) ?? [];
205
196
  },
@@ -208,14 +199,19 @@ export function createPiSessionControl(options) {
208
199
  return {
209
200
  steering: true,
210
201
  followUp: true,
211
- manualCompaction: !!b,
212
- modelSelection: b ? { allowedModels: listModels(b.models) } : false,
213
- // Servable or not. WHICH levels is a property of the session's model, so it rides
202
+ compaction: !!b,
203
+ // Every write a property, a copied record, a removed one — needs the boundary wiring for
204
+ // its LEASE, so they answer the same question: a write that races a run would hang the next
205
+ // turn off a stale branch, or pull the record out from under it.
206
+ fork: !!b,
207
+ delete: !!b,
208
+ // The CONTRACT's list, not a copy of it: a field added to SessionUpdate is advertised
209
+ // without anyone remembering to, and one removed cannot linger here.
210
+ updatable: b ? [...UPDATE_FIELDS] : [],
211
+ // The registry is a deployment fact (any session may be pointed at any of it). Thinking
212
+ // LEVELS are a property of the model a session is running, so they ride
214
213
  // `state().availableThinkingLevels` — a list here could only answer for one model.
215
- thinkingLevel: !!b,
216
- // Gated on the boundary wiring for its LEASE, not its models: moving the leaf is a write,
217
- // and a write that races a run would hang the next turn off a stale branch.
218
- navigate: !!b,
214
+ ...(b ? { allowedModels: listModels(b.models) } : {}),
219
215
  toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
220
216
  usage: false,
221
217
  };
@@ -223,26 +219,28 @@ export function createPiSessionControl(options) {
223
219
  async state(session) {
224
220
  const run = active.get(session);
225
221
  const opened = await sessions.openIfExists(session);
226
- const leafEntryId = opened ? ((await opened.getLeafId()) ?? undefined) : undefined;
222
+ const leafEntryId = opened ? publishedLeaf(opened) : undefined;
227
223
  // What will RUN, not the raw record: a client steering a session needs the pair that executes.
228
224
  // Without a boundary there is no model to resolve against, and the fields are absent.
229
225
  // OBSERVATION IS TOTAL: an unreadable entry chain leaves the pair absent too (the same shape a
230
226
  // control-less deployment answers with) rather than rejecting a read that has no error-code
231
227
  // channel to explain itself. The fault is not swallowed — it surfaces where codes exist: the
232
- // next invoke fails (the harness build walks the same chain) and a boundary dispatch answers
228
+ // next invoke fails (binding a session walks the same chain) and a boundary dispatch answers
233
229
  // `boundary_command_failed`. Here it is a server-side warn.
234
230
  const b = boundary?.();
235
231
  let settings;
236
232
  if (opened && b) {
237
233
  try {
238
- settings = resolveSessionSettings((await activePathEntries(opened)), b.models, b.defaults);
234
+ settings = resolveSessionSettings(activePath(opened), b.models, b.defaults);
239
235
  }
240
236
  catch (error) {
241
237
  log.warn(`[fastagent] session ${session}: settings unreadable (entry chain): ${String(error)}`);
242
238
  }
243
239
  }
240
+ const name = opened?.getSessionName();
244
241
  return {
245
242
  status: run ? "running" : compacting.has(session) ? "compacting" : "idle",
243
+ ...(name ? { name } : {}),
246
244
  ...(run ? { activeRunId: run.runId } : {}),
247
245
  ...(settings
248
246
  ? {
@@ -263,8 +261,28 @@ export function createPiSessionControl(options) {
263
261
  // would race any concurrent append into a leaf the snapshot cannot contain — a live turn
264
262
  // reading as a dangling head. This order makes the journal a superset of the leaf's chain,
265
263
  // which is what lets the published head be trusted as one of the published entries.
266
- const leafEntryId = (await opened.getLeafId()) ?? undefined;
267
- const all = (await opened.getEntries()).filter(isNavigable).map(toSessionEntry);
264
+ const leafEntryId = publishedLeaf(opened);
265
+ const journal = opened.getEntries();
266
+ // The published tree must be SELF-CONTAINED: a `parentId` pointing at an entry this plane does
267
+ // not publish (a label, one of our markers) would break the walk a client does from
268
+ // `leafEntryId` upward — it would stop at an id it cannot look up and report a short path.
269
+ // So a skipped entry is spliced out: its children point at the nearest published ancestor.
270
+ const byId = new Map(journal.map((e) => [e.id, e]));
271
+ const publishedParent = (entry) => {
272
+ let parent = entry.parentId ?? undefined;
273
+ while (parent) {
274
+ const found = byId.get(parent);
275
+ // A gap in the chain is left as a gap — `state()` reports it and the next invoke fails on
276
+ // it (design §7); inventing a parent here would hide a corrupt journal.
277
+ if (!found)
278
+ return parent;
279
+ if (isNavigable(found))
280
+ return parent;
281
+ parent = found.parentId ?? undefined;
282
+ }
283
+ return undefined;
284
+ };
285
+ const all = journal.filter(isNavigable).map((e) => toSessionEntry(e, publishedParent(e)));
268
286
  let entries = all;
269
287
  if (opts?.since !== undefined) {
270
288
  const idx = all.findIndex((e) => e.id === opts.since);
@@ -331,405 +349,519 @@ export function createPiSessionControl(options) {
331
349
  },
332
350
  };
333
351
  },
334
- async dispatch(session, command) {
335
- switch (command.type) {
336
- case "steer":
337
- case "follow_up":
338
- case "abort": {
339
- const run = active.get(session);
340
- if (!run) {
341
- // Run/compaction symmetry: an in-flight manual compaction is a model call too, and
342
- // `abort` is its only door — interrupting the harness converges through the detached
343
- // task's catch into `compaction_finished{aborted}` with the lease released; answering
344
- // no_active_run against a state() that says "compacting" would be a lie.
345
- const comp = command.type === "abort" ? compacting.get(session) : undefined;
346
- if (comp) {
347
- comp.abort();
348
- return { ok: true }; // no runId — the outcome travels as compaction_finished{aborted}
349
- }
350
- // Rejected BEFORE acceptance: no run exists, nothing happened. retryable: false —
351
- // as-is retry fails again; re-dispatch after state() shows an active run.
352
- return {
353
- ok: false,
354
- error: {
355
- code: NO_ACTIVE_RUN_CODE,
356
- message: `no active run for this session — ${command.type} modulates a run an invoke is driving`,
357
- retryable: false,
358
- },
359
- };
360
- }
361
- if (!run.controls) {
362
- // A run EXISTS (state() rightly reports running) but was registered observation-only
363
- // (the observer seam allows run_started without controls). That is a CAPABILITY
364
- // problem, not a run problem — permanent for this wiring, so neither no_active_run
365
- // (would poll forever) nor run_command_failed (transient) fits.
366
- return {
367
- ok: false,
368
- error: {
369
- code: UNSUPPORTED_CAPABILITY_CODE,
370
- message: `the active run registered without modulation controls (observation-only) ${command.type} cannot reach it`,
371
- retryable: false,
372
- },
373
- };
374
- }
375
- try {
376
- if (command.type === "steer")
377
- await run.controls.steer(command.prompt);
378
- else if (command.type === "follow_up")
379
- await run.controls.followUp(command.prompt);
380
- else
381
- await run.controls.abort();
382
- }
383
- catch (error) {
384
- // The run raced us to settlement, failed setup, or the engine refused: still
385
- // pre-acceptance (nothing was queued), distinct from "no run existed". retryable:
386
- // false for the same reason — the run is gone; consult state() before re-dispatching.
387
- return {
388
- ok: false,
389
- error: { code: RUN_COMMAND_FAILED_CODE, message: String(error), retryable: false },
390
- };
391
- }
392
- // Accepted: joined (or stopped) THIS run. The outcome arrives as run_settled.
393
- return { ok: true, runId: run.runId };
352
+ };
353
+ /** No boundary wiring — the write path does not exist in this deployment. A capability-gating
354
+ * client never lands here; one that does gets the same answer every gate publishes. */
355
+ const unsupported = (what) => ({
356
+ ok: false,
357
+ error: {
358
+ code: UNSUPPORTED_CAPABILITY_CODE,
359
+ message: `${what} is not supported by this runtime (no boundary wiring)`,
360
+ retryable: false,
361
+ },
362
+ });
363
+ const noSuchSession = (session) => ({
364
+ ok: false,
365
+ error: { code: NO_SUCH_SESSION_CODE, message: `session "${session}" does not exist`, retryable: false },
366
+ });
367
+ const invalid = (message) => ({
368
+ ok: false,
369
+ error: { code: INVALID_COMMAND_CODE, message, retryable: false },
370
+ });
371
+ const busy = () => ({
372
+ ok: false,
373
+ error: {
374
+ code: SESSION_BUSY_CODE,
375
+ message: "session busy: a run (or another write) is in flight — retry at idle",
376
+ retryable: true,
377
+ },
378
+ });
379
+ const failed = (error) => ({
380
+ ok: false,
381
+ error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
382
+ });
383
+ /** steer / follow_up / abort the run actions. They reach the LIVE run through the controls
384
+ * registered with `run_started`; nothing durable is written. */
385
+ const runAction = async (session, action) => {
386
+ const run = active.get(session);
387
+ if (!run) {
388
+ // Run/compaction symmetry: an in-flight compaction is a model call too, and `abort` is its
389
+ // only door — interrupting it converges through the detached task's catch into
390
+ // `compaction_finished{aborted}` with the lease released; answering no_active_run against a
391
+ // state() that says "compacting" would be a lie.
392
+ const comp = action.type === "abort" ? compacting.get(session) : undefined;
393
+ if (comp) {
394
+ comp.abort();
395
+ return { ok: true }; // no runId — the outcome travels as compaction_finished{aborted}
396
+ }
397
+ // Rejected BEFORE acceptance: no run exists, nothing happened. retryable: false — the same
398
+ // call fails again; call it after state() shows an active run.
399
+ return {
400
+ ok: false,
401
+ error: {
402
+ code: NO_ACTIVE_RUN_CODE,
403
+ message: `no active run for this session ${action.type} modulates a run an invoke is driving`,
404
+ retryable: false,
405
+ },
406
+ };
407
+ }
408
+ if (!run.controls) {
409
+ // A run EXISTS (state() rightly reports running) but was registered observation-only (the
410
+ // observer seam allows run_started without controls). That is a CAPABILITY problem, not a run
411
+ // problem permanent for this wiring, so neither no_active_run (would poll forever) nor
412
+ // run_command_failed (transient) fits.
413
+ return {
414
+ ok: false,
415
+ error: {
416
+ code: UNSUPPORTED_CAPABILITY_CODE,
417
+ message: `the active run registered without modulation controls (observation-only) — ${action.type} cannot reach it`,
418
+ retryable: false,
419
+ },
420
+ };
421
+ }
422
+ try {
423
+ if (action.type === "steer")
424
+ await run.controls.steer(action.prompt);
425
+ else if (action.type === "follow_up")
426
+ await run.controls.followUp(action.prompt);
427
+ else
428
+ await run.controls.abort();
429
+ }
430
+ catch (error) {
431
+ // The run raced us to settlement, failed setup, or the engine refused: still pre-acceptance
432
+ // (nothing was queued), distinct from "no run existed". retryable: false for the same reason —
433
+ // the run is gone; consult state() before calling again.
434
+ return { ok: false, error: { code: RUN_COMMAND_FAILED_CODE, message: String(error), retryable: false } };
435
+ }
436
+ // Accepted: joined (or stopped) THIS run. The outcome arrives as run_settled.
437
+ return { ok: true, runId: run.runId };
438
+ };
439
+ /**
440
+ * {@link Session.update} — validate the whole patch, take the lease once, hand the writes to the
441
+ * store, report what landed.
442
+ *
443
+ * Everything before the lease is validation, which is what makes a rejected patch leave nothing
444
+ * behind. What a value MEANS is decided here (a model spec against the registry, a level against
445
+ * the model it lands on); how a record takes it is the store's.
446
+ */
447
+ const updateOf = async (session, patch) => {
448
+ // Keys, not values: a field this runtime does not know must not be silently skipped — that is a
449
+ // client typo, or a newer client talking to an older serve, and both need to hear about it.
450
+ const named = Object.keys(patch);
451
+ const unknown = named.filter((f) => !UPDATE_FIELDS.includes(f));
452
+ if (unknown.length > 0) {
453
+ return unsupported(`update field(s) ${unknown.join(", ")} — capabilities().updatable lists what this serve sets`);
454
+ }
455
+ const fields = named.filter((f) => patch[f] !== undefined);
456
+ if (fields.length === 0)
457
+ return { ok: true }; // an empty patch asks for nothing, and gets it
458
+ const b = boundary?.();
459
+ if (!b)
460
+ return unsupported(`update(${fields.join(", ")})`);
461
+ // PAYLOAD validation first — before the session is even opened, and long before the lease: an
462
+ // invalid value must not briefly block a run.
463
+ let model;
464
+ if (patch.model !== undefined) {
465
+ const slash = patch.model.indexOf("/");
466
+ model = slash > 0 ? b.models.getModel(patch.model.slice(0, slash), patch.model.slice(slash + 1)) : undefined;
467
+ if (!model) {
468
+ return invalid(`unknown model "${patch.model}" — capabilities().allowedModels lists the accepted specs`);
469
+ }
470
+ }
471
+ if (patch.thinkingLevel !== undefined && !THINKING_LEVELS.has(patch.thinkingLevel)) {
472
+ return invalid(`unknown thinking level "${patch.thinkingLevel}" — state().availableThinkingLevels lists what this session accepts`);
473
+ }
474
+ // A name is the client's own label; the only thing that cannot be one is nothing.
475
+ if (patch.name !== undefined && patch.name.trim() === "")
476
+ return invalid("a session name cannot be empty");
477
+ // Sessions are created by invoke or copied by fork, never minted by an update: an unknown id is
478
+ // rejected, not turned into a ghost record. (Read-only handle — the WRITE one is opened under
479
+ // the lease below, and this one is discarded.)
480
+ const existing = await sessions.openIfExists(session);
481
+ if (!existing)
482
+ return noSuchSession(session);
483
+ if (patch.leafEntryId !== undefined) {
484
+ // A target that cannot BE a leaf is a permanent payload error, not a session error — the same
485
+ // disposition as an unknown model spec. Same predicate `entries()` publishes by, so
486
+ // "everything published is a position" holds by construction rather than by two literals
487
+ // agreeing.
488
+ const entry = existing.getEntry(patch.leafEntryId);
489
+ if (!entry || !isNavigable(entry)) {
490
+ return invalid(entry
491
+ ? `entry "${patch.leafEntryId}" is not a position — entries() publishes every id you can move to, and this is not one of them`
492
+ : `entry "${patch.leafEntryId}" does not exist in session "${session}" — entries() lists the positions`);
493
+ }
494
+ }
495
+ if (patch.thinkingLevel !== undefined) {
496
+ // The same set `state()` showed the client. Reject here rather than record a level the run
497
+ // would not use. The read is guarded because this must never REJECT — the contract promises a
498
+ // SessionResult, so an unreadable chain has to arrive as a code.
499
+ let resolved;
500
+ try {
501
+ // Against the path this patch LANDS on: a leaf move is written first, and the branch it
502
+ // moves to can carry a model override of its own — validating on the path being left would
503
+ // reject a level the destination supports, and accept one it does not.
504
+ resolved = resolveSessionSettings(activePath(existing, patch.leafEntryId), b.models, b.defaults);
505
+ }
506
+ catch (error) {
507
+ return failed(error);
508
+ }
509
+ // An explicit model in the same patch wins over the one that path resolves to: it is applied
510
+ // after the move, so it is what the session ends up running.
511
+ const target = model ?? resolved.model;
512
+ const levels = model ? getSupportedThinkingLevels(model) : resolved.availableThinkingLevels;
513
+ if (!levels.includes(patch.thinkingLevel)) {
514
+ return invalid(`thinking level "${patch.thinkingLevel}" is not supported by ${target.provider}/${target.id} (allowed: ${levels.join(", ")})`);
515
+ }
516
+ }
517
+ // The control plane's writes take the same lease as every run — a write must never race one
518
+ // (design §9).
519
+ const release = b.lease.tryAcquire(session);
520
+ if (!release)
521
+ return busy();
522
+ let applied;
523
+ try {
524
+ // HOW a record takes a property — order, the leaf pointer, the name pi rewrites — is the
525
+ // store's to know. This asks for the writes and is told what landed.
526
+ applied = await sessions.applyProperties(session, {
527
+ ...(patch.name !== undefined ? { name: patch.name } : {}),
528
+ ...(model ? { model: { provider: model.provider, id: model.id } } : {}),
529
+ ...(patch.thinkingLevel !== undefined ? { thinkingLevel: patch.thinkingLevel } : {}),
530
+ ...(patch.leafEntryId !== undefined ? { leafEntryId: patch.leafEntryId } : {}),
531
+ });
532
+ }
533
+ catch (error) {
534
+ // Opening the record failed — nothing was written; the same patch may succeed on retry. The
535
+ // lease is freed by the `finally` on the way out, once.
536
+ return failed(error);
537
+ }
538
+ finally {
539
+ release();
540
+ }
541
+ if (!applied)
542
+ return noSuchSession(session); // vanished in the window: same condition, same code
543
+ if (applied.landed.length > 0) {
544
+ // ONE event for the patch, built from what the RECORD holds. The settings pair rides along
545
+ // whenever anything but the name changed: model and thinking level are one setting, and a
546
+ // moved leaf can drop an override that used to apply.
547
+ let settings;
548
+ if (applied.path) {
549
+ try {
550
+ settings = resolveSessionSettings(applied.path, b.models, b.defaults);
394
551
  }
395
- case "compact":
396
- case "set_model":
397
- case "set_thinking":
398
- case "navigate": {
399
- const b = boundary?.();
400
- if (!b) {
401
- // No boundary wiring: rejected before acceptance; a capability-gating client never
402
- // lands here.
403
- return {
404
- ok: false,
405
- error: {
406
- code: UNSUPPORTED_CAPABILITY_CODE,
407
- message: `command "${command.type}" is not supported by this runtime (no boundary wiring)`,
408
- retryable: false,
409
- },
410
- };
411
- }
412
- // Payload validation BEFORE the lease — an invalid value must not briefly block a run.
413
- /** The durable write for set_model/set_thinking/navigate — undefined for compact (harness
414
- * path). Answers the event to emit. */
415
- let apply;
416
- if (command.type === "set_model") {
417
- const slash = command.model.indexOf("/");
418
- const model = slash > 0 ? b.models.getModel(command.model.slice(0, slash), command.model.slice(slash + 1)) : undefined;
419
- if (!model) {
420
- return {
421
- ok: false,
422
- error: {
423
- code: INVALID_COMMAND_CODE,
424
- message: `unknown model "${command.model}" — capabilities().modelSelection lists the allowed specs`,
425
- retryable: false,
426
- },
427
- };
428
- }
429
- apply = async (s) => {
430
- await s.appendModelChange(model.provider, model.id);
431
- // Both halves: a new model can change which level executes. Nothing is re-recorded to
432
- // make that true — the resolve reports it, so the preference survives a round trip.
433
- const settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
434
- return {
435
- type: "state_changed",
436
- timestamp: Date.now(),
437
- // The CANONICAL spec, same string the durable entry and state() report — the event
438
- // must not echo a client alias the other two surfaces would disagree with.
439
- data: { model: `${model.provider}/${model.id}`, thinkingLevel: settings.thinkingLevel },
440
- };
441
- };
442
- }
443
- else if (command.type === "set_thinking") {
444
- // A payload that is not a level at all — invalid before any session question.
445
- if (!THINKING_LEVELS.has(command.level)) {
446
- return {
447
- ok: false,
448
- error: {
449
- code: INVALID_COMMAND_CODE,
450
- message: `unknown thinking level "${command.level}" — state().availableThinkingLevels lists what this session accepts`,
451
- retryable: false,
452
- },
453
- };
454
- }
455
- apply = async (s) => {
456
- await s.appendThinkingLevelChange(command.level);
457
- return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
458
- };
459
- }
460
- else if (command.type === "navigate") {
461
- apply = async (s) => {
462
- // A move to where the leaf already is writes nothing: pi journals a move as a `leaf`
463
- // record, so an idempotent re-dispatch (a client retry, a UI firing on every
464
- // selection) would otherwise grow the session by a record no plane publishes. The
465
- // EVENT is emitted either way — it reports the resulting position, not the fact that
466
- // a record was written, and a client that dispatched must not have to poll for it.
467
- if ((await s.getLeafId()) !== command.targetId)
468
- await s.moveTo(command.targetId);
469
- // moveTo's postcondition IS "targetId is the leaf" (it validates, then sets); a
470
- // failure throws and travels as boundary_command_failed, so a read-back could only
471
- // re-report what this line already knows. The SETTINGS ride along because a move can
472
- // change them — an override recorded on the branch just left stops applying, and a
473
- // client tracking model/level from the event stream would otherwise show what the
474
- // next turn will not use.
475
- // The move is already durable here, so a settings read that throws (the new path is
476
- // above a gap) must NOT turn into "nothing took effect": report the position without
477
- // the settings and let `state()`'s rejection be where the broken chain surfaces.
478
- let settings;
479
- try {
480
- settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
481
- }
482
- catch (error) {
483
- // Absent rather than stale: the session cannot RUN with an unreadable chain either
484
- // (the harness build walks the same path), so the next invoke fails visibly — this
485
- // event does not need to carry a second signal for it.
486
- log.warn(`[fastagent] session ${session}: leaf moved, settings unreadable: ${String(error)}`);
487
- }
488
- return {
489
- type: "state_changed",
490
- timestamp: Date.now(),
491
- data: {
492
- leafEntryId: command.targetId,
493
- ...(settings
494
- ? {
495
- model: `${settings.model.provider}/${settings.model.id}`,
496
- thinkingLevel: settings.thinkingLevel,
497
- }
498
- : {}),
499
- },
500
- };
501
- };
502
- }
503
- // Sessions are created by invoke, never here: a mutation on an unknown id is rejected,
504
- // not minted into a ghost record. (Existence check before the lease — read-only; the
505
- // WRITE handle is re-opened under the lease below, this one is discarded.)
506
- const existing = await sessions.openIfExists(session);
507
- if (!existing) {
508
- return {
509
- ok: false,
510
- error: {
511
- code: NO_SUCH_SESSION_CODE,
512
- message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
513
- retryable: false,
514
- },
515
- };
516
- }
517
- if (command.type === "navigate") {
518
- // A target that cannot BE a leaf is a permanent payload error, not a session error — the
519
- // same disposition as an unknown model spec. Same predicate `entries()` publishes by, so
520
- // "everything published is navigable" holds by construction rather than by two literals
521
- // agreeing.
522
- const entry = await existing.getEntry(command.targetId);
523
- if (!entry || !isNavigable(entry)) {
524
- return {
525
- ok: false,
526
- error: {
527
- code: INVALID_COMMAND_CODE,
528
- message: entry
529
- ? `entry "${command.targetId}" is a leaf-move record — entries() does not publish those, and they are not positions; navigate to the entry it points at`
530
- : `entry "${command.targetId}" does not exist in session "${session}" — entries() lists the navigable ids`,
531
- retryable: false,
532
- },
533
- };
534
- }
535
- }
536
- if (command.type === "set_thinking") {
537
- // The same set `state()` showed the client. Reject here rather than record a level the
538
- // run would not use. The read is guarded because `dispatch` must never REJECT — the
539
- // transport promises a SessionResult, so an unreadable chain has to arrive as a code.
540
- let resolved;
541
- try {
542
- resolved = resolveSessionSettings((await activePathEntries(existing)), b.models, b.defaults);
543
- }
544
- catch (error) {
545
- return {
546
- ok: false,
547
- error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
548
- };
549
- }
550
- const { model, availableThinkingLevels } = resolved;
551
- if (!availableThinkingLevels.includes(command.level)) {
552
- return {
553
- ok: false,
554
- error: {
555
- code: INVALID_COMMAND_CODE,
556
- message: `thinking level "${command.level}" is not supported by ${model.provider}/${model.id} (allowed: ${availableThinkingLevels.join(", ")})`,
557
- retryable: false,
558
- },
559
- };
560
- }
561
- }
562
- // Boundary mutations are the control plane's only writers: same lease as every run — a
563
- // mutation must never race one (design §9).
564
- const release = b.lease.tryAcquire(session);
565
- if (!release) {
566
- return {
567
- ok: false,
568
- error: {
569
- code: SESSION_BUSY_CODE,
570
- message: "session busy: a run (or another boundary mutation) is in flight — retry at idle",
571
- retryable: true,
572
- },
573
- };
574
- }
575
- if (command.type === "compact") {
576
- // ACCEPT-FAST: compaction is a full model call (tens of seconds is normal) — holding
577
- // the dispatch open until it finishes made acceptance = outcome, the one exception to
578
- // §5.2, and broke remote clients whose request timeouts are sized for control calls.
579
- // The dispatch answers once the work is ADMITTED (lease held, harness built); the
580
- // outcome travels as compaction_finished{summary|error|aborted}, the bounds contract watchers
581
- // already rely on. Pre-acceptance failures (the harness build) still reject here.
582
- // The admission step is EVERYTHING cheap and local: the harness build (the ONE
583
- // canonical resolution of session overrides + auth) plus the compaction PREPARATION
584
- // (a pure branch-read computation) — the boundary between "reject the dispatch" and
585
- // "outcome travels as an event" sits where the work becomes asynchronous and
586
- // expensive: the model call. "Nothing to compact" is thus a pre-acceptance answer,
587
- // never a finished{error} dressed as a failure. The summarization runs through pi's
588
- // compaction primitives instead of harness.compact() for exactly one reason: the
589
- // harness surface passes no signal to its model call, so an in-flight compaction
590
- // would be uncancellable — and `abort` needs a real door (run/compaction symmetry).
591
- let harness;
592
- try {
593
- harness = await b.harnessFactory(session);
594
- }
595
- catch (error) {
596
- release();
597
- return {
598
- ok: false,
599
- error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
600
- };
601
- }
602
- const teardown = async () => {
603
- try {
604
- await harness.abort(); // fresh-harness discipline
605
- }
606
- catch (error) {
607
- log.warn(`[fastagent] compaction harness teardown failed: ${String(error)}`);
608
- }
609
- };
610
- let record;
611
- let preparation;
612
- try {
613
- const bound = harnessSession(harness);
614
- if (!bound)
615
- throw new Error("harness has no bound session (factory invariant broken)");
616
- record = bound;
617
- const prep = prepareCompaction(await record.getBranch(), DEFAULT_COMPACTION_SETTINGS);
618
- if (!prep.ok)
619
- throw prep.error;
620
- if (!prep.value) {
621
- await teardown();
622
- release();
623
- // A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
624
- // machine-distinguish "give up" from "re-dispatch once the session grows", and
625
- // branching on message prose is forbidden by contract.
626
- return {
627
- ok: false,
628
- error: {
629
- code: NOTHING_TO_COMPACT_CODE,
630
- message: "nothing to compact — the session has no compactable history yet; retry after more turns",
631
- retryable: false,
632
- },
633
- };
634
- }
635
- preparation = prep.value;
636
- }
637
- catch (error) {
638
- await teardown();
639
- release();
640
- return {
641
- ok: false,
642
- error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
643
- };
644
- }
645
- const door = new AbortController();
646
- compacting.set(session, door); // admission complete: from here `abort` reaches the model call
647
- emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
648
- void (async () => {
649
- let outcome;
650
- try {
651
- const done = await compact(preparation, b.models, harness.getModel(), command.instructions, door.signal, harness.getThinkingLevel(), SUMMARIZATION_RETRY_POLICY, {
652
- // Retries are otherwise invisible between compaction_started and _finished —
653
- // surface each backoff so a long gap is diagnosable (not confusable with a
654
- // hang): as a session event for attached observers, as a warn for server logs.
655
- onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
656
- log.warn(`[fastagent] compaction retry ${attempt}/${maxAttempts} in ${delayMs}ms (session ${session}): ${errorMessage}`);
657
- emitOwn(session, {
658
- type: "retry_scheduled",
659
- timestamp: Date.now(),
660
- data: { operation: "compaction", attempt, maxAttempts, delayMs, error: errorMessage },
661
- });
662
- },
663
- });
664
- if (!done.ok)
665
- throw done.error;
666
- await record.appendCompaction(done.value.summary, done.value.firstKeptEntryId, done.value.tokensBefore, done.value.details);
667
- outcome = { summary: done.value.summary };
668
- }
669
- catch (error) {
670
- // A deliberate stop is not a failure — run/compaction symmetry with
671
- // run_settled{aborted}: the door's signal is the classification, same discipline
672
- // as run abort attribution (a racing real failure still reads as aborted — the
673
- // intent was live while the work resolved).
674
- outcome = door.signal.aborted ? { aborted: true } : { error: String(error) };
675
- }
676
- await teardown();
677
- // Release BEFORE emitting finished: a watcher seeing finished may dispatch next —
678
- // "finished ⇒ the lease is free and status is no longer compacting" must hold.
679
- compacting.delete(session);
680
- release();
681
- emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
682
- })();
683
- return { ok: true };
684
- }
685
- try {
686
- // The WRITE handle is opened UNDER the lease: a handle from before tryAcquire could
687
- // be a stale snapshot of a run that completed in the window — appending to it would
688
- // hang the override off an outdated leaf.
689
- const fresh = await sessions.openIfExists(session);
690
- if (!fresh) {
691
- // Same real condition as the pre-lease check (the session vanished in the window):
692
- // same code, same disposition — not a retryable internal error.
693
- return {
694
- ok: false,
695
- error: {
696
- code: NO_SUCH_SESSION_CODE,
697
- message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
698
- retryable: false,
699
- },
700
- };
701
- }
702
- // Unreachable by construction: only set_model/set_thinking/navigate reach this branch,
703
- // and all three assign `apply` in validation. Throw rather than silently skip (fail visibly).
704
- if (!apply)
705
- throw new Error("apply unset outside the compact branch (dispatch invariant broken)");
706
- emitOwn(session, await apply(fresh));
707
- }
708
- catch (error) {
709
- // The append failed before anything durable landed — "nothing took effect"; the same
710
- // command may succeed on retry.
711
- return {
712
- ok: false,
713
- error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
714
- };
715
- }
716
- finally {
717
- release();
718
- }
719
- return { ok: true };
552
+ catch (error) {
553
+ // Already durable, so an unresolvable pair must NOT read as "nothing took effect": report
554
+ // the position without it and let the next invoke — which walks the same path — be where
555
+ // the fault surfaces.
556
+ log.warn(`[fastagent] session ${session}: updated, settings unresolvable: ${String(error)}`);
720
557
  }
721
- default:
722
- // Wire input bypasses the TS union (a remote client can send any `type`): a protocol-
723
- // level answer, never an undefined body — the transport promises `ok: false` shapes.
724
- return {
725
- ok: false,
726
- error: {
727
- code: INVALID_COMMAND_CODE,
728
- message: `unknown command type "${String(command.type)}"`,
729
- retryable: false,
730
- },
731
- };
732
558
  }
559
+ emitOwn(session, {
560
+ type: "state_changed",
561
+ timestamp: Date.now(),
562
+ data: {
563
+ ...(applied.landed.includes("leafEntryId") ? { leafEntryId: applied.leafEntryId } : {}),
564
+ ...(settings && applied.landed.some((f) => f !== "name")
565
+ ? { model: `${settings.model.provider}/${settings.model.id}`, thinkingLevel: settings.thinkingLevel }
566
+ : {}),
567
+ ...(applied.landed.includes("name") && applied.name ? { name: applied.name } : {}),
568
+ },
569
+ });
570
+ }
571
+ if (applied.failure !== undefined) {
572
+ // `boundary_command_failed` means nothing durable landed. When something did, the client needs
573
+ // a different sentence — and the fields, so it knows what its retry would repeat.
574
+ return applied.landed.length === 0
575
+ ? failed(applied.failure)
576
+ : {
577
+ ok: false,
578
+ error: {
579
+ code: PARTIAL_UPDATE_CODE,
580
+ message: `applied ${applied.landed.join(", ")}, then failed: ${String(applied.failure)} — read state() before retrying`,
581
+ retryable: false,
582
+ },
583
+ };
584
+ }
585
+ return { ok: true };
586
+ };
587
+ /**
588
+ * ACCEPT-FAST compaction: a full model call (tens of seconds is normal), so holding the call open
589
+ * until it finishes would make acceptance = outcome — the one exception to §5.2, and what broke
590
+ * remote clients whose request timeouts are sized for control calls. This answers once the work is
591
+ * ADMITTED (lease held, session bound); the outcome travels as
592
+ * `compaction_finished{summary|error|aborted}`.
593
+ *
594
+ * Admission is everything cheap and local: binding the session (the ONE canonical resolution of
595
+ * overrides + auth) plus the compaction PREPARATION, a pure branch read. The boundary between
596
+ * "reject" and "the outcome travels as an event" sits where the work becomes asynchronous and
597
+ * expensive: the model call. "Nothing to compact" is therefore a pre-acceptance answer, never a
598
+ * finished{error} dressed as a failure — pi reports it as a throw from compact(), too late.
599
+ */
600
+ const compactOf = async (session, instructions) => {
601
+ const b = boundary?.();
602
+ if (!b)
603
+ return unsupported("compact()");
604
+ const existing = await sessions.openIfExists(session);
605
+ if (!existing)
606
+ return noSuchSession(session);
607
+ const release = b.lease.tryAcquire(session);
608
+ if (!release)
609
+ return busy();
610
+ let bound;
611
+ try {
612
+ bound = await b.sessionFactory(session);
613
+ }
614
+ catch (error) {
615
+ release();
616
+ return failed(error);
617
+ }
618
+ const teardown = () => {
619
+ try {
620
+ bound.dispose();
621
+ }
622
+ catch (error) {
623
+ log.warn(`[fastagent] compaction session teardown failed: ${String(error)}`);
624
+ }
625
+ };
626
+ try {
627
+ // The SAME settings pi will use inside compact(): asking with different thresholds would
628
+ // either reject a compaction pi would have run, or admit one it refuses — and its refusal
629
+ // arrives too late to be a pre-acceptance answer.
630
+ const path = bound.sessionManager.getBranch();
631
+ const prep = prepareCompaction(path, bound.settingsManager.getCompactionSettings());
632
+ if (!prep.ok)
633
+ throw prep.error;
634
+ // Empty is the same answer as absent: pi ships two prepareCompaction implementations
635
+ // (agent-core answers with a Result, coding-agent with undefined) and they disagree on which
636
+ // one an unsummarizable session gets. What they agree on is the CONTENT — no messages to
637
+ // summarize — so that is what the gate reads.
638
+ if (!prep.value || prep.value.messagesToSummarize.length === 0) {
639
+ teardown();
640
+ release();
641
+ // A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
642
+ // machine-distinguish "give up" from "call again once the session grows", and branching on
643
+ // message prose is forbidden by contract.
644
+ return {
645
+ ok: false,
646
+ error: {
647
+ code: NOTHING_TO_COMPACT_CODE,
648
+ message: "nothing to compact — the session has no compactable history yet; retry after more turns",
649
+ retryable: false,
650
+ },
651
+ };
652
+ }
653
+ }
654
+ catch (error) {
655
+ teardown();
656
+ release();
657
+ return failed(error);
658
+ }
659
+ // The door is the session's own compaction abort — a real one, unlike a summarization call with
660
+ // no signal: `abort` must reach the model call (run/compaction symmetry).
661
+ //
662
+ // pi builds the controller that makes it abortable AFTER an internal await, so an abort arriving
663
+ // in that window would find nothing to cancel and the compaction would run to completion — the
664
+ // client's cancel silently doing nothing. The intent is latched and re-applied until it takes
665
+ // (`isCompacting` reports when it has).
666
+ //
667
+ // The retry is DEFENSIVE: the window is one await wide, and the test below lands after it, so
668
+ // this loop is not what makes that test pass. It is here because the window is on the code path,
669
+ // not because it has been observed.
670
+ let aborted = false;
671
+ let running = true; // cleared when the compaction settles, however it settles
672
+ const applyAbort = async () => {
673
+ // WAIT for the controller rather than requiring it: an abort that arrives before pi builds one
674
+ // sees isCompacting false, and a loop that only runs WHILE compacting would exit immediately —
675
+ // leaving the intent unapplied in exactly the window it exists for.
676
+ for (let attempt = 0; attempt < 200 && running; attempt++) {
677
+ if (bound.isCompacting) {
678
+ bound.abortCompaction();
679
+ if (!bound.isCompacting)
680
+ return; // it took
681
+ }
682
+ await new Promise((resolve) => setTimeout(resolve, 1));
683
+ }
684
+ };
685
+ compacting.set(session, {
686
+ abort: () => {
687
+ aborted = true;
688
+ bound.abortCompaction();
689
+ void applyAbort();
690
+ },
691
+ });
692
+ emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
693
+ void (async () => {
694
+ let outcome;
695
+ // Retries are otherwise invisible between compaction_started and _finished — surface each
696
+ // backoff so a long gap is diagnosable (not confusable with a hang): as a session event for
697
+ // attached observers, as a warn for server logs.
698
+ const unsub = bound.subscribe((event) => {
699
+ if (event.type !== "summarization_retry_scheduled")
700
+ return;
701
+ log.warn(`[fastagent] compaction retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms (session ${session}): ${event.errorMessage}`);
702
+ emitOwn(session, {
703
+ type: "retry_scheduled",
704
+ timestamp: Date.now(),
705
+ data: {
706
+ operation: "compaction",
707
+ attempt: event.attempt,
708
+ maxAttempts: event.maxAttempts,
709
+ delayMs: event.delayMs,
710
+ error: event.errorMessage,
711
+ },
712
+ });
713
+ });
714
+ try {
715
+ const done = await bound.compact(instructions);
716
+ outcome = { summary: done.summary };
717
+ }
718
+ catch (error) {
719
+ // A deliberate stop is not a failure — run/compaction symmetry with run_settled{aborted}:
720
+ // the intent is the classification, same discipline as run abort attribution (a racing real
721
+ // failure still reads as aborted).
722
+ outcome = aborted ? { aborted: true } : { error: String(error) };
723
+ }
724
+ running = false;
725
+ unsub();
726
+ teardown();
727
+ // Release BEFORE emitting finished: a watcher seeing finished may act next — "finished ⇒ the
728
+ // lease is free and status is no longer compacting" must hold.
729
+ compacting.delete(session);
730
+ release();
731
+ emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
732
+ })();
733
+ return { ok: true };
734
+ };
735
+ /**
736
+ * Copy a history into a new session. IDEMPOTENT by construction: `into` is the caller's id, so a
737
+ * repeat of a fork that already landed answers `ok: true` and writes nothing — a client retrying a
738
+ * request whose response it never saw does not get a second record. Provenance is what makes that
739
+ * safe rather than merely quiet: the same `into` naming a session that came from somewhere else is
740
+ * a rejection, not an overwrite.
741
+ */
742
+ const forkOf = async (options) => {
743
+ const { from, at, into } = options;
744
+ /** WHICH fork this is: source + branch point. Two forks of one session at different entries are
745
+ * different requests, so a retry of one must not be answered by the other. */
746
+ const provenance = `${from}@${at}`;
747
+ const b = boundary?.();
748
+ if (!b)
749
+ return unsupported("fork()");
750
+ // An id no client could then open: the empty string, `.` and `..` are not URL path segments
751
+ // (isAddressableSession), so minting one would put a row in list() that nothing can address —
752
+ // listed, unopenable by the client that just listed it.
753
+ if (!isAddressableSession(into)) {
754
+ return invalid(`${JSON.stringify(into)} cannot be a session id — the control plane could not address it`);
755
+ }
756
+ const source = await sessions.openIfExists(from);
757
+ if (!source)
758
+ return noSuchSession(from);
759
+ // The entry predicate is the one `entries()` publishes by, so "everything published is forkable"
760
+ // holds by construction — the same argument the leaf move makes.
761
+ const entry = source.getEntry(at);
762
+ if (!entry || !isNavigable(entry)) {
763
+ return invalid(`entry "${at}" is not a forkable position in session "${from}" — entries() lists the ids`);
764
+ }
765
+ const existingTarget = await sessions.openIfExists(into);
766
+ if (existingTarget) {
767
+ // Already forked from HERE: the request already happened, so answering ok is the truth rather
768
+ // than a convenience. Anything else under that id is a different history, and saying yes would
769
+ // be the id lying about what it holds.
770
+ return forkProvenance(existingTarget) === provenance
771
+ ? { ok: true }
772
+ : invalid(`session "${into}" already exists with a different history — fork mints nothing over it`);
773
+ }
774
+ // BOTH ends. The source lease keeps the copy from reading a history a run is mid-write on; the
775
+ // destination lease closes the window the existence check above leaves open — an invoke creating
776
+ // `into`, or a second fork from a DIFFERENT source (whose source lease is another key entirely),
777
+ // otherwise lands between that check and this write. tryAcquire never blocks, so taking two
778
+ // cannot deadlock.
779
+ const release = b.lease.tryAcquire(from);
780
+ if (!release)
781
+ return busy();
782
+ const releaseInto = b.lease.tryAcquire(into);
783
+ if (!releaseInto) {
784
+ release();
785
+ return busy();
786
+ }
787
+ try {
788
+ // Holding the lease is not the same as having looked: re-asked under it, as the update path
789
+ // re-opens its record, so an id taken inside the window is a payload error rather than a
790
+ // store failure the client would read as retryable.
791
+ const raced = await sessions.openIfExists(into);
792
+ if (raced) {
793
+ return forkProvenance(raced) === provenance
794
+ ? { ok: true }
795
+ : invalid(`session "${into}" already exists with a different history — fork mints nothing over it`);
796
+ }
797
+ await sessions.fork(from, at, into, provenance);
798
+ }
799
+ catch (error) {
800
+ // Nothing durable landed: the copy is staged and published by rename.
801
+ return failed(error);
802
+ }
803
+ finally {
804
+ releaseInto();
805
+ release();
806
+ }
807
+ return { ok: true };
808
+ };
809
+ const deleteOf = async (session) => {
810
+ const b = boundary?.();
811
+ if (!b)
812
+ return unsupported("delete()");
813
+ const existing = await sessions.openIfExists(session);
814
+ if (!existing)
815
+ return noSuchSession(session);
816
+ // The same lease as a run: a delete racing one would pull the record out from under it.
817
+ const release = b.lease.tryAcquire(session);
818
+ if (!release)
819
+ return busy();
820
+ try {
821
+ // It was there before the lease and is gone now — the same real condition the check above
822
+ // answers, so the same code.
823
+ if (!(await sessions.delete(session)))
824
+ return noSuchSession(session);
825
+ }
826
+ catch (error) {
827
+ // A delete that throws left the record in place.
828
+ return failed(error);
829
+ }
830
+ finally {
831
+ release();
832
+ }
833
+ // The session is gone, so its live streams have nothing left to report: end them rather than
834
+ // hold connections open on a record that no longer exists. A client's reconnect then reads an
835
+ // empty `state()`, which is the truth.
836
+ for (const sub of [...(subscribers.get(session) ?? [])])
837
+ sub.close();
838
+ subscribers.delete(session);
839
+ return { ok: true };
840
+ };
841
+ const control = {
842
+ capabilities: reads.capabilities,
843
+ commands: reads.commands,
844
+ sessions: {
845
+ // The one read that may REJECT (design §13): `[]` is what a deployment with no sessions
846
+ // answers, so a store that cannot be read must not borrow that shape. The transport turns the
847
+ // throw into a coded non-2xx; nothing here swallows it.
848
+ list: () => sessions.list(),
849
+ fork: forkOf,
850
+ // A PURE BINDING: an id and the closures above it. Nothing is checked here — the calls answer
851
+ // that, each in its own vocabulary — and nothing is cached, so two handles for one id are
852
+ // interchangeable.
853
+ get: (session) => ({
854
+ id: session,
855
+ state: () => reads.state(session),
856
+ entries: (options) => reads.entries(session, options),
857
+ events: () => reads.events(session),
858
+ update: (patch) => updateOf(session, patch),
859
+ steer: (prompt) => runAction(session, { type: "steer", prompt }),
860
+ followUp: (prompt) => runAction(session, { type: "follow_up", prompt }),
861
+ abort: () => runAction(session, { type: "abort" }),
862
+ compact: (options) => compactOf(session, options?.instructions),
863
+ delete: () => deleteOf(session),
864
+ }),
733
865
  },
734
866
  };
735
867
  return { control, observer };