@fastagent-sh/fastagent 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (155) hide show
  1. package/README.md +5 -1
  2. package/dist/channels/agentcore-protocol.d.ts +112 -0
  3. package/dist/channels/agentcore-protocol.js +22 -0
  4. package/dist/channels/agentcore-service.d.ts +0 -4
  5. package/dist/channels/agentcore-service.js +1 -6
  6. package/dist/channels/agentcore-state.d.ts +5 -11
  7. package/dist/channels/agentcore-state.js +4 -1
  8. package/dist/channels/agentcore.d.ts +1 -67
  9. package/dist/channels/agentcore.js +90 -89
  10. package/dist/channels/control.d.ts +1 -1
  11. package/dist/channels/control.js +13 -62
  12. package/dist/channels/discover.d.ts +0 -1
  13. package/dist/channels/discover.js +1 -3
  14. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  15. package/dist/channels/feishu/context-buffer.js +0 -38
  16. package/dist/channels/feishu/crypto.d.ts +0 -2
  17. package/dist/channels/feishu/crypto.js +3 -8
  18. package/dist/channels/feishu/feishu-api.js +2 -4
  19. package/dist/channels/feishu/feishu.js +56 -115
  20. package/dist/channels/feishu/parse.d.ts +4 -3
  21. package/dist/channels/feishu/parse.js +5 -4
  22. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  23. package/dist/channels/feishu/register-webhook.js +43 -57
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  26. package/dist/channels/feishu/setup-mode.js +26 -0
  27. package/dist/channels/feishu/shared-api.d.ts +10 -0
  28. package/dist/channels/feishu/shared-api.js +38 -0
  29. package/dist/channels/http.d.ts +0 -8
  30. package/dist/channels/http.js +4 -56
  31. package/dist/channels/kit/turn-runner.d.ts +59 -0
  32. package/dist/channels/kit/turn-runner.js +84 -0
  33. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  34. package/dist/channels/registration.d.ts +36 -1
  35. package/dist/channels/registration.js +57 -1
  36. package/dist/channels/secret.d.ts +1 -0
  37. package/dist/channels/secret.js +16 -0
  38. package/dist/channels/slack/config-api.d.ts +35 -3
  39. package/dist/channels/slack/config-api.js +51 -9
  40. package/dist/channels/slack/manifest.js +5 -1
  41. package/dist/channels/slack/onboard.d.ts +5 -5
  42. package/dist/channels/slack/onboard.js +41 -20
  43. package/dist/channels/slack/register-webhook.d.ts +3 -2
  44. package/dist/channels/slack/register-webhook.js +34 -14
  45. package/dist/channels/slack/scaffold/channel.ts +3 -10
  46. package/dist/channels/slack/scaffold/slack-send.ts +18 -126
  47. package/dist/channels/slack/shared-api.d.ts +10 -0
  48. package/dist/channels/slack/shared-api.js +34 -0
  49. package/dist/channels/slack/slack-api.d.ts +20 -2
  50. package/dist/channels/slack/slack-api.js +81 -22
  51. package/dist/channels/slack/slack.d.ts +0 -10
  52. package/dist/channels/slack/slack.js +60 -99
  53. package/dist/channels/sse.d.ts +4 -0
  54. package/dist/channels/sse.js +66 -0
  55. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  56. package/dist/channels/telegram/register-webhook.js +44 -42
  57. package/dist/channels/telegram/telegram.js +47 -142
  58. package/dist/channels/wait-health.js +7 -4
  59. package/dist/cli/add-feishu.js +3 -10
  60. package/dist/cli/add-slack.js +7 -20
  61. package/dist/cli/commands/add.d.ts +0 -1
  62. package/dist/cli/commands/add.js +22 -31
  63. package/dist/cli/commands/chat.js +6 -12
  64. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  65. package/dist/cli/commands/deploy/agentcore.js +178 -0
  66. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  67. package/dist/cli/commands/deploy/docker.js +119 -0
  68. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  69. package/dist/cli/commands/deploy/fly.js +131 -0
  70. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  71. package/dist/cli/commands/deploy/railway.js +71 -0
  72. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  73. package/dist/cli/commands/deploy/shared.js +124 -0
  74. package/dist/cli/commands/deploy.d.ts +17 -49
  75. package/dist/cli/commands/deploy.js +39 -618
  76. package/dist/cli/commands/dev.js +17 -46
  77. package/dist/cli/commands/fire.js +6 -12
  78. package/dist/cli/commands/info.js +2 -1
  79. package/dist/cli/commands/invoke.js +4 -11
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +17 -46
  82. package/dist/cli/commands/tool.js +3 -8
  83. package/dist/cli/kernel.d.ts +0 -2
  84. package/dist/cli/kernel.js +0 -2
  85. package/dist/cli/program.js +7 -12
  86. package/dist/cli/serve.d.ts +36 -19
  87. package/dist/cli/serve.js +91 -7
  88. package/dist/cli/shared.d.ts +13 -18
  89. package/dist/cli/shared.js +20 -4
  90. package/dist/deploy/agentcore/forwarder.js +250 -0
  91. package/dist/deploy/agentcore/plan.d.ts +23 -2
  92. package/dist/deploy/agentcore/plan.js +29 -244
  93. package/dist/deploy/agentcore/run.d.ts +4 -20
  94. package/dist/deploy/agentcore/run.js +22 -7
  95. package/dist/deploy/channel-ingress.js +1 -1
  96. package/dist/deploy/docker/run.d.ts +17 -3
  97. package/dist/deploy/docker/run.js +30 -8
  98. package/dist/deploy/fly/plan.js +7 -0
  99. package/dist/deploy/fly/run.d.ts +27 -0
  100. package/dist/deploy/fly/run.js +102 -20
  101. package/dist/deploy/hosts.d.ts +5 -0
  102. package/dist/deploy/hosts.js +4 -0
  103. package/dist/deploy/preflight.js +3 -3
  104. package/dist/deploy/railway/plan.d.ts +5 -0
  105. package/dist/deploy/railway/plan.js +7 -0
  106. package/dist/deploy/railway/run.d.ts +2 -1
  107. package/dist/deploy/railway/run.js +5 -4
  108. package/dist/deploy/secrets.js +0 -17
  109. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  110. package/dist/engines/pi/agent-session-factory.js +109 -85
  111. package/dist/engines/pi/config.d.ts +1 -1
  112. package/dist/engines/pi/create.d.ts +22 -39
  113. package/dist/engines/pi/create.js +58 -73
  114. package/dist/engines/pi/definition.js +8 -8
  115. package/dist/engines/pi/invoke-session.js +13 -30
  116. package/dist/engines/pi/open.d.ts +2 -2
  117. package/dist/engines/pi/open.js +31 -41
  118. package/dist/engines/pi/retry-event.d.ts +6 -0
  119. package/dist/engines/pi/retry-event.js +15 -0
  120. package/dist/engines/pi/session-builder.js +26 -113
  121. package/dist/engines/pi/session-control.d.ts +10 -18
  122. package/dist/engines/pi/session-control.js +47 -81
  123. package/dist/engines/pi/session-settings.d.ts +1 -1
  124. package/dist/engines/pi/session-settings.js +1 -1
  125. package/dist/engines/pi/session-store.d.ts +12 -17
  126. package/dist/engines/pi/session-store.js +18 -40
  127. package/dist/engines/pi/tool-context.d.ts +3 -3
  128. package/dist/engines/pi/tool.d.ts +6 -9
  129. package/dist/engines/pi/tool.js +1 -0
  130. package/dist/feishu.d.ts +1 -0
  131. package/dist/feishu.js +1 -0
  132. package/dist/lark.d.ts +1 -0
  133. package/dist/lark.js +1 -0
  134. package/dist/loader.d.ts +2 -0
  135. package/dist/loader.js +5 -0
  136. package/dist/log.d.ts +9 -17
  137. package/dist/log.js +25 -30
  138. package/dist/paths.d.ts +7 -3
  139. package/dist/paths.js +17 -4
  140. package/dist/scaffold/add-channel.d.ts +6 -1
  141. package/dist/scaffold/add-channel.js +48 -65
  142. package/dist/schedule/wake-alarm.d.ts +1 -12
  143. package/dist/schedule/wake-alarm.js +2 -3
  144. package/dist/service.d.ts +23 -22
  145. package/dist/service.js +7 -73
  146. package/dist/session-remote.d.ts +5 -5
  147. package/dist/session-remote.js +25 -28
  148. package/dist/session.d.ts +1 -1
  149. package/dist/slack.d.ts +2 -0
  150. package/dist/slack.js +1 -0
  151. package/dist/tunnel.d.ts +16 -6
  152. package/dist/tunnel.js +53 -12
  153. package/package.json +5 -4
  154. package/dist/channels/slack/bot-auth.d.ts +0 -15
  155. package/dist/channels/slack/bot-auth.js +0 -135
