@fastagent-sh/fastagent 0.17.1 → 0.19.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 (170) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +12 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +19 -0
  5. package/dist/atomic-write.js +48 -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-service.d.ts +54 -0
  9. package/dist/channels/agentcore-service.js +111 -0
  10. package/dist/channels/agentcore.d.ts +12 -2
  11. package/dist/channels/agentcore.js +6 -4
  12. package/dist/channels/busy.d.ts +1 -1
  13. package/dist/channels/busy.js +1 -1
  14. package/dist/channels/control.d.ts +16 -6
  15. package/dist/channels/control.js +106 -4
  16. package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
  17. package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
  18. package/dist/channels/feishu/card.js +1 -1
  19. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  20. package/dist/channels/feishu/context-buffer.js +4 -4
  21. package/dist/channels/feishu/feishu-api.d.ts +4 -2
  22. package/dist/channels/feishu/feishu-api.js +1 -1
  23. package/dist/channels/feishu/feishu.d.ts +1 -1
  24. package/dist/channels/feishu/feishu.js +45 -15
  25. package/dist/channels/feishu/invoke-turn.d.ts +9 -3
  26. package/dist/channels/feishu/invoke-turn.js +152 -34
  27. package/dist/channels/feishu/parse.js +6 -0
  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 +16 -111
  34. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  35. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +18 -4
  36. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +23 -8
  37. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  38. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  39. package/dist/channels/kit/state.d.ts +6 -0
  40. package/dist/channels/{state.js → kit/state.js} +4 -24
  41. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  42. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  43. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  44. package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +7 -0
  45. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +4 -1
  46. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  47. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  48. package/dist/channels/lark/lark.d.ts +1 -1
  49. package/dist/channels/serve.d.ts +73 -0
  50. package/dist/channels/serve.js +243 -0
  51. package/dist/channels/slack/bot-auth.js +3 -14
  52. package/dist/channels/slack/context-buffer.d.ts +2 -2
  53. package/dist/channels/slack/context-buffer.js +2 -2
  54. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  55. package/dist/channels/slack/invoke-turn.js +9 -6
  56. package/dist/channels/slack/parse.js +2 -2
  57. package/dist/channels/slack/preview.d.ts +1 -1
  58. package/dist/channels/slack/preview.js +11 -3
  59. package/dist/channels/slack/slack-api.js +1 -1
  60. package/dist/channels/slack/slack.d.ts +1 -1
  61. package/dist/channels/slack/slack.js +8 -12
  62. package/dist/channels/slack/welcomed.js +1 -1
  63. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  64. package/dist/channels/telegram/context-buffer.js +2 -2
  65. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  66. package/dist/channels/telegram/invoke-turn.js +2 -2
  67. package/dist/channels/telegram/parse.js +2 -2
  68. package/dist/channels/telegram/preview.d.ts +1 -1
  69. package/dist/channels/telegram/preview.js +6 -2
  70. package/dist/channels/telegram/telegram.d.ts +1 -1
  71. package/dist/channels/telegram/telegram.js +3 -3
  72. package/dist/channels/telegram/turn-store.d.ts +2 -2
  73. package/dist/channels/telegram/turn-store.js +2 -2
  74. package/dist/cli/commands/add.js +1 -1
  75. package/dist/cli/commands/deploy.js +13 -5
  76. package/dist/cli/commands/dev.js +26 -14
  77. package/dist/cli/commands/fire.js +2 -2
  78. package/dist/cli/commands/info.js +28 -5
  79. package/dist/cli/commands/invoke.js +1 -1
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +64 -95
  82. package/dist/cli/commands/tool.js +5 -7
  83. package/dist/cli/program.js +1 -1
  84. package/dist/cli/serve.d.ts +18 -57
  85. package/dist/cli/serve.js +57 -243
  86. package/dist/cli/shared.d.ts +5 -3
  87. package/dist/cli/shared.js +14 -7
  88. package/dist/collect.d.ts +30 -4
  89. package/dist/collect.js +39 -6
  90. package/dist/core.d.ts +3 -5
  91. package/dist/core.js +7 -5
  92. package/dist/deploy/preflight.d.ts +8 -2
  93. package/dist/deploy/preflight.js +22 -4
  94. package/dist/deploy/secrets.d.ts +3 -0
  95. package/dist/deploy/secrets.js +6 -0
  96. package/dist/dev-supervisor.js +16 -4
  97. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  98. package/dist/engines/pi/agent-session-factory.js +314 -0
  99. package/dist/engines/pi/config.d.ts +4 -4
  100. package/dist/engines/pi/config.js +1 -1
  101. package/dist/engines/pi/create.d.ts +56 -42
  102. package/dist/engines/pi/create.js +150 -75
  103. package/dist/engines/pi/definition.d.ts +41 -7
  104. package/dist/engines/pi/definition.js +115 -11
  105. package/dist/engines/pi/invoke-session.d.ts +35 -0
  106. package/dist/engines/pi/invoke-session.js +357 -0
  107. package/dist/engines/pi/login.js +7 -4
  108. package/dist/engines/pi/models.d.ts +59 -11
  109. package/dist/engines/pi/models.js +64 -9
  110. package/dist/engines/pi/open.d.ts +9 -6
  111. package/dist/engines/pi/open.js +20 -10
  112. package/dist/engines/pi/report.d.ts +0 -4
  113. package/dist/engines/pi/report.js +0 -7
  114. package/dist/engines/pi/service.d.ts +23 -0
  115. package/dist/engines/pi/service.js +19 -0
  116. package/dist/engines/pi/session-builder.js +88 -90
  117. package/dist/engines/pi/session-control.d.ts +11 -9
  118. package/dist/engines/pi/session-control.js +114 -77
  119. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  120. package/dist/engines/pi/session-inheritance.js +242 -0
  121. package/dist/engines/pi/session-settings.d.ts +14 -2
  122. package/dist/engines/pi/session-settings.js +18 -0
  123. package/dist/engines/pi/session-store.d.ts +70 -0
  124. package/dist/engines/pi/session-store.js +313 -0
  125. package/dist/engines/pi/tool-context.d.ts +18 -8
  126. package/dist/engines/pi/tool-context.js +23 -1
  127. package/dist/engines/pi/tool.d.ts +5 -5
  128. package/dist/engines/pi/tool.js +1 -1
  129. package/dist/engines/pi/{invoke.d.ts → turn-kit.d.ts} +46 -45
  130. package/dist/engines/pi/turn-kit.js +188 -0
  131. package/dist/engines/pi/wake-tool.js +1 -1
  132. package/dist/index.d.ts +1 -0
  133. package/dist/index.js +1 -0
  134. package/dist/log.d.ts +3 -0
  135. package/dist/log.js +5 -1
  136. package/dist/node.d.ts +16 -0
  137. package/dist/node.js +20 -0
  138. package/dist/paths.d.ts +13 -0
  139. package/dist/paths.js +16 -0
  140. package/dist/pi.d.ts +15 -8
  141. package/dist/pi.js +7 -6
  142. package/dist/scaffold/templates/fastagent.config.mjs +2 -0
  143. package/dist/scaffold/templates/persona.md +2 -2
  144. package/dist/schedule/state.js +4 -9
  145. package/dist/schedule/wakeups.d.ts +1 -1
  146. package/dist/schedule/wakeups.js +1 -1
  147. package/dist/service.d.ts +158 -0
  148. package/dist/service.js +354 -0
  149. package/dist/session-remote.d.ts +1 -1
  150. package/dist/session-remote.js +10 -2
  151. package/dist/session.d.ts +4 -2
  152. package/dist/session.js +1 -1
  153. package/package.json +14 -5
  154. package/dist/channels/state.d.ts +0 -16
  155. package/dist/engines/pi/harness.d.ts +0 -112
  156. package/dist/engines/pi/harness.js +0 -153
  157. package/dist/engines/pi/invoke.js +0 -618
  158. package/dist/engines/pi/read-image.d.ts +0 -4
  159. package/dist/engines/pi/read-image.js +0 -62
  160. package/dist/engines/pi/sessions.d.ts +0 -44
  161. package/dist/engines/pi/sessions.js +0 -182
  162. package/dist/host/node.js +0 -52
  163. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  164. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  165. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  166. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  167. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  168. /package/dist/channels/{text.js → kit/text.js} +0 -0
  169. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  170. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -2,24 +2,22 @@
