@fastagent-sh/fastagent 0.20.0 → 0.21.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 (155) hide show
  1. package/README.md +5 -1
  2. package/dist/channels/agentcore-protocol.d.ts +112 -0
  3. package/dist/channels/agentcore-protocol.js +22 -0
  4. package/dist/channels/agentcore-service.d.ts +0 -4
  5. package/dist/channels/agentcore-service.js +1 -6
  6. package/dist/channels/agentcore-state.d.ts +5 -11
  7. package/dist/channels/agentcore-state.js +4 -1
  8. package/dist/channels/agentcore.d.ts +1 -67
  9. package/dist/channels/agentcore.js +90 -89
  10. package/dist/channels/control.d.ts +1 -1
  11. package/dist/channels/control.js +13 -62
  12. package/dist/channels/discover.d.ts +0 -1
  13. package/dist/channels/discover.js +1 -3
  14. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  15. package/dist/channels/feishu/context-buffer.js +0 -38
  16. package/dist/channels/feishu/crypto.d.ts +0 -2
  17. package/dist/channels/feishu/crypto.js +3 -8
  18. package/dist/channels/feishu/feishu-api.js +2 -4
  19. package/dist/channels/feishu/feishu.js +56 -115
  20. package/dist/channels/feishu/parse.d.ts +4 -3
  21. package/dist/channels/feishu/parse.js +5 -4
  22. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  23. package/dist/channels/feishu/register-webhook.js +43 -57
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  26. package/dist/channels/feishu/setup-mode.js +26 -0
  27. package/dist/channels/feishu/shared-api.d.ts +10 -0
  28. package/dist/channels/feishu/shared-api.js +38 -0
  29. package/dist/channels/http.d.ts +0 -8
  30. package/dist/channels/http.js +4 -56
  31. package/dist/channels/kit/turn-runner.d.ts +59 -0
  32. package/dist/channels/kit/turn-runner.js +84 -0
  33. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  34. package/dist/channels/registration.d.ts +36 -1
  35. package/dist/channels/registration.js +57 -1
  36. package/dist/channels/secret.d.ts +1 -0
  37. package/dist/channels/secret.js +16 -0
  38. package/dist/channels/slack/config-api.d.ts +35 -3
  39. package/dist/channels/slack/config-api.js +51 -9
  40. package/dist/channels/slack/manifest.js +5 -1
  41. package/dist/channels/slack/onboard.d.ts +5 -5
  42. package/dist/channels/slack/onboard.js +41 -20
  43. package/dist/channels/slack/register-webhook.d.ts +3 -2
  44. package/dist/channels/slack/register-webhook.js +34 -14
  45. package/dist/channels/slack/scaffold/channel.ts +3 -10
  46. package/dist/channels/slack/scaffold/slack-send.ts +18 -126
  47. package/dist/channels/slack/shared-api.d.ts +10 -0
  48. package/dist/channels/slack/shared-api.js +34 -0
  49. package/dist/channels/slack/slack-api.d.ts +20 -2
  50. package/dist/channels/slack/slack-api.js +81 -22
  51. package/dist/channels/slack/slack.d.ts +0 -10
  52. package/dist/channels/slack/slack.js +60 -99
  53. package/dist/channels/sse.d.ts +4 -0
  54. package/dist/channels/sse.js +66 -0
  55. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  56. package/dist/channels/telegram/register-webhook.js +44 -42
  57. package/dist/channels/telegram/telegram.js +47 -142
  58. package/dist/channels/wait-health.js +7 -4
  59. package/dist/cli/add-feishu.js +3 -10
  60. package/dist/cli/add-slack.js +7 -20
  61. package/dist/cli/commands/add.d.ts +0 -1
  62. package/dist/cli/commands/add.js +22 -31
  63. package/dist/cli/commands/chat.js +6 -12
  64. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  65. package/dist/cli/commands/deploy/agentcore.js +178 -0
  66. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  67. package/dist/cli/commands/deploy/docker.js +119 -0
  68. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  69. package/dist/cli/commands/deploy/fly.js +131 -0
  70. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  71. package/dist/cli/commands/deploy/railway.js +71 -0
  72. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  73. package/dist/cli/commands/deploy/shared.js +124 -0
  74. package/dist/cli/commands/deploy.d.ts +17 -49
  75. package/dist/cli/commands/deploy.js +39 -618
  76. package/dist/cli/commands/dev.js +17 -46
  77. package/dist/cli/commands/fire.js +6 -12
  78. package/dist/cli/commands/info.js +2 -1
  79. package/dist/cli/commands/invoke.js +4 -11
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +17 -46
  82. package/dist/cli/commands/tool.js +3 -8
  83. package/dist/cli/kernel.d.ts +0 -2
  84. package/dist/cli/kernel.js +0 -2
  85. package/dist/cli/program.js +7 -12
  86. package/dist/cli/serve.d.ts +36 -19
  87. package/dist/cli/serve.js +91 -7
  88. package/dist/cli/shared.d.ts +13 -18
  89. package/dist/cli/shared.js +20 -4
  90. package/dist/deploy/agentcore/forwarder.js +250 -0
  91. package/dist/deploy/agentcore/plan.d.ts +23 -2
  92. package/dist/deploy/agentcore/plan.js +29 -244
  93. package/dist/deploy/agentcore/run.d.ts +4 -20
  94. package/dist/deploy/agentcore/run.js +22 -7
  95. package/dist/deploy/channel-ingress.js +1 -1
  96. package/dist/deploy/docker/run.d.ts +17 -3
  97. package/dist/deploy/docker/run.js +30 -8
  98. package/dist/deploy/fly/plan.js +7 -0
  99. package/dist/deploy/fly/run.d.ts +27 -0
  100. package/dist/deploy/fly/run.js +102 -20
  101. package/dist/deploy/hosts.d.ts +5 -0
  102. package/dist/deploy/hosts.js +4 -0
  103. package/dist/deploy/preflight.js +3 -3
  104. package/dist/deploy/railway/plan.d.ts +5 -0
  105. package/dist/deploy/railway/plan.js +7 -0
  106. package/dist/deploy/railway/run.d.ts +2 -1
  107. package/dist/deploy/railway/run.js +5 -4
  108. package/dist/deploy/secrets.js +0 -17
  109. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  110. package/dist/engines/pi/agent-session-factory.js +109 -85
  111. package/dist/engines/pi/config.d.ts +1 -1
  112. package/dist/engines/pi/create.d.ts +22 -39
  113. package/dist/engines/pi/create.js +58 -73
  114. package/dist/engines/pi/definition.js +8 -8
  115. package/dist/engines/pi/invoke-session.js +13 -30
  116. package/dist/engines/pi/open.d.ts +2 -2
  117. package/dist/engines/pi/open.js +31 -41
  118. package/dist/engines/pi/retry-event.d.ts +6 -0
  119. package/dist/engines/pi/retry-event.js +15 -0
  120. package/dist/engines/pi/session-builder.js +26 -113
  121. package/dist/engines/pi/session-control.d.ts +10 -18
  122. package/dist/engines/pi/session-control.js +47 -81
  123. package/dist/engines/pi/session-settings.d.ts +1 -1
  124. package/dist/engines/pi/session-settings.js +1 -1
  125. package/dist/engines/pi/session-store.d.ts +12 -17
  126. package/dist/engines/pi/session-store.js +18 -40
  127. package/dist/engines/pi/tool-context.d.ts +3 -3
  128. package/dist/engines/pi/tool.d.ts +6 -9
  129. package/dist/engines/pi/tool.js +1 -0
  130. package/dist/feishu.d.ts +1 -0
  131. package/dist/feishu.js +1 -0
  132. package/dist/lark.d.ts +1 -0
  133. package/dist/lark.js +1 -0
  134. package/dist/loader.d.ts +2 -0
  135. package/dist/loader.js +5 -0
  136. package/dist/log.d.ts +9 -17
  137. package/dist/log.js +25 -30
  138. package/dist/paths.d.ts +7 -3
  139. package/dist/paths.js +17 -4
  140. package/dist/scaffold/add-channel.d.ts +6 -1
  141. package/dist/scaffold/add-channel.js +48 -65
  142. package/dist/schedule/wake-alarm.d.ts +1 -12
  143. package/dist/schedule/wake-alarm.js +2 -3
  144. package/dist/service.d.ts +23 -22
  145. package/dist/service.js +7 -73
  146. package/dist/session-remote.d.ts +5 -5
  147. package/dist/session-remote.js +25 -28
  148. package/dist/session.d.ts +1 -1
  149. package/dist/slack.d.ts +2 -0
  150. package/dist/slack.js +1 -0
  151. package/dist/tunnel.d.ts +16 -6
  152. package/dist/tunnel.js +53 -12
  153. package/package.json +5 -4
  154. package/dist/channels/slack/bot-auth.d.ts +0 -15
  155. package/dist/channels/slack/bot-auth.js +0 -135