package/dist/service.d.ts CHANGED
@@ -1,3 +1,19 @@
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
+ */
1
17
  import type { Agent } from "./agent.ts";
2
18
  import { type PrefixMount } from "./channels/serve.ts";
3
19
  import { type LoadedLongConnectionChannel } from "./channels/discover.ts";
@@ -35,20 +51,17 @@ export declare function routesFor(agentDir: string, agent: Agent, stateRoot: str
35
51
  * load after this ran against an empty base.
36
52
  */
37
53
  export declare function assertNoControlPlaneCollision(channelRoutes: Routes, plane: PrefixMount): void;
38
- export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, stateRoot: string, options?: {
39
- tunnel?: boolean;
54
+ export declare function mountSessionControl(routes: Routes, control: SessionControl | undefined, options?: {
40
55
  agent?: Agent;
41
- host?: string;
42
56
  }): {
43
57
  routes: Routes;
44
58
  mounts: PrefixMount[];
45
- /** The plane's bearer token and prefix — how an embedder distributes access without a discovery file. */
59
+ /** The plane's bearer token and prefix — how a caller distributes access (the CLI writes it to
60
+ * `<stateRoot>/control.json` for local discovery; an embedder hands it out itself). */
46
61
  control?: {
47
62
  token: string;
48
63
  prefix: string;
49
64
  };
50
- /** Write the local discovery file; returns its removal. Installs no signal handlers. */
51
- announce: (boundPort: number) => () => void;
52
65
  };
53
66
  /**
54
67
  * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
@@ -88,20 +101,14 @@ export interface AgentService {
88
101
  * fails to come up, after closing the service: a host must not report itself serving while a
89
102
  * declared channel is dead, and health answers 503 until this resolves. */
90
103
  ready: Promise<void>;
91
- /** The control plane's bearer token and prefix, when `sessionControl` is on — how an embedder
92
- * hands access to a client without a discovery file. */
104
+ /** The control plane's bearer token and prefix, when `sessionControl` is on — how a caller hands
105
+ * access to a client. The CLI writes it to `<stateRoot>/control.json` for `fastagent attach`; an
106
+ * embedder mounted inside a larger app has no port of its own to describe and distributes it
107
+ * itself. */
93
108
  control?: {
94
109
  token: string;
95
110
  prefix: string;
96
111
  };
97
- /** Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the plane,
98
- * once the port is known. Optional: an embedder mounted inside a larger app has no port of its
99
- * own to describe and uses {@link AgentService.control} instead.
100
- *
101
- * Removed by `close()`. Not by an `exit` handler: installing one is a decision about the whole
102
- * process, which a mounted library does not get to make. A hard exit therefore leaves the file
103
- * behind — advisory, overwritten by the next boot, and the client's own error stays honest. */
104
- announce(boundPort: number): void;
105
112
  /** Stop long connections and schedules. Idempotent; also runs when `options.signal` aborts. */
106
113
  close(): Promise<void>;
107
114
  }
@@ -111,12 +118,6 @@ export interface MountAgentServiceOptions {
111
118
  * must get the SAME one, which is why this is a hook rather than the caller's own call. `dev`
112
119
  * passes `logAgentLoop`. */
113
120
  wrapAgent?: (agent: Agent) => Agent;
114
- /** Passed through to the control plane mount: `--tunnel` widens its warning, `host` names the
115
- * bind address in the discovery file. */
116
- control?: {
117
- tunnel?: boolean;
118
- host?: string;
119
- };
120
121
  /** Aborting this closes the service, exactly like calling {@link AgentService.close}. */
121
122
  signal?: AbortSignal;
122
123
  /** Called when a long connection ends on its own — a dropped socket-mode channel, say. The CLI
package/dist/service.js CHANGED
@@ -1,23 +1,3 @@
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
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";
@@ -25,7 +5,8 @@ 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 { log, reportModuleLoadFailures } from "./log.js";
8
+ import { log } from "./log.js";
9
+ import { reportModuleLoadFailures } from "./loader.js";
29
10
  /** Default wait for a channel's `closed` before reporting it stuck. A channel that ignores its
30
11
  * abort signal must not hang a caller's teardown — or, during a failed start, keep the original
31
12
  * error from arriving. The CLI passes a shorter one: its own forced exit must come AFTER this, or
@@ -70,7 +51,7 @@ export async function routesFor(agentDir, agent, stateRoot, control, options = {
70
51
  control,
71
52
  });
72
53
  for (const c of collisions) {
73
- 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`);
74
55
  }
75
56
  reportModuleLoadFailures(failures);
76
57
  if (failures.length > 0 || collisions.length > 0) {
@@ -113,9 +94,9 @@ export function assertNoControlPlaneCollision(channelRoutes, plane) {
113
94
  `rename the channel route or disable sessionControl in fastagent.config`);
114
95
  }
115
96
  }
116
- export function mountSessionControl(routes, control, stateRoot, options = {}) {
97
+ export function mountSessionControl(routes, control, options = {}) {
117
98
  if (!control)
118
- return { routes, mounts: [], announce: () => () => { } };
99
+ return { routes, mounts: [] };
119
100
  // WHO OWNS the secret. Per-boot mint is right locally: discovery is `control.json` and its file
120
101
  // permissions, which works because both holders share a filesystem. A deployment removes that
121
102
  // premise — a token minted in the container is replaced every restart and reachable only by shelling
@@ -142,45 +123,7 @@ export function mountSessionControl(routes, control, stateRoot, options = {}) {
142
123
  const token = injected || crypto.randomUUID();
143
124
  const plane = createControlPlane(control, { token, agent: options.agent });
144
125
  assertNoControlPlaneCollision(routes, plane);
145
- return {
146
- routes,
147
- mounts: [plane],
148
- control: { token, prefix: plane.prefix },
149
- // Writes the discovery file and hands back its removal. It installs NO signal handlers: a
150
- // library mounted inside someone's app must not change how that app exits — the CLI wires the
151
- // returned cleanup into its own shutdown, an embedder into `close()`.
152
- announce: (boundPort) => {
153
- mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
154
- const path = join(stateRoot, "control.json");
155
- const url = `http://${clientHost(options.host)}:${boundPort}`;
156
- writeFileAtomic(path, `${JSON.stringify({ url, token })}\n`, 0o600);
157
- log.info(`[fastagent] session control on /control/* (token in ${path})`);
158
- // LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths
159
- // warn loudly, and the LAN path must not be the silent third way past the local trust story.
160
- // A loopback bind closes exactly that reach, so it earns silence.
161
- const bind = classifyBind(options.host);
162
- if (bind !== "loopback") {
163
- log.warn(`[fastagent] the port binds ${bind === "wildcard" ? "all interfaces" : `${options.host} (off this machine)`}: ` +
164
- "/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
165
- "(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs: design §14)");
166
- }
167
- if (options.tunnel) {
168
- // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
169
- log.warn("[fastagent] --tunnel exposes /control/* (steer, stop, rewrite or delete a session) at the public tunnel URL, " +
170
- "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs: design §14)");
171
- }
172
- // Removed on shutdown so a stale file cannot point a client at a dead port: `attach` then
173
- // fails with "cannot read" (accurate) instead of a stale token's misleading 401/ECONNREFUSED.
174
- return () => {
175
- try {
176
- rmSync(path, { force: true });
177
- }
178
- catch {
179
- /* the file is advisory — shutdown must not fail on it */
180
- }
181
- };
182
- },
183
- };
126
+ return { routes, mounts: [plane], control: { token, prefix: plane.prefix } };
184
127
  }
