@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
@@ -2,10 +2,16 @@
2
2
  * SHARED fire-and-forget side-task tracking. Channels launch work off the request path (stop
3
3
  * feedback, DM welcomes) that must not block the transport ACK but MUST be drained on shutdown
4
4
  * (`turnsIdle`) — otherwise a reply in flight when the process exits is silently dropped. Error
5
- * handling stays with the caller: track() only guarantees the drain sees the task settle.
5
+ * handling stays with the caller: track() only guarantees the drain sees the task SETTLE, and settle
6
+ * includes reject. A caller that handles its error on a separate branch (`p.catch(log); track(p)`)
7
+ * still hands us a promise that rejects, and a drain that propagated it would fail the channel's whole
8
+ * `turnsIdle` over one side task. A rejection that reaches us is logged — we
9
+ * cannot tell a missing `.catch` from one on a separate branch, so the line is a visibility floor
10
+ * rather than a diagnosis, and without it a dropped side task leaves no trace anywhere.
6
11
  */
7
12
  import { beginWork } from "../busy.js";
8
- export function createTaskTracker() {
13
+ import { log } from "../../log.js";
14
+ export function createTaskTracker(label) {
9
15
  const tasks = new Set();
10
16
  return {
11
17
  track(task) {
@@ -18,8 +24,8 @@ export function createTaskTracker() {
18
24
  workDone();
19
25
  tasks.delete(task);
20
26
  })
21
- .catch(() => { }); // the caller's chain owns the error
27
+ .catch((error) => log.warn(`${label} side task rejected: ${String(error)}`));
22
28
  },
23
- drain: () => Promise.all(tasks).then(() => undefined),
29
+ drain: () => Promise.allSettled(tasks).then(() => undefined),
24
30
  };
25
31
  }
