@fastagent-sh/fastagent 0.19.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 (212) hide show
  1. package/README.md +5 -1
  2. package/dist/atomic-write.d.ts +11 -7
  3. package/dist/atomic-write.js +16 -11
  4. package/dist/channels/agentcore-limits.d.ts +9 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-protocol.d.ts +112 -0
  7. package/dist/channels/agentcore-protocol.js +22 -0
  8. package/dist/channels/agentcore-service.d.ts +12 -15
  9. package/dist/channels/agentcore-service.js +15 -24
  10. package/dist/channels/agentcore-state.d.ts +5 -11
  11. package/dist/channels/agentcore-state.js +4 -1
  12. package/dist/channels/agentcore.d.ts +9 -83
  13. package/dist/channels/agentcore.js +101 -93
  14. package/dist/channels/control.d.ts +50 -12
  15. package/dist/channels/control.js +251 -157
  16. package/dist/channels/discover.d.ts +26 -5
  17. package/dist/channels/discover.js +13 -26
  18. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  19. package/dist/channels/feishu/context-buffer.js +0 -38
  20. package/dist/channels/feishu/crypto.d.ts +0 -2
  21. package/dist/channels/feishu/crypto.js +3 -8
  22. package/dist/channels/feishu/feishu-api.js +7 -12
  23. package/dist/channels/feishu/feishu.js +86 -128
  24. package/dist/channels/feishu/parse.d.ts +4 -3
  25. package/dist/channels/feishu/parse.js +5 -4
  26. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  27. package/dist/channels/feishu/register-webhook.js +43 -57
  28. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  29. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  30. package/dist/channels/feishu/setup-mode.js +26 -0
  31. package/dist/channels/feishu/shared-api.d.ts +10 -0
  32. package/dist/channels/feishu/shared-api.js +38 -0
  33. package/dist/channels/http.d.ts +0 -8
  34. package/dist/channels/http.js +4 -56
  35. package/dist/channels/kit/attachment-path.d.ts +12 -0
  36. package/dist/channels/kit/attachment-path.js +43 -0
  37. package/dist/channels/kit/context-buffer.d.ts +9 -0
  38. package/dist/channels/kit/context-buffer.js +11 -0
  39. package/dist/channels/kit/signature.d.ts +12 -0
  40. package/dist/channels/kit/signature.js +17 -0
  41. package/dist/channels/kit/stop-command.js +3 -3
  42. package/dist/channels/kit/tasks.d.ts +1 -1
  43. package/dist/channels/kit/tasks.js +10 -4
  44. package/dist/channels/kit/turn-runner.d.ts +59 -0
  45. package/dist/channels/kit/turn-runner.js +84 -0
  46. package/dist/channels/kit/turn-store.d.ts +19 -3
  47. package/dist/channels/kit/turn-store.js +29 -2
  48. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  49. package/dist/channels/registration.d.ts +36 -1
  50. package/dist/channels/registration.js +57 -1
  51. package/dist/channels/secret.d.ts +1 -0
  52. package/dist/channels/secret.js +16 -0
  53. package/dist/channels/slack/config-api.d.ts +35 -3
  54. package/dist/channels/slack/config-api.js +51 -9
  55. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  56. package/dist/channels/slack/invoke-turn.js +5 -1
  57. package/dist/channels/slack/manifest.js +5 -1
  58. package/dist/channels/slack/onboard.d.ts +5 -5
  59. package/dist/channels/slack/onboard.js +44 -23
  60. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  61. package/dist/channels/slack/onboarding-state.js +13 -20
  62. package/dist/channels/slack/register-webhook.d.ts +3 -2
  63. package/dist/channels/slack/register-webhook.js +35 -15
  64. package/dist/channels/slack/scaffold/channel.ts +3 -10
  65. package/dist/channels/slack/scaffold/slack-send.ts +18 -141
  66. package/dist/channels/slack/shared-api.d.ts +10 -0
  67. package/dist/channels/slack/shared-api.js +34 -0
  68. package/dist/channels/slack/slack-api.d.ts +20 -2
  69. package/dist/channels/slack/slack-api.js +100 -73
  70. package/dist/channels/slack/slack.d.ts +0 -10
  71. package/dist/channels/slack/slack.js +68 -107
  72. package/dist/channels/sse.d.ts +4 -0
  73. package/dist/channels/sse.js +66 -0
  74. package/dist/channels/telegram/parse.d.ts +21 -1
  75. package/dist/channels/telegram/parse.js +65 -11
  76. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  77. package/dist/channels/telegram/register-webhook.js +44 -42
  78. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  79. package/dist/channels/telegram/telegram-api.js +5 -6
  80. package/dist/channels/telegram/telegram.d.ts +2 -2
  81. package/dist/channels/telegram/telegram.js +97 -204
  82. package/dist/channels/wait-health.js +7 -4
  83. package/dist/cli/add-feishu.js +3 -10
  84. package/dist/cli/add-slack.js +13 -25
  85. package/dist/cli/commands/add.d.ts +0 -1
  86. package/dist/cli/commands/add.js +22 -35
  87. package/dist/cli/commands/attach.d.ts +2 -4
  88. package/dist/cli/commands/attach.js +17 -15
  89. package/dist/cli/commands/chat.js +6 -12
  90. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  91. package/dist/cli/commands/deploy/agentcore.js +178 -0
  92. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  93. package/dist/cli/commands/deploy/docker.js +119 -0
  94. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  95. package/dist/cli/commands/deploy/fly.js +131 -0
  96. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  97. package/dist/cli/commands/deploy/railway.js +71 -0
  98. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  99. package/dist/cli/commands/deploy/shared.js +124 -0
  100. package/dist/cli/commands/deploy.d.ts +38 -25
  101. package/dist/cli/commands/deploy.js +101 -631
  102. package/dist/cli/commands/dev.js +20 -76
  103. package/dist/cli/commands/fire.js +6 -12
  104. package/dist/cli/commands/info.js +1 -1
  105. package/dist/cli/commands/init.js +1 -2
  106. package/dist/cli/commands/invoke.js +4 -11
  107. package/dist/cli/commands/schedule.js +1 -1
  108. package/dist/cli/commands/start.js +41 -80
  109. package/dist/cli/commands/tool.js +3 -8
  110. package/dist/cli/kernel.d.ts +0 -2
  111. package/dist/cli/kernel.js +0 -2
  112. package/dist/cli/program.js +7 -12
  113. package/dist/cli/serve.d.ts +42 -34
  114. package/dist/cli/serve.js +107 -28
  115. package/dist/cli/shared.d.ts +52 -27
  116. package/dist/cli/shared.js +69 -6
  117. package/dist/deploy/agentcore/forwarder.js +250 -0
  118. package/dist/deploy/agentcore/logs.d.ts +2 -2
  119. package/dist/deploy/agentcore/logs.js +2 -2
  120. package/dist/deploy/agentcore/plan.d.ts +34 -11
  121. package/dist/deploy/agentcore/plan.js +52 -259
  122. package/dist/deploy/agentcore/run.d.ts +9 -24
  123. package/dist/deploy/agentcore/run.js +35 -43
  124. package/dist/deploy/channel-ingress.d.ts +73 -0
  125. package/dist/deploy/channel-ingress.js +101 -0
  126. package/dist/deploy/docker/plan.d.ts +3 -7
  127. package/dist/deploy/docker/plan.js +4 -15
  128. package/dist/deploy/docker/run.d.ts +35 -4
  129. package/dist/deploy/docker/run.js +40 -8
  130. package/dist/deploy/fly/plan.d.ts +4 -5
  131. package/dist/deploy/fly/plan.js +14 -22
  132. package/dist/deploy/fly/run.d.ts +36 -23
  133. package/dist/deploy/fly/run.js +129 -83
  134. package/dist/deploy/hosts.d.ts +5 -0
  135. package/dist/deploy/hosts.js +4 -0
  136. package/dist/deploy/preflight.d.ts +6 -7
  137. package/dist/deploy/preflight.js +27 -23
  138. package/dist/deploy/railway/plan.d.ts +9 -5
  139. package/dist/deploy/railway/plan.js +18 -26
  140. package/dist/deploy/railway/run.d.ts +11 -10
  141. package/dist/deploy/railway/run.js +44 -43
  142. package/dist/deploy/runner.js +22 -2
  143. package/dist/deploy/secrets.d.ts +18 -9
  144. package/dist/deploy/secrets.js +63 -28
  145. package/dist/dev-supervisor.js +3 -4
  146. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  147. package/dist/engines/pi/agent-session-factory.js +111 -106
  148. package/dist/engines/pi/auth.js +43 -43
  149. package/dist/engines/pi/config.d.ts +2 -2
  150. package/dist/engines/pi/create.d.ts +22 -27
  151. package/dist/engines/pi/create.js +59 -74
  152. package/dist/engines/pi/definition.d.ts +1 -1
  153. package/dist/engines/pi/definition.js +11 -12
  154. package/dist/engines/pi/invoke-session.js +13 -30
  155. package/dist/engines/pi/login.js +32 -16
  156. package/dist/engines/pi/open.d.ts +3 -4
  157. package/dist/engines/pi/open.js +32 -42
  158. package/dist/engines/pi/retry-event.d.ts +6 -0
  159. package/dist/engines/pi/retry-event.js +15 -0
  160. package/dist/engines/pi/search-tools.js +1 -1
  161. package/dist/engines/pi/service.d.ts +1 -1
  162. package/dist/engines/pi/service.js +8 -0
  163. package/dist/engines/pi/session-builder.js +26 -140
  164. package/dist/engines/pi/session-control.d.ts +27 -22
  165. package/dist/engines/pi/session-control.js +551 -490
  166. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  167. package/dist/engines/pi/session-inheritance.js +95 -76
  168. package/dist/engines/pi/session-markers.d.ts +48 -0
  169. package/dist/engines/pi/session-markers.js +59 -0
  170. package/dist/engines/pi/session-settings.d.ts +5 -5
  171. package/dist/engines/pi/session-settings.js +8 -5
  172. package/dist/engines/pi/session-store.d.ts +91 -26
  173. package/dist/engines/pi/session-store.js +413 -82
  174. package/dist/engines/pi/tool-context.d.ts +24 -11
  175. package/dist/engines/pi/tool-context.js +29 -4
  176. package/dist/engines/pi/tool.d.ts +6 -9
  177. package/dist/engines/pi/tool.js +3 -2
  178. package/dist/env.js +1 -2
  179. package/dist/feishu.d.ts +1 -0
  180. package/dist/feishu.js +1 -0
  181. package/dist/lark.d.ts +1 -0
  182. package/dist/lark.js +1 -0
  183. package/dist/loader.d.ts +51 -7
  184. package/dist/loader.js +84 -18
  185. package/dist/log.d.ts +9 -17
  186. package/dist/log.js +25 -30
  187. package/dist/paths.d.ts +26 -3
  188. package/dist/paths.js +43 -5
  189. package/dist/scaffold/add-channel.d.ts +6 -1
  190. package/dist/scaffold/add-channel.js +50 -67
  191. package/dist/scaffold/init.js +7 -2
  192. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  193. package/dist/schedule/discover.js +3 -15
  194. package/dist/schedule/wake-alarm.d.ts +14 -19
  195. package/dist/schedule/wake-alarm.js +89 -48
  196. package/dist/schedule/wakeups.d.ts +1 -1
  197. package/dist/schedule/wakeups.js +10 -7
  198. package/dist/service.d.ts +24 -24
  199. package/dist/service.js +33 -77
  200. package/dist/session-remote.d.ts +12 -7
  201. package/dist/session-remote.js +185 -118
  202. package/dist/session.d.ts +227 -93
  203. package/dist/session.js +61 -23
  204. package/dist/slack.d.ts +2 -0
  205. package/dist/slack.js +1 -0
  206. package/dist/telegram.d.ts +1 -1
  207. package/dist/telegram.js +1 -1
  208. package/dist/tunnel.d.ts +34 -9
  209. package/dist/tunnel.js +83 -50
  210. package/package.json +5 -4
  211. package/dist/channels/slack/bot-auth.d.ts +0 -15
  212. package/dist/channels/slack/bot-auth.js +0 -135