185
128
  /**
186
129
  * Load and start the agent's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
@@ -221,11 +164,7 @@ export async function mountAgentService(opened, options = {}) {
221
164
  const agent = options.wrapAgent?.(opened.agent) ?? opened.agent;
222
165
  const closeTimeoutMs = options.closeTimeoutMs ?? CLOSE_DEADLINE_MS;
223
166
  const routed = await routesFor(agentDir, agent, stateRoot, sessionControl, { builtinInvoke: true });
224
- const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
225
- agent,
226
- ...(options.control?.tunnel !== undefined ? { tunnel: options.control.tunnel } : {}),
227
- ...(options.control?.host !== undefined ? { host: options.control.host } : {}),
228
- });
167
+ const withControl = mountSessionControl(routed.routes, sessionControl, { agent });
229
168
  // Composed BEFORE anything starts. `router` re-validates what `loadChannels` and the control
230
169
  // mount already checked, so on THIS path it should not fail — but "should not" is not an ordering
231
170
  // guarantee, and a throw after the scheduler ticks and channels dial would leave both running
@@ -233,7 +172,6 @@ export async function mountAgentService(opened, options = {}) {
233
172
  const handler = router(withControl.routes, withControl.mounts);
234
173
  const scheduled = await startSchedules(agentDir, agent, stateRoot, opened.selfSchedule);
235
174
  const abort = new AbortController();
236
- let unannounce;
237
175
  // A connection that drops while others are still dialling must not be undone by their later
238
176
  // readiness: the service is missing a declared channel from that moment on, whatever else arrives.
239
177
  let dropped = false;
@@ -259,7 +197,6 @@ export async function mountAgentService(opened, options = {}) {
259
197
  abort.abort();
260
198
  scheduled.stop();
261
199
  options.signal?.removeEventListener("abort", onAbort);
262
- unannounce?.(); // a stale discovery file would point a client at a dead port
263
200
  // A failure to stop is the caller's to know about — swallowing it would let `close()` report
264
201
  // success over a channel still holding on. Bounded, because a channel that ignores its abort
265
202
  // signal must not hang the teardown either.
@@ -368,9 +305,6 @@ export async function mountAgentService(opened, options = {}) {
368
305
  schedules: scheduled.schedules,
369
306
  ready,
370
307
  ...(withControl.control ? { control: withControl.control } : {}),
371
- announce: (boundPort) => {
372
- unannounce = withControl.announce(boundPort);
373
- },
374
308
  close,
375
309
  };
376
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
@@ -1,4 +1,4 @@
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
3
  import { isAddressableSession, } from "./session.js";
4
4
  /** Dead-connection watchdog for SSE reads: the server heartbeats every SSE_HEARTBEAT_MS, so a
@@ -58,7 +58,7 @@ async function controlError(res) {
58
58
  // status over: both travel in one error rather than a bare SyntaxError from a rejection path.
59
59
  return new ControlRequestError(res.status, `${body} (declared application/json but did not parse)`);
60
60
  }
61
- return new ControlRequestError(res.status, body, typeof parsed.code === "string" ? parsed.code : undefined);
61
+ return new ControlRequestError(res.status, body, typeof parsed?.code === "string" ? parsed.code : undefined);
62
62
  }
63
63
  /**
64
64
  * Connect and return a remote `SessionControl`. Async because `capabilities()` is synchronous in
@@ -130,7 +130,12 @@ export async function connectSessionControl(options) {
130
130
  catch (parseError) {
131
131
  throw new Error(`control events: non-JSON data on the stream (${String(parseError)}) — protocol mismatch?`);
132
132
  }
133
- if (typeof wire.seq !== "number" || typeof wire.event !== "object" || wire.event === null) {
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") {
134
139
  throw new Error("control events: malformed envelope — the endpoint does not speak this protocol version");
135
140
  }
136
141
  // Envelope checks — consumed HERE. (epoch is not compared: it cannot change within
@@ -156,6 +161,7 @@ export async function connectSessionControl(options) {
156
161
  }
157
162
  finally {
158
163
  watchdog.stop();
164
+ abort.abort();
159
165
  }
160
166
  })();
161
167
  return {
@@ -288,9 +294,7 @@ export function connectAgent(options) {
288
294
  };
289
295
  return;
290
296
  }
291
- // Exactly-one-terminal discipline across the wire: a drop AFTER the server's terminal
292
- // must not append a second one (catch included), and a stream that ends WITHOUT one
293
- // (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.
294
298
  let terminalSeen = false;
295
299
  // Armed BEFORE the fetch — the run's driver must not hang on a black-holed connect
296
300
  // either (the connect await is a pending read; headers arriving disarm it).
@@ -328,38 +332,30 @@ export function connectAgent(options) {
328
332
  event = JSON.parse(data);
329
333
  }
330
334
  catch (parseError) {
331
- // Protocol drift (version skew, non-SSE middlebox), NOT transport trouble:
332
- // re-sending the same prompt cannot fix an unparseable stream — retryable: false.
333
- // (Guarded by terminalSeen: garbage AFTER the terminal must not add a second one.)
334
- if (!terminalSeen) {
335
- yield {
336
- type: "failed",
337
- details: `remote invoke: unparseable event on the stream (${String(parseError)})`,
338
- retryable: false,
339
- };
340
- }
335
+ yield {
336
+ type: "failed",
337
+ details: `remote invoke: unparseable event on the stream (${String(parseError)})`,
338
+ retryable: false,
339
+ };
341
340
  return;
342
341
  }
343
342
  // Shape check, same discipline as the events plane: `data: null` / `data: 42` is
344
343
  // valid JSON but protocol drift — it must not TypeError into the catch below and be
345
344
  // misclassified as retryable network trouble.
346
345
  if (typeof event !== "object" || event === null || typeof event.type !== "string") {
347
- if (!terminalSeen) {
348
- yield {
349
- type: "failed",
350
- details: "remote invoke: non-event data on the stream — protocol mismatch?",
351
- retryable: false,
352
- };
353
- }
346
+ yield {
347
+ type: "failed",
348
+ details: "remote invoke: non-event data on the stream — protocol mismatch?",
349
+ retryable: false,
350
+ };
354
351
  return;
355
352
  }
356
- if (event.type === "completed" || event.type === "failed")
357
- terminalSeen = true;
353
+ terminalSeen = event.type === "completed" || event.type === "failed";
358
354
  yield event;
355
+ if (terminalSeen)
356
+ return;
359
357
  }
360
- if (!terminalSeen) {
361
- yield { type: "failed", details: "remote invoke: stream ended without a terminal", retryable: true };
362
- }
358
+ yield { type: "failed", details: "remote invoke: stream ended without a terminal", retryable: true };
363
359
  }
364
360
  catch (error) {
365
361
  if (abort.signal.aborted) {
@@ -377,6 +373,7 @@ export function connectAgent(options) {
377
373
  }
378
374
  finally {
379
375
  watchdog.stop();
376
+ abort.abort();
380
377
  }
381
378
  })();
382
379
  // ONE stream per invoke, like a local async generator (which is its own iterator): a second
package/dist/session.d.ts CHANGED
@@ -290,7 +290,7 @@ export interface SessionState {
290
290
  /** Set by `update({ name })`, so a client that opens a session directly gets the same label the list
291
291
  * showed. */
