@fastagent-sh/fastagent 0.18.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +23 -0
  5. package/dist/atomic-write.js +53 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { SSE_HEARTBEAT_MS } from "./channels/http.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,104 @@ 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.seq !== "number" || typeof wire.event !== "object" || wire.event === null) {
134
+ throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
135
+ }
136
+ // Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
137
+ // one connection — see the header note.) A gap THROWS like a protocol mismatch: the
138
+ // consumer's failure path (budget, its own io) owns the diagnostic — a library-level
139
+ // log would bypass consumer output discipline, and a silent clean end would be
140
+ // indistinguishable from the server closing normally.
141
+ if (wire.seq !== nextSeq) {
142
+ throw new Error(`control events: sequence gap (expected ${nextSeq}, got ${wire.seq}) — events were lost in transit; resync via entries()`);
143
+ }
144
+ nextSeq = wire.seq + 1;
145
+ yield wire.event;
146
+ }
147
+ }
148
+ catch (error) {
149
+ if (abort.signal.aborted) {
150
+ if (watchdog.stale()) {
151
+ throw new Error(`control events: no bytes for ${SSE_IDLE_LIMIT_MS / 1000}s (heartbeats absent) — dead connection; resync via entries()`);
152
+ }
153
+ return; // the consumer walked away — clean end, not an error
154
+ }
155
+ throw error;
156
+ }
157
+ finally {
158
+ watchdog.stop();
159
+ }
160
+ })();
161
+ return {
162
+ [Symbol.asyncIterator]() {
163
+ const abort = new AbortController();
164
+ // Abort-first cancellation (see abortFirstIterator): aborting the connection unblocks a
165
+ // generator suspended on a quiet stream read.
166
+ return abortFirstIterator(openStream(abort), () => abort.abort());
167
+ },
168
+ };
169
+ };
170
+ /** A write that answers a `SessionResult`: the result rides HTTP 200 either way (`ok: false` is a
171
+ * protocol answer, not a transport failure), so a non-2xx here is a REAL transport/auth fault. */
172
+ const write = async (path, method, body) => {
173
+ const res = await fetchFn(`${base}${path}`, {
174
+ method,
175
+ headers: body === undefined ? headers : { ...headers, "content-type": "application/json" },
176
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
177
+ signal: AbortSignal.timeout(PAYLOAD_TIMEOUT_MS),
178
+ });
179
+ if (!res.ok)
180
+ throw await controlError(res);
181
+ return (await res.json());
182
+ };
183
+ const id = (session) => encodeURIComponent(session);
66
184
  return {
67
185
  capabilities: () => capabilities,
68
186
  // NOT prefetched like capabilities: a live definition can grow a skill between calls, so the
@@ -82,98 +200,50 @@ export async function connectSessionControl(options) {
82
200
  throw error;
83
201
  }
84
202
  },
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
- }
203
+ sessions: {
204
+ // Rejects when the deployment cannot enumerate its store the coded 503 arrives as a
205
+ // ControlRequestError carrying `sessions_unavailable`, so a client can tell it from an
206
+ // unreachable endpoint instead of retrying forever.
207
+ list: () => get("/control/sessions", PAYLOAD_TIMEOUT_MS),
208
+ // PUT: the fork is idempotent, and so is the request that carries it. `into` becomes a path
209
+ // segment exactly like `get`'s id, so it is refused on the same rule — without this the local
210
+ // plane answers `invalid_command` while the wire answers 404 from a URL that normalised away.
211
+ // ASYNC, so the guard REJECTS rather than throwing out of a method typed `Promise`: a caller
212
+ // that wrote `.catch(…)` — or handed this to `Promise.all` — must not be surprised by a
213
+ // synchronous throw. (`get` may throw: it is synchronous by signature.)
214
+ fork: async ({ from, at, into }) => {
215
+ if (!isAddressableSession(into)) {
216
+ throw new Error(`session id ${JSON.stringify(into)} cannot travel as a URL path segment — this transport cannot address it`);
155
217
  }
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;
164
- }
165
- finally {
166
- watchdog.stop();
218
+ return write(`/control/sessions/${id(into)}`, "PUT", { from, at });
219
+ },
220
+ // The local hub's handle is a pure binding; so is this one — an id and the transport above it.
221
+ // Nothing is FETCHED here, which is what keeps the two isomorphic. What is checked is the one
222
+ // thing the wire cannot express: `.` and `..` survive `encodeURIComponent` and are then
223
+ // normalised away by URL parsing, so every call on such a handle would arrive at a DIFFERENT
224
+ // route — `.` reads as the collection (200 JSON, which the SSE reader ends as a silently empty
225
+ // stream) and `..` as a 404 the local plane answers normally. Refused at the binding, where a
226
+ // caller can see it, rather than once per call in a place it looks like a server answer.
227
+ get: (session) => {
228
+ if (!isAddressableSession(session)) {
229
+ throw new Error(`session id ${JSON.stringify(session)} cannot travel as a URL path segment — this transport cannot address it`);
167
230
  }
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
- };
231
+ return {
232
+ id: session,
233
+ state: () => get(`/control/sessions/${id(session)}`),
234
+ entries: (options) => get(`/control/sessions/${id(session)}/entries${options?.since !== undefined ? `?since=${encodeURIComponent(options.since)}` : ""}`, PAYLOAD_TIMEOUT_MS),
235
+ events: () => eventsOf(session),
236
+ update: (patch) => write(`/control/sessions/${id(session)}`, "PATCH", patch),
237
+ steer: (prompt) => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "steer", prompt }),
238
+ followUp: (prompt) => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "follow_up", prompt }),
239
+ abort: () => write(`/control/sessions/${id(session)}/actions`, "POST", { type: "abort" }),
240
+ compact: (options) => write(`/control/sessions/${id(session)}/actions`, "POST", {
241
+ type: "compact",
242
+ ...(options?.instructions !== undefined ? { instructions: options.instructions } : {}),
243
+ }),
244
+ delete: () => write(`/control/sessions/${id(session)}`, "DELETE"),
245
+ };
246
+ },
177
247
  },
178
248
  };
179
249
  }