package/dist/service.js CHANGED
@@ -1,32 +1,12 @@
1
- /**
2
- * The product, as one call: an agent directory becomes a live service.
3
- *
4
- * That phrase is the promise on the README, and until this existed only the CLI could keep it. The
5
- * assembly parts live here too — `routesFor`, `mountSessionControl`, `startSchedules` — because a
6
- * public entry may not reach into `cli/`: that directory decides process-level things (`fail.ts`
7
- * calls `process.exit`) which a library mounted inside someone's app does not get to decide.
8
- * Everything else was parts: assemble the agent, discover channels, mount the control plane, start
9
- * schedules, open long connections, compose a router. An embedder had to know that list and get its
10
- * order right, and getting it wrong is silent: a plane that 404s while advertising itself, a
11
- * schedule that never fires.
12
- *
13
- * So the assembly lives here, and `dev`/`start` are callers. AgentCore is the one exception, and a
14
- * substantive one: its channels load lazily after a state-snapshot restore, so it cannot use an
15
- * assembly that discovers them eagerly (cli/commands/start.ts says so at the branch).
16
- */
17
- import { mkdirSync, rmSync } from "node:fs";
18
- import { writeFileAtomic } from "./atomic-write.js";
19
- import { join } from "node:path";
20
- import { classifyBind, clientHost } from "./bind.js";
21
- import { createControlPlane } from "./channels/control.js";
1
+ import { CONTROL_TOKEN_ENV, createControlPlane } from "./channels/control.js";
22
2
  import { createInvokeHandler } from "./channels/http.js";