@@ -0,0 +1,15 @@
1
+ /** Manual compaction retries are session-scoped; retries inside an invoke carry its runId. */
2
+ export function toRetryScheduledEvent(event, runId) {
3
+ return {
4
+ type: "retry_scheduled",
5
+ timestamp: Date.now(),
6
+ ...(runId === undefined ? {} : { runId }),
7
+ data: {
8
+ operation: event.type === "auto_retry_start" ? "assistant" : "compaction",
9
+ attempt: event.attempt,
10
+ maxAttempts: event.maxAttempts,
11
+ delayMs: event.delayMs,
12
+ error: event.errorMessage,
13
+ },
14
+ };
15
+ }
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * The shared definition-aware session builder: open a directory's assembled agent as a resident pi
3
- * `AgentSessionRuntime`. Extracted from chat.ts (session-control Phase 0) as the proof of the
4
- * assembly seam independently instantiable, running the SAME agent that `dev`/`start` serve. The
5
- * TUI (chat.ts) is its one consumer: the session control plane (Phases 1–3) was built on the invoke
6
- * pipeline instead of this resident runtime, so control-plane observation covers invoke-driven runs
7
- * and deliberately not chat sessions (design §10/§15).
3
+ * `AgentSessionRuntime`, running the SAME agent that `dev`/`start` serve. The TUI (chat.ts) is its
4
+ * one consumer: the session control plane was built on the invoke pipeline instead of this resident
5
+ * runtime, so control-plane observation covers invoke-driven runs and deliberately not chat sessions
6
+ * (docs/design/session-control.md §10, §15).
8
7
  *