@@ -0,0 +1,59 @@
1
+ import type { ContextBuffer } from "./context-buffer.ts";
2
+ import { type TurnRecordBase, type TurnStore } from "./turn-store.ts";
3
+ /** A pending turn is the persisted intent minus its attempt count, plus live-only fields the
4
+ * channel adds (a notice's message id) — never persisted, reconstructed fresh on replay. */
5
+ export type PendingBase<S extends TurnRecordBase> = Omit<S, "attempts">;
6
+ export interface TurnRunnerOptions<R extends PendingBase<S>, S extends TurnRecordBase, E> {
7
+ label: string;
8
+ store: TurnStore<S>;
9
+ buffer: ContextBuffer<E>;
10
+ /** Delivery dedup by platform id, recorded post-persist (Slack, Feishu). */
11
+ seen?: {
12
+ add(id: string): void;
13
+ };
14
+ /** The persisted intent for a pending turn — drops the live-only fields. */
15
+ toStored(rec: R): S;
16
+ /** A recovered intent as a pending turn — live-only fields start absent. */
17
+ fromStored(stored: S): R;
18
+ /** The context-buffer bucket this turn folds. */
19
+ bufferKey(rec: R): string;
20
+ /** The place, for the lifecycle log line (`chat=… thread=…`). */
21
+ where(rec: R): string;
22
+ /** Queue feedback when a turn is scheduled BEHIND an active one. Returns what the runner awaits at
23
+ * dequeue (so the turn reliably takes the notice over instead of racing it) and, optionally, how
24
+ * to cancel a notice that has not fired yet. `done` may reject — posting the notice is a platform
25
+ * call — and the runner logs that and runs the turn anyway. */
26
+ onQueuedBehind?(rec: R): {
27
+ done: Promise<void>;
28
+ cancel?: () => void;
29
+ };
30
+ /** Runs before the attempt is counted. Answer false to leave the intent untouched for a later run
31
+ * (Slack: its transport is known to be down, so an Agent turn now would have nowhere to answer). */
32
+ beforeRun?(rec: R): Promise<boolean>;
33
+ /** The attempt could not be recorded (disk failure): a restart replays the turn, so say so on any
34
+ * notice it holds rather than leaving it pinned at "Queued". */
35
+ onDeferred(rec: R): void;
36
+ /** The turn started the ceiling's worth of times without finishing: tell the asker. */
37
+ notifyDropped(rec: R): void;
38
+ /** Run the turn. `onCompleted` is the durable-commit point (the turn's `completed` event): it drops
39
+ * the intent and commits the folded discussion, in that order. A throw is logged as the turn's
40
+ * failure; the intent is dropped either way. */
41
+ execute(rec: R, discussion: {
42
+ text: string;
43
+ consumed: E[];
44
+ }, onCompleted: () => void): Promise<void>;
45
+ }
46
+ export interface TurnRunner<R, S> {
47
+ /** Accept a turn: persist its intent (pre-ACK — a failed write throws so the platform redelivers),
48
+ * record the delivery id, enqueue. Recovery re-enqueues without re-persisting. */
49
+ submit(rec: R, persist: boolean): void;
50
+ /** Re-enqueue the turns a prior crash left mid-flight; returns them so a channel can continue its
51
+ * arrival counter. */
52
+ recover(): S[];
53
+ /** Resolve once no turn is in flight — the test/observability seam. */
54
+ idle(): Promise<void>;
55
+ }
56
+ export declare function createTurnRunner<R extends PendingBase<S> & {
57
+ id: string;
58
+ session: string;
59
+ }, S extends TurnRecordBase, E>(options: TurnRunnerOptions<R, S, E>): TurnRunner<R, S>;
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The durable-turn LIFECYCLE the stateful chat channels share, over the kit's parts: accept
3
+ * (persist pre-ACK, dedup, enqueue) → dequeue (settle the queue notice, count the attempt against
4
+ * the poison ceiling, fold the buffered discussion) → execute → end (log, drop the intent). Telegram,
5
+ * Slack and Feishu each wrote this out; the copies had already drifted in small ways that were not
6
+ * decisions (which one deletes its notice on defer, which one logs the duration on failure).
7
+ *
8
+ * What stays with the platform is everything that names a platform object: how a queue notice is
9
+ * mounted and taken over, what the prompt looks like, how attachments resolve, what a dropped turn
10
+ * says and where. Those arrive as hooks; the ORDER they run in is this module's.
11
+ */
12
+ import { log } from "../../log.js";
13
+ import { createTurnQueue } from "./turn-queue.js";
14
+ import { commitAnsweredTurn } from "./turn-store.js";
15
+ export function createTurnRunner(options) {
16
+ const { label, store, buffer, seen, onQueuedBehind } = options;
17
+ const notices = new Map();
18
+ const queue = createTurnQueue({
19
+ label,
20
+ onQueuedBehind: onQueuedBehind && ((rec) => notices.set(rec.id, onQueuedBehind(rec))),
21
+ run: async (rec) => {
22
+ // Runs at DEQUEUE time (serialized). The queue wait is over: cancel a notice that has not
23
+ // fired, then settle so the turn's preview handle is final — in the common path this await is
24
+ // instant. BEFORE the ceiling check so a dropped or deferred turn can take the notice over too.
25
+ const notice = notices.get(rec.id);
26
+ notice?.cancel?.();
27
+ // The notice is the PLATFORM's feedback, not the turn: a failed post must not throw out of the
28
+ // run, which would leak the persisted intent (removed only below) into a replay that burns an
29
+ // attempt against the ceiling. Logged, then the turn proceeds without its handle.
30
+ await notice?.done.catch((error) => log.warn(`${label} queue notice failed: turn=${rec.id} session=${rec.session}: ${String(error)}`));
31
+ notices.delete(rec.id);
32
+ if (options.beforeRun && !(await options.beforeRun(rec)))
33
+ return;
34
+ const decision = store.startAttempt(rec.id);
35
+ if (decision === "exceeded") {
36
+ options.notifyDropped(rec);
37
+ return;
38
+ }
39
+ if (decision === "defer") {
40
+ options.onDeferred(rec);
41
+ return;
42
+ }
43
+ const startedAt = Date.now();
44
+ log.info(`${label} turn start: turn=${rec.id} session=${rec.session} ${options.where(rec)}`);
45
+ // Snapshot the discussion at dequeue; commit only this snapshot on `completed`, so a message
46
+ // arriving while the turn runs stays buffered for the next answered turn.
47
+ const bufferKey = options.bufferKey(rec);
48
+ const discussion = buffer.peek(bufferKey);
49
+ try {
50
+ await options.execute(rec, discussion, () => commitAnsweredTurn(store, buffer, { id: rec.id, bufferKey, consumed: discussion.consumed }));
51
+ log.info(`${label} turn done: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms)`);
52
+ }
53
+ catch (error) {
54
+ log.error(`${label} turn failed: turn=${rec.id} session=${rec.session} (${Date.now() - startedAt}ms): ${String(error)}`);
55
+ }
56
+ finally {
57
+ // Fallback removal for the caught-error paths (a `failed` event or a transport throw): those
58
+ // never reach the completed hook. Idempotent. Only an INTERRUPTED run (this finally never
59
+ // runs — a crash or SIGTERM deploy) leaves the record for replay; a transport throw is
60
+ // dropped, not retried (safe retry needs an L2 delivery key).
61
+ store.remove(rec.id);
62
+ }
63
+ },
64
+ });
65
+ const submit = (rec, persist) => {
66
+ if (persist) {
67
+ store.add(options.toStored(rec)); // pre-ACK: a failed write throws → 500 → redelivery
68
+ seen?.add(rec.id); // post-persist — recording first could turn a failed write into silent loss
69
+ }
70
+ queue.accept(rec);
71
+ };
72
+ return {
73
+ submit,
74
+ recover() {
75
+ const recovered = store.recover();
76
+ if (recovered.length > 0)
77
+ log.info(`${label} recovering ${recovered.length} unfinished turn(s) from a prior run`);
78
+ for (const stored of recovered)
79
+ submit(options.fromStored(stored), false);
80
+ return recovered;
81
+ },
82
+ idle: () => queue.idle(),
83
+ };
84
+ }
@@ -1,3 +1,4 @@
1
+ import type { ContextBuffer } from "./context-buffer.ts";
1
2
  /** What every persisted turn record carries regardless of channel: identity, the session whose FIFO
2
3
  * chain it runs on, and how many times it has STARTED executing without finishing (0 until its first
3
4
  * run; bumped at each `startAttempt`). The channel's record type extends this with everything its
@@ -20,14 +21,14 @@ export interface TurnStore<T extends TurnRecordBase> {
20
21
  recover(): T[];
21
22
  /** Called when a turn is about to RUN (dequeued). Returns:
22
23
  * - "run": bumped its persisted execution count; go ahead.
23
- * - "exceeded": over `maxAttempts` starts without finishing (killed mid-run every time, whatever the
24
- * cause); the record is dropped and the runner notifies the asker.
24
+ * - "exceeded": over {@link MAX_TURN_ATTEMPTS} starts without finishing (killed mid-run every time,
25
+ * whatever the cause); the record is dropped and the runner notifies the asker.
25
26
  * - "defer": the bump could not be persisted — skip this cycle (fail closed: an unpersisted count
26
27
  * would let a poison turn re-run forever); the record stays on disk and replays on the next start
27
28
  * (a restart is required — disk recovery alone does not re-run it). The runner does NOT notify.
28
29
  * An id with no record returns "run" (untracked): a completed turn's `remove` cleared it, so the
29
30
  * redelivery-double-run tail (see the header's pre-ACK window) lands here. */