292
292
  name?: string;
293
- /** `compacting` refers to Phase 2 MANUAL compaction at a session boundary. Automatic overflow
293
+ /** `compacting` refers to MANUAL compaction (`compact`) at a session boundary. Automatic overflow
294
294
  * compaction happens inside a run's activity window and reports as `running`. */
295
295
  status: "idle" | "running" | "compacting";
296
296
  activeRunId?: string;
package/dist/slack.d.ts CHANGED
@@ -1,2 +1,4 @@
1
1
  /** `@fastagent-sh/fastagent/slack` — the first-party Slack Events API bot-channel surface. */
2
2
  export { slackChannel, defaultSlackRoute, slackEnvelope, verifySlackSignature, type SlackChannelOptions, type SlackEventEnvelope, type SlackFile, type SlackMessageEvent, type SlackRendering, type SlackRoute, type SlackFailure, } from "./channels/slack/slack.ts";
3
+ export { slackTransport, type SlackTransport } from "./channels/slack/shared-api.ts";
4
+ export type { SentSlackMessage, SlackTarget, UploadedSlackFile } from "./channels/slack/slack-api.ts";
package/dist/slack.js CHANGED
@@ -1,2 +1,3 @@
1
1
  /** `@fastagent-sh/fastagent/slack` — the first-party Slack Events API bot-channel surface. */