9
8
  * FIDELITY: pi's vanilla discovery (AGENTS.md walk to repo root, machine-global skills/extensions)
10
9
  * is suppressed; fastagent's assembly is INJECTED into pi's session:
@@ -28,16 +27,15 @@
28
27
  * workspace.
29
28
  */
30
29
  import { existsSync, readFileSync } from "node:fs";
31
- import { join, resolve } from "node:path";
30
+ import { resolve } from "node:path";
32
31
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
33
- import { SessionManager, createAgentSessionFromServices, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, } from "@earendil-works/pi-coding-agent";
34
- import { definitionResourceLoaderOptions, reportExtensionErrors } from "./agent-session-factory.js";
32
+ import { SessionManager, createAgentSessionRuntime, createAgentSessionServices, getAgentDir, } from "@earendil-works/pi-coding-agent";
33
+ import { bindPiSession, definitionResourceLoaderOptions, reportExtensionErrors } from "./agent-session-factory.js";
35
34
  import { resolveModel } from "./config.js";
36
35
  import { assembleSystemPrompt, piBasePrompt } from "./create.js";
37
36
  import { canonicalPath, loadAgentDefinition, loadExtensionPaths } from "./definition.js";
38
- import { createPiModelRuntime, probeAuthSource } from "./models.js";
39
- import { log, reportModuleLoadFailures } from "../../log.js";
40
- import { agentSessionManager, sessionToolActivation, turnContext, } from "./tool-context.js";
37
+ import { createPiModelRuntime } from "./models.js";
38
+ import { reportModuleLoadFailures } from "../../loader.js";
41
39
  import { reportFindingsIfChanged, reportToolCollisions } from "./report.js";
42
40
  import { resolveAgentAssembly } from "./open.js";
