@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/tunnel.js CHANGED
@@ -7,11 +7,10 @@
7
7
  * Process orchestration, not assembly — lives outside the engine, beside dev-supervisor.ts.
8
8
  */
9
9
  import { spawn } from "node:child_process";
10
- import { readdirSync } from "node:fs";
11
- import { join } from "node:path";
12
10
  import { registerFeishuWebhook } from "./channels/feishu/register-webhook.js";
13
11
  import { registerSlackWebhook } from "./channels/slack/register-webhook.js";
14
12
  import { registerTelegramWebhook } from "./channels/telegram/register-webhook.js";
13
+ import { pointChannelsAt } from "./deploy/channel-ingress.js";
15
14
  import { dotEnvPath, loadDotEnv } from "./env.js";
16
15
  import { resolveStateRoot } from "./paths.js";
17
16
  import { log } from "./log.js";
@@ -24,6 +23,28 @@ const TUNNEL_URL_RE = /https:\/\/(?!api\.)[a-z0-9-]+\.trycloudflare\.com/i;
24
23
  export function parseTunnelUrl(chunk) {
25
24
  return chunk.match(TUNNEL_URL_RE)?.[0];
26
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;
27
48
  /** Global timer (rather than timers/promises) so timeout/retry behavior is deterministic under fake timers. */
28
49
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
29
50
  const TUNNEL_ATTEMPTS = 3;
@@ -32,12 +53,13 @@ const TUNNEL_RETRY_MS = 2000;
32
53
  const TUNNEL_START_TIMEOUT_MS = 30_000;
33
54
  const spawnCloudflared = (port) => spawn("cloudflared", ["tunnel", "--url", `http://localhost:${port}`], { stdio: ["ignore", "pipe", "pipe"] });
34
55
  /**
35
- * Start a Cloudflare quick tunnel to localhost:`port`, resolving once its public URL appears.
36
- * cloudflared sometimes exits before printing a URL (a transient trycloudflare API error), so retry a
37
- * few times. ALWAYS resolves to undefined WITH an operator log saying why — missing binary, the exit
38
- * reason, or "gave up after retries" never silently; serving continues without a tunnel either way.
39
- * (Edge warmup AFTER the URL appears is handled downstream: the telegram registrar polls /health before
40
- * 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.
41
63
  */
42
64
  export async function startCloudflareTunnel(port, spawnFn = spawnCloudflared, attemptTimeoutMs = TUNNEL_START_TIMEOUT_MS) {
43
65
  for (let attempt = 1; attempt <= TUNNEL_ATTEMPTS; attempt++) {
@@ -56,26 +78,35 @@ export async function startCloudflareTunnel(port, spawnFn = spawnCloudflared, at
56
78
  }
57
79
  return undefined;
58
80
  }
59
- /** 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). */
60
83
  function spawnTunnelOnce(port, spawnFn, timeoutMs) {
61
84
  return new Promise((resolve) => {
62
85
  const child = spawnFn(port);
63
86
  let settled = false;
64
87
  let timer;
88
+ let handOver;
65
89
  let tail = ""; // recent output, surfaced as the failure reason
90
+ let assigned; // printed well before the tunnel can carry anything
66
91
  const finish = (result) => {
67
92
  if (settled)
68
93
  return;
69
94
  settled = true;
70
95
  if (timer)
71
96
  clearTimeout(timer);
97
+ if (handOver)
98
+ clearTimeout(handOver);
72
99
  resolve(result);
73
100
  };
101
+ const handOverNow = (url) => finish({ tunnel: { url, close: () => child.kill("SIGTERM") } });
74
102
  const onChunk = (buf) => {
75
103
  tail = (tail + String(buf)).slice(-600);
76
- const url = parseTunnelUrl(String(buf));
77
- if (url)
78
- 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();
79
110
  };
80
111
  child.stdout?.on("data", onChunk);
81
112
  child.stderr?.on("data", onChunk); // cloudflared prints the URL (and its errors) on stderr
@@ -92,6 +123,15 @@ function spawnTunnelOnce(port, spawnFn, timeoutMs) {
92
123
  });
93
124
  child.on("exit", () => finish({ fatal: false, detail: lastErrorLine(tail) }));
94
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
+ }
95
135
  child.kill("SIGTERM");
96
136
  finish({ fatal: false, detail: `timed out after ${Math.round(timeoutMs / 1000)}s waiting for a public URL` });
97
137
  }, timeoutMs);
@@ -106,23 +146,23 @@ function lastErrorLine(tail) {
106
146
  .filter(Boolean);
107
147
  return ([...lines].reverse().find((l) => /err|error|failed/i.test(l)) ?? lines.at(-1) ?? "").slice(0, 200);
108
148
  }
109
- /** Channel basenames present in `<dir>/channels/`. */
110
- function channelBasenames(dir) {
111
- try {
112
- return readdirSync(join(dir, "channels"))
113
- .filter((n) => /\.(ts|js|mjs)$/.test(n) && !n.endsWith(".d.ts"))
114
- .map((n) => n.replace(/\.(ts|js|mjs)$/, ""));
115
- }
116
- catch {
117
- return [];
118
- }
119
- }
120
149
  /**
121
150
  * Print the public URL and wire up first-party webhook channels found under `dir` (the agent
122
151
  * ROOT): Telegram and Feishu/Lark use runtime credentials; onboarded Slack uses its owner-local config
123
152
  * token; GitHub and a manually scaffolded Slack app receive explicit console URLs.
153
+ *
154
+ * Returns what each registrar ANSWERED, because two kinds of caller need different things from a
155
+ * failure. `dev`/`start` are long-running: a webhook that did not register is a logged problem, not
156
+ * a reason to stop serving, and they void this. `deploy … --run` is a command that exits, and an
157
+ * exit 0 there tells its caller the deployment is reachable — so it feeds these through
158
+ * `registrationGate`, exactly as the fly/railway/agentcore runners feed their own registrar calls
159
+ * (docker used to be the one host that could not, because this returned nothing).
124
160
  */
125
- export async function announceWebhooks(dir, baseUrl, opts = {}) {
161
+ export async function announceWebhooks(dir, baseUrl,
162
+ /** Every declared channel with its ingress. Not a pre-filtered list: this used to accept "the route
163
+ * channels" and default to every basename in `channels/`, which pointed a webhook at a
164
+ * long-connection channel whenever a caller forgot to filter. {@link pointChannelsAt} filters. */
165
+ channels, opts = {}) {
126
166
  log.info(`[fastagent] public URL: ${baseUrl}`);
127
167
  try {
128
168
  loadDotEnv(dir); // webhook registrars read channel credentials from .env
@@ -135,33 +175,26 @@ export async function announceWebhooks(dir, baseUrl, opts = {}) {
135
175
  // missing-credential guidance. loadDotEnv keeps throwing for the synchronous command callers.
136
176
  log.warn(`[fastagent] could not read ${dotEnvPath(dir)}: ${error.message} — continuing without it`);
137
177
  }
138
- // Serving passes the validated route-channel subset. The basename fallback preserves the public
139
- // helper's behavior for callers that did not assemble channels first.
140
- const routeChannels = opts.routeChannels ?? channelBasenames(dir);
141
- if (routeChannels.length === 0)
142
- return;
143
- // Readiness is the registrar's job now: a fresh quick tunnel returns Cloudflare 530 for ~20-30s before
144
- // its origin connects, and the automatic registrars poll /health before configuring the platform (the
145
- // same wait the deploy runners rely on). GitHub needs no wait — the operator adds that webhook by hand.
146
- if (routeChannels.includes("telegram"))
147
- await registerTelegramWebhook(baseUrl);
148
- if (routeChannels.includes("github")) {
149
- log.info(`[fastagent] github: add a webhook in your repo (Settings → Webhooks): Payload URL = ${baseUrl}/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
150
- }
151
- if (routeChannels.includes("slack")) {
152
- await registerSlackWebhook(baseUrl, {
153
- stateRoot: opts.stateRoot ?? resolveStateRoot(dir),
154
- log: (message) => log.info(message),
155
- });
156
- }
157
- // feishu/lark register programmatically too (application-v7 config PATCH — telegram-setWebhook
158
- // parity), once per mounted kind (each kind is its own app with its own credentials); the registrar
159
- // owns its own health wait and degrades to the manual console instruction.
178
+ // Readiness is the registrar's job: a fresh quick tunnel returns Cloudflare 530 for ~20-30s before its
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.
181
+ //
182
+ // The registrars differ from the deploy path's in what they carry, not in which channels they answer
183
+ // for: this one narrates to the log and opens the console for a manual Feishu step.
160
184
  const feishuOptions = {
161
185
  onManualRegistration: ({ consoleUrl }) => opts.openUrl?.(consoleUrl),
162
186
  };
163
- if (routeChannels.includes("feishu"))
164
- await registerFeishuWebhook(baseUrl, "feishu", feishuOptions);
165
- if (routeChannels.includes("lark"))
166
- await registerFeishuWebhook(baseUrl, "lark", feishuOptions);
187
+ return pointChannelsAt({
188
+ baseUrl,
189
+ channels,
190
+ log: (message) => log.info(`[fastagent] ${message}`),
191
+ registrars: {
192
+ telegram: (url) => registerTelegramWebhook(url),
193
+ slack: (url) => registerSlackWebhook(url, {
194
+ stateRoot: opts.stateRoot ?? resolveStateRoot(dir),
195
+ log: (message) => log.info(message),
196
+ }),
197
+ feishu: (url, kind) => registerFeishuWebhook(url, kind, feishuOptions),
198
+ },
199
+ });
167
200
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fastagent-sh/fastagent",
3
- "version": "0.19.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>;
@@ -1,135 +0,0 @@
1
- /** Rotating Slack bot-token provider backed by owner-only channel state. */
2
- import { readFileSync } from "node:fs";
3
- import { writeFileAtomic } from "../../atomic-write.js";
4
- const REFRESH_EARLY_MS = 5 * 60_000;
5
- const REQUEST_TIMEOUT_MS = 30_000;
6
- function isState(value) {
7
- const state = value;
8
- return (typeof state === "object" &&
9
- state !== null &&
10
- state.version === 1 &&
11
- typeof state.accessToken === "string" &&
12
- typeof state.refreshToken === "string" &&
13
- typeof state.expiresAt === "number");
14
- }
15
- function load(path) {
16
- let raw;
17
- try {
18
- raw = readFileSync(path, "utf8");
19
- }
20
- catch (error) {
21
- if (error.code === "ENOENT")
22
- return undefined;
23
- throw new Error(`Slack bot auth state ${path} is unreadable: ${String(error)}`, { cause: error });
24
- }
25
- try {
26
- const value = JSON.parse(raw);
27
- if (!isState(value))
28
- throw new Error("unexpected shape/version");
29
- return value;
30
- }
31
- catch (error) {
32
- // A stale env refresh token may already have been consumed. Never hide corrupt rotating state by
33
- // falling back to it: recovery requires restoring the durable file or reinstalling the app.
34
- throw new Error(`Slack bot auth state ${path} is invalid: ${String(error)}`, { cause: error });
35
- }
36
- }
37
- function save(path, state) {
38
- writeFileAtomic(path, JSON.stringify(state), 0o600);
39
- }
40
- async function refreshSlackBotToken(input) {
41
- const authorization = Buffer.from(`${input.clientId}:${input.clientSecret}`, "utf8").toString("base64");
42
- let lastError;
43
- // Slack keeps a short grace window for a just-consumed refresh token. One retry recovers the
44
- // committed-but-response-lost boundary without creating an unbounded refresh loop.
45
- for (let attempt = 0; attempt < 2; attempt++) {
46
- try {
47
- const response = await input.fetch(`${input.apiBaseUrl}/oauth.v2.access`, {
48
- method: "POST",
49
- headers: {
50
- authorization: `Basic ${authorization}`,
51
- "content-type": "application/x-www-form-urlencoded",
52
- },
53
- body: new URLSearchParams({
54
- grant_type: "refresh_token",
55
- refresh_token: input.refreshToken,
56
- }).toString(),
57
- signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
58
- });
59
- const data = (await response.json().catch(() => ({})));
60
- if (!response.ok ||
61
- data.ok !== true ||
62
- !data.access_token ||
63
- !data.refresh_token ||
64
- typeof data.expires_in !== "number") {
65
- throw new Error(`Slack rejected bot-token refresh (HTTP ${response.status})`);
66
- }
67
- return {
68
- version: 1,
69
- accessToken: data.access_token,
70
- refreshToken: data.refresh_token,
71
- expiresAt: Date.now() + data.expires_in * 1_000,
72
- };
73
- }
74
- catch (error) {
75
- lastError = error;
76
- }
77
- }
78
- throw new Error("Slack bot-token rotation failed; restore credentials or reinstall the app", { cause: lastError });
79
- }
80
- /** Latest local rotating credentials for `deploy --run`. The remote runtime still prefers its own
81
- * durable volume; this overlay prevents a locally consumed refresh token from being redeployed. */
82
- export function readSlackBotAuthEnv(statePath) {
83
- const state = load(statePath);
84
- return state
85
- ? {
86
- SLACK_BOT_TOKEN: state.accessToken,
87
- SLACK_BOT_REFRESH_TOKEN: state.refreshToken,
88
- SLACK_BOT_TOKEN_EXPIRES_AT: String(state.expiresAt),
89
- }
90
- : {};
91
- }
92
- /** Resolve the current bot token, refreshing once per process when it approaches expiry. */
93
- export function createSlackBotTokenProvider(options) {
94
- const rotatingFields = [options.botRefreshToken, options.clientId, options.clientSecret, options.botTokenExpiresAt];
95
- const rotating = rotatingFields.some((value) => value !== undefined);
96
- if (rotating && rotatingFields.some((value) => value === undefined || value === "")) {
97
- throw new Error("Slack token rotation requires botRefreshToken, clientId, clientSecret, and botTokenExpiresAt together");
98
- }
99
- if (rotating && (!Number.isFinite(options.botTokenExpiresAt) || options.botTokenExpiresAt <= 0)) {
100
- throw new Error("Slack botTokenExpiresAt must be a positive epoch-millisecond value");
101
- }
102
- if (!rotating)
103
- return async () => options.botToken;
104
- const configured = {
105
- version: 1,
106
- accessToken: options.botToken,
107
- refreshToken: options.botRefreshToken,
108
- expiresAt: options.botTokenExpiresAt,
109
- };
110
- const persisted = load(options.statePath);
111
- // Deploy may carry a newer pair from the owner machine onto an existing remote volume; ordinary
112
- // restarts carry the original stale env and therefore keep the newer persisted pair.
113
- let state = persisted && persisted.expiresAt >= configured.expiresAt ? persisted : configured;
114
- let refreshing;
115
- return async () => {
116
- if (state.expiresAt > Date.now() + REFRESH_EARLY_MS)
117
- return state.accessToken;
118
- refreshing ??= refreshSlackBotToken({
119
- refreshToken: state.refreshToken,
120
- clientId: options.clientId,
121
- clientSecret: options.clientSecret,
122
- apiBaseUrl: (options.apiBaseUrl ?? "https://slack.com/api").replace(/\/$/, ""),
123
- fetch: options.fetch ?? globalThis.fetch,
124
- })
125
- .then((next) => {
126
- save(options.statePath, next);
127
- state = next;
128
- })
129
- .finally(() => {
130
- refreshing = undefined;
131
- });
132
- await refreshing;
133
- return state.accessToken;
134
- };
135
- }