2
2
  export { slackChannel, defaultSlackRoute, slackEnvelope, verifySlackSignature, } from "./channels/slack/slack.js";
3
+ export { slackTransport } from "./channels/slack/shared-api.js";
package/dist/tunnel.d.ts CHANGED
@@ -15,15 +15,25 @@ export interface Tunnel {
15
15
  }
16
16
  /** Extract a Cloudflare quick-tunnel URL from a chunk of cloudflared output, if present. */
17
17
  export declare function parseTunnelUrl(chunk: string): string | undefined;
18
+ /** Whether cloudflared's output so far reports an edge connection — see {@link TUNNEL_CONNECTED_RE}. */
19
+ export declare function hasTunnelConnection(output: string): boolean;
20
+ /**
21
+ * How long after that connection to hand the URL over. The one number here that is not observed: 5s
22
+ * covers the widest of the 7 measured gaps (3.9s), all taken over cloudflared's http2 transport
23
+ * because this network blocks its QUIC. Costs a beat of `dev --tunnel` start-up; buys the first
24
+ * registration attempt landing on a name that resolves.
25
+ */
26
+ export declare const TUNNEL_DNS_LAG_MS = 5000;
18
27
  /** How cloudflared is launched; injectable so tests can drive the child without a real process. */
19
28
  type SpawnCloudflared = (port: number) => ChildProcess;