2
2
  * The pi implementation of the session control plane: observation (`state`/`entries`/`events`,
3
3
  * design Phase 1) and run modulation (`dispatch`: steer/follow_up/abort, Phase 2a) over
4
4
  * invoke-driven runs. `createPiSessionControl` returns the neutral `SessionControl` plus the
5
- * {@link SessionObserver} to plug into the invoke pipeline (`createPiAgentFromHarness({ observer })`)
5
+ * {@link SessionObserver} to plug into the invoke pipeline (`createPiAgent({ observer })`)
6
6
  * — the hub derives everything from the rich event stream (plus the {@link RunControls} the
7
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
8
+ * stays in the session repository (read via {@link PiSessionRecordStore}), live truth in the events the
9
9
  * data plane emits, modulation in the controls the data plane registers.
10
10
  *
11
11
  * Boundary mutations (Phase 2b: compact/set_model/set_thinking/navigate) take the same lease as runs;
12
12
  * without boundary wiring they are rejected before acceptance with `unsupported_capability` — a
13
13
  * 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
16
  import { SESSION_BUSY_CODE } from "../../agent.js";
17
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
18
  import { listModels } from "./config.js";
19
- import { SUMMARIZATION_RETRY_POLICY, harnessSession } from "./harness.js";
20
- import { THINKING_LEVELS, resolveSessionSettings } from "./session-settings.js";
19
+ import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
21
20
  import { log } from "../../log.js";
22
- import { activePathEntries } from "./sessions.js";
23
21
  // ── Entry normalization (durable plane) ──────────────────────────────────────
24
22
  /** Concatenated plain text of a message's content blocks (the L0 rendering payload). A custom
25
23
  * AgentMessage role may carry no `content` at all — that reads as empty, not a crash. */
@@ -34,7 +32,7 @@ function textOf(content) {
34
32
  .join("");
35
33
  }
36
34
  /**
37
- * pi `SessionTreeEntry` → neutral {@link SessionEntry}. Message entries map onto the guaranteed
35
+ * pi `PiSessionEntry` → neutral {@link SessionEntry}. Message entries map onto the guaranteed
38
36
  * kind vocabulary (user/assistant/tool) with a minimal render payload; every other engine record
39
37
  * keeps its pi type as an open-set kind with an EMPTY payload — present so `parentId` chains and
40
38
  * cursors stay intact, skippable by contract, and no pi message class leaks through the adapter.
@@ -82,8 +80,12 @@ function toSessionEntry(entry) {
82
80
  * put the branch head off every conversation path. Withheld from the published plane and refused as
83
81
  * a target THROUGH THIS ONE PREDICATE, so a second exclusion cannot make the two disagree.
84
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. */
85
87
  function isNavigable(entry) {
86
- return entry.type !== "leaf";
88
+ return entry.type !== "label";
87
89
  }
88
90
  // ── Live fan-out (events plane) ──────────────────────────────────────────────
89
91
  /** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
@@ -223,19 +225,19 @@ export function createPiSessionControl(options) {
223
225
  async state(session) {
224
226
  const run = active.get(session);
225
227
  const opened = await sessions.openIfExists(session);
226
- const leafEntryId = opened ? ((await opened.getLeafId()) ?? undefined) : undefined;
228
+ const leafEntryId = opened ? (opened.getLeafId() ?? undefined) : undefined;
227
229
  // What will RUN, not the raw record: a client steering a session needs the pair that executes.
228
230
  // Without a boundary there is no model to resolve against, and the fields are absent.
229
231
  // OBSERVATION IS TOTAL: an unreadable entry chain leaves the pair absent too (the same shape a
230
232
  // control-less deployment answers with) rather than rejecting a read that has no error-code
231
233
  // 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
234
+ // next invoke fails (binding a session walks the same chain) and a boundary dispatch answers
233
235
  // `boundary_command_failed`. Here it is a server-side warn.
234
236
  const b = boundary?.();
235
237
  let settings;
236
238
  if (opened && b) {
237
239
  try {
238
- settings = resolveSessionSettings((await activePathEntries(opened)), b.models, b.defaults);
240
+ settings = resolveSessionSettings(activePath(opened), b.models, b.defaults);
239
241
  }
240
242
  catch (error) {
241
243
  log.warn(`[fastagent] session ${session}: settings unreadable (entry chain): ${String(error)}`);
@@ -263,8 +265,8 @@ export function createPiSessionControl(options) {
263
265
  // would race any concurrent append into a leaf the snapshot cannot contain — a live turn
264
266
  // reading as a dangling head. This order makes the journal a superset of the leaf's chain,
265
267
  // 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);
268
+ const leafEntryId = opened.getLeafId() ?? undefined;
269
+ const all = opened.getEntries().filter(isNavigable).map(toSessionEntry);
268
270
  let entries = all;
269
271
  if (opts?.since !== undefined) {
270
272
  const idx = all.findIndex((e) => e.id === opts.since);
@@ -339,7 +341,7 @@ export function createPiSessionControl(options) {
339
341
  const run = active.get(session);
340
342
  if (!run) {
341
343
  // 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
344
+ // `abort` is its only door — interrupting it converges through the detached
343
345
  // task's catch into `compaction_finished{aborted}` with the lease released; answering
344
346
  // no_active_run against a state() that says "compacting" would be a lie.
345
347
  const comp = command.type === "abort" ? compacting.get(session) : undefined;
@@ -410,8 +412,8 @@ export function createPiSessionControl(options) {
410
412
  };
411
413
  }
412
414
  // 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
+ /** 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. */
415
417
  let apply;
416
418
  if (command.type === "set_model") {
417
419
  const slash = command.model.indexOf("/");
@@ -427,10 +429,10 @@ export function createPiSessionControl(options) {
427
429
  };
428
430
  }
429
431
  apply = async (s) => {
430
- await s.appendModelChange(model.provider, model.id);
432
+ s.appendModelChange(model.provider, model.id);
431
433
  // Both halves: a new model can change which level executes. Nothing is re-recorded to
432
434
  // 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);
435
+ const settings = resolveSessionSettings(activePath(s), b.models, b.defaults);
434
436
  return {
435
437
  type: "state_changed",
436
438
  timestamp: Date.now(),
@@ -453,7 +455,7 @@ export function createPiSessionControl(options) {
453
455
  };
454
456
  }
455
457
  apply = async (s) => {
456
- await s.appendThinkingLevelChange(command.level);
458
+ s.appendThinkingLevelChange(command.level);
457
459
  return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
458
460
  };
459
461
  }
@@ -464,9 +466,9 @@ export function createPiSessionControl(options) {
464
466
  // selection) would otherwise grow the session by a record no plane publishes. The
465
467
  // EVENT is emitted either way — it reports the resulting position, not the fact that
466
468
  // 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
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
470
472
  // failure throws and travels as boundary_command_failed, so a read-back could only
471
473
  // re-report what this line already knows. The SETTINGS ride along because a move can
472
474
  // change them — an override recorded on the branch just left stops applying, and a
@@ -477,11 +479,11 @@ export function createPiSessionControl(options) {
477
479
  // the settings and let `state()`'s rejection be where the broken chain surfaces.
478
480
  let settings;
479
481
  try {
480
- settings = resolveSessionSettings((await activePathEntries(s)), b.models, b.defaults);
482
+ settings = resolveSessionSettings(activePath(s), b.models, b.defaults);
481
483
  }
482
484
  catch (error) {
483
485
  // 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
486
+ // (binding a session walks the same path), so the next invoke fails visibly — this
485
487
  // event does not need to carry a second signal for it.
486
488
  log.warn(`[fastagent] session ${session}: leaf moved, settings unreadable: ${String(error)}`);
487
489
  }
@@ -519,7 +521,7 @@ export function createPiSessionControl(options) {
519
521
  // same disposition as an unknown model spec. Same predicate `entries()` publishes by, so
520
522
  // "everything published is navigable" holds by construction rather than by two literals
521
523
  // agreeing.
522
- const entry = await existing.getEntry(command.targetId);
524
+ const entry = existing.getEntry(command.targetId);
523
525
  if (!entry || !isNavigable(entry)) {
524
526
  return {
525
527
  ok: false,
@@ -539,7 +541,7 @@ export function createPiSessionControl(options) {
539
541
  // transport promises a SessionResult, so an unreadable chain has to arrive as a code.
540
542
  let resolved;
541
543
  try {
542
- resolved = resolveSessionSettings((await activePathEntries(existing)), b.models, b.defaults);
544
+ resolved = resolveSessionSettings(activePath(existing), b.models, b.defaults);
543
545
  }
544
546
  catch (error) {
545
547
  return {
@@ -576,21 +578,19 @@ export function createPiSessionControl(options) {
576
578
  // ACCEPT-FAST: compaction is a full model call (tens of seconds is normal) — holding
577
579
  // the dispatch open until it finishes made acceptance = outcome, the one exception to
578
580
  // §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;
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
592
  try {
593
- harness = await b.harnessFactory(session);
593
+ bound = await b.sessionFactory(session);
594
594
  }
595
595
  catch (error) {
596
596
  release();
@@ -599,26 +599,28 @@ export function createPiSessionControl(options) {
599
599
  error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
600
600
  };
601
601
  }
602
- const teardown = async () => {
602
+ const teardown = () => {
603
603
  try {
604
- await harness.abort(); // fresh-harness discipline
604
+ bound.dispose();
605
605
  }
606
606
  catch (error) {
607
- log.warn(`[fastagent] compaction harness teardown failed: ${String(error)}`);
607
+ log.warn(`[fastagent] compaction session teardown failed: ${String(error)}`);
608
608
  }
609
609
  };
610
- let record;
611
- let preparation;
612
610
  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);
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());
618
616
  if (!prep.ok)
619
617
  throw prep.error;
620
- if (!prep.value) {
621
- await teardown();
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();
622
624
  release();
623
625
  // A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
624
626
  // machine-distinguish "give up" from "re-dispatch once the session grows", and
@@ -632,48 +634,83 @@ export function createPiSessionControl(options) {
632
634
  },
633
635
  };
634
636
  }
635
- preparation = prep.value;
636
637
  }
637
638
  catch (error) {
638
- await teardown();
639
+ teardown();
639
640
  release();
640
641
  return {
641
642
  ok: false,
642
643
  error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
643
644
  };
644
645
  }
645
- const door = new AbortController();
646
- compacting.set(session, door); // admission complete: from here `abort` reaches the model call
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
+ });
647
679
  emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
648
680
  void (async () => {
649
681
  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
- });
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,
662
698
  },
663
699
  });
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 };
700
+ });
701
+ try {
702
+ const done = await bound.compact(command.instructions);
703
+ outcome = { summary: done.summary };
668
704
  }