43
41
  /**
@@ -51,14 +49,9 @@ sessionManager) {
51
49
  async function resolveAssembly(cwd) {
52
50
  // The shared front half — the SAME placement/config/model-spec/tool/auth resolution the serving
53
51
  // opener uses (open.ts); those inputs cannot drift between the two consumption shapes.
54
- // (Definition→prompt assembly is NOT shared: serving re-reads live per invoke, this runtime is a
55
- // startup snapshot and pi appends skills/env itself — see the header.) `tools` arrives with
56
- // search_tools applied; deferral is EMULATED below like serving
57
- // (what you iterate is what you serve): the initial active set excludes deferred tools (applied
58
- // on the session in createRuntime — pi's session starts all-active), and the activation bridge
59
- // above rides the same turn context, so the SAME search_tools works against pi's AgentSession
60
- // instead of the served one.
61
- const { config, modelSpec, agentDir, authPath, stateRoot, tools, deferredToolNames, toolCollisions, toolFailures } = await resolveAgentAssembly(cwd, options);
52
+ // Serving re-reads the definition per invoke; chat keeps a startup snapshot. Both pass identity
53
+ // and project context to Pi, which appends skills/cwd. `tools` already includes search_tools.
54
+ const { config, modelSpec, agentDir, authPath, stateRoot, tools, toolCollisions, toolFailures } = await resolveAgentAssembly(cwd, options);
62
55
  reportToolCollisions(toolCollisions);
63
56
  reportModuleLoadFailures(toolFailures);
64
57
  // ONE hub owns model resolution AND per-request auth; see models.ts.
@@ -71,33 +64,6 @@ sessionManager) {
71
64
  // Assembly-time, like serving's: this whole function is memoized, so the scan and its warnings
72
65
  // happen once per runtime rather than per session rebuild (/new, /resume, fork).
73
66
  const extensionPaths = await loadExtensionPaths(agentDir, { cwd, env });
74
- // fastagent mounts pi's complete coding set itself; `noTools: "builtin"` below keeps the runtime
75
- // from adding duplicate copies.
76
- // Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
77
- // it). Each execute runs inside the turn context with the CURRENT session's activation bridge — the
78
- // assembly is memoized across /new//resume/fork rebuilds while the session changes, so the bridge
79
- // resolves through sessionRef at call time, exactly like the serving path resolves its session.
80
- const customToolDefs = tools.map((t) => ({
81
- name: t.name,
82
- label: t.name,
83
- description: t.description ?? "",
84
- parameters: t.parameters,
85
- // Propagate the execution mode — an activating tool (the builtin loader) declares "sequential"
86
- // so pi serializes its batch; without this, pi's outer active-set diff double-stamps parallels.
87
- executionMode: t.executionMode,
88
- execute: (id, params, signal) => {
89
- const bound = sessionRef.current;
90
- // Unreachable by construction (createRuntime sets sessionRef before any turn can run a tool).
91
- // Throw rather than silently run outside the turn context — that would disguise a broken
92
- // session-lifecycle invariant as a normal out-of-turn call (fail visibly).
93
- if (!bound)
94
- throw new Error("tool executed before its session was built (lifecycle invariant broken)");
95
- return turnContext.run({ cwd, sessionManager: bound.sessionManager, tools: bound.activation },
96
- // Lower-level MountedTools may consume the fifth-argument env. Directory coding tools are
97
- // cwd-bound and ignore it; authored tools read FastAgent's turnContext instead.
98
- () => t.execute(id, params, signal, undefined, { env }));
99
- },
100
- }));
101
67
  // base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
102
68
  // them here would duplicate them).
103
69
  const systemPrompt = assembleSystemPrompt({
@@ -107,14 +73,11 @@ sessionManager) {
107
73
  return {
108
74
  modelRuntime,
109
75
  modelSpec,
110
- authPath,
111
76
  // Serving honors config.thinkingLevel (config → L2); the resident session must too (fidelity).
112
77
  thinkingLevel: config.thinkingLevel,
113
78
  definition,
114
79
  extensionPaths,
115
- customTools: tools,
116
- customToolDefs,
117
- deferredToolNames,
80
+ tools,
118
81
  systemPrompt,
119
82
  };
120
83
  }
@@ -124,15 +87,6 @@ sessionManager) {
124
87
  // load edits. And keep it workspace-scoped — `.env` is process-global, so a switch to another cwd
125
88
  // would leak env or require mutating global env at runtime.
126
89
  const rootCwd = canonicalPath(dir);
127
- // The CURRENT pi session + its activation bridge, BOUND TOGETHER — rebuilt on /new//resume/fork
128
- // while the memoized assembly (and its tool execute closures) stays. The bridge shares the
129
- // session's lifetime because a tool call has to see what the previous one activated. Note on
130
- // parallel batches: pi wraps SDK customTools in its own before/after active-set diff, so an
131
- // activating tool must carry `executionMode: "sequential"` (the builtin loader does) — pi then runs
132
- // the whole batch serially and the outer diff sees correct snapshots.
133
- // NO activation record here: pi's chat session has nowhere to put one (see sessionToolActivation's
134
- // `onActivated`), which is the documented divergence from serving — a resumed chat re-discovers.
135
- const sessionRef = {};
136
90
  let assembly;
137
91
  const assemblyFor = (cwd) => {
138
92
  // Canonical paths: pi's process.cwd() fallback is a realpath, so a symlinked workspace would
@@ -144,12 +98,8 @@ sessionManager) {
144
98
  assembly ??= resolveAssembly(rootCwd);
145
99
  return assembly;
146
100
  };
147
- // The credential hint belongs to the RUNTIME, not to each session it builds: model resolution
148
- // moved into createRuntime (extensions must load first), and repeating this on every /new,
149
- // /resume and fork would nag about a setting that did not change.
150
- let credentialHintShown = false;
151
101
  const createRuntime = async ({ cwd, sessionManager, sessionStartEvent }) => {
152
- const { modelRuntime, modelSpec, authPath, thinkingLevel, definition, extensionPaths, customToolDefs, deferredToolNames, systemPrompt, } = await assemblyFor(cwd);
102
+ const { modelRuntime, modelSpec, thinkingLevel, definition, extensionPaths, tools, systemPrompt } = await assemblyFor(cwd);
153
103
  // Per session, NOT memoized with the assembly: pi replaces the session on /new, /resume and
154
104
  // fork, and its extension contract is that the replacement gets freshly loaded extensions
155
105
  // rather than the previous session's objects. The expensive halves (model hub, auth) are shared
@@ -172,62 +122,25 @@ sessionManager) {
172
122
  // AFTER the services, because an extension may be what defines the model. `registerProvider()`
173
123
  // is pi's documented way for one to add providers, and extensions do not execute until the
174
124
  // services are built — resolving first fails a definition whose configured model comes from its
175
- // own extension with a bare "unknown model", after warning about credentials for a provider
176
- // that does not exist yet.
177
- //
178
- // MIGRATION HINT (deliberate breaking change): chat historically used pi's own `~/.pi` auth; it
179
- // now reads the agent's credential file like every other command. Probe the RESOLVED model's
180
- // provider through the normal resolution path (stored credential OR env var — an env-authed
181
- // user is fine and must not be warned): only when that provider has no usable auth AND pi's old
182
- // file exists does the bare provider error get its cause named.
183
- if (!credentialHintShown &&
184
- (await probeAuthSource(modelRuntime, modelSpec)) === undefined &&
185
- existsSync(join(getAgentDir(), "auth.json"))) {
186
- credentialHintShown = true;
187
- log.warn(`[fastagent] no credentials for ${modelSpec} in ${authPath} — this runtime no longer reads ` +
188
- `pi's ~/.pi auth; run \`fastagent login\` (or /login in the TUI) to store credentials for this agent`);
189
- }
125
+ // own extension with a bare "unknown model".
190
126
  const model = resolveModel(modelRuntime, modelSpec);
191
- const result = await createAgentSessionFromServices({
127
+ // The same bind serving performs, minus the activation record: pi's chat session has nowhere to
128
+ // put one, which is the documented divergence — a resumed chat re-discovers via search_tools.
129
+ // NOT bound to the host here: InteractiveMode.bindCurrentSessionExtensions() calls
130
+ // session.bindExtensions() with the TUI's uiContext, abort handler and command actions — binding
131
+ // here too would emit session_start twice per chat, so an extension opening a resource on start
132
+ // would open two.
133
+ const result = await bindPiSession({
192
134
  services,
193
135
  sessionManager,
194
136
  sessionStartEvent,
195
137
  model,
196
138
  thinkingLevel,
197
- // NO `tools` allowlist. It would freeze the tool set at build time, and pi lets an extension
198
- // register from `session_start`, a command, or any handler those names would not be in a
199
- // startup snapshot, so `refreshTools()` would filter them straight back out and the extension
200
- // would look like it did nothing. `noTools: "builtin"` gets the same guarantee the allowlist
201
- // was really there for (the machine's `defaultTools` setting cannot add pi's own copies on top
202
- // of ours) without freezing anything.
203
- noTools: "builtin",
204
- customTools: customToolDefs,
139
+ tools,
140
+ // A tool must see one spelling of the workspace, including when opened through a symlink.
141
+ cwd: rootCwd,
142
+ recordActivations: false,
205
143
  });
206
- sessionRef.current = {
207
- session: result.session,
208
- sessionManager: agentSessionManager(result.session, result.session.sessionManager.getSessionId()),
209
- activation: sessionToolActivation(result.session),
210
- };
211
- // NOT bound here: the HOST does it. InteractiveMode.bindCurrentSessionExtensions() calls
212
- // session.bindExtensions() with the TUI's uiContext, abort handler and command actions — binding
213
- // here too would emit session_start twice per chat, so an extension opening a resource on start
214
- // would open two.
215
- // Deferral emulation: pi starts THIS agent's tools active — every coding tool it mounted, plus
216
- // every custom and extension tool. So narrow by SUBTRACTING the deferred names from whatever is
217
- // active, rather than stating a set: an exact-set-equality gate would silently stop narrowing the
218
- // day pi activates one more.
219
- //
220
- // Applied on EVERY build including /resume: pi's chat session does not record activations (its
221
- // SessionContext has no activeToolNames), so "restore prior activations" is not implementable
222
- // here — deferral stays consistently ON and a resumed conversation re-discovers via search_tools
223
- // (a documented divergence from serving, where activations persist in the session). The deferred
224
- // SET comes from the shared assembly (one definition of "deferred"), never recomputed here.
225
- if (deferredToolNames.length > 0) {
226
- const active = result.session.getActiveToolNames();
227
- if (deferredToolNames.some((n) => active.includes(n))) {
228
- result.session.setActiveToolsByName(active.filter((n) => !deferredToolNames.includes(n)));
229
- }
230
- }
231
144
  return { ...result, services, diagnostics: services.diagnostics };
232
145
  };
233
146
  const runtime = await createAgentSessionRuntime(createRuntime, {
@@ -12,22 +12,17 @@
12
12
  * Writes take the same lease as runs. Without boundary wiring they reject before acceptance with
13
13
  * `unsupported_capability` — a client gating on `capabilities()` never sends them.
14
14
  */