23
3
  import { text } from "./channels/respond.js";
24
- import { parseRouteKey, pathUnderPrefix } from "./channels/serve.js";
4
+ import { parseRouteKey, pathUnderPrefix, router } from "./channels/serve.js";
25
5
  import { loadChannels } from "./channels/discover.js";
26
6
  import { loadSchedules } from "./schedule/discover.js";
27
7
  import { createScheduler } from "./schedule/scheduler.js";
28
- import { router } from "./channels/serve.js";
29
- import { log, reportModuleLoadFailures } from "./log.js";
8
+ import { log } from "./log.js";
9
+ import { reportModuleLoadFailures } from "./loader.js";
30
10
  /** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
31
11
  * abort signal must not hang a caller's teardown — or, during a failed start, keep the original
32
12
  * error from arriving. The CLI passes a shorter one: its own forced exit must come AFTER this, or
@@ -71,7 +51,7 @@ export async function routesFor(agentDir, agent, stateRoot, control, options = {
71
51
  control,
72
52
  });
73
53
  for (const c of collisions) {
74
- console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
54
+ log.warn(`[fastagent] channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
75
55
  }
76
56
  reportModuleLoadFailures(failures);
77
57
  if (failures.length > 0 || collisions.length > 0) {
@@ -114,51 +94,36 @@ export function assertNoControlPlaneCollision(channelRoutes, plane) {
114
94
  `rename the channel route or disable sessionControl in fastagent.config`);
115
95
  }
116
96
  }
117
- export function mountSessionControl(routes, control, stateRoot, options = {}) {
97
+ export function mountSessionControl(routes, control, options = {}) {
118
98
  if (!control)
119
- return { routes, mounts: [], announce: () => () => { } };
120
- const token = crypto.randomUUID();
99
+ return { routes, mounts: [] };
100
+ // WHO OWNS the secret. Per-boot mint is right locally: discovery is `control.json` and its file
101
+ // permissions, which works because both holders share a filesystem. A deployment removes that
102
+ // premise — a token minted in the container is replaced every restart and reachable only by shelling
103
+ // in — so there the deployer mints it and injects it here, like the wake/ingress secrets.
104
+ // Trimmed on read, like `.env` values already are: a token pasted from a dashboard with a trailing
105
+ // newline would otherwise become the box's token verbatim, and every caller holding the clean value
106
+ // gets a bare 401 — the undiagnosable symptom, one character wide.
107
+ const injected = process.env[CONTROL_TOKEN_ENV]?.trim();
108
+ // SET BUT EMPTY is the deployed default, not an edge case: the generated Compose topology writes
109
+ // every secret as `NAME: "${NAME:-}"`, so an operator who skipped this one lands here. Falling back
110
+ // silently would leave exactly the symptom the injection exists to remove — a token the caller does
111
+ // not have — with nothing in the log to tell it apart from a deployment that never asked.
112
+ if (injected === "") {
113
+ log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is set but empty — minting a per-boot token instead; callers holding ` +
114
+ "the deploy-time value will get 401 (set it, or read the minted one from control.json on the box)");
115
+ }
116
+ else if (injected !== undefined && injected.length < 16) {
117
+ // Length is a crude proxy for entropy — sixteen `a`s pass. It is aimed at `changeme`, which this
118
+ // change makes newly dangerous: the plane went from unusable-in-a-deployment to usable by whoever
119
+ // holds this string, and the empty case is the only other thing that says anything.
120
+ log.warn(`[fastagent] ${CONTROL_TOKEN_ENV} is ${injected.length} characters — it is the ONLY thing between ` +
121
+ "/control/* (steer, stop, rewrite a session) and anyone who can reach the port; use a random value (uuidgen)");
122
+ }
123
+ const token = injected || crypto.randomUUID();
121
124
  const plane = createControlPlane(control, { token, agent: options.agent });
122
125
  assertNoControlPlaneCollision(routes, plane);
123
- return {
124
- routes,
125
- mounts: [plane],
126
- control: { token, prefix: plane.prefix },
127
- // Writes the discovery file and hands back its removal. It installs NO signal handlers: a
128
- // library mounted inside someone's app must not change how that app exits — the CLI wires the
129
- // returned cleanup into its own shutdown, an embedder into `close()`.
130
- announce: (boundPort) => {
131
- mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
132
- const path = join(stateRoot, "control.json");
133
- const url = `http://${clientHost(options.host)}:${boundPort}`;
134
- writeFileAtomic(path, `${JSON.stringify({ url, token })}\n`, 0o600);
135
- log.info(`[fastagent] session control on /control/* (token in ${path})`);
136
- // LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths
137
- // warn loudly, and the LAN path must not be the silent third way past the local trust story.
138
- // A loopback bind closes exactly that reach, so it earns silence.
139
- const bind = classifyBind(options.host);
140
- if (bind !== "loopback") {
141
- log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
142
- "/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
143
- "(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
144
- }
145
- if (options.tunnel) {
146
- // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
147
- log.warn("[fastagent] --tunnel exposes /control/* (steer/abort/set_model) at the public tunnel URL, " +
148
- "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
149
- }
150
- // Removed on shutdown so a stale file cannot point a client at a dead port: `attach` then
151
- // fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
152
- return () => {
153
- try {
154
- rmSync(path, { force: true });
155
- }
156
- catch {
157
- /* the file is advisory — shutdown must not fail on it */
158
- }
159
- };
160
- },
161
- };
126
+ return { routes, mounts: [plane], control: { token, prefix: plane.prefix } };
162
127
  }
