@fastagent-sh/fastagent 0.14.0 → 0.15.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 (209) hide show
  1. package/README.md +3 -3
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +8 -11
  10. package/dist/channels/feishu/context-buffer.js +6 -67
  11. package/dist/channels/feishu/feishu-api.d.ts +11 -3
  12. package/dist/channels/feishu/feishu-api.js +20 -2
  13. package/dist/channels/feishu/feishu.d.ts +28 -16
  14. package/dist/channels/feishu/feishu.js +210 -188
  15. package/dist/channels/feishu/invoke-turn.d.ts +7 -20
  16. package/dist/channels/feishu/invoke-turn.js +10 -62
  17. package/dist/channels/feishu/model.d.ts +2 -1
  18. package/dist/channels/feishu/normalize.d.ts +2 -1
  19. package/dist/channels/feishu/owned-threads.d.ts +2 -6
  20. package/dist/channels/feishu/parse.d.ts +1 -1
  21. package/dist/channels/feishu/preview.d.ts +4 -7
  22. package/dist/channels/feishu/preview.js +24 -140
  23. package/dist/channels/feishu/register-app.d.ts +2 -1
  24. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  25. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  26. package/dist/channels/feishu/setup-mode.js +2 -0
  27. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  28. package/dist/channels/feishu/ws-ingress.js +136 -0
  29. package/dist/channels/github/github.js +8 -6
  30. package/dist/channels/http.d.ts +14 -0
  31. package/dist/channels/http.js +35 -2
  32. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  33. package/dist/channels/invoke-turn-kit.js +87 -0
  34. package/dist/channels/lark/lark.d.ts +4 -2
  35. package/dist/channels/lark/lark.js +4 -1
  36. package/dist/channels/lark/onboard.d.ts +8 -4
  37. package/dist/channels/lark/onboard.js +8 -0
  38. package/dist/channels/preview-kit.d.ts +109 -0
  39. package/dist/channels/preview-kit.js +183 -0
  40. package/dist/channels/seen.d.ts +5 -0
  41. package/dist/channels/seen.js +35 -0
  42. package/dist/channels/slack/bot-auth.d.ts +15 -0
  43. package/dist/channels/slack/bot-auth.js +146 -0
  44. package/dist/channels/slack/config-api.d.ts +60 -0
  45. package/dist/channels/slack/config-api.js +149 -0
  46. package/dist/channels/slack/context-buffer.d.ts +24 -0
  47. package/dist/channels/slack/context-buffer.js +37 -0
  48. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  49. package/dist/channels/slack/invoke-turn.js +63 -0
  50. package/dist/channels/slack/manifest.d.ts +49 -0
  51. package/dist/channels/slack/manifest.js +69 -0
  52. package/dist/channels/slack/model.d.ts +67 -0
  53. package/dist/channels/slack/model.js +2 -0
  54. package/dist/channels/slack/onboard.d.ts +41 -0
  55. package/dist/channels/slack/onboard.js +120 -0
  56. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  57. package/dist/channels/slack/onboarding-state.js +69 -0
  58. package/dist/channels/slack/owned-threads.d.ts +6 -0
  59. package/dist/channels/slack/owned-threads.js +43 -0
  60. package/dist/channels/slack/parse.d.ts +23 -0
  61. package/dist/channels/slack/parse.js +81 -0
  62. package/dist/channels/slack/preview.d.ts +24 -0
  63. package/dist/channels/slack/preview.js +359 -0
  64. package/dist/channels/slack/reaction.d.ts +24 -0
  65. package/dist/channels/slack/reaction.js +62 -0
  66. package/dist/channels/slack/register-webhook.d.ts +10 -0
  67. package/dist/channels/slack/register-webhook.js +49 -0
  68. package/dist/channels/slack/scaffold/channel.ts +33 -0
  69. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  70. package/dist/channels/slack/setup-server.d.ts +17 -0
  71. package/dist/channels/slack/setup-server.js +103 -0
  72. package/dist/channels/slack/slack-api.d.ts +77 -0
  73. package/dist/channels/slack/slack-api.js +415 -0
  74. package/dist/channels/slack/slack.d.ts +58 -0
  75. package/dist/channels/slack/slack.js +451 -0
  76. package/dist/channels/slack/welcomed.d.ts +5 -0
  77. package/dist/channels/slack/welcomed.js +32 -0
  78. package/dist/channels/state.js +3 -3
  79. package/dist/channels/stop-command.d.ts +6 -0
  80. package/dist/channels/stop-command.js +36 -0
  81. package/dist/channels/tasks.d.ts +13 -0
  82. package/dist/channels/tasks.js +10 -0
  83. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  84. package/dist/channels/telegram/context-buffer.js +6 -85
  85. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  86. package/dist/channels/telegram/invoke-turn.js +11 -58
  87. package/dist/channels/telegram/preview.d.ts +4 -7
  88. package/dist/channels/telegram/preview.js +24 -142
  89. package/dist/channels/telegram/telegram.js +23 -9
  90. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  91. package/dist/channels/{feishu/text.js → text.js} +1 -1
  92. package/dist/channels/turn-queue.js +1 -1
  93. package/dist/channels/turn-store.d.ts +1 -1
  94. package/dist/channels/turn-store.js +2 -3
  95. package/dist/cli/add-feishu.d.ts +27 -0
  96. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  97. package/dist/cli/add-slack.d.ts +10 -0
  98. package/dist/cli/add-slack.js +204 -0
  99. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  100. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  101. package/dist/cli/commands/add.d.ts +4 -0
  102. package/dist/cli/commands/add.js +152 -20
  103. package/dist/cli/commands/attach.d.ts +82 -0
  104. package/dist/cli/commands/attach.js +559 -0
  105. package/dist/cli/commands/chat.d.ts +1 -0
  106. package/dist/cli/commands/chat.js +6 -1
  107. package/dist/cli/commands/deploy.d.ts +2 -0
  108. package/dist/cli/commands/deploy.js +78 -22
  109. package/dist/cli/commands/dev.js +11 -5
  110. package/dist/cli/commands/fire.js +3 -3
  111. package/dist/cli/commands/info.js +2 -2
  112. package/dist/cli/commands/invoke.js +2 -3
  113. package/dist/cli/commands/login.js +14 -25
  114. package/dist/cli/commands/models.js +1 -1
  115. package/dist/cli/commands/start.js +15 -8
  116. package/dist/cli/commands/tool.js +2 -1
  117. package/dist/cli/fail.d.ts +0 -2
  118. package/dist/cli/fail.js +1 -1
  119. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  120. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  121. package/dist/cli/kernel.d.ts +3 -2
  122. package/dist/cli/models-view.d.ts +21 -0
  123. package/dist/cli/models-view.js +66 -0
  124. package/dist/cli/program.js +79 -21
  125. package/dist/cli/serve.d.ts +38 -18
  126. package/dist/cli/serve.js +197 -39
  127. package/dist/cli/shared.d.ts +26 -6
  128. package/dist/cli/shared.js +163 -33
  129. package/dist/collect.d.ts +14 -3
  130. package/dist/collect.js +24 -0
  131. package/dist/core.d.ts +3 -1
  132. package/dist/core.js +2 -0
  133. package/dist/deploy/docker/plan.d.ts +2 -2
  134. package/dist/deploy/docker/plan.js +8 -6
  135. package/dist/deploy/fly/plan.d.ts +3 -1
  136. package/dist/deploy/fly/plan.js +12 -7
  137. package/dist/deploy/fly/run.d.ts +5 -4
  138. package/dist/deploy/fly/run.js +16 -5
  139. package/dist/deploy/preflight.d.ts +10 -4
  140. package/dist/deploy/preflight.js +53 -9
  141. package/dist/deploy/railway/plan.d.ts +3 -1
  142. package/dist/deploy/railway/plan.js +12 -4
  143. package/dist/deploy/railway/run.d.ts +5 -4
  144. package/dist/deploy/railway/run.js +15 -5
  145. package/dist/deploy/runner.d.ts +2 -1
  146. package/dist/deploy/secrets.d.ts +2 -1
  147. package/dist/deploy/secrets.js +23 -3
  148. package/dist/dev-supervisor.d.ts +0 -2
  149. package/dist/dev-supervisor.js +7 -3
  150. package/dist/engines/pi/channel.d.ts +22 -16
  151. package/dist/engines/pi/channel.js +90 -60
  152. package/dist/engines/pi/chat.d.ts +4 -16
  153. package/dist/engines/pi/chat.js +8 -261
  154. package/dist/engines/pi/config.d.ts +14 -9
  155. package/dist/engines/pi/config.js +33 -18
  156. package/dist/engines/pi/create.d.ts +22 -2
  157. package/dist/engines/pi/create.js +20 -14
  158. package/dist/engines/pi/harness.d.ts +49 -0
  159. package/dist/engines/pi/harness.js +90 -2
  160. package/dist/engines/pi/invoke.d.ts +55 -2
  161. package/dist/engines/pi/invoke.js +304 -19
  162. package/dist/engines/pi/login.d.ts +11 -0
  163. package/dist/engines/pi/login.js +16 -4
  164. package/dist/engines/pi/models.d.ts +56 -10
  165. package/dist/engines/pi/models.js +61 -23
  166. package/dist/engines/pi/session-builder.d.ts +16 -0
  167. package/dist/engines/pi/session-builder.js +308 -0
  168. package/dist/engines/pi/session-control.d.ts +50 -0
  169. package/dist/engines/pi/session-control.js +604 -0
  170. package/dist/engines/pi/sessions.d.ts +17 -2
  171. package/dist/engines/pi/sessions.js +9 -0
  172. package/dist/engines/pi/tool-context.d.ts +18 -11
  173. package/dist/engines/pi/tool-context.js +3 -9
  174. package/dist/engines/pi/tool.d.ts +6 -8
  175. package/dist/engines/pi/tool.js +6 -1
  176. package/dist/engines/pi/wake-tool.d.ts +0 -3
  177. package/dist/engines/pi/wake-tool.js +9 -7
  178. package/dist/engines/pi/workspace.d.ts +52 -0
  179. package/dist/engines/pi/workspace.js +72 -15
  180. package/dist/feishu.d.ts +1 -1
  181. package/dist/feishu.js +1 -1
  182. package/dist/host/node.d.ts +23 -6
  183. package/dist/host/node.js +5 -4
  184. package/dist/index.d.ts +1 -0
  185. package/dist/index.js +1 -0
  186. package/dist/lark.d.ts +1 -1
  187. package/dist/lark.js +1 -1
  188. package/dist/observe.js +3 -0
  189. package/dist/pi.d.ts +5 -4
  190. package/dist/pi.js +2 -1
  191. package/dist/scaffold/add-channel.d.ts +19 -6
  192. package/dist/scaffold/add-channel.js +126 -17
  193. package/dist/scaffold/templates/fastagent.config.mjs +4 -3
  194. package/dist/schedule/wakeups.d.ts +0 -3
  195. package/dist/schedule/wakeups.js +1 -1
  196. package/dist/session-remote.d.ts +53 -0
  197. package/dist/session-remote.js +336 -0
  198. package/dist/session.d.ts +265 -0
  199. package/dist/session.js +37 -0
  200. package/dist/slack.d.ts +2 -0
  201. package/dist/slack.js +2 -0
  202. package/dist/tunnel.d.ts +7 -4
  203. package/dist/tunnel.js +21 -10
  204. package/package.json +19 -7
  205. package/dist/channels/feishu/seen.d.ts +0 -5
  206. package/dist/channels/feishu/seen.js +0 -47
  207. package/dist/cli-add-feishu.d.ts +0 -8
  208. package/dist/cli-models.d.ts +0 -11
  209. package/dist/cli-models.js +0 -20