15
- import { type ThinkingLevel } from "@earendil-works/pi-agent-core";
15
+ import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
16
16
  import { type Models } from "@earendil-works/pi-ai";
17
17
  import { type AgentCommand, type SessionControl, type SessionEvent } from "../../session.ts";
18
18
  import type { SessionObserver, Lease } from "./turn-kit.ts";
19
19
  import type { AnyModel } from "./models.ts";
20
20
  import type { PiAgentSessionFactory } from "./invoke-session.ts";
21
21
  import type { PiSessionRecordStore } from "./session-store.ts";
22
- /** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
23
- * connection the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
24
- * has its buffer FROZEN at the cap (memory bounded — the actual goal: ≈10k small events ≈ a few
25
- * MB worst case per stuck connection) and its subscription marked closed. The close is observed
26
- * via pulls — which a stalled connection by definition does not make — so a consumer that RESUMES
27
- * pulling first drains the frozen backlog, then gets done (no buffered event dropped), while a
28
- * permanently stalled one holds the frozen buffer until its TCP connection dies. Recovery either
29
- * way is the standard reconnect+backfill, semantically lossless. */
22
+ /** Per-subscriber backlog limits: event count and UTF-8 JSON bytes. Overflow freezes the accepted
23
+ * prefix; the reader drains it before seeing done, then reconnects and backfills via entries(). */
30
24
  export declare const SUBSCRIBER_BUFFER_CAP = 10000;
25
+ export declare const SUBSCRIBER_BUFFER_BYTES: number;
31
26
  /** What the plane's writes (`update` / `compact` / `fork` / `delete`) need — the SAME instances the
32
27
  * agent assembly uses: the lease (a write must not race a run), the model registry (validation +
33
28
  * allowedModels), and the session factory (compaction is a model call). Writes go through the
@@ -49,15 +44,12 @@ export interface PiBoundaryWiring {
49
44
  export interface CreatePiSessionControlOptions {
50
45
  /** Read-only access to the durable session records (the same root the agent writes). */
51
46
  sessions: PiSessionRecordStore;