20
29
  /**
21
- * Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL appears.
22
- * cloudflared sometimes exits before printing a URL (a transient trycloudflare API error), so retry a
23
- * few times. ALWAYS resolves to undefined WITH an operator log saying why — missing binary, the exit
24
- * reason, or "gave up after retries" never silently; serving continues without a tunnel either way.
25
- * (Edge warmup AFTER the URL appears is handled downstream: the telegram registrar polls /health before
26
- * it calls setWebhook, so a not-yet-routable tunnel just delays registration rather than failing it.)
30
+ * Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL is assigned AND
31
+ * the tunnel has an edge connection — the URL is printed first and is not usable yet
32
+ * ({@link hasTunnelConnection}). cloudflared sometimes exits before printing a URL (a transient
33
+ * trycloudflare API error), so retry a few times. ALWAYS resolves to undefined WITH an operator log
34
+ * saying why missing binary, the exit reason, or "gave up after retries" never silently; serving
35
+ * continues without a tunnel either way. What remains after this is the PLATFORM's own warm-up, which
36
+ * each registrar absorbs by retrying while the platform reports it cannot yet verify the URL.
27
37
  */
28
38
  export declare function startCloudflareTunnel(port: number, spawnFn?: SpawnCloudflared, attemptTimeoutMs?: number): Promise<Tunnel | undefined>;
