@fastagent-sh/fastagent 0.13.0 → 0.15.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 (238) hide show
  1. package/README.md +5 -5
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +43 -0
  10. package/dist/channels/feishu/context-buffer.js +72 -0
  11. package/dist/channels/feishu/crypto.d.ts +4 -2
  12. package/dist/channels/feishu/crypto.js +4 -2
  13. package/dist/channels/feishu/feishu-api.d.ts +15 -7
  14. package/dist/channels/feishu/feishu-api.js +22 -4
  15. package/dist/channels/feishu/feishu.d.ts +38 -16
  16. package/dist/channels/feishu/feishu.js +286 -151
  17. package/dist/channels/feishu/invoke-turn.d.ts +24 -31
  18. package/dist/channels/feishu/invoke-turn.js +61 -62
  19. package/dist/channels/feishu/model.d.ts +98 -0
  20. package/dist/channels/feishu/model.js +9 -0
  21. package/dist/channels/feishu/normalize.d.ts +23 -0
  22. package/dist/channels/feishu/normalize.js +132 -0
  23. package/dist/channels/feishu/owned-threads.d.ts +7 -0
  24. package/dist/channels/feishu/owned-threads.js +47 -0
  25. package/dist/channels/feishu/parse.d.ts +21 -103
  26. package/dist/channels/feishu/parse.js +35 -145
  27. package/dist/channels/feishu/preview.d.ts +4 -7
  28. package/dist/channels/feishu/preview.js +26 -142
  29. package/dist/channels/feishu/register-app.d.ts +2 -1
  30. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  31. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  32. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  33. package/dist/channels/feishu/setup-mode.js +2 -0
  34. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  35. package/dist/channels/feishu/ws-ingress.js +136 -0
  36. package/dist/channels/github/github.js +8 -6
  37. package/dist/channels/http.d.ts +14 -0
  38. package/dist/channels/http.js +35 -2
  39. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  40. package/dist/channels/invoke-turn-kit.js +87 -0
  41. package/dist/channels/lark/lark.d.ts +4 -2
  42. package/dist/channels/lark/lark.js +4 -1
  43. package/dist/channels/lark/onboard.d.ts +8 -4
  44. package/dist/channels/lark/onboard.js +8 -0
  45. package/dist/channels/lark/scaffold/channel.ts +10 -3
  46. package/dist/channels/preview-kit.d.ts +109 -0
  47. package/dist/channels/preview-kit.js +183 -0
  48. package/dist/channels/seen.d.ts +5 -0
  49. package/dist/channels/seen.js +35 -0
  50. package/dist/channels/slack/bot-auth.d.ts +15 -0
  51. package/dist/channels/slack/bot-auth.js +146 -0
  52. package/dist/channels/slack/config-api.d.ts +60 -0
  53. package/dist/channels/slack/config-api.js +149 -0
  54. package/dist/channels/slack/context-buffer.d.ts +24 -0
  55. package/dist/channels/slack/context-buffer.js +37 -0
  56. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  57. package/dist/channels/slack/invoke-turn.js +63 -0
  58. package/dist/channels/slack/manifest.d.ts +49 -0
  59. package/dist/channels/slack/manifest.js +69 -0
  60. package/dist/channels/slack/model.d.ts +67 -0
  61. package/dist/channels/slack/model.js +2 -0
  62. package/dist/channels/slack/onboard.d.ts +41 -0
  63. package/dist/channels/slack/onboard.js +120 -0
  64. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  65. package/dist/channels/slack/onboarding-state.js +69 -0
  66. package/dist/channels/slack/owned-threads.d.ts +6 -0
  67. package/dist/channels/slack/owned-threads.js +43 -0
  68. package/dist/channels/slack/parse.d.ts +23 -0
  69. package/dist/channels/slack/parse.js +81 -0
  70. package/dist/channels/slack/preview.d.ts +24 -0
  71. package/dist/channels/slack/preview.js +359 -0
  72. package/dist/channels/slack/reaction.d.ts +24 -0
  73. package/dist/channels/slack/reaction.js +62 -0
  74. package/dist/channels/slack/register-webhook.d.ts +10 -0
  75. package/dist/channels/slack/register-webhook.js +49 -0
  76. package/dist/channels/slack/scaffold/channel.ts +33 -0
  77. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  78. package/dist/channels/slack/setup-server.d.ts +17 -0
  79. package/dist/channels/slack/setup-server.js +103 -0
  80. package/dist/channels/slack/slack-api.d.ts +77 -0
  81. package/dist/channels/slack/slack-api.js +415 -0
  82. package/dist/channels/slack/slack.d.ts +58 -0
  83. package/dist/channels/slack/slack.js +451 -0
  84. package/dist/channels/slack/welcomed.d.ts +5 -0
  85. package/dist/channels/slack/welcomed.js +32 -0
  86. package/dist/channels/state.js +3 -3
  87. package/dist/channels/stop-command.d.ts +6 -0
  88. package/dist/channels/stop-command.js +36 -0
  89. package/dist/channels/tasks.d.ts +13 -0
  90. package/dist/channels/tasks.js +10 -0
  91. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  92. package/dist/channels/telegram/context-buffer.js +6 -85
  93. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  94. package/dist/channels/telegram/invoke-turn.js +11 -58
  95. package/dist/channels/telegram/preview.d.ts +4 -7
  96. package/dist/channels/telegram/preview.js +24 -142
  97. package/dist/channels/telegram/telegram.js +23 -9
  98. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  99. package/dist/channels/{feishu/text.js → text.js} +1 -1
  100. package/dist/channels/turn-queue.js +1 -1
  101. package/dist/channels/turn-store.d.ts +1 -1
  102. package/dist/channels/turn-store.js +2 -3
  103. package/dist/cli/add-feishu.d.ts +27 -0
  104. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  105. package/dist/cli/add-slack.d.ts +10 -0
  106. package/dist/cli/add-slack.js +204 -0
  107. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  108. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  109. package/dist/cli/commands/add.d.ts +13 -0
  110. package/dist/cli/commands/add.js +274 -0
  111. package/dist/cli/commands/attach.d.ts +82 -0
  112. package/dist/cli/commands/attach.js +559 -0
  113. package/dist/cli/commands/chat.d.ts +4 -0
  114. package/dist/cli/commands/chat.js +21 -0
  115. package/dist/cli/commands/deploy.d.ts +15 -0
  116. package/dist/cli/commands/deploy.js +394 -0
  117. package/dist/cli/commands/dev.d.ts +11 -0
  118. package/dist/cli/commands/dev.js +82 -0
  119. package/dist/cli/commands/fire.d.ts +7 -0
  120. package/dist/cli/commands/fire.js +45 -0
  121. package/dist/cli/commands/info.d.ts +7 -0
  122. package/dist/cli/commands/info.js +108 -0
  123. package/dist/cli/commands/init.d.ts +8 -0
  124. package/dist/cli/commands/init.js +81 -0
  125. package/dist/cli/commands/invoke.d.ts +7 -0
  126. package/dist/cli/commands/invoke.js +28 -0
  127. package/dist/cli/commands/login.d.ts +6 -0
  128. package/dist/cli/commands/login.js +52 -0
  129. package/dist/cli/commands/models.d.ts +1 -0
  130. package/dist/cli/commands/models.js +15 -0
  131. package/dist/cli/commands/schedule.d.ts +12 -0
  132. package/dist/cli/commands/schedule.js +89 -0
  133. package/dist/cli/commands/start.d.ts +10 -0
  134. package/dist/cli/commands/start.js +97 -0
  135. package/dist/cli/commands/tool.d.ts +1 -0
  136. package/dist/cli/commands/tool.js +38 -0
  137. package/dist/cli/fail.d.ts +17 -0
  138. package/dist/cli/fail.js +32 -0
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +90 -0
  142. package/dist/cli/kernel.js +190 -0
  143. package/dist/cli/models-view.d.ts +21 -0
  144. package/dist/cli/models-view.js +66 -0
  145. package/dist/cli/program.d.ts +11 -0
  146. package/dist/cli/program.js +479 -0
  147. package/dist/cli/serve.d.ts +48 -0
  148. package/dist/cli/serve.js +248 -0
  149. package/dist/cli/shared.d.ts +44 -0
  150. package/dist/cli/shared.js +246 -0
  151. package/dist/cli.js +8 -1329
  152. package/dist/collect.d.ts +14 -3
  153. package/dist/collect.js +24 -0
  154. package/dist/core.d.ts +3 -1
  155. package/dist/core.js +2 -0
  156. package/dist/deploy/docker/plan.d.ts +45 -0
  157. package/dist/deploy/docker/plan.js +141 -0
  158. package/dist/deploy/docker/run.d.ts +40 -0
  159. package/dist/deploy/docker/run.js +126 -0
  160. package/dist/deploy/fly/plan.d.ts +3 -1
  161. package/dist/deploy/fly/plan.js +12 -7
  162. package/dist/deploy/fly/run.d.ts +5 -4
  163. package/dist/deploy/fly/run.js +16 -5
  164. package/dist/deploy/preflight.d.ts +10 -4
  165. package/dist/deploy/preflight.js +57 -12
  166. package/dist/deploy/railway/plan.d.ts +3 -1
  167. package/dist/deploy/railway/plan.js +12 -4
  168. package/dist/deploy/railway/run.d.ts +5 -4
  169. package/dist/deploy/railway/run.js +15 -5
  170. package/dist/deploy/runner.d.ts +6 -2
  171. package/dist/deploy/runner.js +1 -0
  172. package/dist/deploy/secrets.d.ts +2 -1
  173. package/dist/deploy/secrets.js +23 -3
  174. package/dist/dev-supervisor.d.ts +0 -2
  175. package/dist/dev-supervisor.js +7 -3
  176. package/dist/engines/pi/auth.js +160 -46
  177. package/dist/engines/pi/channel.d.ts +22 -16
  178. package/dist/engines/pi/channel.js +90 -60
  179. package/dist/engines/pi/chat.d.ts +4 -16
  180. package/dist/engines/pi/chat.js +8 -188
  181. package/dist/engines/pi/config.d.ts +23 -9
  182. package/dist/engines/pi/config.js +35 -5
  183. package/dist/engines/pi/create.d.ts +36 -7
  184. package/dist/engines/pi/create.js +63 -22
  185. package/dist/engines/pi/harness.d.ts +65 -1
  186. package/dist/engines/pi/harness.js +166 -2
  187. package/dist/engines/pi/invoke.d.ts +56 -3
  188. package/dist/engines/pi/invoke.js +340 -20
  189. package/dist/engines/pi/login.d.ts +11 -0
  190. package/dist/engines/pi/login.js +17 -5
  191. package/dist/engines/pi/models.d.ts +56 -10
  192. package/dist/engines/pi/models.js +61 -23
  193. package/dist/engines/pi/search-tools.d.ts +10 -0
  194. package/dist/engines/pi/search-tools.js +138 -0
  195. package/dist/engines/pi/session-builder.d.ts +16 -0
  196. package/dist/engines/pi/session-builder.js +308 -0
  197. package/dist/engines/pi/session-control.d.ts +50 -0
  198. package/dist/engines/pi/session-control.js +604 -0
  199. package/dist/engines/pi/sessions.d.ts +17 -2
  200. package/dist/engines/pi/sessions.js +9 -0
  201. package/dist/engines/pi/tool-context.d.ts +46 -11
  202. package/dist/engines/pi/tool-context.js +11 -9
  203. package/dist/engines/pi/tool.d.ts +35 -6
  204. package/dist/engines/pi/tool.js +47 -1
  205. package/dist/engines/pi/wake-tool.d.ts +0 -3
  206. package/dist/engines/pi/wake-tool.js +9 -7
  207. package/dist/engines/pi/workspace.d.ts +56 -1
  208. package/dist/engines/pi/workspace.js +75 -16
  209. package/dist/feishu.d.ts +1 -1
  210. package/dist/feishu.js +1 -1
  211. package/dist/host/node.d.ts +23 -6
  212. package/dist/host/node.js +5 -4
  213. package/dist/index.d.ts +1 -0
  214. package/dist/index.js +1 -0
  215. package/dist/lark.d.ts +1 -1
  216. package/dist/lark.js +1 -1
  217. package/dist/observe.js +3 -0
  218. package/dist/pi.d.ts +6 -4
  219. package/dist/pi.js +2 -1
  220. package/dist/scaffold/add-channel.d.ts +19 -6
  221. package/dist/scaffold/add-channel.js +127 -16
  222. package/dist/scaffold/templates/fastagent.config.mjs +5 -3
  223. package/dist/schedule/wakeups.d.ts +0 -3
  224. package/dist/schedule/wakeups.js +1 -1
  225. package/dist/session-remote.d.ts +53 -0
  226. package/dist/session-remote.js +336 -0
  227. package/dist/session.d.ts +265 -0
  228. package/dist/session.js +37 -0
  229. package/dist/slack.d.ts +2 -0
  230. package/dist/slack.js +2 -0
  231. package/dist/tunnel.d.ts +7 -4
  232. package/dist/tunnel.js +21 -10
  233. package/package.json +22 -7
  234. package/dist/channels/feishu/seen.d.ts +0 -5
  235. package/dist/channels/feishu/seen.js +0 -47
  236. package/dist/cli-add-feishu.d.ts +0 -8
  237. package/dist/cli-models.d.ts +0 -11
  238. package/dist/cli-models.js +0 -20