52
- /** Boundary-mutation wiring, as a LAZY thunk: the hub's observer must exist before the agent
53
- * assembly that produces these parts, so the hub asks for them at dispatch time instead
54
- * (assembly completes before any dispatch can arrive). Absent / undefined → boundary commands
55
- * are gated off in `capabilities()` and rejected `unsupported_capability`. */
56
- boundary?: () => PiBoundaryWiring | undefined;
57
- /** The definition's names, as a LAZY thunk for the same reason {@link boundary} is one (the hub
58
- * exists before the assembly that can read a definition) — and async because the definition is
59
- * live: this must re-read it, not close over a boot snapshot, or `commands()` would advertise a
60
- * list the next turn no longer runs.
47
+ /** Boundary-mutation wiring the assembly's own parts. Absent boundary commands are gated off
48
+ * in `capabilities()` and rejected `unsupported_capability`. */
49
+ boundary?: PiBoundaryWiring;
50
+ /** The definition's names, as an async thunk because the definition is live: this must re-read it,
51
+ * not close over a boot snapshot, or `commands()` would advertise a list the next turn no longer
52
+ * runs.
61
53
  *
62
54
  * OPTIONAL because absence is a TRUE answer for the assembly that omits it: a hub over an L1
63
55
  * agent (`createPiAgent({ model, instructions, tools })`) has no definition and therefore no
@@ -1,25 +1,28 @@
1
- /**
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 })`).
5
- *
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
- */
15
- import { prepareCompaction } from "@earendil-works/pi-agent-core";
1
+ import { findCutPoint, getLatestCompactionEntry, sessionEntryToContextMessages, } from "@earendil-works/pi-coding-agent";
16
2
  import { getSupportedThinkingLevels } from "@earendil-works/pi-ai";
17
3
  import { SESSION_BUSY_CODE } from "../../agent.js";
18
4
  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";
19
5
  import { listModels } from "./config.js";
20
6
  import { forkProvenance, isNavigable, publishedLeaf } from "./session-markers.js";
7
+ import { toRetryScheduledEvent } from "./retry-event.js";
21
8
  import { THINKING_LEVELS, activePath, resolveSessionSettings } from "./session-settings.js";
22
9
  import { log } from "../../log.js";
10
+ /** Admission uses coding-agent's cut point and context rules, including a split-turn prefix.
11
+ * Its prepareCompaction is private; agent-core's namesake uses a different journal format. */
12
+ function hasCompactableHistory(path, keepRecentTokens) {
13
+ if (path.at(-1)?.type === "compaction")
14
+ return false;
15
+ const previous = getLatestCompactionEntry(path);
16
+ let start = 0;
17
+ if (previous) {
18
+ const kept = path.findIndex((entry) => entry.id === previous.firstKeptEntryId);
19
+ start = kept >= 0 ? kept : path.indexOf(previous) + 1;
20
+ }
21
+ const { firstKeptEntryIndex } = findCutPoint(path, start, path.length, keepRecentTokens);
22
+ return path
23
+ .slice(start, firstKeptEntryIndex)
24
+ .some((entry) => entry.type !== "compaction" && sessionEntryToContextMessages(entry).length > 0);
25
+ }
23
26
  // ── Entry normalization (durable plane) ──────────────────────────────────────
24
27
  /** Concatenated plain text of a message's content blocks (the L0 rendering payload). A custom
25
28
  * AgentMessage role may carry no `content` at all — that reads as empty, not a crash. */
@@ -76,16 +79,11 @@ function toSessionEntry(entry, parentId) {
76
79
  return { ...base, kind: entry.type, data: {} };
77
80
  }
78
81
  // ── Live fan-out (events plane) ──────────────────────────────────────────────
79
- /** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
80
- * connection the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
81
- * has its buffer FROZEN at the cap (memory bounded — the actual goal: ≈10k small events ≈ a few
82
- * MB worst case per stuck connection) and its subscription marked closed. The close is observed
83
- * via pulls — which a stalled connection by definition does not make — so a consumer that RESUMES
84
- * pulling first drains the frozen backlog, then gets done (no buffered event dropped), while a
85
- * permanently stalled one holds the frozen buffer until its TCP connection dies. Recovery either
86
- * way is the standard reconnect+backfill, semantically lossless. */
82
+ /** Per-subscriber backlog limits: event count and UTF-8 JSON bytes. Overflow freezes the accepted
83
+ * prefix; the reader drains it before seeing done, then reconnects and backfills via entries(). */
87
84
  export const SUBSCRIBER_BUFFER_CAP = 10_000;
88
- /** One subscriber's push→pull queue, capped at {@link SUBSCRIBER_BUFFER_CAP}. `close()` settles a
85
+ export const SUBSCRIBER_BUFFER_BYTES = 8 * 1024 * 1024;
86
+ /** One subscriber's bounded push→pull queue. `close()` settles a
89
87
  * pending pull — an async generator suspended on a quiet stream cannot be ended by `return()`
90
88
  * alone (it queues behind the never-settling await), so teardown needs this explicit door. */