163
128
  /**
164
129
  * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
@@ -199,11 +164,7 @@ export async function mountAgentService(opened, options = {}) {
199
164
  const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
200
165
  const closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_DEADLINE_MS;
201
166
  const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: true });
202
- const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
203
- agent,
204
- ...(options.control?.tunnel !== undefined ? { tunnel: options.control.tunnel } : {}),
205
- ...(options.control?.host !== undefined ? { host: options.control.host } : {}),
206
- });
167
+ const withControl = mountSessionControl(routed.routes, sessionControl, { agent });
207
168
  // Composed BEFORE anything starts. `router` re-validates what `loadChannels` and the control
208
169
  // mount already checked, so on THIS path it should not fail — but "should not" is not an ordering
209
170
  // guarantee, and a throw after the scheduler ticks and channels dial would leave both running
@@ -211,7 +172,6 @@ export async function mountAgentService(opened, options = {}) {
211
172
  const handler = router(withControl.routes, withControl.mounts);
212
173
  const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule);
213
174
  const abort = new AbortController();
214
- let unannounce;
215
175
  // A connection that drops while others are still dialling must not be undone by their later
216
176
  // readiness: the service is missing a declared channel from that moment on, whatever else arrives.
217
177
  let dropped = false;
@@ -237,7 +197,6 @@ export async function mountAgentService(opened, options = {}) {
237
197
  abort.abort();
238
198
  scheduled.stop();
239
199
  options.signal?.removeEventListener("abort", onAbort);
240
- unannounce?.(); // a stale discovery file would point a client at a dead port
241
200
  // A failure to stop is the caller's to know about — swallowing it would let `close()` report
242
201
  // success over a channel still holding on. Bounded, because a channel that ignores its abort
243
202
  // signal must not hang the teardown either.
@@ -346,9 +305,6 @@ export async function mountAgentService(opened, options = {}) {
346
305
  schedules: scheduled.schedules,
347
306
  ready,
348
307
  ...(withControl.control ? { control: withControl.control } : {}),
349
- announce: (boundPort) => {
350
- unannounce = withControl.announce(boundPort);
351
- },
352
308
  close,
353
309
  };
354
310
  }
@@ -1,9 +1,9 @@
1
1
  /**
2
- * The remote `SessionControl` — the client half of the Phase 3 transport (design §13). Engine- and
3
- * server-neutral: speaks only the wire protocol `createControlPlane` serves (HTTP JSON + SSE with the
4
- * {sessionId, epoch, seq, event} envelope) and re-exposes the SAME `SessionControl` interface, so
5
- * local and remote consumers are isomorphic — client code does not change when the agent moves out
6
- * of process.
2
+ * The remote `SessionControl` — the client half of the HTTP + SSE transport
3
+ * (docs/design/session-control.md §13). Engine- and server-neutral: speaks only the wire protocol
4
+ * `createControlPlane` serves (HTTP JSON + SSE with the {sessionId, epoch, seq, event} envelope) and
5
+ * re-exposes the SAME `SessionControl` interface, so local and remote consumers are isomorphic —
6
+ * client code does not change when the agent moves out of process.
7
7
  *
8
8
  * Envelope consumption is internal: a seq gap (loss in transit on this connection) — and any
9
9
  * mid-stream transport failure, a server restart included (its connections drop) — THROWS from
@@ -15,13 +15,18 @@
15
15
  * visible as a thrown iteration error, a failed request as a rejected promise.
16
16
  */