@@ -0,0 +1,604 @@
1
+ /**
2
+ * The pi implementation of the session control plane: observation (`state`/`entries`/`events`,
3
+ * design Phase 1) and run modulation (`dispatch`: steer/follow_up/abort, Phase 2a) over
4
+ * invoke-driven runs. `createPiSessionControl` returns the neutral `SessionControl` plus the
5
+ * {@link SessionObserver} to plug into the invoke pipeline (`createPiAgentFromHarness({ observer })`)
6
+ * — the hub derives everything from the rich event stream (plus the {@link RunControls} the
7
+ * run_started event carries), holds no durable state of its own, and never writes: durable truth
8
+ * stays in the session repository (read via {@link PiSessionReader}), live truth in the events the
9
+ * data plane emits, modulation in the controls the data plane registers.
10
+ *
11
+ * Boundary mutations (Phase 2b: compact/set_model/set_thinking) 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.
14
+ */
15
+ import { DEFAULT_COMPACTION_SETTINGS, compact, prepareCompaction } from "@earendil-works/pi-agent-core";
16
+ 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 { listModels } from "./config.js";
19
+ import { SUMMARIZATION_RETRY_POLICY, THINKING_LEVELS, harnessSession, lastOverrideEntries, } from "./harness.js";
20
+ import { log } from "../../log.js";
21
+ // ── Entry normalization (durable plane) ──────────────────────────────────────
22
+ /** Concatenated plain text of a message's content blocks (the L0 rendering payload). A custom
23
+ * AgentMessage role may carry no `content` at all — that reads as empty, not a crash. */
24
+ function textOf(content) {
25
+ if (typeof content === "string")
26
+ return content;
27
+ if (!Array.isArray(content))
28
+ return "";
29
+ return content
30
+ .filter((b) => b.type === "text" && typeof b.text === "string")
31
+ .map((b) => b.text)
32
+ .join("");
33
+ }
34
+ /**
35
+ * pi `SessionTreeEntry` → neutral {@link SessionEntry}. Message entries map onto the guaranteed
36
+ * kind vocabulary (user/assistant/tool) with a minimal render payload; every other engine record
37
+ * keeps its pi type as an open-set kind with an EMPTY payload — present so `parentId` chains and
38
+ * cursors stay intact, skippable by contract, and no pi message class leaks through the adapter.
39
+ */
40
+ function toSessionEntry(entry) {
41
+ const base = {
42
+ id: entry.id,
43
+ parentId: entry.parentId ?? undefined,
44
+ timestamp: Date.parse(entry.timestamp),
45
+ };
46
+ if (entry.type === "message") {
47
+ const m = entry.message;
48
+ if (m.role === "user")
49
+ return { ...base, kind: "user", data: { text: textOf(m.content) } };
50
+ if (m.role === "assistant") {
51
+ const toolCalls = m.content
52
+ .filter((b) => b.type === "toolCall")
53
+ .map((b) => ({ id: b.id ?? "", name: b.name ?? "" }));
54
+ const data = { text: textOf(m.content) };
55
+ if (toolCalls.length > 0)
56
+ data.toolCalls = toolCalls;
57
+ return { ...base, kind: "assistant", data };
58
+ }
59
+ if (m.role === "toolResult") {
60
+ return {
61
+ ...base,
62
+ kind: "tool",
63
+ data: {
64
+ toolCallId: m.toolCallId,
65
+ toolName: m.toolName,
66
+ isError: m.isError ?? false,
67
+ text: textOf(m.content),
68
+ },
69
+ };
70
+ }
71
+ // A custom AgentMessage role (channel/extension-defined): open-set kind, skippable.
72
+ return { ...base, kind: `message:${m.role}`, data: {} };
73
+ }
74
+ return { ...base, kind: entry.type, data: {} };
75
+ }
76
+ // ── Live fan-out (events plane) ──────────────────────────────────────────────
77
+ /** Ceiling for one subscriber's unconsumed backlog. A consumer this far behind (a stalled remote
78
+ * connection — the wire's ReadableStream backpressure stops pulling while invokes keep pushing)
79
+ * has its buffer FROZEN at the cap (memory bounded — the actual goal: ≈10k small events ≈ a few
80
+ * MB worst case per stuck connection) and its subscription marked closed. The close is observed
81
+ * via pulls — which a stalled connection by definition does not make — so a consumer that RESUMES
82
+ * pulling first drains the frozen backlog, then gets done (no buffered event dropped), while a
83
+ * permanently stalled one holds the frozen buffer until its TCP connection dies. Recovery either
84
+ * way is the standard reconnect+backfill, semantically lossless. */
85
+ export const SUBSCRIBER_BUFFER_CAP = 10_000;
86
+ /** One subscriber's push→pull queue, capped at {@link SUBSCRIBER_BUFFER_CAP}. `close()` settles a
87
+ * pending pull — an async generator suspended on a quiet stream cannot be ended by `return()`
88
+ * alone (it queues behind the never-settling await), so teardown needs this explicit door. */
89
+ class Subscriber {
90
+ /** For the overflow diagnostic only — a warn without the session is not actionable on a
91
+ * multi-session serve. (Explicit assignment: TS parameter properties break Node's strip-only
92
+ * type erasure, which the CLI runs under.) */
93
+ session;
94
+ constructor(session) {
95
+ this.session = session;
96
+ }
97
+ buffer = [];
98
+ // A QUEUE of waiters, not a single slot: concurrent next() calls are contract-legal (any wrapper
99
+ // may poll twice), and a single `wake` field would let the second await overwrite the first's
100
+ // resolver — hanging the first next() forever. Every wake flushes all waiters; each re-checks the
101
+ // buffer and re-queues if another consumer won the event.
102
+ wakes = [];
103
+ closed = false;
104
+ flush() {
105
+ const wakes = this.wakes;
106
+ this.wakes = [];
107
+ for (const wake of wakes)
108
+ wake();
109
+ }
110
+ push(event) {
111
+ if (this.closed)
112
+ return;
113
+ if (this.buffer.length >= SUBSCRIBER_BUFFER_CAP) {
114
+ 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
+ this.close();
116
+ return;
117
+ }
118
+ this.buffer.push(event);
119
+ this.flush();
120
+ }
121
+ close() {
122
+ this.closed = true;
123
+ this.flush();
124
+ }
125
+ async next() {
126
+ while (true) {
127
+ if (this.buffer.length > 0)
128
+ return { done: false, value: this.buffer.shift() };
129
+ if (this.closed)
130
+ return { done: true, value: undefined };
131
+ await new Promise((resolve) => {
132
+ this.wakes.push(resolve);
133
+ });
134
+ }
135
+ }
136
+ }
137
+ /**
138
+ * Build the observation hub. Wire `observer` into the SAME agent assembly that serves the sessions:
139
+ *
140
+ * ```ts
141
+ * const { control, observer } = createPiSessionControl({ sessions });
142
+ * const agent = createPiAgent({ ..., sessions, observer });
143
+ * ```
144
+ */
145
+ export function createPiSessionControl(options) {
146
+ const { sessions, boundary } = options;
147
+ /** Live run state per session — derived purely from run_started/run_settled and the controls
148
+ * registered with run_started. */
149
+ const active = new Map();
150
+ const subscribers = new Map();
151
+ /** Sessions with a manual compaction in flight — reported as `status: "compacting"`, keyed to
152
+ * the summarization's AbortController so `abort` has a door into it (run/compaction symmetry:
153
+ * both are model calls a client must be able to stop). Set at ADMISSION, cleared by the
154
+ * detached task before `compaction_finished`. */
155
+ const compacting = new Map();
156
+ /** Fan an event out to this session's subscribers — shared by the observer (run events) and the
157
+ * boundary mutations (session-level events, no runId). */
158
+ const fanOut = (session, event) => {
159
+ const subs = subscribers.get(session);
160
+ if (subs)
161
+ for (const sub of [...subs])
162
+ sub.push(event);
163
+ };
164
+ /** Emit a HUB-generated event: subscribers first, then the external tap — the boundary-event
165
+ * half of a full-vocabulary tap (run events reach it through the observer composition). */
166
+ const emitOwn = (session, event) => {
167
+ fanOut(session, event);
168
+ try {
169
+ options.tap?.(session, event);
170
+ }
171
+ catch (error) {
172
+ // Same discipline as the data plane's observer guard: a broken tap is its own problem.
173
+ log.warn(`[fastagent] session-control tap threw (event ${event.type}): ${String(error)}`);
174
+ }
175
+ };
176
+ const observer = (session, event, run) => {
177
+ if (event.type === "run_started" && event.runId) {
178
+ active.set(session, { runId: event.runId, controls: run, pending: { steering: 0, followUp: 0 } });
179
+ }
180
+ else if (event.type === "run_settled") {
181
+ active.delete(session);
182
+ }
183
+ else if (event.type === "queue_changed") {
184
+ const entry = active.get(session);
185
+ if (entry)
186
+ entry.pending = event.data;
187
+ }
188
+ fanOut(session, event);
189
+ };
190
+ const control = {
191
+ capabilities: () => {
192
+ const b = boundary?.();
193
+ return {
194
+ steering: true,
195
+ followUp: true,
196
+ manualCompaction: !!b,
197
+ modelSelection: b ? { allowedModels: listModels(b.models) } : false,
198
+ thinkingLevel: b ? { allowedLevels: [...THINKING_LEVELS] } : false,
199
+ toolProgress: true, // tool_progress IS delivered (replace-semantics snapshots)
200
+ usage: false,
201
+ };
202
+ },
203
+ async state(session) {
204
+ const run = active.get(session);
205
+ const opened = await sessions.openIfExists(session);
206
+ const leafEntryId = opened ? ((await opened.getLeafId()) ?? undefined) : undefined;
207
+ // The durable overrides (set_model / set_thinking), via the SAME walk the harness resolve
208
+ // uses (lastOverrideEntries) — one physical implementation, so the reporting surface and the
209
+ // execution surface can never disagree on which record is "the" override. Reported as
210
+ // recorded, even if the current registry lacks the model (state reports session truth; the
211
+ // harness resolve owns the execution fallback).
212
+ let model;
213
+ let thinkingLevel;
214
+ if (opened) {
215
+ const recorded = lastOverrideEntries((await opened.getEntries()));
216
+ if (recorded.model)
217
+ model = `${recorded.model.provider}/${recorded.model.modelId}`;
218
+ thinkingLevel = recorded.thinkingLevel;
219
+ }
220
+ return {
221
+ status: run ? "running" : compacting.has(session) ? "compacting" : "idle",
222
+ ...(run ? { activeRunId: run.runId } : {}),
223
+ ...(model !== undefined ? { model } : {}),
224
+ ...(thinkingLevel !== undefined ? { thinkingLevel } : {}),
225
+ pending: run ? { ...run.pending } : { steering: 0, followUp: 0 },
226
+ ...(leafEntryId ? { leafEntryId } : {}),
227
+ };
228
+ },
229
+ async entries(session, opts) {
230
+ const opened = await sessions.openIfExists(session);
231
+ if (!opened)
232
+ return { entries: [] };
233
+ const all = (await opened.getEntries()).map(toSessionEntry);
234
+ const leafEntryId = (await opened.getLeafId()) ?? undefined;
235
+ let entries = all;
236
+ if (opts?.since !== undefined) {
237
+ const idx = all.findIndex((e) => e.id === opts.since);
238
+ // Unknown cursor → full backfill (correct, merely larger): the client's cursor may predate
239
+ // a repository the session was rebuilt into; silently skipping records would lose history.
240
+ if (idx >= 0)
241
+ entries = all.slice(idx + 1);
242
+ }
243
+ return { entries, ...(leafEntryId ? { leafEntryId } : {}) };
244
+ },
245
+ events(session) {
246
+ // EVERY ITERATION IS A FRESH SUBSCRIPTION — the per-subscription state lives inside
247
+ // asyncIterator(), matching the remote client (one connection per iteration): two concurrent
248
+ // iterations each get the full stream, and one iteration's end does not poison the next.
249
+ // Registration happens on the FIRST next(), not at iterator creation: subscription semantics
250
+ // = you are subscribed while you iterate; an iterator obtained but never driven must not
251
+ // buffer. Teardown goes through Subscriber.close() so a `return()` on a QUIET stream
252
+ // resolves promptly instead of queueing behind a never-settling pull — without it every
253
+ // attach/detach against an idle session would leak a permanently registered subscriber.
254
+ return {
255
+ [Symbol.asyncIterator]() {
256
+ let sub;
257
+ // `finished` is its own state: `sub === undefined` alone would conflate "not yet
258
+ // registered" with "terminated", and a post-done next() would silently REGISTER A FRESH
259
+ // subscription — the exact ghost-subscriber leak this class exists to prevent, reachable
260
+ // by any wrapper that polls one extra time. done is terminal, per the iterator protocol.
261
+ let finished = false;
262
+ const cleanup = () => {
263
+ finished = true;
264
+ if (!sub)
265
+ return;
266
+ sub.close();
267
+ const set = subscribers.get(session);
268
+ if (set) {
269
+ set.delete(sub);
270
+ if (set.size === 0)
271
+ subscribers.delete(session);
272
+ }
273
+ sub = undefined;
274
+ };
275
+ return {
276
+ async next() {
277
+ if (finished)
278
+ return { done: true, value: undefined };
279
+ if (!sub) {
280
+ sub = new Subscriber(session);
281
+ let set = subscribers.get(session);
282
+ if (!set) {
283
+ set = new Set();
284
+ subscribers.set(session, set);
285
+ }
286
+ set.add(sub);
287
+ }
288
+ const result = await sub.next();
289
+ if (result.done)
290
+ cleanup();
291
+ return result;
292
+ },
293
+ async return(value) {
294
+ cleanup();
295
+ return { done: true, value: value };
296
+ },
297
+ };
298
+ },
299
+ };
300
+ },
301
+ async dispatch(session, command) {
302
+ switch (command.type) {
303
+ case "steer":
304
+ case "follow_up":
305
+ case "abort": {
306
+ const run = active.get(session);
307
+ if (!run) {
308
+ // Run/compaction symmetry: an in-flight manual compaction is a model call too, and
309
+ // `abort` is its only door — interrupting the harness converges through the detached
310
+ // task's catch into `compaction_finished{aborted}` with the lease released; answering
311
+ // no_active_run against a state() that says "compacting" would be a lie.
312
+ const comp = command.type === "abort" ? compacting.get(session) : undefined;
313
+ if (comp) {
314
+ comp.abort();
315
+ return { ok: true }; // no runId — the outcome travels as compaction_finished{aborted}
316
+ }
317
+ // Rejected BEFORE acceptance: no run exists, nothing happened. retryable: false —
318
+ // as-is retry fails again; re-dispatch after state() shows an active run.
319
+ return {
320
+ ok: false,
321
+ error: {
322
+ code: NO_ACTIVE_RUN_CODE,
323
+ message: `no active run for this session — ${command.type} modulates a run an invoke is driving`,
324
+ retryable: false,
325
+ },
326
+ };
327
+ }
328
+ if (!run.controls) {
329
+ // A run EXISTS (state() rightly reports running) but was registered observation-only
330
+ // (the observer seam allows run_started without controls). That is a CAPABILITY
331
+ // problem, not a run problem — permanent for this wiring, so neither no_active_run
332
+ // (would poll forever) nor run_command_failed (transient) fits.
333
+ return {
334
+ ok: false,
335
+ error: {
336
+ code: UNSUPPORTED_CAPABILITY_CODE,
337
+ message: `the active run registered without modulation controls (observation-only) — ${command.type} cannot reach it`,
338
+ retryable: false,
339
+ },
340
+ };
341
+ }
342
+ try {
343
+ if (command.type === "steer")
344
+ await run.controls.steer(command.prompt);
345
+ else if (command.type === "follow_up")
346
+ await run.controls.followUp(command.prompt);
347
+ else
348
+ await run.controls.abort();
349
+ }
350
+ catch (error) {
351
+ // The run raced us to settlement, failed setup, or the engine refused: still
352
+ // pre-acceptance (nothing was queued), distinct from "no run existed". retryable:
353
+ // false for the same reason — the run is gone; consult state() before re-dispatching.
354
+ return {
355
+ ok: false,
356
+ error: { code: RUN_COMMAND_FAILED_CODE, message: String(error), retryable: false },
357
+ };
358
+ }
359
+ // Accepted: joined (or stopped) THIS run. The outcome arrives as run_settled.
360
+ return { ok: true, runId: run.runId };
361
+ }
362
+ case "compact":
363
+ case "set_model":
364
+ case "set_thinking": {
365
+ const b = boundary?.();
366
+ if (!b) {
367
+ // No boundary wiring: rejected before acceptance; a capability-gating client never
368
+ // lands here.
369
+ return {
370
+ ok: false,
371
+ error: {
372
+ code: UNSUPPORTED_CAPABILITY_CODE,
373
+ message: `command "${command.type}" is not supported by this runtime (no boundary wiring)`,
374
+ retryable: false,
375
+ },
376
+ };
377
+ }
378
+ // Payload validation BEFORE the lease — an invalid value must not briefly block a run.
379
+ /** The entry-append for set_model/set_thinking — undefined for compact (harness path). */
380
+ let apply;
381
+ if (command.type === "set_model") {
382
+ const slash = command.model.indexOf("/");
383
+ const model = slash > 0 ? b.models.getModel(command.model.slice(0, slash), command.model.slice(slash + 1)) : undefined;
384
+ if (!model) {
385
+ return {
386
+ ok: false,
387
+ error: {
388
+ code: INVALID_COMMAND_CODE,
389
+ message: `unknown model "${command.model}" — capabilities().modelSelection lists the allowed specs`,
390
+ retryable: false,
391
+ },
392
+ };
393
+ }
394
+ apply = async (s) => {
395
+ await s.appendModelChange(model.provider, model.id);
396
+ // The CANONICAL spec, same string the durable entry and state() report — the event
397
+ // must not echo a client alias the other two surfaces would disagree with.
398
+ return { type: "state_changed", timestamp: Date.now(), data: { model: `${model.provider}/${model.id}` } };
399
+ };
400
+ }
401
+ else if (command.type === "set_thinking") {
402
+ if (!THINKING_LEVELS.has(command.level)) {
403
+ return {
404
+ ok: false,
405
+ error: {
406
+ code: INVALID_COMMAND_CODE,
407
+ message: `unknown thinking level "${command.level}" — capabilities().thinkingLevel lists the allowed values`,
408
+ retryable: false,
409
+ },
410
+ };
411
+ }
412
+ apply = async (s) => {
413
+ await s.appendThinkingLevelChange(command.level);
414
+ return { type: "state_changed", timestamp: Date.now(), data: { thinkingLevel: command.level } };
415
+ };
416
+ }
417
+ // Sessions are created by invoke, never here: a mutation on an unknown id is rejected,
418
+ // not minted into a ghost record. (Existence check before the lease — read-only; the
419
+ // WRITE handle is re-opened under the lease below, this one is discarded.)
420
+ if (!(await sessions.openIfExists(session))) {
421
+ return {
422
+ ok: false,
423
+ error: {
424
+ code: NO_SUCH_SESSION_CODE,
425
+ message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
426
+ retryable: false,
427
+ },
428
+ };
429
+ }
430
+ // Boundary mutations are the control plane's only writers: same lease as every run — a
431
+ // mutation must never race one (design §9).
432
+ const release = b.lease.tryAcquire(session);
433
+ if (!release) {
434
+ return {
435
+ ok: false,
436
+ error: {
437
+ code: SESSION_BUSY_CODE,
438
+ message: "session busy: a run (or another boundary mutation) is in flight — retry at idle",
439
+ retryable: true,
440
+ },
441
+ };
442
+ }
443
+ if (command.type === "compact") {
444
+ // ACCEPT-FAST: compaction is a full model call (tens of seconds is normal) — holding
445
+ // the dispatch open until it finishes made acceptance = outcome, the one exception to
446
+ // §5.2, and broke remote clients whose request timeouts are sized for control calls.
447
+ // The dispatch answers once the work is ADMITTED (lease held, harness built); the
448
+ // outcome travels as compaction_finished{summary|error|aborted}, the bounds contract watchers
449
+ // already rely on. Pre-acceptance failures (the harness build) still reject here.
450
+ // The admission step is EVERYTHING cheap and local: the harness build (the ONE
451
+ // canonical resolution of session overrides + auth) plus the compaction PREPARATION
452
+ // (a pure branch-read computation) — the boundary between "reject the dispatch" and
453
+ // "outcome travels as an event" sits where the work becomes asynchronous and
454
+ // expensive: the model call. "Nothing to compact" is thus a pre-acceptance answer,
455
+ // never a finished{error} dressed as a failure. The summarization runs through pi's
456
+ // compaction primitives instead of harness.compact() for exactly one reason: the
457
+ // harness surface passes no signal to its model call, so an in-flight compaction
458
+ // would be uncancellable — and `abort` needs a real door (run/compaction symmetry).
459
+ let harness;
460
+ try {
461
+ harness = await b.harnessFactory(session);
462
+ }
463
+ catch (error) {
464
+ release();
465
+ return {
466
+ ok: false,
467
+ error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
468
+ };
469
+ }
470
+ const teardown = async () => {
471
+ try {
472
+ await harness.abort(); // fresh-harness discipline
473
+ }
474
+ catch (error) {
475
+ log.warn(`[fastagent] compaction harness teardown failed: ${String(error)}`);
476
+ }
477
+ };
478
+ let record;
479
+ let preparation;
480
+ try {
481
+ const bound = harnessSession(harness);
482
+ if (!bound)
483
+ throw new Error("harness has no bound session (factory invariant broken)");
484
+ record = bound;
485
+ const prep = prepareCompaction(await record.getBranch(), DEFAULT_COMPACTION_SETTINGS);
486
+ if (!prep.ok)
487
+ throw prep.error;
488
+ if (!prep.value) {
489
+ await teardown();
490
+ release();
491
+ // A no-op, not a failure — its OWN code (the NO_ACTIVE_RUN pattern): a client must
492
+ // machine-distinguish "give up" from "re-dispatch once the session grows", and
493
+ // branching on message prose is forbidden by contract.
494
+ return {
495
+ ok: false,
496
+ error: {
497
+ code: NOTHING_TO_COMPACT_CODE,
498
+ message: "nothing to compact — the session has no compactable history yet; retry after more turns",
499
+ retryable: false,
500
+ },
501
+ };
502
+ }
503
+ preparation = prep.value;
504
+ }
505
+ catch (error) {
506
+ await teardown();
507
+ release();
508
+ return {
509
+ ok: false,
510
+ error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
511
+ };
512
+ }
513
+ const door = new AbortController();
514
+ compacting.set(session, door); // admission complete: from here `abort` reaches the model call
515
+ emitOwn(session, { type: "compaction_started", timestamp: Date.now(), data: {} });
516
+ void (async () => {
517
+ let outcome;
518
+ try {
519
+ const done = await compact(preparation, b.models, harness.getModel(), command.instructions, door.signal, harness.getThinkingLevel(), SUMMARIZATION_RETRY_POLICY, {
520
+ // Retries are otherwise invisible between compaction_started and _finished —
521
+ // surface each backoff so a long gap is diagnosable (not confusable with a
522
+ // hang): as a session event for attached observers, as a warn for server logs.
523
+ onRetryScheduled: (attempt, maxAttempts, delayMs, errorMessage) => {
524
+ log.warn(`[fastagent] compaction retry ${attempt}/${maxAttempts} in ${delayMs}ms (session ${session}): ${errorMessage}`);
525
+ emitOwn(session, {
526
+ type: "retry_scheduled",
527
+ timestamp: Date.now(),
528
+ data: { operation: "compaction", attempt, maxAttempts, delayMs, error: errorMessage },
529
+ });
530
+ },
531
+ });
532
+ if (!done.ok)
533
+ throw done.error;
534
+ await record.appendCompaction(done.value.summary, done.value.firstKeptEntryId, done.value.tokensBefore, done.value.details);
535
+ outcome = { summary: done.value.summary };
536
+ }
537
+ catch (error) {
538
+ // A deliberate stop is not a failure — run/compaction symmetry with
539
+ // run_settled{aborted}: the door's signal is the classification, same discipline
540
+ // as run abort attribution (a racing real failure still reads as aborted — the
541
+ // intent was live while the work resolved).
542
+ outcome = door.signal.aborted ? { aborted: true } : { error: String(error) };
543
+ }
544
+ await teardown();
545
+ // Release BEFORE emitting finished: a watcher seeing finished may dispatch next —
546
+ // "finished ⇒ the lease is free and status is no longer compacting" must hold.
547
+ compacting.delete(session);
548
+ release();
549
+ emitOwn(session, { type: "compaction_finished", timestamp: Date.now(), data: outcome });
550
+ })();
551
+ return { ok: true };
552
+ }
553
+ try {
554
+ // The WRITE handle is opened UNDER the lease: a handle from before tryAcquire could
555
+ // be a stale snapshot of a run that completed in the window — appending to it would
556
+ // hang the override off an outdated leaf.
557
+ const fresh = await sessions.openIfExists(session);
558
+ if (!fresh) {
559
+ // Same real condition as the pre-lease check (the session vanished in the window):
560
+ // same code, same disposition — not a retryable internal error.
561
+ return {
562
+ ok: false,
563
+ error: {
564
+ code: NO_SUCH_SESSION_CODE,
565
+ message: `session "${session}" does not exist — sessions are created by invoke, not by boundary mutations`,
566
+ retryable: false,
567
+ },
568
+ };
569
+ }
570
+ // Unreachable by construction: only set_model/set_thinking reach this branch, and
571
+ // both assign `apply` in validation. Throw rather than silently skip (fail visibly).
572
+ if (!apply)
573
+ throw new Error("apply unset outside the compact branch (dispatch invariant broken)");
574
+ emitOwn(session, await apply(fresh));
575
+ }
576
+ catch (error) {
577
+ // The append failed before anything durable landed — "nothing took effect"; the same
578
+ // command may succeed on retry.
579
+ return {
580
+ ok: false,
581
+ error: { code: BOUNDARY_COMMAND_FAILED_CODE, message: String(error), retryable: true },
582
+ };
583
+ }
584
+ finally {
585
+ release();
586
+ }
587
+ return { ok: true };
588
+ }
589
+ default:
590
+ // Wire input bypasses the TS union (a remote client can send any `type`): a protocol-
591
+ // level answer, never an undefined body — the transport promises `ok: false` shapes.
592
+ return {
593
+ ok: false,
594
+ error: {
595
+ code: INVALID_COMMAND_CODE,
596
+ message: `unknown command type "${String(command.type)}"`,
597
+ retryable: false,
598
+ },
599
+ };
600
+ }
601
+ },
602
+ };
603
+ return { control, observer };
604
+ }
@@ -3,8 +3,23 @@ import type { Session } from "@earendil-works/pi-agent-core";
3
3
  export interface PiSessionStore {
4
4
  openOrCreate(sessionId: string): Promise<Session>;
5
5
  }
