@fastagent-sh/fastagent 0.19.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 (131) hide show
  1. package/dist/atomic-write.d.ts +11 -7
  2. package/dist/atomic-write.js +16 -11
  3. package/dist/channels/agentcore-limits.d.ts +9 -0
  4. package/dist/channels/agentcore-limits.js +9 -0
  5. package/dist/channels/agentcore-service.d.ts +12 -11
  6. package/dist/channels/agentcore-service.js +14 -18
  7. package/dist/channels/agentcore.d.ts +11 -19
  8. package/dist/channels/agentcore.js +14 -7
  9. package/dist/channels/control.d.ts +50 -12
  10. package/dist/channels/control.js +239 -96
  11. package/dist/channels/discover.d.ts +26 -4
  12. package/dist/channels/discover.js +12 -23
  13. package/dist/channels/feishu/feishu-api.js +5 -8
  14. package/dist/channels/feishu/feishu.js +35 -18
  15. package/dist/channels/kit/attachment-path.d.ts +12 -0
  16. package/dist/channels/kit/attachment-path.js +43 -0
  17. package/dist/channels/kit/context-buffer.d.ts +9 -0
  18. package/dist/channels/kit/context-buffer.js +11 -0
  19. package/dist/channels/kit/signature.d.ts +12 -0
  20. package/dist/channels/kit/signature.js +17 -0
  21. package/dist/channels/kit/stop-command.js +3 -3
  22. package/dist/channels/kit/tasks.d.ts +1 -1
  23. package/dist/channels/kit/tasks.js +10 -4
  24. package/dist/channels/kit/turn-store.d.ts +19 -3
  25. package/dist/channels/kit/turn-store.js +29 -2
  26. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  27. package/dist/channels/slack/invoke-turn.js +5 -1
  28. package/dist/channels/slack/onboard.js +5 -5
  29. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  30. package/dist/channels/slack/onboarding-state.js +13 -20
  31. package/dist/channels/slack/register-webhook.js +1 -1
  32. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  33. package/dist/channels/slack/slack-api.js +22 -54
  34. package/dist/channels/slack/slack.js +13 -13
  35. package/dist/channels/telegram/parse.d.ts +21 -1
  36. package/dist/channels/telegram/parse.js +65 -11
  37. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  38. package/dist/channels/telegram/telegram-api.js +5 -6
  39. package/dist/channels/telegram/telegram.d.ts +2 -2
  40. package/dist/channels/telegram/telegram.js +57 -69
  41. package/dist/cli/add-slack.js +6 -5
  42. package/dist/cli/commands/add.js +1 -5
  43. package/dist/cli/commands/attach.d.ts +2 -4
  44. package/dist/cli/commands/attach.js +17 -15
  45. package/dist/cli/commands/deploy.d.ts +50 -5
  46. package/dist/cli/commands/deploy.js +183 -134
  47. package/dist/cli/commands/dev.js +4 -31
  48. package/dist/cli/commands/info.js +1 -2
  49. package/dist/cli/commands/init.js +1 -2
  50. package/dist/cli/commands/start.js +31 -41
  51. package/dist/cli/serve.d.ts +12 -21
  52. package/dist/cli/serve.js +20 -25
  53. package/dist/cli/shared.d.ts +40 -10
  54. package/dist/cli/shared.js +51 -4
  55. package/dist/deploy/agentcore/logs.d.ts +2 -2
  56. package/dist/deploy/agentcore/logs.js +2 -2
  57. package/dist/deploy/agentcore/plan.d.ts +12 -10
  58. package/dist/deploy/agentcore/plan.js +39 -31
  59. package/dist/deploy/agentcore/run.d.ts +5 -4
  60. package/dist/deploy/agentcore/run.js +13 -36
  61. package/dist/deploy/channel-ingress.d.ts +73 -0
  62. package/dist/deploy/channel-ingress.js +101 -0
  63. package/dist/deploy/docker/plan.d.ts +3 -7
  64. package/dist/deploy/docker/plan.js +4 -15
  65. package/dist/deploy/docker/run.d.ts +18 -1
  66. package/dist/deploy/docker/run.js +10 -0
  67. package/dist/deploy/fly/plan.d.ts +4 -5
  68. package/dist/deploy/fly/plan.js +7 -22
  69. package/dist/deploy/fly/run.d.ts +9 -23
  70. package/dist/deploy/fly/run.js +32 -68
  71. package/dist/deploy/preflight.d.ts +6 -7
  72. package/dist/deploy/preflight.js +26 -22
  73. package/dist/deploy/railway/plan.d.ts +4 -5
  74. package/dist/deploy/railway/plan.js +11 -26
  75. package/dist/deploy/railway/run.d.ts +9 -9
  76. package/dist/deploy/railway/run.js +40 -40
  77. package/dist/deploy/runner.js +22 -2
  78. package/dist/deploy/secrets.d.ts +18 -9
  79. package/dist/deploy/secrets.js +64 -12
  80. package/dist/dev-supervisor.js +3 -4
  81. package/dist/engines/pi/agent-session-factory.js +20 -39
  82. package/dist/engines/pi/auth.js +43 -43
  83. package/dist/engines/pi/config.d.ts +1 -1
  84. package/dist/engines/pi/create.d.ts +14 -2
  85. package/dist/engines/pi/create.js +2 -2
  86. package/dist/engines/pi/definition.d.ts +1 -1
  87. package/dist/engines/pi/definition.js +3 -4
  88. package/dist/engines/pi/login.js +32 -16
  89. package/dist/engines/pi/open.d.ts +1 -2
  90. package/dist/engines/pi/open.js +1 -1
  91. package/dist/engines/pi/search-tools.js +1 -1
  92. package/dist/engines/pi/service.d.ts +1 -1
  93. package/dist/engines/pi/service.js +8 -0
  94. package/dist/engines/pi/session-builder.js +6 -33
  95. package/dist/engines/pi/session-control.d.ts +19 -6
  96. package/dist/engines/pi/session-control.js +565 -470
  97. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  98. package/dist/engines/pi/session-inheritance.js +95 -76
  99. package/dist/engines/pi/session-markers.d.ts +48 -0
  100. package/dist/engines/pi/session-markers.js +59 -0
  101. package/dist/engines/pi/session-settings.d.ts +4 -4
  102. package/dist/engines/pi/session-settings.js +7 -4
  103. package/dist/engines/pi/session-store.d.ts +83 -13
  104. package/dist/engines/pi/session-store.js +408 -55
  105. package/dist/engines/pi/tool-context.d.ts +21 -8
  106. package/dist/engines/pi/tool-context.js +29 -4
  107. package/dist/engines/pi/tool.js +2 -2
  108. package/dist/env.js +1 -2
  109. package/dist/loader.d.ts +49 -7
  110. package/dist/loader.js +79 -18
  111. package/dist/paths.d.ts +19 -0
  112. package/dist/paths.js +27 -2
  113. package/dist/scaffold/add-channel.js +2 -2
  114. package/dist/scaffold/init.js +7 -2
  115. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  116. package/dist/schedule/discover.js +3 -15
  117. package/dist/schedule/wake-alarm.d.ts +13 -7
  118. package/dist/schedule/wake-alarm.js +88 -46
  119. package/dist/schedule/wakeups.d.ts +1 -1
  120. package/dist/schedule/wakeups.js +10 -7
  121. package/dist/service.d.ts +1 -2
  122. package/dist/service.js +27 -5
  123. package/dist/session-remote.d.ts +7 -2
  124. package/dist/session-remote.js +162 -92
  125. package/dist/session.d.ts +226 -92
  126. package/dist/session.js +61 -23
  127. package/dist/telegram.d.ts +1 -1
  128. package/dist/telegram.js +1 -1
  129. package/dist/tunnel.d.ts +18 -3
  130. package/dist/tunnel.js +32 -40
  131. package/package.json +1 -1