669
705
  catch (error) {
670
706
  // 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) };
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) };
675
710
  }
676
- await teardown();
711
+ running = false;
712
+ unsub();
713
+ teardown();
677
714
  // Release BEFORE emitting finished: a watcher seeing finished may dispatch next —
678
715
  // "finished ⇒ the lease is free and status is no longer compacting" must hold.
679
716
  compacting.delete(session);
@@ -0,0 +1,44 @@
1
+ import { SessionManager } from "@earendil-works/pi-coding-agent";
2
+ /** What a Caller names when a new session should start from an existing one. */
3
+ export interface SessionInheritance {
4
+ /** The session to inherit from. Missing or unreadable → start empty, with a warn: context is not
5
+ * the ask, and losing it must not cost the turn. */
6
+ parentSession: string;
7
+ /** Opaque markers that MAY locate the branch point on the parent's active path (searched in
8
+ * message content, first hit wins, most recent occurrence). No match → the parent's present. */
9
+ branchHints?: string[];
10
+ }
11
+ /**
12
+ * The branch point this inheritance should copy up to, and the parent's path — the decision half,
13
+ * shared by both backends because WHERE a thread branches is policy, not storage.
14
+ */
15
+ export declare function inheritanceCut(parent: SessionManager, branchHints?: string[]): {
16
+ at: string;
17
+ } | undefined;
18
+ /**
19
+ * Copy the parent's path up to `at` into `child`, entry by entry — what a backend with no FILE to
20
+ * fork has to do instead. Kinds pi models as facts about an entry rather than positions (labels,
21
+ * the session name) are not copied: they describe the parent's record, not the thread's history.
22
+ */
23
+ export declare function copyBranchInto(parent: SessionManager, child: SessionManager, at: string): void;
24
+ /**
25
+ * Fork `parent` into a record named `id`, up to the branch point the hints locate, in `stagingDir`.
26
+ *
27
+ * Two pi calls rather than one, because neither alone does it: `createBranchedSession` copies
28
+ * exactly the path to an entry but names the result with a generated id, and `forkFrom` takes an id
29
+ * but copies everything. The intermediate is deleted; it exists for one call.
30
+ *
31
+ * STAGED, not published: the caller finishes the record (crash reconciliation) and moves it into
32
+ * place. Publishing here and finishing after would leave a half-prepared record under the id on any
33
+ * later failure — and the fallback path would then create a SECOND record with the same id, making
34
+ * which one a lookup finds a matter of directory order.
35
+ *
36
+ * Returns undefined when inheritance cannot be honored — the caller starts the session empty.
37
+ */
38
+ export declare function forkForInheritance(options: {
39
+ parent: SessionManager;
40
+ id: string;
41
+ cwd: string;
42
+ stagingDir: string;
43
+ branchHints?: string[];
44
+ }): SessionManager | undefined;