17
17
  import type { Agent } from "./agent.ts";
18
- import type { SessionControl } from "./session.ts";
18
+ import { type SessionControl } from "./session.ts";
19
19
  /** A control request the server answered with a non-2xx status. Carries the STRUCTURED status so a
20
20
  * consumer distinguishing auth failure (401 — stale token, unrecoverable) from transient transport
21
21
  * trouble branches on `status`, never on message prose. */
22
22
  export declare class ControlRequestError extends Error {
23
23
  readonly status: number;
24
- constructor(status: number, body: string);
24
+ /** The plane's own error code, when the reply carried one (`sessions()` is the only read that
25
+ * does today — design §13). Absent for a plain-text rejection (401) or a proxy's page: a caller
26
+ * distinguishing "this deployment cannot list sessions" from "the endpoint is unreachable" reads
27
+ * THIS, not the status. */
28
+ readonly code?: string;
29
+ constructor(status: number, body: string, code?: string);
25
30
  }
26
31
  /** Connection parameters shared by BOTH remote planes (`connectSessionControl` and
27
32
  * `connectAgent`) — plane-neutral on purpose: one endpoint, one token, two contracts. */
@@ -1,5 +1,6 @@
1
- import { SSE_HEARTBEAT_MS } from "./channels/http.js";
1
+ import { SSE_HEARTBEAT_MS } from "./channels/sse.js";
2
2
  import { abortFirstIterator } from "./collect.js";
3
+ import { isAddressableSession, } from "./session.js";
3
4
  /** Dead-connection watchdog for SSE reads: the server heartbeats every SSE_HEARTBEAT_MS, so a
4
5
  * PENDING READ seeing no bytes (of ANY kind — comments included) for this many missed beats
5
6
  * means the connection is a black hole. The stream is aborted and surfaced as an error, so a
@@ -31,11 +32,34 @@ function idleWatchdog(abort) {
31
32
  * trouble branches on `status`, never on message prose. */