@@ -0,0 +1,183 @@
1
+ import { truncateCodePointPrefix, truncateCodePointSuffix } from "./text.js";
2
+ /** The customer-facing default: neutral, no leaked internals. Differentiate on retryability and always
3
+ * offer a next step (Slack agent-design: never leave the user with a dead-end "something went wrong").
4
+ * The non-retryable branch keeps the "something went wrong" phrase deliberately — it is neutral (we only
5
+ * know a boolean, never the specific limitation) and shared verbatim across channels. */
6
+ export function defaultErrorMessage(failed) {
7
+ return failed.retryable
8
+ ? "⚠️ Temporary problem — please try again in a moment."
9
+ : "⚠️ Sorry, something went wrong. Try rephrasing, or check I have access to what you need.";
10
+ }
11
+ /** Customer-facing live-preview line for an engine-internal retry backoff (the advisory `retrying`
12
+ * event): neutral, no leaked internals — the reason stays in operator logs. */
13
+ export const RETRY_NOTICE = "⏳ Temporary problem — retrying…";
14
+ /** The placeholder shown before any reasoning/tool/text arrives. */
15
+ export const THINKING_PLACEHOLDER = "💭 Thinking…";
16
+ export function createTurnView() {
17
+ return { thinking: "", tools: [], toolById: new Map(), answer: "", retrying: false };
18
+ }
19
+ /**
20
+ * Apply one event to the view state. Returns true when the view changed (the caller repaints).
21
+ * This is the ONE place the shared view rules live: tool labels are humanized with a compact arg
22
+ * summary, and any progress event closes an open retry notice (a stale "retrying" line must never
23
+ * outlive actual progress). Terminal events only close the notice — they are the caller's business.
24
+ */
25
+ export function applyTurnEvent(view, e) {
26
+ const closedRetry = view.retrying && e.type !== "retrying";
27
+ if (closedRetry)
28
+ view.retrying = false;
29
+ switch (e.type) {
30
+ case "text":
31
+ view.answer += e.delta;
32
+ if (view.answerSince === undefined && view.answer.trim() !== "")
33
+ view.answerSince = Date.now();
34
+ return true;
35
+ case "thinking":
36
+ view.thinking += e.delta;
37
+ return true;
38
+ case "tool_started": {
39
+ const arg = summarizeToolArgs(e.args);
40
+ const name = humanizeToolName(e.name);
41
+ const line = { label: arg ? `${name} ${arg}` : name, status: "running" };
42
+ view.tools.push(line);
43
+ view.toolById.set(e.id, line);
44
+ return true;
45
+ }
46
+ case "tool_ended": {
47
+ const line = view.toolById.get(e.id);
48
+ if (line)
49
+ line.status = e.isError ? "error" : "ok";
50
+ return true;
51
+ }
52
+ case "retrying":
53
+ view.retrying = true;
54
+ return true;
55
+ default:
56
+ return closedRetry;
57
+ }
58
+ }
59
+ const TOOL_MARK = { running: "…", ok: "✓", error: "✗" };
60
+ /** The tool-activity block: one `🔧 label …/✓/✗` line per call, in call order. */
61
+ export function toolLines(view) {
62
+ return view.tools.map((t) => `🔧 ${t.label} ${TOOL_MARK[t.status]}`).join("\n");
63
+ }
64
+ /** The reasoning peek: the most recent tail of the (growing) reasoning, one line, code-point safe.
65
+ * Process, not the answer — renderers show it live only, never in the persisted final message. */
66
+ export function thinkingLine(view, maxTail) {
67
+ const t = view.thinking.replace(/\s+/g, " ").trim();
68
+ return t === "" ? "" : `💭 ${truncateCodePointSuffix(t, maxTail)}`;
69
+ }
70
+ /**
71
+ * The shared answer-reveal policy: the answer stays hidden until its first delta has aged one
72
+ * throttle window (`ageMs` — each platform passes its own pacing constant). The pump's leading-edge
73
+ * flush would otherwise turn the very first content delta (often a lone character or unbalanced
74
+ * markup) into its own frame — the short-reply flicker (placeholder → "O" → "OK."). Aging is
75
+ * anchored at delta ARRIVAL (`answerSince`, set by the reducer) so an in-flight write can't skew the
76
+ * clock, and there is deliberately NO timer at the boundary: a young answer surfaces on the next
77
+ * content-driven pass, so a turn completing within the window delivers the final answer only.
78
+ */
79
+ export function revealedAnswer(view, ageMs) {
80
+ if (view.answer.trim() === "" || view.answerSince === undefined)
81
+ return "";
82
+ return Date.now() - view.answerSince >= ageMs ? view.answer : "";
83
+ }
84
+ /** Compose body parts (thinking/tools/retry/answer) into one frame: skip empties, blank-line joins. */
85
+ export function composeTurnBody(parts) {
86
+ return parts
87
+ .filter((s) => s.trim() !== "")
88
+ .join("\n\n")
89
+ .trim();
90
+ }
91
+ export function createPreviewPump(opts) {
92
+ let dirty = false;
93
+ let pumping = false;
94
+ let stopped = false;
95
+ let errored = false;
96
+ let pumpDone;
97
+ let wakeThrottle; // set while mid-throttle; finish() cuts it short
98
+ const runPump = async () => {
99
+ pumping = true;
100
+ try {
101
+ while (dirty && !stopped) {
102
+ dirty = false;
103
+ try {
104
+ await opts.flush();
105
+ }
106
+ catch (error) {
107
+ if (!errored) {
108
+ errored = true;
109
+ opts.onError(error);
110
+ }
111
+ }
112
+ if (dirty && !stopped) {
113
+ await new Promise((resolve) => {
114
+ const t = setTimeout(resolve, opts.throttleMs);
115
+ wakeThrottle = () => {
116
+ clearTimeout(t);
117
+ resolve();
118
+ };
119
+ });
120
+ wakeThrottle = undefined;
121
+ }
122
+ }
123
+ }
124
+ finally {
125
+ pumping = false;
126
+ }
127
+ };
128
+ return {
129
+ touch() {
130
+ dirty = true;
131
+ if (!pumping)
132
+ pumpDone = runPump();
133
+ },
134
+ async finish() {
135
+ stopped = true;
136
+ wakeThrottle?.();
137
+ await pumpDone?.catch(() => { });
138
+ },
139
+ };
140
+ }
141
+ /** Max length (code points) of a tool's arg preview in the live view. */
142
+ const TOOL_ARG_MAX = 48;
143
+ /** Max length (code points) of a humanized tool label. */
144
+ const TOOL_NAME_MAX = 80;
145
+ /** One-line, truncated at code-point boundaries: collapse whitespace so a multi-line command/arg
146
+ * stays on one line, and never tear a surrogate pair mid-emoji. */
147
+ function clip(s) {
148
+ const one = s.replace(/\s+/g, " ").trim();
149
+ return truncateCodePointPrefix(one, TOOL_ARG_MAX);
150
+ }
151
+ /**
152
+ * A compact, human-readable preview of a tool call's args so the live view reads `🔧 read AGENTS.md`
153
+ * rather than just `🔧 read`. Generic (a channel knows no tool schemas): show the salient value — the
154
+ * first primitive field, conventionally the subject (path / command / query / url) — else compact JSON.
155
+ */
156
+ function summarizeToolArgs(args) {
157
+ if (args === null || typeof args !== "object" || Array.isArray(args))
158
+ return clip(String(args));
159
+ const values = Object.values(args);
160
+ const primary = values.find((v) => typeof v === "string" || typeof v === "number");
161
+ if (primary !== undefined)
162
+ return clip(String(primary));
163
+ return values.length > 0 ? clip(JSON.stringify(args)) : "";
164
+ }
165
+ /**
166
+ * A plain-language label for a tool call, following Slack's agent-design guidance to name what a tool
167
+ * does rather than expose a raw identifier ("Create issue", not "create_issue"; "Github: create issue",
168
+ * not "mcp__github__create_issue"). Deliberately generic and engine-neutral: it only reshapes the
169
+ * identifier string — it never invents semantics and never exposes arguments. An `mcp__server__tool`
170
+ * identifier becomes `server: tool`; any other identifier has its separators normalized to spaces and
171
+ * its first letter capitalized.
172
+ */
173
+ export function humanizeToolName(name) {
174
+ const normalize = (s) => s
175
+ .replace(/[_\-.]+/g, " ")
176
+ .replace(/\s+/g, " ")
177
+ .trim();
178
+ const mcp = /^mcp__(.+?)__(.+)$/.exec(name);
179
+ const base = mcp ? `${normalize(mcp[1] ?? "")}: ${normalize(mcp[2] ?? "")}` : normalize(name);
180
+ const label = base.trim() || name.trim() || "Tool";
181
+ const capitalized = label.charAt(0).toUpperCase() + label.slice(1);
182
+ return truncateCodePointPrefix(capitalized, TOOL_NAME_MAX);
183
+ }
@@ -0,0 +1,5 @@
1
+ export interface SeenRing {
2
+ has(id: string): boolean;
3
+ add(id: string): void;
4
+ }
5
+ export declare function createSeenRing(path: string, label: string, cap?: number): SeenRing;
@@ -0,0 +1,35 @@
1
+ /** Best-effort bounded durable dedup ring, recorded only after the caller's pre-ACK side effect is durable. */
2
+ import { log } from "../log.js";
3
+ import { loadStateFile, saveStateFile } from "./state.js";
4
+ export function createSeenRing(path, label, cap = 2000) {
5
+ const raw = loadStateFile(path);
6
+ const order = raw === undefined
7
+ ? []
8
+ : Array.isArray(raw) && raw.every((id) => typeof id === "string")
9
+ ? raw.slice(-cap)
10
+ : undefined;
11
+ if (order === undefined)
12
+ log.warn(`${label} unexpected shape in ${path} — starting with no seen ids`);
13
+ const values = order ?? [];
14
+ const ids = new Set(values);
15
+ return {
16
+ has: (id) => ids.has(id),
17
+ add(id) {
18
+ if (ids.has(id))
19
+ return;
20
+ ids.add(id);
21
+ values.push(id);
22
+ while (values.length > cap) {
23
+ const evicted = values.shift();
24
+ if (evicted !== undefined)
25
+ ids.delete(evicted);
26
+ }
27
+ try {
28
+ saveStateFile(path, values);
29
+ }
30
+ catch (error) {
31
+ log.warn(`${label} seen-ring write failed (delivery dedup is in-memory until restart): ${String(error)}`);
32
+ }
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,15 @@
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>;
@@ -0,0 +1,146 @@
1
+ /** Rotating Slack bot-token provider backed by owner-only channel state. */
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
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
+ mkdirSync(dirname(path), { recursive: true });
39
+ const temp = `${path}.${process.pid}.${Date.now()}.tmp`;
40
+ try {
41
+ writeFileSync(temp, JSON.stringify(state), { mode: 0o600 });
42
+ chmodSync(temp, 0o600);
43
+ renameSync(temp, path);
44
+ chmodSync(path, 0o600);
45
+ }
46
+ catch (error) {
47
+ rmSync(temp, { force: true });
48
+ throw error;
49
+ }
50
+ }
51
+ async function refreshSlackBotToken(input) {
52
+ const authorization = Buffer.from(`${input.clientId}:${input.clientSecret}`, "utf8").toString("base64");
53
+ let lastError;
54
+ // Slack keeps a short grace window for a just-consumed refresh token. One retry recovers the
55
+ // committed-but-response-lost boundary without creating an unbounded refresh loop.
56
+ for (let attempt = 0; attempt < 2; attempt++) {
57
+ try {
58
+ const response = await input.fetch(`${input.apiBaseUrl}/oauth.v2.access`, {
59
+ method: "POST",
60
+ headers: {
61
+ authorization: `Basic ${authorization}`,
62
+ "content-type": "application/x-www-form-urlencoded",
63
+ },
64
+ body: new URLSearchParams({
65
+ grant_type: "refresh_token",
66
+ refresh_token: input.refreshToken,
67
+ }).toString(),
68
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
69
+ });
70
+ const data = (await response.json().catch(() => ({})));
71
+ if (!response.ok ||
72
+ data.ok !== true ||
73
+ !data.access_token ||
74
+ !data.refresh_token ||
75
+ typeof data.expires_in !== "number") {
76
+ throw new Error(`Slack rejected bot-token refresh (HTTP ${response.status})`);
77
+ }
78
+ return {
79
+ version: 1,
80
+ accessToken: data.access_token,
81
+ refreshToken: data.refresh_token,
82
+ expiresAt: Date.now() + data.expires_in * 1_000,
83
+ };
84
+ }
85
+ catch (error) {
86
+ lastError = error;
87
+ }
88
+ }
89
+ throw new Error("Slack bot-token rotation failed; restore credentials or reinstall the app", { cause: lastError });
90
+ }
91
+ /** Latest local rotating credentials for `deploy --run`. The remote runtime still prefers its own
92
+ * durable volume; this overlay prevents a locally consumed refresh token from being redeployed. */
93
+ export function readSlackBotAuthEnv(statePath) {
94
+ const state = load(statePath);
95
+ return state
96
+ ? {
97
+ SLACK_BOT_TOKEN: state.accessToken,
98
+ SLACK_BOT_REFRESH_TOKEN: state.refreshToken,
99
+ SLACK_BOT_TOKEN_EXPIRES_AT: String(state.expiresAt),
100
+ }
101
+ : {};
102
+ }
103
+ /** Resolve the current bot token, refreshing once per process when it approaches expiry. */
104
+ export function createSlackBotTokenProvider(options) {
105
+ const rotatingFields = [options.botRefreshToken, options.clientId, options.clientSecret, options.botTokenExpiresAt];
106
+ const rotating = rotatingFields.some((value) => value !== undefined);
107
+ if (rotating && rotatingFields.some((value) => value === undefined || value === "")) {
108
+ throw new Error("Slack token rotation requires botRefreshToken, clientId, clientSecret, and botTokenExpiresAt together");
109
+ }
110
+ if (rotating && (!Number.isFinite(options.botTokenExpiresAt) || options.botTokenExpiresAt <= 0)) {
111
+ throw new Error("Slack botTokenExpiresAt must be a positive epoch-millisecond value");
112
+ }
113
+ if (!rotating)
114
+ return async () => options.botToken;
115
+ const configured = {
116
+ version: 1,
117
+ accessToken: options.botToken,
118
+ refreshToken: options.botRefreshToken,
119
+ expiresAt: options.botTokenExpiresAt,
120
+ };
121
+ const persisted = load(options.statePath);
122
+ // Deploy may carry a newer pair from the owner machine onto an existing remote volume; ordinary
123
+ // restarts carry the original stale env and therefore keep the newer persisted pair.
124
+ let state = persisted && persisted.expiresAt >= configured.expiresAt ? persisted : configured;
125
+ let refreshing;
126
+ return async () => {
127
+ if (state.expiresAt > Date.now() + REFRESH_EARLY_MS)
128
+ return state.accessToken;
129
+ refreshing ??= refreshSlackBotToken({
130
+ refreshToken: state.refreshToken,
131
+ clientId: options.clientId,
132
+ clientSecret: options.clientSecret,
133
+ apiBaseUrl: (options.apiBaseUrl ?? "https://slack.com/api").replace(/\/$/, ""),
134
+ fetch: options.fetch ?? globalThis.fetch,
135
+ })
136
+ .then((next) => {
137
+ save(options.statePath, next);
138
+ state = next;
139
+ })
140
+ .finally(() => {
141
+ refreshing = undefined;
142
+ });
143
+ await refreshing;
144
+ return state.accessToken;
145
+ };
146
+ }
@@ -0,0 +1,60 @@
1
+ import type { SlackAppManifest } from "./manifest.ts";
2
+ interface SlackErrorShape {
3
+ ok?: boolean;
4
+ error?: string;
5
+ errors?: {
6
+ message?: string;
7
+ pointer?: string;
8
+ }[];
9
+ }
10
+ export declare class SlackConfigApiError extends Error {
11
+ readonly code: string;
12
+ readonly status: number;
13
+ constructor(method: string, status: number, data: SlackErrorShape, fallback: string);
14
+ }
15
+ export interface SlackAppCredentials {
16
+ appId: string;
17
+ clientId: string;
18
+ clientSecret: string;
19
+ signingSecret: string;
20
+ oauthAuthorizeUrl?: string;
21
+ }
22
+ /** Create is intentionally single-attempt: an ambiguous response may already have created the app. */
23
+ export declare function createSlackApp(configToken: string, manifest: SlackAppManifest, options?: {
24
+ apiBaseUrl?: string;
25
+ fetch?: typeof fetch;
26
+ }): Promise<SlackAppCredentials>;
27
+ export declare function updateSlackAppManifest(configToken: string, appId: string, manifest: SlackAppManifest, options?: {
28
+ apiBaseUrl?: string;
29
+ fetch?: typeof fetch;
30
+ }): Promise<void>;
31
+ export interface RotatedSlackConfigToken {
32
+ token: string;
33
+ refreshToken: string;
34
+ expiresAt: number;
35
+ teamId?: string;
36
+ }
37
+ export declare function rotateSlackConfigToken(refreshToken: string, options?: {
38
+ apiBaseUrl?: string;
39
+ fetch?: typeof fetch;
40
+ }): Promise<RotatedSlackConfigToken>;
41
+ export interface SlackOAuthResult {
42
+ botToken: string;
43
+ botRefreshToken: string;
44
+ botTokenExpiresAt: number;
45
+ appId: string;
46
+ teamId: string;
47
+ teamName?: string;
48
+ botUserId?: string;
49
+ scopes: string[];
50
+ }
51
+ export declare function exchangeSlackOAuthCode(input: {
52
+ clientId: string;
53
+ clientSecret: string;
54
+ code: string;
55
+ redirectUrl: string;
56
+ }, options?: {
57
+ apiBaseUrl?: string;
58
+ fetch?: typeof fetch;
59
+ }): Promise<SlackOAuthResult>;
60
+ export {};
@@ -0,0 +1,149 @@
1
+ const SLACK_API = "https://slack.com/api";
2
+ const REQUEST_TIMEOUT_MS = 30_000;
3
+ export class SlackConfigApiError extends Error {
4
+ code;
5
+ status;
6
+ constructor(method, status, data, fallback) {
7
+ const code = data.error ?? fallback;
8
+ const fields = data.errors
9
+ ?.map((error) => `${error.pointer ?? "manifest"}: ${error.message ?? "invalid"}`)
10
+ .join("; ");
11
+ super(`Slack ${method} failed: ${code}${fields ? ` — ${fields}` : ""}`);
12
+ this.name = "SlackConfigApiError";
13
+ this.code = code;
14
+ this.status = status;
15
+ }
16
+ }
17
+ async function slackJson(method, body, options = {}) {
18
+ const fetchFn = options.fetch ?? globalThis.fetch;
19
+ let response;
20
+ let raw;
21
+ try {
22
+ response = await fetchFn(`${options.apiBaseUrl ?? SLACK_API}/${method}`, {
23
+ method: "POST",
24
+ headers: {
25
+ "content-type": "application/json; charset=utf-8",
26
+ ...(options.token ? { authorization: `Bearer ${options.token}` } : {}),
27
+ },
28
+ body: JSON.stringify(body),
29
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
30
+ });
31
+ raw = await response.text();
32
+ }
33
+ catch (error) {
34
+ throw new Error(`Slack ${method} request failed before receiving a response`, { cause: error });
35
+ }
36
+ let data;
37
+ try {
38
+ data = JSON.parse(raw);
39
+ }
40
+ catch {
41
+ throw new Error(`Slack ${method} failed: HTTP ${response.status} returned non-JSON`);
42
+ }
43
+ if (!response.ok || data.ok !== true) {
44
+ throw new SlackConfigApiError(method, response.status, data, raw.trim().slice(0, 200) || "unknown_error");
45
+ }
46
+ return data;
47
+ }
48
+ /** Create is intentionally single-attempt: an ambiguous response may already have created the app. */
49
+ export async function createSlackApp(configToken, manifest, options = {}) {
50
+ const data = await slackJson("apps.manifest.create", { manifest: JSON.stringify(manifest) }, { ...options, token: configToken });
51
+ const credentials = data.credentials;
52
+ if (!data.app_id || !credentials?.client_id || !credentials.client_secret || !credentials.signing_secret) {
53
+ throw new Error("Slack apps.manifest.create succeeded but returned incomplete app credentials");
54
+ }
55
+ return {
56
+ appId: data.app_id,
57
+ clientId: credentials.client_id,
58
+ clientSecret: credentials.client_secret,
59
+ signingSecret: credentials.signing_secret,
60
+ oauthAuthorizeUrl: data.oauth_authorize_url,
61
+ };
62
+ }
63
+ export async function updateSlackAppManifest(configToken, appId, manifest, options = {}) {
64
+ await slackJson("apps.manifest.update", { app_id: appId, manifest: JSON.stringify(manifest) }, { ...options, token: configToken });
65
+ }
66
+ export async function rotateSlackConfigToken(refreshToken, options = {}) {
67
+ const data = await slackJson("tooling.tokens.rotate", { refresh_token: refreshToken }, options);
68
+ if (!data.token || !data.refresh_token || typeof data.exp !== "number") {
69
+ throw new Error("Slack tooling.tokens.rotate succeeded but returned incomplete token credentials");
70
+ }
71
+ return {
72
+ token: data.token,
73
+ refreshToken: data.refresh_token,
74
+ expiresAt: data.exp * 1000,
75
+ teamId: data.team_id,
76
+ };
77
+ }
78
+ function slackOAuthFailure(status, code) {
79
+ const reason = (() => {
80
+ switch (code) {
81
+ case "bad_client_secret":
82
+ return "Slack rejected the app client secret";
83
+ case "invalid_client_id":
84
+ return "Slack rejected the app client ID";
85
+ case "bad_redirect_uri":
86
+ case "invalid_redirect_uri":
87
+ return "Slack rejected the temporary OAuth redirect URL";
88
+ case "invalid_code":
89
+ return "Slack rejected the authorization code";
90
+ case "code_already_used":
91
+ return "the Slack authorization code was already used";
92
+ case "access_denied":
93
+ return "the Slack installation was not approved";
94
+ default:
95
+ return "Slack rejected the OAuth exchange";
96
+ }
97
+ })();
98
+ return new Error(`${reason} (HTTP ${status}); re-run fastagent add slack to resume installation`);
99
+ }
100
+ export async function exchangeSlackOAuthCode(input, options = {}) {
101
+ const authorization = Buffer.from(`${input.clientId}:${input.clientSecret}`, "utf8").toString("base64");
102
+ const fetchFn = options.fetch ?? globalThis.fetch;
103
+ let response;
104
+ let raw;
105
+ try {
106
+ response = await fetchFn(`${options.apiBaseUrl ?? SLACK_API}/oauth.v2.access`, {
107
+ method: "POST",
108
+ headers: {
109
+ authorization: `Basic ${authorization}`,
110
+ "content-type": "application/x-www-form-urlencoded",
111
+ },
112
+ body: new URLSearchParams({ code: input.code, redirect_uri: input.redirectUrl }).toString(),
113
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
114
+ });
115
+ raw = await response.text();
116
+ }
117
+ catch (error) {
118
+ throw new Error("Slack oauth.v2.access request failed before receiving a response", { cause: error });
119
+ }
120
+ let data;
121
+ try {
122
+ data = JSON.parse(raw);
123
+ }
124
+ catch {
125
+ throw new Error(`Slack oauth.v2.access failed: HTTP ${response.status} returned non-JSON`);
126
+ }
127
+ if (!response.ok || data.ok !== true)
128
+ 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");
135
+ }
136
+ return {
137
+ botToken: data.access_token,
138
+ botRefreshToken: data.refresh_token,
139
+ botTokenExpiresAt: Date.now() + data.expires_in * 1_000,
140
+ appId: data.app_id,
141
+ teamId: data.team.id,
142
+ teamName: data.team.name,
143
+ botUserId: data.bot_user_id,
144
+ scopes: (data.scope ?? "")
145
+ .split(",")
146
+ .map((scope) => scope.trim())
147
+ .filter(Boolean),
148
+ };
149
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Slack's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
3
+ * the entry shape, its fold-line rendering, and buffered-file selection. Durable, bounded
4
+ * unsummoned Slack discussion folded into the next answered turn in the same place.
5
+ */
6
+ import { type ContextBuffer } from "../context-buffer.ts";
7
+ export interface SlackBufferEntry {
8
+ sender: string;
9
+ body: string;
10
+ messageId: string;
11
+ replyTo?: string;
12
+ fileIds?: string[];
13
+ }
14
+ export interface SlackBufferedFileRef {
15
+ id: string;
16
+ from: string;
17
+ messageId: string;
18
+ }
19
+ export declare function collectSlackBufferedFiles(consumed: SlackBufferEntry[], primaryIds: Set<string>): {
20
+ files: SlackBufferedFileRef[];
21
+ skipped: number;
22
+ };
23
+ export type SlackContextBuffer = ContextBuffer<SlackBufferEntry>;
24
+ export declare function createSlackContextBuffer(path: string, label: string): SlackContextBuffer;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Slack's half of the shared context buffer (mechanics + consume protocol: ../context-buffer.ts):
3
+ * the entry shape, its fold-line rendering, and buffered-file selection. Durable, bounded
4
+ * unsummoned Slack discussion folded into the next answered turn in the same place.
5
+ */
6
+ import { BUFFER_ATTACH_MAX, createContextBuffer as createGenericContextBuffer, } from "../context-buffer.js";
7
+ function bufferLine(entry) {
8
+ const meta = [`msg ${entry.messageId}`, entry.replyTo ? `thread root ${entry.replyTo}` : undefined]
9
+ .filter(Boolean)
10
+ .join(", ");
11
+ return `${entry.sender} (${meta}): ${entry.body}`;
12
+ }
13
+ export function collectSlackBufferedFiles(consumed, primaryIds) {
14
+ const seen = new Set();
15
+ const files = [];
16
+ for (const entry of consumed) {
17
+ for (const id of entry.fileIds ?? []) {
18
+ if (primaryIds.has(id) || seen.has(id))
19
+ continue;
20
+ seen.add(id);
21
+ files.push({ id, from: entry.sender, messageId: entry.messageId });
22
+ }
23
+ }
24
+ return { files: files.slice(-BUFFER_ATTACH_MAX), skipped: Math.max(0, files.length - BUFFER_ATTACH_MAX) };
25
+ }
26
+ function isEntry(value) {
27
+ const entry = value;
28
+ return (typeof entry?.sender === "string" &&
29
+ typeof entry.body === "string" &&
30
+ typeof entry.messageId === "string" &&
31
+ (entry.replyTo === undefined || typeof entry.replyTo === "string") &&
32
+ (entry.fileIds === undefined ||
33
+ (Array.isArray(entry.fileIds) && entry.fileIds.every((id) => typeof id === "string"))));
34
+ }
35
+ export function createSlackContextBuffer(path, label) {
36
+ return createGenericContextBuffer({ path, label, isEntry, line: bufferLine });
37
+ }
@@ -0,0 +1,19 @@
1
+ /** Resolve Slack file IDs at dequeue, then stream one engine-neutral Agent turn. */
2
+ import type { Agent, AgentEvent } from "../../agent.ts";
3
+ import { type BusyRetry } from "../invoke-turn-kit.ts";
4
+ import type { SlackBufferedFileRef } from "./context-buffer.ts";
5
+ import type { SlackApi } from "./slack-api.ts";
6
+ export interface SlackTurnTransport {
7
+ api: SlackApi;
8
+ channelId: string;
9
+ filesDir: string;
10
+ label: string;
11
+ }
12
+ export interface SlackTurnAttachments {
13
+ primaryFileIds: string[];
14
+ buffered: {
15
+ files: SlackBufferedFileRef[];
16
+ skipped: number;
17
+ };
18
+ }
19
+ export declare function invokeSlackTurn(agent: Agent, session: string, text: string, transport: SlackTurnTransport, attachments: SlackTurnAttachments, onCompleted?: () => void, busyRetry?: BusyRetry): AsyncIterable<AgentEvent>;