29
39
  /**
package/dist/tunnel.js CHANGED
@@ -23,6 +23,28 @@ const TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i;
23
23
  export function parseTunnelUrl(chunk) {
24
24
  return chunk.match(TUNNEL_URL_RE)?.[0];
25
25
  }
26
+ /**
27
+ * cloudflared's line for an established edge connection. THIS, not the URL, is when the hostname
28
+ * begins to exist: `*.trycloudflare.com` is no wildcard, and a quick tunnel's record is published
29
+ * once the tunnel registers a connection — so the URL is printed while the name is still NXDOMAIN.
30
+ * A platform told about it inside that window answers "Failed to resolve host" and goes on answering
31
+ * it far longer than any registrar's retry budget (#435); the record going live seconds later does
32
+ * not undo the answer it already gave, which is why more retries were never the fix.
33
+ *
34
+ * Measured over 7 quick tunnels: the URL at +0s, this line at +1.1-1.9s, the record 0.5-3.9s later.
35
+ */
36
+ const TUNNEL_CONNECTED_RE = /Registered tunnel connection/i;
37
+ /** Whether cloudflared's output so far reports an edge connection — see {@link TUNNEL_CONNECTED_RE}. */
38
+ export function hasTunnelConnection(output) {
39
+ return TUNNEL_CONNECTED_RE.test(output);
40
+ }
41
+ /**
42
+ * How long after that connection to hand the URL over. The one number here that is not observed: 5s
43
+ * covers the widest of the 7 measured gaps (3.9s), all taken over cloudflared's http2 transport
44
+ * because this network blocks its QUIC. Costs a beat of `dev --tunnel` start-up; buys the first
45
+ * registration attempt landing on a name that resolves.
46
+ */
47
+ export const TUNNEL_DNS_LAG_MS = 5000;
26
48
  /** Global timer (rather than timers/promises) so timeout/retry behavior is deterministic under fake timers. */
27
49
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
28
50
  const TUNNEL_ATTEMPTS = 3;
@@ -31,12 +53,13 @@ const TUNNEL_RETRY_MS = 2000;
31
53
  const TUNNEL_START_TIMEOUT_MS = 30_000;
32
54
  const spawnCloudflared = (port) => spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], { stdio: ["ignore", "pipe", "pipe"] });
33
55
  /**
34
- * Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL appears.
35
- * cloudflared sometimes exits before printing a URL (a transient trycloudflare API error), so retry a
36
- * few times. ALWAYS resolves to undefined WITH an operator log saying why — missing binary, the exit
37
- * reason, or "gave up after retries" never silently; serving continues without a tunnel either way.
38
- * (Edge warmup AFTER the URL appears is handled downstream: the telegram registrar polls /health before
39
- * it calls setWebhook, so a not-yet-routable tunnel just delays registration rather than failing it.)
56
+ * Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL is assigned AND
57
+ * the tunnel has an edge connection — the URL is printed first and is not usable yet
58
+ * ({@link hasTunnelConnection}). cloudflared sometimes exits before printing a URL (a transient
59
+ * trycloudflare API error), so retry a few times. ALWAYS resolves to undefined WITH an operator log
60
+ * saying why missing binary, the exit reason, or "gave up after retries" never silently; serving
61
+ * continues without a tunnel either way. What remains after this is the PLATFORM's own warm-up, which
62
+ * each registrar absorbs by retrying while the platform reports it cannot yet verify the URL.
40
63
  */