32
33
  export class ControlRequestError extends Error {
33
34
  status;
34
- constructor(status, body) {
35
+ /** The plane's own error code, when the reply carried one (`sessions()` is the only read that
36
+ * does today — design §13). Absent for a plain-text rejection (401) or a proxy's page: a caller
37
+ * distinguishing "this deployment cannot list sessions" from "the endpoint is unreachable" reads
38
+ * THIS, not the status. */
39
+ code;
40
+ constructor(status, body, code) {
35
41
  super(`control request failed: ${status} ${body}`);
36
42
  this.status = status;
43
+ if (code !== undefined)
44
+ this.code = code;
37
45
  }
38
46
  }
47
+ /** A non-2xx reply as an error, carrying the plane's code when the reply declared one. */
48
+ async function controlError(res) {
49
+ const body = await res.text();
50
+ if (!res.headers.get("content-type")?.includes("application/json"))
51
+ return new ControlRequestError(res.status, body);
52
+ let parsed;
53
+ try {
54
+ parsed = JSON.parse(body);
55
+ }
56
+ catch {
57
+ // The reply declared JSON and is not — a protocol fault worth seeing, but not worth losing the
58
+ // status over: both travel in one error rather than a bare SyntaxError from a rejection path.
59
+ return new ControlRequestError(res.status, `${body} (declared application/json but did not parse)`);
60
+ }
61
+ return new ControlRequestError(res.status, body, typeof parsed?.code === "string" ? parsed.code : undefined);
62
+ }
39
63
  /**
40
64
  * Connect and return a remote `SessionControl`. Async because `capabilities()` is synchronous in
41
65
  * the contract: the static declaration is fetched ONCE here and served from memory — which also
@@ -59,10 +83,110 @@ export async function connectSessionControl(options) {
59
83
  const get = async (path, timeoutMs = REQUEST_TIMEOUT_MS) => {
60
84
  const res = await fetchFn(`${base}${path}`, { headers, signal: AbortSignal.timeout(timeoutMs) });
61
85
  if (!res.ok)
62
- throw new ControlRequestError(res.status, await res.text());
86
+ throw await controlError(res);
63
87
  return (await res.json());
64
88
  };
65
89
  const capabilities = await get("/control/capabilities");
90
+ const eventsOf = (session) => {
91
+ // Each ITERATION opens its own connection (gen/abort created inside asyncIterator), matching
92
+ // the local hub's "every iteration is a fresh subscription" — a shared single-use generator
93
+ // would make the second for-await silently empty, breaking local/remote isomorphism.
94
+ // The abort controller lives OUTSIDE the generator: a consumer's `return()`/`break` while the
95
+ // generator is suspended on a quiet SSE read must abort the fetch FIRST — an async generator's
96
+ // own finally only runs after the pending await settles, which a silent stream never does.
97
+ const openStream = (abort) => (async function* iterate() {
98
+ // Armed BEFORE the fetch: the connect phase (headers never arriving from a black-holed
99
+ // endpoint) is otherwise a window no timeout covers — the same watchdog terminates it,
100
+ // with headers-arrival counting as the first sign of life.
101
+ const watchdog = idleWatchdog(abort);
102
+ watchdog.arm(); // the connect await is a pending read
103
+ try {
104
+ const res = await fetchFn(`${base}/control/sessions/${encodeURIComponent(session)}/events`, {
105
+ headers,
106
+ signal: abort.signal,
107
+ });
108
+ watchdog.disarm(); // headers arrived
109
+ if (!res.ok) {
110
+ // The error body is a pending read too — a half-dead tunnel serving 4xx headers then
111
+ // black-holing the body must not hang the round outside every budget. Re-armed: the
112
+ // watchdog aborts the read and the round fails with the dead-connection diagnosis.
113
+ watchdog.arm();
114
+ throw new ControlRequestError(res.status, await res.text());
115
+ }
116
+ if (!res.body)
117
+ throw new Error("control events: response has no body");
118
+ let nextSeq = 0;
119
+ for await (const data of sseData(res.body, watchdog)) {
120
+ // Parse discipline, same as the other two wire planes (dispatch parses, invoke
121
+ // classifies drift): a non-JSON or non-envelope payload is PROTOCOL MISMATCH —
122
+ // thrown, so a consumer's failure budget applies — never misdiagnosed as an
123
+ // in-transit gap whose remedy (reconnect) can never fix it.
124
+ let wire;
125
+ try {
126
+ // The ONE envelope type (control.ts's WireEvent) — an inline shape would let the
127
+ // envelope drift server-side while this cast silently kept the old fields.
128
+ wire = JSON.parse(data);
129
+ }
130
+ catch (parseError) {
131
+ throw new Error(`control events: non-JSON data on the stream (${String(parseError)}) — protocol mismatch?`);
132
+ }
133
+ if (typeof wire !== "object" ||
134
+ wire === null ||
135
+ typeof wire.seq !== "number" ||
136
+ typeof wire.event !== "object" ||
137
+ wire.event === null ||
138
+ typeof wire.event.type !== "string") {
139
+ throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
140
+ }
141
+ // Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
142
+ // one connection — see the header note.) A gap THROWS like a protocol mismatch: the
143
+ // consumer's failure path (budget, its own io) owns the diagnostic — a library-level
144
+ // log would bypass consumer output discipline, and a silent clean end would be
145
+ // indistinguishable from the server closing normally.
146
+ if (wire.seq !== nextSeq) {
147
+ throw new Error(`control events: sequence gap (expected ${nextSeq}, got ${wire.seq}) — events were lost in transit; resync via entries()`);
148
+ }
149
+ nextSeq = wire.seq + 1;
150
+ yield wire.event;
151
+ }
152
+ }
153
+ catch (error) {
154
+ if (abort.signal.aborted) {
155
+ if (watchdog.stale()) {
156
+ throw new Error(`control events: no bytes for ${SSE_IDLE_LIMIT_MS / 1000}s (heartbeats absent) — dead connection; resync via entries()`);
157
+ }
158
+ return; // the consumer walked away — clean end, not an error
159
+ }
160
+ throw error;
161
+ }
162
+ finally {
163
+ watchdog.stop();
164
+ abort.abort();
165
+ }
166
+ })();
167
+ return {
168
+ [Symbol.asyncIterator]() {
169
+ const abort = new AbortController();
170
+ // Abort-first cancellation (see abortFirstIterator): aborting the connection unblocks a
171
+ // generator suspended on a quiet stream read.
172
+ return abortFirstIterator(openStream(abort), () => abort.abort());
173
+ },
174
+ };
175
+ };
176
+ /** A write that answers a `SessionResult`: the result rides HTTP 200 either way (`ok: false` is a
177
+ * protocol answer, not a transport failure), so a non-2xx here is a REAL transport/auth fault. */
178
+ const write = async (path, method, body) => {
179
+ const res = await fetchFn(`${base}${path}`, {
180
+ method,
181
+ headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
182
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
183
+ signal: AbortSignal.timeout(PAYLOAD_TIMEOUT_MS),
184
+ });
185
+ if (!res.ok)
186
+ throw await controlError(res);
187
+ return (await res.json());
188
+ };
189
+ const id = (session) => encodeURIComponent(session);
66
190
  return {
67
191
  capabilities: () => capabilities,
68
192
  // NOT prefetched like capabilities: a live definition can grow a skill between calls, so the
@@ -82,98 +206,50 @@ export async function connectSessionControl(options) {
82
206
  throw error;
83
207
  }
84
208
  },
85
- state: (session) => get(`/control/state?session=${encodeURIComponent(session)}`),
86
- entries: (session, opts) => get(`/control/entries?session=${encodeURIComponent(session)}${opts?.since !== undefined ? `&since=${encodeURIComponent(opts.since)}` : ""}`, PAYLOAD_TIMEOUT_MS),
87
- async dispatch(session, command) {
88
- const res = await fetchFn(`${base}/control/dispatch`, {
89
- method: "POST",
90
- headers: { ...headers, "content-type": "application/json" },
91
- body: JSON.stringify({ session, command }),
92
- signal: AbortSignal.timeout(PAYLOAD_TIMEOUT_MS),
93
- });
94
- if (!res.ok)
95
- throw new ControlRequestError(res.status, await res.text());
96
- return (await res.json());
97
- },
98
- events(session) {
99
- // Each ITERATION opens its own connection (gen/abort created inside asyncIterator), matching
100
- // the local hub's "every iteration is a fresh subscription" — a shared single-use generator
101
- // would make the second for-await silently empty, breaking local/remote isomorphism.
102
- // The abort controller lives OUTSIDE the generator: a consumer's `return()`/`break` while the
103
- // generator is suspended on a quiet SSE read must abort the fetch FIRST — an async generator's
104
- // own finally only runs after the pending await settles, which a silent stream never does.
105
- const openStream = (abort) => (async function* iterate() {
106
- // Armed BEFORE the fetch: the connect phase (headers never arriving from a black-holed
107
- // endpoint) is otherwise a window no timeout covers — the same watchdog terminates it,
108
- // with headers-arrival counting as the first sign of life.
109
- const watchdog = idleWatchdog(abort);
110
- watchdog.arm(); // the connect await is a pending read
111
- try {
112
- const res = await fetchFn(`${base}/control/events?session=${encodeURIComponent(session)}`, {
113
- headers,
114
- signal: abort.signal,
115
- });
116
- watchdog.disarm(); // headers arrived
117
- if (!res.ok) {
118
- // The error body is a pending read too — a half-dead tunnel serving 4xx headers then
119
- // black-holing the body must not hang the round outside every budget. Re-armed: the
120
- // watchdog aborts the read and the round fails with the dead-connection diagnosis.
121
- watchdog.arm();
122
- throw new ControlRequestError(res.status, await res.text());
123
- }
124
- if (!res.body)
125
- throw new Error("control events: response has no body");
126
- let nextSeq = 0;
127
- for await (const data of sseData(res.body, watchdog)) {
128
- // Parse discipline, same as the other two wire planes (dispatch parses, invoke
129
- // classifies drift): a non-JSON or non-envelope payload is PROTOCOL MISMATCH —
130
- // thrown, so a consumer's failure budget applies — never misdiagnosed as an
131
- // in-transit gap whose remedy (reconnect) can never fix it.
132
- let wire;
133
- try {
134
- // The ONE envelope type (control.ts's WireEvent) — an inline shape would let the
135
- // envelope drift server-side while this cast silently kept the old fields.
136
- wire = JSON.parse(data);
137
- }
138
- catch (parseError) {
139
- throw new Error(`control events: non-JSON data on the stream (${String(parseError)}) — protocol mismatch?`);
140
- }
141
- if (typeof wire.seq !== "number" || typeof wire.event !== "object" || wire.event === null) {
142
- throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
143
- }
144
- // Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
145
- // one connection — see the header note.) A gap THROWS like a protocol mismatch: the
146
- // consumer's failure path (budget, its own io) owns the diagnostic — a library-level
147
- // log would bypass consumer output discipline, and a silent clean end would be
148
- // indistinguishable from the server closing normally.
149
- if (wire.seq !== nextSeq) {
150
- throw new Error(`control events: sequence gap (expected ${nextSeq}, got ${wire.seq}) — events were lost in transit; resync via entries()`);
151
- }
152
- nextSeq = wire.seq + 1;
153
- yield wire.event;
154
- }
209
+ sessions: {
210
+ // Rejects when the deployment cannot enumerate its store the coded 503 arrives as a
211
+ // ControlRequestError carrying `sessions_unavailable`, so a client can tell it from an
212
+ // unreachable endpoint instead of retrying forever.
213
+ list: () => get("/control/sessions", PAYLOAD_TIMEOUT_MS),
214
+ // PUT: the fork is idempotent, and so is the request that carries it. `into` becomes a path
215
+ // segment exactly like `get`'s id, so it is refused on the same rule — without this the local
216
+ // plane answers `invalid_command` while the wire answers 404 from a URL that normalised away.
217
+ // ASYNC, so the guard REJECTS rather than throwing out of a method typed `Promise`: a caller
218
+ // that wrote `.catch(…)` — or handed this to `Promise.all` — must not be surprised by a
219
+ // synchronous throw. (`get` may throw: it is synchronous by signature.)
220
+ fork: async ({ from, at, into }) => {
221
+ if (!isAddressableSession(into)) {
222
+ throw new Error(`session id ${JSON.stringify(into)} cannot travel as a URL path segment — this transport cannot address it`);
155
223
  }
156
- catch (error) {
157
- if (abort.signal.aborted) {
158
- if (watchdog.stale()) {
159
- throw new Error(`control events: no bytes for ${SSE_IDLE_LIMIT_MS / 1000}s (heartbeats absent) dead connection; resync via entries()`);
160
- }
161
- return; // the consumer walked away clean end, not an error
162
- }
163
- throw error;
224
+ return write(`/control/sessions/${id(into)}`, "PUT", { from, at });
225
+ },
226
+ // The local hub's handle is a pure binding; so is this one — an id and the transport above it.
227
+ // Nothing is FETCHED here, which is what keeps the two isomorphic. What is checked is the one
228
+ // thing the wire cannot express: `.` and `..` survive `encodeURIComponent` and are then
229
+ // normalised away by URL parsing, so every call on such a handle would arrive at a DIFFERENT
230
+ // route — `.` reads as the collection (200 JSON, which the SSE reader ends as a silently empty
231
+ // stream) and `..` as a 404 the local plane answers normally. Refused at the binding, where a
232
+ // caller can see it, rather than once per call in a place it looks like a server answer.
233
+ get: (session) => {
234
+ if (!isAddressableSession(session)) {
235
+ throw new Error(`session id ${JSON.stringify(session)} cannot travel as a URL path segment — this transport cannot address it`);
164
236
  }
165
- finally {
166
- watchdog.stop();
167
- }
168
- })();
169
- return {
170
- [Symbol.asyncIterator]() {
171
- const abort = new AbortController();
172
- // Abort-first cancellation (see abortFirstIterator): aborting the connection unblocks a
173
- // generator suspended on a quiet stream read.
174
- return abortFirstIterator(openStream(abort), () => abort.abort());
175
- },
176
- };
237
+ return {
238
+ id: session,
239
+ state: () => get(`/control/sessions/${id(session)}`),
240
+ entries: (options) => get(`/control/sessions/${id(session)}/entries${options?.since !== undefined ? `?since=${encodeURIComponent(options.since)}` : ""}`, PAYLOAD_TIMEOUT_MS),
241
+ events: () => eventsOf(session),
242
+ update: (patch) => write(`/control/sessions/${id(session)}`, "PATCH", patch),
243
+ steer: (prompt) => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "steer", prompt }),
244
+ followUp: (prompt) => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "follow_up", prompt }),
245
+ abort: () => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "abort" }),
246
+ compact: (options) => write(`/control/sessions/${id(session)}/actions`, "POST", {
247
+ type: "compact",
248
+ ...(options?.instructions !== undefined ? { instructions: options.instructions } : {}),
249
+ }),
250
+ delete: () => write(`/control/sessions/${id(session)}`, "DELETE"),
251
+ };
252
+ },
177
253
  },