91
89
  class Subscriber {
@@ -96,7 +94,9 @@ class Subscriber {
96
94
  constructor(session) {
97
95
  this.session = session;
98
96
  }
97
+ // Serialized snapshots cannot grow when a producer later mutates its event or tool result.
99
98
  buffer = [];
99
+ bufferedBytes = 0;
100
100
  // A QUEUE of waiters, not a single slot: concurrent next() calls are contract-legal (any wrapper
101
101
  // may poll twice), and a single `wake` field would let the second await overwrite the first's
102
102
  // resolver — hanging the first next() forever. Every wake flushes all waiters; each re-checks the
@@ -112,12 +112,15 @@ class Subscriber {
112
112
  push(event) {
113
113
  if (this.closed)
114
114
  return;
115
- if (this.buffer.length >= SUBSCRIBER_BUFFER_CAP) {
116
- log.warn(`[fastagent] session-control subscriber for session "${this.session}" is ${SUBSCRIBER_BUFFER_CAP} events behind — no further events buffered; its stream ends after draining the backlog (or at connection death), then the client resyncs via entries()`);
115
+ const json = JSON.stringify(event);
116
+ const bytes = Buffer.byteLength(json);
117
+ if (this.buffer.length >= SUBSCRIBER_BUFFER_CAP || this.bufferedBytes + bytes > SUBSCRIBER_BUFFER_BYTES) {
118
+ log.warn(`[fastagent] session-control subscriber for session "${this.session}" exceeded its buffer limit (${SUBSCRIBER_BUFFER_CAP} events / ${SUBSCRIBER_BUFFER_BYTES} bytes; ${this.bufferedBytes} bytes queued, ${bytes} incoming); its stream ends after draining the backlog, then the client resyncs via entries()`);
117
119
  this.close();
118
120
  return;
119
121
  }
120
- this.buffer.push(event);
122
+ this.buffer.push({ json, bytes });
123
+ this.bufferedBytes += bytes;
121
124
  this.flush();
122
125
  }
123
126
  close() {
@@ -126,8 +129,11 @@ class Subscriber {
126
129
  }
127
130
  async next() {
128
131
  while (true) {
129
- if (this.buffer.length > 0)
130
- return { done: false, value: this.buffer.shift() };
132
+ const item = this.buffer.shift();
133
+ if (item) {
134
+ this.bufferedBytes -= item.bytes;
135
+ return { done: false, value: JSON.parse(item.json) };
136
+ }
131
137
  if (this.closed)
132
138
  return { done: true, value: undefined };
133
139
  await new Promise((resolve) => {
@@ -195,7 +201,7 @@ export function createPiSessionControl(options) {
195
201
  return (await options.commands?.()) ?? [];
196
202
  },
197
203
  capabilities: () => {
198
- const b = boundary?.();
204
+ const b = boundary;
199
205
  return {
200
206
  steering: true,
201
207
  followUp: true,
@@ -227,7 +233,7 @@ export function createPiSessionControl(options) {
227
233
  // channel to explain itself. The fault is not swallowed — it surfaces where codes exist: the
228
234
  // next invoke fails (binding a session walks the same chain) and a boundary dispatch answers
229
235
  // `boundary_command_failed`. Here it is a server-side warn.
230
- const b = boundary?.();
236
+ const b = boundary;
231
237
  let settings;
232
238
  if (opened && b) {
233
239
  try {
@@ -455,7 +461,7 @@ export function createPiSessionControl(options) {
455
461
  const fields = named.filter((f) => patch[f] !== undefined);
456
462
  if (fields.length === 0)
457
463
  return { ok: true }; // an empty patch asks for nothing, and gets it
458
- const b = boundary?.();
464
+ const b = boundary;
459
465
  if (!b)
460
466
  return unsupported(`update(${fields.join(", ")})`);
461
467
  // PAYLOAD validation first — before the session is even opened, and long before the lease: an
@@ -598,7 +604,7 @@ export function createPiSessionControl(options) {
598
604
  * finished{error} dressed as a failure — pi reports it as a throw from compact(), too late.
599
605
  */
600
606
  const compactOf = async (session, instructions) => {
601
- const b = boundary?.();
607
+ const b = boundary;
602
608
  if (!b)
603
609
  return unsupported("compact()");
604
610
  const existing = await sessions.openIfExists(session);
@@ -628,14 +634,7 @@ export function createPiSessionControl(options) {
628
634
  // either reject a compaction pi would have run, or admit one it refuses — and its refusal
629
635
  // arrives too late to be a pre-acceptance answer.
630
636
  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) {
637
+ if (!hasCompactableHistory(path, bound.settingsManager.getCompactionSettings().keepRecentTokens)) {
639
638
  teardown();
640
639
  release();
641
640
  // A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
@@ -656,37 +655,13 @@ export function createPiSessionControl(options) {
656
655
  release();
657
656
  return failed(error);
658
657
  }
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.
658
+ // Pi creates the abort controller after an await and emits compaction_start immediately after.
659
+ // Retain early cancellation until that event; there is no time limit on session startup.
670
660
  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
661
  compacting.set(session, {
686
662
  abort: () => {
687
663
  aborted = true;
688
664
  bound.abortCompaction();
689
- void applyAbort();
690
665
  },
691
666
  });
692
667
  emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
@@ -696,20 +671,12 @@ export function createPiSessionControl(options) {
696
671
  // backoff so a long gap is diagnosable (not confusable with a hang): as a session event for
697
672
  // attached observers, as a warn for server logs.
698
673
  const unsub = bound.subscribe((event) => {
674
+ if (event.type === "compaction_start" && event.reason === "manual" && aborted)
675
+ bound.abortCompaction();
699
676
  if (event.type !== "summarization_retry_scheduled")
700
677
  return;
701
678
  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
- });
679
+ emitOwn(session, toRetryScheduledEvent(event));
713
680
  });
714
681
  try {
715
682
  const done = await bound.compact(instructions);
@@ -721,7 +688,6 @@ export function createPiSessionControl(options) {
721
688
  // failure still reads as aborted).
722
689
  outcome = aborted ? { aborted: true } : { error: String(error) };
723
690
  }
724
- running = false;
725
691
  unsub();
726
692
  teardown();
727
693
  // Release BEFORE emitting finished: a watcher seeing finished may act next — "finished ⇒ the
@@ -744,7 +710,7 @@ export function createPiSessionControl(options) {
744
710
  /** WHICH fork this is: source + branch point. Two forks of one session at different entries are
745
711
  * different requests, so a retry of one must not be answered by the other. */
746
712
  const provenance = `${from}@${at}`;
747
- const b = boundary?.();
713
+ const b = boundary;
748
714
  if (!b)
749
715
  return unsupported("fork()");
750
716
  // An id no client could then open: the empty string, `.` and `..` are not URL path segments
@@ -807,7 +773,7 @@ export function createPiSessionControl(options) {
807
773
  return { ok: true };
808
774
  };
809
775
  const deleteOf = async (session) => {
810
- const b = boundary?.();
776
+ const b = boundary;
811
777
  if (!b)
812
778
  return unsupported("delete()");
813
779
  const existing = await sessions.openIfExists(session);
@@ -35,7 +35,7 @@ export interface SessionSettings {
35
35
  thinkingLevel: ThinkingLevel;
36
36
  /** What `update({ thinkingLevel })` accepts for this session. */
37
37
  availableThinkingLevels: string[];
38
- /** Recorded but not honored only the execution path reports it (as a warn). */
38
+ /** Recorded overrides that could not be applied. */
39
39
  dropped?: {
40
40
  model?: string;
41
41
  thinkingLevel?: {
@@ -49,7 +49,7 @@ export function resolveSessionSettings(entries, models, defaults) {
49
49
  dropped.model = `${recorded.model.provider}/${recorded.model.modelId}`;
50
50
  }
51
51
  const availableThinkingLevels = getSupportedThinkingLevels(model);
52
- let thinkingLevel = defaults.thinkingLevel;
52
+ let thinkingLevel = clampThinkingLevel(model, defaults.thinkingLevel);
53
53
  if (recorded.thinkingLevel !== undefined) {
54
54
  const level = recorded.thinkingLevel;
55
55
  if (!THINKING_LEVELS.has(level)) {
@@ -85,10 +85,10 @@ interface AppliedProperties {
85
85
  * produce one output. `_` escapes itself for the same reason. A trailing `.` or `-` is legal
86
86
  * mid-name but not at the end, so it escapes too.
87
87
  *
88
- * Injective within this encoding — which is only sufficient because new records live in their own
89
- * directory. The older spelling draws names from the same character set (it stored a room literally
90
- * called `s42` as `s42`, which is also this encoding of `42`), so one directory would make some
91
- * names ambiguous no matter how either side spells them.
88
+ * Injective within this encoding — which is only sufficient because this store's records live in
89
+ * their own directory. A name this store did not write draws from the same character set and can
90
+ * still decode: a file called `s42` is also this encoding of `42`, so one directory would make some
91
+ * names ambiguous no matter how this side spells them.
92
92
  *
93
93
  * Readability is deliberate: `-1001234567890` becomes `s-1001234567890`, so an operator can still
94
94
  * tell which room a file belongs to.
@@ -110,24 +110,19 @@ export declare function callerSessionId(recordId: string): string | undefined;
110
110
  * each header and would make a renamed agent directory look like an empty store (see
111
111
  * {@link recordFiles}).
112
112
  *
113
- * NEW records live in a subdirectory of their own, because the two engines cannot share a namespace:
114
- * both spell ids into `[A-Za-z0-9._-]`, so neither can claim a prefix the other cannot produce, and
115
- * a directory holding both would have names that belong to two conversations at once in whichever
116
- * direction it is read. Separate directories make each side's own injectivity sufficient.
117
- *
118
- * A PRE-EXISTING record is continued in place: looked up by the older spelling, which is injective
119
- * on its own terms, and appended to where it lies. Both spellings are the same v3 jsonl, so a
120
- * conversation started before this store keeps going rather than restarting empty. Nothing on disk
121
- * is rewritten.
113
+ * Records live in a subdirectory of their own, because a name this store did not write can still
114
+ * decode to a Caller id: a file called `s42` beside them is also this encoding of `42`, so a shared
115
+ * directory would answer `42` with a record it does not own. Only this directory is scanned, which
116
+ * makes this encoding's own injectivity sufficient a record written before this store existed
117
+ * lies outside it and is never read.
122
118
  *
123
119
  * SCOPE OF "open-or-create": idempotent against a store that is serialized per session, which is what
124
120
  * the serving path provides — the single-writer lease is taken before any store call, so no two
125
121
  * turns of one conversation reach this at once. What it does NOT do is arbitrate a FIRST open racing
126
122
  * across processes: two instances that scan before either writes will both create, and the
127
- * conversation forks into two records. sessions.ts states the same boundary for the same reason
128
- * ("the serving path serializes it with the single-writer lease before reaching any store"), and a
129
- * horizontally-scaled deployment that wants more owes a lease that spans its instances — an
130
- * in-process one cannot arbitrate between them, and a file lock here would only look like it could.
123
+ * conversation forks into two records. A horizontally-scaled deployment that wants more owes a lease
124
+ * that spans its instances an in-process one cannot arbitrate between them, and a file lock here
125
+ * would only look like it could.
131
126
  */
132
127
  export declare function piSessionRecordStore(options: {
133
128
  dir: string;