@@ -1,21 +1,23 @@
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 (`createPiAgent({ 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 PiSessionRecordStore}), 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
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";
20
+ import { forkProvenance, isNavigable, publishedLeaf } from "./session-markers.js";
19
21
  import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
20
22
  import { log } from "../../log.js";
21
23
  // ── Entry normalization (durable plane) ──────────────────────────────────────
@@ -37,10 +39,10 @@ function textOf(content) {
37
39
  * keeps its pi type as an open-set kind with an EMPTY payload — present so `parentId` chains and
38
40
  * cursors stay intact, skippable by contract, and no pi message class leaks through the adapter.
39
41
  */
40
- function toSessionEntry(entry) {
42
+ function toSessionEntry(entry, parentId) {
41
43
  const base = {
42
44
  id: entry.id,
43
- parentId: entry.parentId ?? undefined,
45
+ parentId,
44
46
  timestamp: Date.parse(entry.timestamp),
45
47
  };
46
48
  if (entry.type === "message") {
@@ -73,20 +75,6 @@ function toSessionEntry(entry) {
73
75
  }
74
76
  return { ...base, kind: entry.type, data: {} };
75
77
  }
76
- /**
77
- * THE invariant the client's rule rests on: everything `entries()` publishes is a legal `navigate`
78
- * target. pi's `leaf` records are the exception — they journal a MOVE rather than mark a position
79
- * (their parentId is the OLD leaf, nothing is ever chained onto them), so navigating to one would
80
- * put the branch head off every conversation path. Withheld from the published plane and refused as
81
- * a target THROUGH THIS ONE PREDICATE, so a second exclusion cannot make the two disagree.
82
- */
83
- /** Every position a client may move the branch head to. `label` is metadata ABOUT an entry, not a
84
- * position in the conversation, so it is neither published nor navigable — the client's rule stays
85
- * "anything published is navigable". pi's own branch() is a pointer move and writes no record, so
86
- * there is nothing else to exclude. */
87
- function isNavigable(entry) {
88
- return entry.type !== "label";
89
- }
90
78
  // ── Live fan-out (events plane) ──────────────────────────────────────────────
91
79
  /** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
92
80
  * connection — the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
@@ -201,7 +189,8 @@ export function createPiSessionControl(options) {
201
189
  }
202
190
  fanOut(session, event);
203
191
  };
204
- const control = {
192
+ // The reads, plus the two sessionless declarations. Bound onto a handle below.
193
+ const reads = {
205
194
  async commands() {
206
195
  return (await options.commands?.()) ?? [];
207
196
  },
@@ -210,14 +199,19 @@ export function createPiSessionControl(options) {
210
199
  return {
211
200
  steering: true,
212
201
  followUp: true,
213
- manualCompaction: !!b,
214
- modelSelection: b ? { allowedModels: listModels(b.models) } : false,
215
- // 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
216
213
  // `state().availableThinkingLevels` — a list here could only answer for one model.
217
- thinkingLevel: !!b,
218
- // Gated on the boundary wiring for its LEASE, not its models: moving the leaf is a write,
219
- // and a write that races a run would hang the next turn off a stale branch.
220
- navigate: !!b,
214
+ ...(b ? { allowedModels: listModels(b.models) } : {}),
221
215
  toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
222
216
  usage: false,
223
217
  };
@@ -225,7 +219,7 @@ export function createPiSessionControl(options) {
225
219
  async state(session) {
226
220
  const run = active.get(session);
227
221
  const opened = await sessions.openIfExists(session);
228
- const leafEntryId = opened ? (opened.getLeafId() ?? undefined) : undefined;
222
+ const leafEntryId = opened ? publishedLeaf(opened) : undefined;
229
223
  // What will RUN, not the raw record: a client steering a session needs the pair that executes.
230
224
  // Without a boundary there is no model to resolve against, and the fields are absent.
231
225
  // OBSERVATION IS TOTAL: an unreadable entry chain leaves the pair absent too (the same shape a
@@ -243,8 +237,10 @@ export function createPiSessionControl(options) {
243
237
  log.warn(`[fastagent] session ${session}: settings unreadable (entry chain): ${String(error)}`);
244
238
  }
245
239
  }
240
+ const name = opened?.getSessionName();
246
241
  return {
247
242
  status: run ? "running" : compacting.has(session) ? "compacting" : "idle",
243
+ ...(name ? { name } : {}),
248
244
  ...(run ? { activeRunId: run.runId } : {}),
249
245
  ...(settings
250
246
  ? {
@@ -265,8 +261,28 @@ export function createPiSessionControl(options) {
265
261
  // would race any concurrent append into a leaf the snapshot cannot contain — a live turn
266
262
  // reading as a dangling head. This order makes the journal a superset of the leaf's chain,
267
263
  // which is what lets the published head be trusted as one of the published entries.
268
- const leafEntryId = opened.getLeafId() ?? undefined;
269
- const all = 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)));
270
286
  let entries = all;
271
287
  if (opts?.since !== undefined) {
272
288
  const idx = all.findIndex((e) => e.id === opts.since);
@@ -333,440 +349,519 @@ export function createPiSessionControl(options) {
333
349
  },
334
350
  };
335
351
  },
336
- async dispatch(session, command) {
337
- switch (command.type) {
338
- case "steer":
339
- case "follow_up":
340
- case "abort": {
341
- const run = active.get(session);
342
- if (!run) {
343
- // Run/compaction symmetry: an in-flight manual compaction is a model call too, and
344
- // `abort` is its only door — interrupting it converges through the detached
345
- // task's catch into `compaction_finished{aborted}` with the lease released; answering
346
- // no_active_run against a state() that says "compacting" would be a lie.
347
- const comp = command.type === "abort" ? compacting.get(session) : undefined;
348
- if (comp) {
349
- comp.abort();
350
- return { ok: true }; // no runId — the outcome travels as compaction_finished{aborted}
351
- }
352
- // Rejected BEFORE acceptance: no run exists, nothing happened. retryable: false —
353
- // as-is retry fails again; re-dispatch after state() shows an active run.
354
- return {
355
- ok: false,
356
- error: {
357
- code: NO_ACTIVE_RUN_CODE,
358
- message: `no active run for this session — ${command.type} modulates a run an invoke is driving`,
359
- retryable: false,
360
- },
361
- };
362
- }
363
- if (!run.controls) {
364
- // A run EXISTS (state() rightly reports running) but was registered observation-only
365
- // (the observer seam allows run_started without controls). That is a CAPABILITY
366
- // problem, not a run problem — permanent for this wiring, so neither no_active_run
367
- // (would poll forever) nor run_command_failed (transient) fits.
368
- return {
369
- ok: false,
370
- error: {
371
- code: UNSUPPORTED_CAPABILITY_CODE,
372
- message: `the active run registered without modulation controls (observation-only) ${command.type} cannot reach it`,
373
- retryable: false,
374
- },
375
- };
376
- }
377
- try {
378
- if (command.type === "steer")
379
- await run.controls.steer(command.prompt);
380
- else if (command.type === "follow_up")
381
- await run.controls.followUp(command.prompt);
382
- else
383
- await run.controls.abort();
384
- }
385
- catch (error) {
386
- // The run raced us to settlement, failed setup, or the engine refused: still
387
- // pre-acceptance (nothing was queued), distinct from "no run existed". retryable:
388
- // false for the same reason — the run is gone; consult state() before re-dispatching.
389
- return {
390
- ok: false,
391
- error: { code: RUN_COMMAND_FAILED_CODE, message: String(error), retryable: false },
392
- };
393
- }
394
- // Accepted: joined (or stopped) THIS run. The outcome arrives as run_settled.
395
- 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);
396
551
  }
397
- case "compact":
398
- case "set_model":
399
- case "set_thinking":
400
- case "navigate": {
401
- const b = boundary?.();
402
- if (!b) {
403
- // No boundary wiring: rejected before acceptance; a capability-gating client never
404
- // lands here.
405
- return {
406
- ok: false,
407
- error: {
408
- code: UNSUPPORTED_CAPABILITY_CODE,
409
- message: `command "${command.type}" is not supported by this runtime (no boundary wiring)`,
410
- retryable: false,
411
- },
412
- };
413
- }
414
- // Payload validation BEFORE the lease — an invalid value must not briefly block a run.
415
- /** The durable write for set_model/set_thinking/navigate — undefined for compact, which
416
- * writes through pi's own compaction. Answers the event to emit. */
417
- let apply;
418
- if (command.type === "set_model") {
419
- const slash = command.model.indexOf("/");
420
- const model = slash > 0 ? b.models.getModel(command.model.slice(0, slash), command.model.slice(slash + 1)) : undefined;
421
- if (!model) {
422
- return {
423
- ok: false,
424
- error: {
425
- code: INVALID_COMMAND_CODE,
426
- message: `unknown model "${command.model}" — capabilities().modelSelection lists the allowed specs`,
427
- retryable: false,
428
- },
429
- };
430
- }
431
- apply = async (s) => {
432
- s.appendModelChange(model.provider, model.id);
433
- // Both halves: a new model can change which level executes. Nothing is re-recorded to
434
- // make that true — the resolve reports it, so the preference survives a round trip.
435
- const settings = resolveSessionSettings(activePath(s), b.models, b.defaults);
436
- return {
437
- type: "state_changed",
438
- timestamp: Date.now(),
439
- // The CANONICAL spec, same string the durable entry and state() report — the event
440
- // must not echo a client alias the other two surfaces would disagree with.
441
- data: { model: `${model.provider}/${model.id}`, thinkingLevel: settings.thinkingLevel },
442
- };
443
- };
444
- }
445
- else if (command.type === "set_thinking") {
446
- // A payload that is not a level at all — invalid before any session question.
447
- if (!THINKING_LEVELS.has(command.level)) {
448
- return {
449
- ok: false,
450
- error: {
451
- code: INVALID_COMMAND_CODE,
452
- message: `unknown thinking level "${command.level}" — state().availableThinkingLevels lists what this session accepts`,
453
- retryable: false,
454
- },
455
- };
456
- }
457
- apply = async (s) => {
458
- s.appendThinkingLevelChange(command.level);
459
- return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
460
- };
461
- }
462
- else if (command.type === "navigate") {
463
- apply = async (s) => {
464
- // A move to where the leaf already is writes nothing: pi journals a move as a `leaf`
465
- // record, so an idempotent re-dispatch (a client retry, a UI firing on every
466
- // selection) would otherwise grow the session by a record no plane publishes. The
467
- // EVENT is emitted either way — it reports the resulting position, not the fact that
468
- // a record was written, and a client that dispatched must not have to poll for it.
469
- if (s.getLeafId() !== command.targetId)
470
- s.branch(command.targetId);
471
- // branch()'s postcondition IS "targetId is the leaf" (it validates, then sets); a
472
- // failure throws and travels as boundary_command_failed, so a read-back could only
473
- // re-report what this line already knows. The SETTINGS ride along because a move can
474
- // change them — an override recorded on the branch just left stops applying, and a
475
- // client tracking model/level from the event stream would otherwise show what the
476
- // next turn will not use.
477
- // The move is already durable here, so a settings read that throws (the new path is
478
- // above a gap) must NOT turn into "nothing took effect": report the position without
479
- // the settings and let `state()`'s rejection be where the broken chain surfaces.
480
- let settings;
481
- try {
482
- settings = resolveSessionSettings(activePath(s), b.models, b.defaults);
483
- }
484
- catch (error) {
485
- // Absent rather than stale: the session cannot RUN with an unreadable chain either
486
- // (binding a session walks the same path), so the next invoke fails visibly — this
487
- // event does not need to carry a second signal for it.
488
- log.warn(`[fastagent] session ${session}: leaf moved, settings unreadable: ${String(error)}`);
489
- }
490
- return {
491
- type: "state_changed",
492
- timestamp: Date.now(),
493
- data: {
494
- leafEntryId: command.targetId,
495
- ...(settings
496
- ? {
497
- model: `${settings.model.provider}/${settings.model.id}`,
498
- thinkingLevel: settings.thinkingLevel,
499
- }
500
- : {}),
501
- },
502
- };
503
- };
504
- }
505
- // Sessions are created by invoke, never here: a mutation on an unknown id is rejected,
506
- // not minted into a ghost record. (Existence check before the lease — read-only; the
507
- // WRITE handle is re-opened under the lease below, this one is discarded.)
508
- const existing = await sessions.openIfExists(session);
509
- if (!existing) {
510
- return {
511
- ok: false,
512
- error: {
513
- code: NO_SUCH_SESSION_CODE,
514
- message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
515
- retryable: false,
516
- },
517
- };
518
- }
519
- if (command.type === "navigate") {
520
- // A target that cannot BE a leaf is a permanent payload error, not a session error — the
521
- // same disposition as an unknown model spec. Same predicate `entries()` publishes by, so
522
- // "everything published is navigable" holds by construction rather than by two literals
523
- // agreeing.
524
- const entry = existing.getEntry(command.targetId);
525
- if (!entry || !isNavigable(entry)) {
526
- return {
527
- ok: false,
528
- error: {
529
- code: INVALID_COMMAND_CODE,
530
- message: entry
531
- ? `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`
532
- : `entry "${command.targetId}" does not exist in session "${session}" — entries() lists the navigable ids`,
533
- retryable: false,
534
- },
535
- };
536
- }
537
- }
538
- if (command.type === "set_thinking") {
539
- // The same set `state()` showed the client. Reject here rather than record a level the
540
- // run would not use. The read is guarded because `dispatch` must never REJECT — the
541
- // transport promises a SessionResult, so an unreadable chain has to arrive as a code.
542
- let resolved;
543
- try {
544
- resolved = resolveSessionSettings(activePath(existing), b.models, b.defaults);
545
- }
546
- catch (error) {
547
- return {
548
- ok: false,
549
- error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
550
- };
551
- }
552
- const { model, availableThinkingLevels } = resolved;
553
- if (!availableThinkingLevels.includes(command.level)) {
554
- return {
555
- ok: false,
556
- error: {
557
- code: INVALID_COMMAND_CODE,
558
- message: `thinking level "${command.level}" is not supported by ${model.provider}/${model.id} (allowed: ${availableThinkingLevels.join(", ")})`,
559
- retryable: false,
560
- },
561
- };
562
- }
563
- }
564
- // Boundary mutations are the control plane's only writers: same lease as every run — a
565
- // mutation must never race one (design §9).
566
- const release = b.lease.tryAcquire(session);
567
- if (!release) {
568
- return {
569
- ok: false,
570
- error: {
571
- code: SESSION_BUSY_CODE,
572
- message: "session busy: a run (or another boundary mutation) is in flight — retry at idle",
573
- retryable: true,
574
- },
575
- };
576
- }
577
- if (command.type === "compact") {
578
- // ACCEPT-FAST: compaction is a full model call (tens of seconds is normal) — holding
579
- // the dispatch open until it finishes made acceptance = outcome, the one exception to
580
- // §5.2, and broke remote clients whose request timeouts are sized for control calls.
581
- // The dispatch answers once the work is ADMITTED (lease held, session bound); the
582
- // outcome travels as compaction_finished{summary|error|aborted}, the bounds contract
583
- // watchers already rely on.
584
- //
585
- // The admission step is everything cheap and local: binding the session (the ONE
586
- // canonical resolution of overrides + auth) plus the compaction PREPARATION, a pure
587
- // branch read. The boundary between "reject the dispatch" and "the outcome travels as
588
- // an event" sits where the work becomes asynchronous and expensive: the model call.
589
- // "Nothing to compact" is therefore a pre-acceptance answer, never a finished{error}
590
- // dressed as a failure — pi reports it as a throw from compact(), too late to reject.
591
- let bound;
592
- try {
593
- bound = await b.sessionFactory(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 = () => {
603
- try {
604
- bound.dispose();
605
- }
606
- catch (error) {
607
- log.warn(`[fastagent] compaction session teardown failed: ${String(error)}`);
608
- }
609
- };
610
- try {
611
- // The SAME settings pi will use inside compact(): asking with different thresholds
612
- // would either reject a compaction pi would have run, or admit one it refuses - and
613
- // its refusal arrives too late to be a pre-acceptance answer.
614
- const path = bound.sessionManager.getBranch();
615
- const prep = prepareCompaction(path, bound.settingsManager.getCompactionSettings());
616
- if (!prep.ok)
617
- throw prep.error;
618
- // Empty is the same answer as absent: pi ships two prepareCompaction implementations
619
- // (agent-core answers with a Result, coding-agent with undefined) and they disagree on
620
- // which one an unsummarizable session gets. What they agree on is the CONTENT — no
621
- // messages to summarize — so that is what the gate reads.
622
- if (!prep.value || prep.value.messagesToSummarize.length === 0) {
623
- teardown();
624
- release();
625
- // A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
626
- // machine-distinguish "give up" from "re-dispatch once the session grows", and
627
- // branching on message prose is forbidden by contract.
628
- return {
629
- ok: false,
630
- error: {
631
- code: NOTHING_TO_COMPACT_CODE,
632
- message: "nothing to compact — the session has no compactable history yet; retry after more turns",
633
- retryable: false,
634
- },
635
- };
636
- }
637
- }
638
- catch (error) {
639
- teardown();
640
- release();
641
- return {
642
- ok: false,
643
- error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
644
- };
645
- }
646
- // The door is the session's own compaction abort — a real one, unlike a summarization
647
- // call with no signal: `abort` must reach the model call (run/compaction symmetry).
648
- //
649
- // pi builds the controller that makes it abortable AFTER an internal await, so an abort
650
- // arriving in that window would find nothing to cancel and the compaction would run to
651
- // completion — the client's cancel silently doing nothing. The intent is latched and
652
- // re-applied until it takes (`isCompacting` reports when it has).
653
- //
654
- // The retry is DEFENSIVE: the window is one await wide, and the test below lands after
655
- // it, so this loop is not what makes that test pass. It is here because the window is on
656
- // the code path, not because it has been observed.
657
- let aborted = false;
658
- let running = true; // cleared when the compaction settles, however it settles
659
- const applyAbort = async () => {
660
- // WAIT for the controller rather than requiring it: an abort that arrives before pi
661
- // builds one sees isCompacting false, and a loop that only runs WHILE compacting would
662
- // exit immediately — leaving the intent unapplied in exactly the window it exists for.
663
- for (let attempt = 0; attempt < 200 && running; attempt++) {
664
- if (bound.isCompacting) {
665
- bound.abortCompaction();
666
- if (!bound.isCompacting)
667
- return; // it took
668
- }
669
- await new Promise((resolve) => setTimeout(resolve, 1));
670
- }
671
- };
672
- compacting.set(session, {
673
- abort: () => {
674
- aborted = true;
675
- bound.abortCompaction();
676
- void applyAbort();
677
- },
678
- });
679
- emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
680
- void (async () => {
681
- let outcome;
682
- // Retries are otherwise invisible between compaction_started and _finished — surface
683
- // each backoff so a long gap is diagnosable (not confusable with a hang): as a session
684
- // event for attached observers, as a warn for server logs.
685
- const unsub = bound.subscribe((event) => {
686
- if (event.type !== "summarization_retry_scheduled")
687
- return;
688
- log.warn(`[fastagent] compaction retry ${event.attempt}/${event.maxAttempts} in ${event.delayMs}ms (session ${session}): ${event.errorMessage}`);
689
- emitOwn(session, {
690
- type: "retry_scheduled",
691
- timestamp: Date.now(),
692
- data: {
693
- operation: "compaction",
694
- attempt: event.attempt,
695
- maxAttempts: event.maxAttempts,
696
- delayMs: event.delayMs,
697
- error: event.errorMessage,
698
- },
699
- });
700
- });
701
- try {
702
- const done = await bound.compact(command.instructions);
703
- outcome = { summary: done.summary };
704
- }
705
- catch (error) {
706
- // A deliberate stop is not a failure — run/compaction symmetry with
707
- // run_settled{aborted}: the intent is the classification, same discipline as run
708
- // abort attribution (a racing real failure still reads as aborted).
709
- outcome = aborted ? { aborted: true } : { error: String(error) };
710
- }
711
- running = false;
712
- unsub();
713
- teardown();
714
- // Release BEFORE emitting finished: a watcher seeing finished may dispatch next —
715
- // "finished ⇒ the lease is free and status is no longer compacting" must hold.
716
- compacting.delete(session);
717
- release();
718
- emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
719
- })();
720
- return { ok: true };
721
- }
722
- try {
723
- // The WRITE handle is opened UNDER the lease: a handle from before tryAcquire could
724
- // be a stale snapshot of a run that completed in the window — appending to it would
725
- // hang the override off an outdated leaf.
726
- const fresh = await sessions.openIfExists(session);
727
- if (!fresh) {
728
- // Same real condition as the pre-lease check (the session vanished in the window):
729
- // same code, same disposition — not a retryable internal error.
730
- return {
731
- ok: false,
732
- error: {
733
- code: NO_SUCH_SESSION_CODE,
734
- message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
735
- retryable: false,
736
- },
737
- };
738
- }
739
- // Unreachable by construction: only set_model/set_thinking/navigate reach this branch,
740
- // and all three assign `apply` in validation. Throw rather than silently skip (fail visibly).
741
- if (!apply)
742
- throw new Error("apply unset outside the compact branch (dispatch invariant broken)");
743
- emitOwn(session, await apply(fresh));
744
- }
745
- catch (error) {
746
- // The append failed before anything durable landed — "nothing took effect"; the same
747
- // command may succeed on retry.
748
- return {
749
- ok: false,
750
- error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
751
- };
752
- }
753
- finally {
754
- release();
755
- }
756
- 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)}`);
757
557
  }
758
- default:
759
- // Wire input bypasses the TS union (a remote client can send any `type`): a protocol-
760
- // level answer, never an undefined body — the transport promises `ok: false` shapes.
761
- return {
762
- ok: false,
763
- error: {
764
- code: INVALID_COMMAND_CODE,
765
- message: `unknown command type "${String(command.type)}"`,
766
- retryable: false,
767
- },
768
- };
769
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
+ }),
770
865
  },
771
866
  };
772
867
  return { control, observer };