178
254
  };
179
255
  }
@@ -218,9 +294,7 @@ export function connectAgent(options) {
218
294
  };
219
295
  return;
220
296
  }
221
- // Exactly-one-terminal discipline across the wire: a drop AFTER the server's terminal
222
- // must not append a second one (catch included), and a stream that ends WITHOUT one
223
- // (server died mid-run) must be closed with a failed — never a terminal-less end.
297
+ // A terminal closes the stream. Cleanup errors must not append a second terminal.
224
298
  let terminalSeen = false;
225
299
  // Armed BEFORE the fetch — the run's driver must not hang on a black-holed connect
226
300
  // either (the connect await is a pending read; headers arriving disarm it).
@@ -258,38 +332,30 @@ export function connectAgent(options) {
258
332
  event = JSON.parse(data);
259
333
  }
260
334
  catch (parseError) {
261
- // Protocol drift (version skew, non-SSE middlebox), NOT transport trouble:
262
- // re-sending the same prompt cannot fix an unparseable stream — retryable: false.
263
- // (Guarded by terminalSeen: garbage AFTER the terminal must not add a second one.)
264
- if (!terminalSeen) {
265
- yield {
266
- type: "failed",
267
- details: `remote invoke: unparseable event on the stream (${String(parseError)})`,
268
- retryable: false,
269
- };
270
- }
335
+ yield {
336
+ type: "failed",
337
+ details: `remote invoke: unparseable event on the stream (${String(parseError)})`,
338
+ retryable: false,
339
+ };
271
340
  return;
272
341
  }