6
+ /**
7
+ * OPEN-EXISTING sibling of {@link PiSessionStore} (session-control.ts): an unknown session answers
8
+ * `undefined`, never creates one — sessions are the data plane's monopoly. Two consumers:
9
+ * - the OBSERVATION plane (`state()`/`entries()`), strictly read-only (design §16 invariant 4);
10
+ * - the control plane's BOUNDARY writers (`set_model`/`set_thinking`), which append override
11
+ * records to the returned handle after an existence check, under the run lease.
12
+ * `openIfExists` skips the open-time crash reconciliation (that appends repair entries — a write
13
+ * the observation plane must not perform). The boundary writers are safe WITHOUT it only because
14
+ * override records are not messages — they cannot create or interact with a dangling tool_use
15
+ * pair. Writing MESSAGE-class records through this handle would bypass that repair: use
16
+ * `openOrCreate` for anything that enters the transcript.
17
+ */
18
+ export interface PiSessionReader {
19
+ openIfExists(sessionId: string): Promise<Session | undefined>;
20
+ }
6
21
  /** In-process store (pi InMemorySessionRepo). Continuity lives and dies with the instance. */
7
- export declare function inMemorySessionStore(): PiSessionStore;
22
+ export declare function inMemorySessionStore(): PiSessionStore & PiSessionReader;
8
23
  /**
9
24
  * Disk-backed store (pi JsonlSessionRepo under `dir`): restart the process, conversations continue.
10
25
  * `cwd` is recorded in session metadata; defaults to process.cwd().
@@ -12,4 +27,4 @@ export declare function inMemorySessionStore(): PiSessionStore;
12
27
  export declare function jsonlSessionStore(options: {
13
28
  dir: string;
14
29
  cwd?: string;
15
- }): PiSessionStore;
30
+ }): PiSessionStore & PiSessionReader;
@@ -100,6 +100,10 @@ export function inMemorySessionStore() {
100
100
  await reconcileInterruptedToolCalls(session);
101
101
  return session;
102
102
  },
103
+ async openIfExists(sessionId) {
104
+ const existing = (await repo.list()).find((m) => m.id === sessionId);
105
+ return existing ? repo.open(existing) : undefined;
106
+ },
103
107
  };
104
108
  }
105
109
  /**
@@ -122,6 +126,11 @@ export function jsonlSessionStore(options) {
122
126
  await reconcileInterruptedToolCalls(session);
123
127
  return session;
124
128
  },
129
+ async openIfExists(sessionId) {
130
+ const id = encodeSessionId(sessionId);
131
+ const existing = (await repo.list({ cwd })).find((m) => m.id === id);
132
+ return existing ? repo.open(existing) : undefined;
133
+ },
125
134
  };
126
135
  }
127
136
  /** Injective filename-safe encoding: [A-Za-z0-9._-] verbatim, the rest %-escaped. */