30
- startAttempt(id: string, maxAttempts: number): "run" | "exceeded" | "defer";
31
+ startAttempt(id: string): "run" | "exceeded" | "defer";
31
32
  }
32
33
  export interface TurnStoreOptions<T extends TurnRecordBase> {
33
34
  /** Log prefix naming the consumer (e.g. "[telegram]") — the store itself is channel-neutral. */
@@ -39,4 +40,19 @@ export interface TurnStoreOptions<T extends TurnRecordBase> {
39
40
  * (telegram: numeric update_id; lark: an explicit per-record seq). */
40
41
  order: (a: T, b: T) => number;
41
42
  }
43
+ /**
44
+ * End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
45
+ *
46
+ * The ORDER is the safety property, which is why this is a function and not two lines at each call
47
+ * site. A crash between the two writes may re-fold already-answered context into the next summon —
48
+ * additive and harmless. The reverse order leaves intent on disk with its context already consumed,
49
+ * so the replay runs the same turn with its context stripped.
50
+ *
51
+ * Called from the turn's `completed` event, when the fold provably lives in the durable session.
52
+ */
53
+ export declare function commitAnsweredTurn<T extends TurnRecordBase, E>(store: TurnStore<T>, buffer: ContextBuffer<E>, turn: {
54
+ id: string;
55
+ bufferKey: string;
56
+ consumed: E[];
57
+ }): void;
42
58
  export declare function createTurnStore<T extends TurnRecordBase>(path: string, opts: TurnStoreOptions<T>): TurnStore<T>;
@@ -51,6 +51,33 @@
51
51
  */
52
52
  import { log } from "../../log.js";
53
53
  import { loadStateFile, saveStateFile } from "./state.js";
54
+ /**
55
+ * How many times a turn may START without finishing before it is dropped rather than run again — the
56
+ * poison-turn ceiling described at length above. One value for every channel: it prices replay against
57
+ * DEPLOY frequency, which is a property of how fastagent is operated, not of which chat platform is in
58
+ * front of it.
59
+ *
60
+ * Known limitation: the count cannot tell a self-inflicted crash from an external SIGTERM (there is no
61
+ * graceful drain), so a legitimately long turn interrupted by this many successive deploys is dropped
62
+ * as if it were poison. Three is a bet that such a turn is an outlier, not a defence against one:
63
+ * catching SIGTERM to spare it would reintroduce the drain the design refuses — raise this constant
64
+ * instead if such turns are expected.
65
+ */
66
+ const MAX_TURN_ATTEMPTS = 3;
67
+ /**
68
+ * End an ANSWERED turn: drop its durable intent, then commit the discussion it folded in.
69
+ *
70
+ * The ORDER is the safety property, which is why this is a function and not two lines at each call
71
+ * site. A crash between the two writes may re-fold already-answered context into the next summon —
72
+ * additive and harmless. The reverse order leaves intent on disk with its context already consumed,
73
+ * so the replay runs the same turn with its context stripped.
74
+ *
75
+ * Called from the turn's `completed` event, when the fold provably lives in the durable session.
76
+ */
77
+ export function commitAnsweredTurn(store, buffer, turn) {
78
+ store.remove(turn.id);
79
+ buffer.commit(turn.bufferKey, turn.consumed);
80
+ }
54
81
  export function createTurnStore(path, opts) {
55
82
  const { label, isRecord, order } = opts;
56
83
  const load = () => {
@@ -102,12 +129,12 @@ export function createTurnStore(path, opts) {
102
129
  // happening to survive the load's JSON round-trip.
103
130
  return [...turns.values()].sort(order);
104
131
  },
105
- startAttempt(id, maxAttempts) {
132
+ startAttempt(id) {
106
133
  const rec = turns.get(id);
107
134
  if (!rec)
108
135
  return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
109
136
  const attempts = rec.attempts + 1;
110
- if (attempts > maxAttempts) {
137
+ if (attempts > MAX_TURN_ATTEMPTS) {
111
138
  // State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
112
139
  // this whether IT poisoned the process or a deploy/OOM took it down each time.
113
140
  log.error(`${label} dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
@@ -1,58 +1,8 @@
1
1
  import { defineTool, z } from "@fastagent-sh/fastagent";
2
+ import { larkTransport } from "@fastagent-sh/fastagent/lark";
2
3
 
3
- // Send a message to a Lark chat. In a CHAT turn the channel delivers the reply itself — this
4
- // tool is for turns NO channel is carrying: a scheduled turn (schedules/<name>.ts) or a self-scheduled
5
- // wake-up, whose plain reply is not delivered anywhere. The chatId comes from the [lark: chat …]
6
- // context line in a chat turn; a scheduled turn has no such line, so the schedule's prompt must name
7
- // the target chat id. tools/ is auto-discovered.
8
-
9
- // Embedded copy of the channel transport's discipline: a timeout so a wedged connection can't hang
10
- // the tool call (and the turn), named errors, and success gated on the body's own code===0.
11
- // Deliberately NO rate-limit retry — a tool error goes back to the agent, which can decide to retry;
12
- // fail-fast beats a silently sleeping tool.
13
- const BASE = "https://open.larksuite.com";
14
-
15
- async function callApi(path: string, body: unknown, token?: string): Promise<Record<string, unknown>> {
16
- let res: Response;
17
- let raw: string;
18
- try {
19
- res = await fetch(`${BASE}${path}`, {
20
- method: "POST",
21
- headers: {
22
- "content-type": "application/json; charset=utf-8",
23
- ...(token ? { authorization: `Bearer ${token}` } : {}),
24
- },
25
- body: JSON.stringify(body),
26
- signal: AbortSignal.timeout(30_000),
27
- });
28
- raw = await res.text();
29
- } catch (e) {
30
- throw new Error(`lark ${path}: ${String(e)}`, { cause: e });
31
- }
32
- let data: { code?: number; msg?: string; [k: string]: unknown };
33
- try {
34
- data = JSON.parse(raw) as typeof data;
35
- } catch {
36
- data = {};
37
- }
38
- if (!res.ok || data.code !== 0) {
39
- throw new Error(`lark ${path} failed: ${res.status} ${data.msg ?? "response was not the expected JSON"}`);
40
- }
41
- return data;
42
- }
43
-
44
- async function tenantToken(): Promise<string> {
45
- const appId = process.env.LARK_APP_ID;
46
- const appSecret = process.env.LARK_APP_SECRET;
47
- if (!appId || !appSecret) throw new Error("LARK_APP_ID / LARK_APP_SECRET are not set");
48
- const data = await callApi("/open-apis/auth/v3/tenant_access_token/internal", {
49
- app_id: appId,
50
- app_secret: appSecret,
51
- });
52
- const token = data.tenant_access_token;
53
- if (typeof token !== "string") throw new Error("lark tenant_access_token: response carried no token");
54
- return token;
55
- }
4
+ // Proactive delivery uses the mounted channel's credentials, gateway and token cache. With no
5
+ // channel mounted (fire/invoke/tool), it reads LARK_APP_ID / LARK_APP_SECRET from the environment.
56
6
 
57
7
  export default defineTool({
58
8
  description:
@@ -70,23 +20,18 @@ export default defineTool({
70
20
  text: z.string().optional().describe("plain text message to send"),
71
21
  markdown: z.string().optional().describe("markdown to send as a card"),
72
22
  }),
73
- async execute({ chatId, text, markdown }) {
23
+ async execute({ chatId, text, markdown }, ctx) {
74
24
  if ((text === undefined) === (markdown === undefined)) {
75
25
  throw new Error("pass exactly one of `text` (plain) or `markdown` (a card)");
76
26
  }
77
- const token = await tenantToken();
78
- const msg =
79
- text !== undefined
80
- ? { msg_type: "text", content: JSON.stringify({ text }) }
81
- : {
82
- msg_type: "interactive",
83
- // An inline static card: one markdown element, no entity/streaming machinery needed here.
84
- content: JSON.stringify({
85
- schema: "2.0",
86
- body: { elements: [{ tag: "markdown", content: markdown }] },
87
- }),
88
- };
89
- await callApi(`/open-apis/im/v1/messages?receive_id_type=chat_id`, { receive_id: chatId, ...msg }, token);
27
+ const api = larkTransport(ctx.cwd);
28
+ if (text !== undefined) await api.sendText({ chatId }, text);
29
+ else {
30
+ await api.sendMessage(chatId, "interactive", JSON.stringify({
31
+ schema: "2.0",
32
+ body: { elements: [{ tag: "markdown", content: markdown }] },
33
+ }));
34
+ }
90
35
  return `sent ${text !== undefined ? "message" : "card"} to chat ${chatId}`;
91
36
  },
92
37
  });
@@ -10,6 +10,41 @@
10
10
  * unreachable) and a cloud without the config API (the Lark cloud-lag 404 — no re-run can ever
11
11
  * register it; the console is the only path).
12
12
  * - "failed": this run ends with the webhook NOT registered, and acting + re-running can fix it
13
- * (health timeout, a permanent config error, exhausted retries).
13
+ * (a permanent config error, or the platform still unable to reach the URL when the retries ran out).
14
14
  */
15
15
  export type RegistrationOutcome = "registered" | "manual" | "failed";
16
+ export declare const REGISTRATION_RETRY_MS = 10000;
17
+ /**
18
+ * What `deploy --run` spends instead: 180s, because a host CLI returns before the deployment serves —
19
+ * the same gap a tunnel has, on a scale no local signal reports (`docker compose --tunnel` is the one
20
+ * host whose ingress does report it, and its driver waits on it the way the tunnel does).
21
+ *
22
+ * `railway up --ci` returns when the BUILD ends — container start, healthcheck and a freshly minted
23
+ * domain's DNS all happen after that, and railway-deploy.live.test.ts allows 180s for exactly this.
24
+ * Registration failure GATES the deploy (registration-gate.ts), so a budget shorter than the host's
25
+ * own start-up turns a working deployment into a re-run instruction.
26
+ *
27
+ * N attempts buy (N - 1) waits — {@link retryWhile} only waits BETWEEN calls — so 180s of patience is
28
+ * 19, not 18. At 18 the last call went out at t=170s and a host that started answering in the final
29
+ * ten seconds was still reported as a deploy to re-run.
30
+ */
31
+ export declare const DEPLOY_REGISTRATION_ATTEMPTS = 19;
32
+ /**
33
+ * Call, and while `retryable` says the failure is the platform not reaching the URL YET, call again —
34
+ * the one retry loop every registrar spends {@link REGISTRATION_ATTEMPTS} through. Retryability and
35
+ * what a final failure MEANS stay with each platform (their vocabularies differ, and one registrar's
36
+ * terminal state is another's `"manual"`); the counting, the announcement and the wait do not.
37
+ *
38
+ * `onRetry` runs BEFORE the wait — registrars announce there (a silent minute reads as a hang), and
39
+ * `add slack` also drops its duplicate-guard marker so it never spans a sleep. The last error is
40
+ * thrown, so a caller can tell "still unreachable" from "a config error" in one place.
41
+ */
42
+ export declare function retryWhile<T>(call: () => Promise<T>, retryable: (error: unknown) => boolean, options?: {
43
+ attempts?: number;
44
+ retryMs?: number;
45
+ onRetry?: (info: {
46
+ attempt: number;
47
+ attempts: number;
48
+ error: unknown;
49
+ }) => void;
50
+ }): Promise<T>;
@@ -1 +1,57 @@
1
- export {};
1
+ /**
2
+ * SHARED: how long a registrar waits for the PLATFORM to be able to reach a freshly minted public URL.
3
+ * One judgement (a new tunnel's warm-up), so one pair of numbers — telegram, feishu, slack registration
4
+ * and `add slack` all spend it.
5
+ *
6
+ * Sized for a tunnel, which is live before its URL is HANDED OVER — not before it is printed, which is
7
+ * what this budget once had to absorb: a quick tunnel's hostname is published when the tunnel registers
8
+ * an edge connection, seconds after cloudflared prints it, and no budget covers a platform that already
9
+ * answered "cannot resolve" (#435). `startCloudflareTunnel` waits for that connection, so what is left
10
+ * here is the platform's own warm-up. Applied by {@link retryWhile} as its default; a DEPLOY is slower
11
+ * and its callers pass {@link DEPLOY_REGISTRATION_ATTEMPTS} instead.
12
+ */
13
+ const REGISTRATION_ATTEMPTS = 8;
14
+ export const REGISTRATION_RETRY_MS = 10_000;
15
+ /**
16
+ * What `deploy --run` spends instead: 180s, because a host CLI returns before the deployment serves —
17
+ * the same gap a tunnel has, on a scale no local signal reports (`docker compose --tunnel` is the one
18
+ * host whose ingress does report it, and its driver waits on it the way the tunnel does).
19
+ *
20
+ * `railway up --ci` returns when the BUILD ends — container start, healthcheck and a freshly minted
21
+ * domain's DNS all happen after that, and railway-deploy.live.test.ts allows 180s for exactly this.
22
+ * Registration failure GATES the deploy (registration-gate.ts), so a budget shorter than the host's
23
+ * own start-up turns a working deployment into a re-run instruction.
24
+ *
25
+ * N attempts buy (N - 1) waits — {@link retryWhile} only waits BETWEEN calls — so 180s of patience is
26
+ * 19, not 18. At 18 the last call went out at t=170s and a host that started answering in the final
27
+ * ten seconds was still reported as a deploy to re-run.
28
+ */
29
+ export const DEPLOY_REGISTRATION_ATTEMPTS = 19;
30
+ /** Sleep on the GLOBAL timer (not `node:timers/promises`) so tests can drive it with fake timers — the
31
+ * same reason feishu-api.ts does. What must happen BEFORE this wait is the point of `onRetry`. */
32
+ const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
33
+ /**
34
+ * Call, and while `retryable` says the failure is the platform not reaching the URL YET, call again —
35
+ * the one retry loop every registrar spends {@link REGISTRATION_ATTEMPTS} through. Retryability and
36
+ * what a final failure MEANS stay with each platform (their vocabularies differ, and one registrar's
37
+ * terminal state is another's `"manual"`); the counting, the announcement and the wait do not.
38
+ *
39
+ * `onRetry` runs BEFORE the wait — registrars announce there (a silent minute reads as a hang), and
40
+ * `add slack` also drops its duplicate-guard marker so it never spans a sleep. The last error is
41
+ * thrown, so a caller can tell "still unreachable" from "a config error" in one place.
42
+ */
43
+ export async function retryWhile(call, retryable, options = {}) {
44
+ const attempts = options.attempts ?? REGISTRATION_ATTEMPTS;
45
+ const retryMs = options.retryMs ?? REGISTRATION_RETRY_MS;
46
+ for (let attempt = 1;; attempt++) {
47
+ try {
48
+ return await call();
49
+ }
50
+ catch (error) {
51
+ if (attempt >= attempts || !retryable(error))
52
+ throw error;
53
+ options.onRetry?.({ attempt, attempts, error });
54
+ await wait(retryMs);
55
+ }
56
+ }
57
+ }
@@ -0,0 +1 @@
1
+ export declare function secretEquals(given: unknown, expected: string | undefined): boolean;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * The ONE constant-time comparison every shared-secret gate uses: a webhook secret token, a
3
+ * signature, a bearer token, an envelope secret. Timing-safe on equal lengths; a length mismatch
4
+ * answers false without leaking a prefix. Non-string input reads as no secret — and an EMPTY
5
+ * expected value never matches, so a gate whose secret was never configured cannot be passed by
6
+ * sending none.
7
+ */
8
+ import { Buffer } from "node:buffer";
9
+ import { timingSafeEqual } from "node:crypto";
10
+ export function secretEquals(given, expected) {
11
+ if (typeof given !== "string" || !expected)
12
+ return false;
13
+ const a = Buffer.from(given);
14
+ const b = Buffer.from(expected);
15
+ return a.length === b.length && timingSafeEqual(a, b);
16
+ }
@@ -10,8 +10,40 @@ interface SlackErrorShape {
10
10
  export declare class SlackConfigApiError extends Error {
11
11
  readonly code: string;
12
12
  readonly status: number;
13
+ /** Kept, not just formatted into the message: which FIELD Slack rejected is how a caller tells a
14
+ * not-yet-reachable URL from a malformed one, and a message match cannot see the difference. */
15
+ readonly errors: {
16
+ message?: string;
17
+ pointer?: string;
18
+ }[];
13
19
  constructor(method: string, status: number, data: SlackErrorShape, fallback: string);
14
20
  }
21
+ /**
22
+ * Whether a manifest call failed because Slack could not verify the `request_url` it carries — the
23
+ * platform's own readiness verdict on a freshly minted public URL, and the reason no local `/health`
24
+ * poll precedes these calls (#421). Slack validates the manifest BEFORE acting on it, so a call that
25
+ * ends here changed nothing and is safe to repeat.
26
+ *
27
+ * Read from the rejected FIELD (`invalid_manifest` + {@link rejectsRequestUrl}), not from `request_url`
28
+ * appearing anywhere in the formatted string: that also matched errors which merely mention the field.
29
+ *
30
+ * KNOWN IMPRECISION: this cannot separate "not reachable yet" from "this URL is unacceptable" — both
31
+ * arrive the same way, and the wording that would tell them apart has not been observed against the
32
+ * real API, so guessing at it would risk never retrying at all. A permanently bad URL therefore costs
33
+ * the full budget before it is reported. Every URL here is a tunnel's or a host's, so it is well-formed
34
+ * https by construction; narrow this the day a real rejection is captured.
35
+ */
36
+ export declare function isSlackRequestUrlUnverified(error: unknown): boolean;
37
+ /**
38
+ * Whether a manifest write was refused because the app has token rotation on, which Slack never lets
39
+ * off again — the refusal every app created by a release up to 0.20 meets, since this release sends
40
+ * `token_rotation_enabled: false`. Slack documents field rejections as `invalid_manifest` plus an
41
+ * `errors[]` of message + pointer; the bare code was what a real refusal once showed. Both shapes are
42
+ * read: a miss here sends the operator to repair configuration tokens that are not the problem.
43
+ */
44
+ export declare function isSlackRotationLocked(error: unknown): boolean;
45
+ /** The upgrade every rotating-app refusal points at. */
46
+ export declare const SLACK_ROTATING_APP_UPGRADE = "this app has token rotation on, which this release no longer uses and Slack cannot turn off \u2014 create a new app (docs/slack.md \u2192 \"Upgrading from a rotating-token app\")";
15
47
  export interface SlackAppCredentials {
16
48
  appId: string;
17
49
  clientId: string;
@@ -19,7 +51,9 @@ export interface SlackAppCredentials {
19
51
  signingSecret: string;
20
52
  oauthAuthorizeUrl?: string;
21
53
  }
22
- /** Create is intentionally single-attempt: an ambiguous response may already have created the app. */
54
+ /** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never
55
+ * retries itself. The one repeatable failure — a `request_url` Slack could not verify, which it
56
+ * rejects before creating anything — is retried by the caller ({@link isSlackRequestUrlUnverified}). */
23
57
  export declare function createSlackApp(configToken: string, manifest: SlackAppManifest, options?: {
24
58
  apiBaseUrl?: string;
25
59
  fetch?: typeof fetch;
@@ -40,8 +74,6 @@ export declare function rotateSlackConfigToken(refreshToken: string, options?: {
40
74
  }): Promise<RotatedSlackConfigToken>;
41
75
  export interface SlackOAuthResult {
42
76
  botToken: string;
43
- botRefreshToken: string;
44
- botTokenExpiresAt: number;
45
77
  appId: string;
46
78
  teamId: string;
47
79
  teamName?: string;
@@ -1,8 +1,20 @@
1
1
  const SLACK_API = "https://slack.com/api";
2
2
  const REQUEST_TIMEOUT_MS = 30_000;
3
+ /** One `errors[]` entry that means "Slack could not verify this URL", by either signal it may carry.
4
+ * The pointer is the precise one but an INFERENCE — the only reply captured from the real API is the
5
+ * PERMANENT shape error, which reports on the parent pointer (`/settings/event_subscriptions`). If the
6
+ * unverifiable case reports there too, a pointer-only check would retry zero times and gate the deploy
7
+ * immediately, which is the failure this whole path exists to prevent. So a message about VERIFICATION
8
+ * (challenge wording included) counts as well, while one that merely names the field does not — the
9
+ * captured permanent error says "Event Subscription requires a Request URL". */
10
+ const rejectsRequestUrl = (e) => e.pointer?.endsWith("/request_url") === true ||
11
+ (/request.?url/i.test(e.message ?? "") && /verif|challenge/i.test(e.message ?? ""));
3
12
  export class SlackConfigApiError extends Error {
4
13
  code;
5
14
  status;
15
+ /** Kept, not just formatted into the message: which FIELD Slack rejected is how a caller tells a
16
+ * not-yet-reachable URL from a malformed one, and a message match cannot see the difference. */
17
+ errors;
6
18
  constructor(method, status, data, fallback) {
7
19
  const code = data.error ?? fallback;
8
20
  const fields = data.errors
@@ -12,8 +24,42 @@ export class SlackConfigApiError extends Error {
12
24
  this.name = "SlackConfigApiError";
13
25
  this.code = code;
14
26
  this.status = status;
27
+ this.errors = data.errors ?? [];
15
28
  }
16
29
  }
30
+ /**
31
+ * Whether a manifest call failed because Slack could not verify the `request_url` it carries — the
32
+ * platform's own readiness verdict on a freshly minted public URL, and the reason no local `/health`
33
+ * poll precedes these calls (#421). Slack validates the manifest BEFORE acting on it, so a call that
34
+ * ends here changed nothing and is safe to repeat.
35
+ *
36
+ * Read from the rejected FIELD (`invalid_manifest` + {@link rejectsRequestUrl}), not from `request_url`
37
+ * appearing anywhere in the formatted string: that also matched errors which merely mention the field.
38
+ *
39
+ * KNOWN IMPRECISION: this cannot separate "not reachable yet" from "this URL is unacceptable" — both
40
+ * arrive the same way, and the wording that would tell them apart has not been observed against the
41
+ * real API, so guessing at it would risk never retrying at all. A permanently bad URL therefore costs
42
+ * the full budget before it is reported. Every URL here is a tunnel's or a host's, so it is well-formed
43
+ * https by construction; narrow this the day a real rejection is captured.
44
+ */
45
+ export function isSlackRequestUrlUnverified(error) {
46
+ return (error instanceof SlackConfigApiError && error.code === "invalid_manifest" && error.errors.some(rejectsRequestUrl));
47
+ }
48
+ /**
49
+ * Whether a manifest write was refused because the app has token rotation on, which Slack never lets
50
+ * off again — the refusal every app created by a release up to 0.20 meets, since this release sends
51
+ * `token_rotation_enabled: false`. Slack documents field rejections as `invalid_manifest` plus an
52
+ * `errors[]` of message + pointer; the bare code was what a real refusal once showed. Both shapes are
53
+ * read: a miss here sends the operator to repair configuration tokens that are not the problem.
54
+ */
55
+ export function isSlackRotationLocked(error) {
56
+ if (!(error instanceof SlackConfigApiError))
57
+ return false;
58
+ return (error.code === "cannot_disable_once_enabled" ||
59
+ error.errors.some((e) => e.message?.includes("cannot_disable_once_enabled") || e.pointer?.endsWith("token_rotation_enabled")));
60
+ }
61
+ /** The upgrade every rotating-app refusal points at. */
62
+ export const SLACK_ROTATING_APP_UPGRADE = 'this app has token rotation on, which this release no longer uses and Slack cannot turn off — create a new app (docs/slack.md → "Upgrading from a rotating-token app")';
17
63
  async function slackJson(method, body, options = {}) {
18
64
  const fetchFn = options.fetch ?? globalThis.fetch;
19
65
  let response;
@@ -45,7 +91,9 @@ async function slackJson(method, body, options = {}) {
45
91
  }
46
92
  return data;
47
93
  }
48
- /** Create is intentionally single-attempt: an ambiguous response may already have created the app. */
94
+ /** Single-attempt BY DESIGN: an ambiguous response may already have created the app, so this never
95
+ * retries itself. The one repeatable failure — a `request_url` Slack could not verify, which it
96
+ * rejects before creating anything — is retried by the caller ({@link isSlackRequestUrlUnverified}). */
49
97
  export async function createSlackApp(configToken, manifest, options = {}) {
50
98
  const data = await slackJson("apps.manifest.create", { manifest: JSON.stringify(manifest) }, { ...options, token: configToken });
51
99
  const credentials = data.credentials;
@@ -126,17 +174,11 @@ export async function exchangeSlackOAuthCode(input, options = {}) {
126
174
  }
127
175
  if (!response.ok || data.ok !== true)
128
176
  throw slackOAuthFailure(response.status, data.error);
129
- if (!data.access_token ||
130
- !data.refresh_token ||
131
- typeof data.expires_in !== "number" ||
132
- !data.app_id ||
133
- !data.team?.id) {
134
- throw new Error("Slack oauth.v2.access succeeded but returned incomplete rotating bot credentials/app identity");
177
+ if (!data.access_token || !data.app_id || !data.team?.id) {
178
+ throw new Error("Slack oauth.v2.access succeeded but returned incomplete bot credentials/app identity");
135
179
  }
136
180
  return {
137
181
  botToken: data.access_token,
138
- botRefreshToken: data.refresh_token,
139
- botTokenExpiresAt: Date.now() + data.expires_in * 1_000,
140
182
  appId: data.app_id,
141
183
  teamId: data.team.id,
142
184
  teamName: data.team.name,