273
342
  // Shape check, same discipline as the events plane: `data: null` / `data: 42` is
274
343
  // valid JSON but protocol drift — it must not TypeError into the catch below and be
275
344
  // misclassified as retryable network trouble.
276
345
  if (typeof event !== "object" || event === null || typeof event.type !== "string") {
277
- if (!terminalSeen) {
278
- yield {
279
- type: "failed",
280
- details: "remote invoke: non-event data on the stream — protocol mismatch?",
281
- retryable: false,
282
- };
283
- }
346
+ yield {
347
+ type: "failed",
348
+ details: "remote invoke: non-event data on the stream — protocol mismatch?",
349
+ retryable: false,
350
+ };
284
351
  return;
285
352
  }
286
- if (event.type === "completed" || event.type === "failed")
287
- terminalSeen = true;
353
+ terminalSeen = event.type === "completed" || event.type === "failed";
288
354
  yield event;
355
+ if (terminalSeen)
356
+ return;
289
357
  }
290
- if (!terminalSeen) {
291
- yield { type: "failed", details: "remote invoke: stream ended without a terminal", retryable: true };
292
- }
358
+ yield { type: "failed", details: "remote invoke: stream ended without a terminal", retryable: true };
293
359
  }
294
360
  catch (error) {
295
361
  if (abort.signal.aborted) {
@@ -307,6 +373,7 @@ export function connectAgent(options) {
307
373
  }
308
374
  finally {
309
375
  watchdog.stop();
376
+ abort.abort();
310
377
  }
311
378
  })();
312
379
  // ONE stream per invoke, like a local async generator (which is its own iterator): a second