41
64
  export async function startCloudflareTunnel(port, spawnFn = spawnCloudflared, attemptTimeoutMs = TUNNEL_START_TIMEOUT_MS) {
42
65
  for (let attempt = 1; attempt <= TUNNEL_ATTEMPTS; attempt++) {
@@ -55,26 +78,35 @@ export async function startCloudflareTunnel(port, spawnFn = spawnCloudflared, at
55
78
  }
56
79
  return undefined;
57
80
  }
58
- /** One cloudflared launch: a Tunnel on the first URL, or a failure (missing binary / exit before a URL). */
81
+ /** One cloudflared launch: a Tunnel once its URL is assigned AND the edge connection is up, or a
82
+ * failure (missing binary / exit before a URL). */
59
83
  function spawnTunnelOnce(port, spawnFn, timeoutMs) {
60
84
  return new Promise((resolve) => {
61
85
  const child = spawnFn(port);
62
86
  let settled = false;
63
87
  let timer;
88
+ let handOver;
64
89
  let tail = ""; // recent output, surfaced as the failure reason
90
+ let assigned; // printed well before the tunnel can carry anything
65
91
  const finish = (result) => {
66
92
  if (settled)
67
93
  return;
68
94
  settled = true;
69
95
  if (timer)
70
96
  clearTimeout(timer);
97
+ if (handOver)
98
+ clearTimeout(handOver);
71
99
  resolve(result);
72
100
  };
101
+ const handOverNow = (url) => finish({ tunnel: { url, close: () => child.kill("SIGTERM") } });
73
102
  const onChunk = (buf) => {
74
103
  tail = (tail + String(buf)).slice(-600);
75
- const url = parseTunnelUrl(String(buf));
76
- if (url)
77
- finish({ tunnel: { url, close: () => child.kill("SIGTERM") } });
104
+ assigned ??= parseTunnelUrl(String(buf));
105
+ if (!assigned || handOver || !hasTunnelConnection(tail))
106
+ return;
107
+ const url = assigned;
108
+ handOver = setTimeout(() => handOverNow(url), TUNNEL_DNS_LAG_MS);
109
+ handOver.unref();
78
110
  };
79
111
  child.stdout?.on("data", onChunk);
80
112
  child.stderr?.on("data", onChunk); // cloudflared prints the URL (and its errors) on stderr
@@ -91,6 +123,15 @@ function spawnTunnelOnce(port, spawnFn, timeoutMs) {
91
123
  });
92
124
  child.on("exit", () => finish({ fatal: false, detail: lastErrorLine(tail) }));
93
125
  timer = setTimeout(() => {
126
+ // A URL whose tunnel never connected is not worth a fresh one — the next attempt meets the same
127
+ // network. Serve it and name what is wrong, rather than retrying into the same wall.
128
+ if (assigned) {
129
+ log.warn(`[fastagent] --tunnel: cloudflared never reported an edge connection for ${assigned} within ` +
130
+ `${Math.round(timeoutMs / 1000)}s — serving it anyway. Nothing reaches a tunnel that has not ` +
131
+ "connected, so a webhook registration that cannot resolve the host is this, not the platform.");
132
+ handOverNow(assigned);
133
+ return;
134
+ }
94
135
  child.kill("SIGTERM");
95
136
  finish({ fatal: false, detail: `timed out after ${Math.round(timeoutMs / 1000)}s waiting for a public URL` });
96
137
  }, timeoutMs);
@@ -135,8 +176,8 @@ channels, opts = {}) {
135
176
  log.warn(`[fastagent] could not read ${dotEnvPath(dir)}: ${error.message} — continuing without it`);
136
177
  }
137
178
  // Readiness is the registrar's job: a fresh quick tunnel returns Cloudflare 530 for ~20-30s before its
138
- // origin connects, and the automatic registrars poll /health before configuring the platform (the same
139
- // wait the deploy runners rely on). GitHub needs no wait — the operator adds that webhook by hand.
179
+ // origin connects, and each registrar absorbs that by retrying the platform call whose own URL
180
+ // verification reports it. GitHub needs no wait — the operator adds that webhook by hand.
140
181
  //
141
182
  // The registrars differ from the deploy path's in what they carry, not in which channels they answer
142
183
  // for: this one narrates to the log and opens the console for a manual Feishu step.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Vibe first. Then FastAgent: turn a local agent directory into a live service in your app, on GitHub, Telegram, Slack, or behind any channel.",
5
5
  "keywords": [
6
6
  "agent",
@@ -94,6 +94,7 @@
94
94
  "build": "tsc -p tsconfig.build.json",
95
95
  "prepack": "npm run build",
96
96
  "test": "vitest --run",
97
+ "test:live": "vitest --run --config vitest.live.config.ts",
97
98
  "typecheck": "tsc --noEmit",
98
99
  "lint": "biome check src test && node scripts/check-doc-links.mjs && knip",
99
100
  "format": "biome check --write src test",
@@ -105,9 +106,9 @@
105
106
  },
106
107
  "dependencies": {
107
108
  "@clack/prompts": "^1.6.0",
108
- "@earendil-works/pi-agent-core": "^0.84.3",
109
- "@earendil-works/pi-ai": "^0.84.3",
110
- "@earendil-works/pi-coding-agent": "^0.84.3",
109
+ "@earendil-works/pi-agent-core": "^0.85.1",
110
+ "@earendil-works/pi-ai": "^0.85.1",
111
+ "@earendil-works/pi-coding-agent": "^0.85.1",
111
112
  "@hono/node-server": "^2.1.1",
112
113
  "@larksuiteoapi/node-sdk": "^1.71.1",
113
114
  "@octokit/webhooks-methods": "^6.0.0",
@@ -1,15 +0,0 @@
1
- export interface SlackBotTokenProviderOptions {
2
- statePath: string;
3
- botToken: string;
4
- botRefreshToken?: string;
5
- clientId?: string;
6
- clientSecret?: string;
7
- botTokenExpiresAt?: number;
8
- apiBaseUrl?: string;
9
- fetch?: typeof fetch;
10
- }
11
- /** Latest local rotating credentials for `deploy --run`. The remote runtime still prefers its own
12
- * durable volume; this overlay prevents a locally consumed refresh token from being redeployed. */
13
- export declare function readSlackBotAuthEnv(statePath: string): Record<string, string>;
14
- /** Resolve the current bot token, refreshing once per process when it approaches expiry. */
15
- export declare function createSlackBotTokenProvider(options: SlackBotTokenProviderOptions): () => Promise<string>;