@fastagent-sh/fastagent 0.14.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.
- package/README.md +3 -3
- package/dist/agent.d.ts +20 -0
- package/dist/agent.js +9 -0
- package/dist/channels/context-buffer.d.ts +30 -0
- package/dist/channels/context-buffer.js +98 -0
- package/dist/channels/control.d.ts +28 -0
- package/dist/channels/control.js +214 -0
- package/dist/channels/feishu/card.js +1 -1
- package/dist/channels/feishu/context-buffer.d.ts +8 -11
- package/dist/channels/feishu/context-buffer.js +6 -67
- package/dist/channels/feishu/feishu-api.d.ts +11 -3
- package/dist/channels/feishu/feishu-api.js +20 -2
- package/dist/channels/feishu/feishu.d.ts +28 -16
- package/dist/channels/feishu/feishu.js +210 -188
- package/dist/channels/feishu/invoke-turn.d.ts +7 -20
- package/dist/channels/feishu/invoke-turn.js +10 -62
- package/dist/channels/feishu/model.d.ts +2 -1
- package/dist/channels/feishu/normalize.d.ts +2 -1
- package/dist/channels/feishu/owned-threads.d.ts +2 -6
- package/dist/channels/feishu/parse.d.ts +1 -1
- package/dist/channels/feishu/preview.d.ts +4 -7
- package/dist/channels/feishu/preview.js +24 -140
- package/dist/channels/feishu/register-app.d.ts +2 -1
- package/dist/channels/feishu/register-webhook.d.ts +2 -1
- package/dist/channels/feishu/setup-mode.d.ts +8 -0
- package/dist/channels/feishu/setup-mode.js +2 -0
- package/dist/channels/feishu/ws-ingress.d.ts +28 -0
- package/dist/channels/feishu/ws-ingress.js +136 -0
- package/dist/channels/github/github.js +8 -6
- package/dist/channels/http.d.ts +14 -0
- package/dist/channels/http.js +35 -2
- package/dist/channels/invoke-turn-kit.d.ts +65 -0
- package/dist/channels/invoke-turn-kit.js +87 -0
- package/dist/channels/lark/lark.d.ts +4 -2
- package/dist/channels/lark/lark.js +4 -1
- package/dist/channels/lark/onboard.d.ts +8 -4
- package/dist/channels/lark/onboard.js +8 -0
- package/dist/channels/preview-kit.d.ts +109 -0
- package/dist/channels/preview-kit.js +183 -0
- package/dist/channels/seen.d.ts +5 -0
- package/dist/channels/seen.js +35 -0
- package/dist/channels/slack/bot-auth.d.ts +15 -0
- package/dist/channels/slack/bot-auth.js +146 -0
- package/dist/channels/slack/config-api.d.ts +60 -0
- package/dist/channels/slack/config-api.js +149 -0
- package/dist/channels/slack/context-buffer.d.ts +24 -0
- package/dist/channels/slack/context-buffer.js +37 -0
- package/dist/channels/slack/invoke-turn.d.ts +19 -0
- package/dist/channels/slack/invoke-turn.js +63 -0
- package/dist/channels/slack/manifest.d.ts +49 -0
- package/dist/channels/slack/manifest.js +69 -0
- package/dist/channels/slack/model.d.ts +67 -0
- package/dist/channels/slack/model.js +2 -0
- package/dist/channels/slack/onboard.d.ts +41 -0
- package/dist/channels/slack/onboard.js +120 -0
- package/dist/channels/slack/onboarding-state.d.ts +31 -0
- package/dist/channels/slack/onboarding-state.js +69 -0
- package/dist/channels/slack/owned-threads.d.ts +6 -0
- package/dist/channels/slack/owned-threads.js +43 -0
- package/dist/channels/slack/parse.d.ts +23 -0
- package/dist/channels/slack/parse.js +81 -0
- package/dist/channels/slack/preview.d.ts +24 -0
- package/dist/channels/slack/preview.js +359 -0
- package/dist/channels/slack/reaction.d.ts +24 -0
- package/dist/channels/slack/reaction.js +62 -0
- package/dist/channels/slack/register-webhook.d.ts +10 -0
- package/dist/channels/slack/register-webhook.js +49 -0
- package/dist/channels/slack/scaffold/channel.ts +33 -0
- package/dist/channels/slack/scaffold/slack-send.ts +171 -0
- package/dist/channels/slack/setup-server.d.ts +17 -0
- package/dist/channels/slack/setup-server.js +103 -0
- package/dist/channels/slack/slack-api.d.ts +77 -0
- package/dist/channels/slack/slack-api.js +415 -0
- package/dist/channels/slack/slack.d.ts +58 -0
- package/dist/channels/slack/slack.js +451 -0
- package/dist/channels/slack/welcomed.d.ts +5 -0
- package/dist/channels/slack/welcomed.js +32 -0
- package/dist/channels/state.js +3 -3
- package/dist/channels/stop-command.d.ts +6 -0
- package/dist/channels/stop-command.js +36 -0
- package/dist/channels/tasks.d.ts +13 -0
- package/dist/channels/tasks.js +10 -0
- package/dist/channels/telegram/context-buffer.d.ts +8 -17
- package/dist/channels/telegram/context-buffer.js +6 -85
- package/dist/channels/telegram/invoke-turn.d.ts +5 -22
- package/dist/channels/telegram/invoke-turn.js +11 -58
- package/dist/channels/telegram/preview.d.ts +4 -7
- package/dist/channels/telegram/preview.js +24 -142
- package/dist/channels/telegram/telegram.js +23 -9
- package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
- package/dist/channels/{feishu/text.js → text.js} +1 -1
- package/dist/channels/turn-queue.js +1 -1
- package/dist/channels/turn-store.d.ts +1 -1
- package/dist/channels/turn-store.js +2 -3
- package/dist/cli/add-feishu.d.ts +27 -0
- package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
- package/dist/cli/add-slack.d.ts +10 -0
- package/dist/cli/add-slack.js +204 -0
- package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
- package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
- package/dist/cli/commands/add.d.ts +4 -0
- package/dist/cli/commands/add.js +152 -20
- package/dist/cli/commands/attach.d.ts +82 -0
- package/dist/cli/commands/attach.js +559 -0
- package/dist/cli/commands/chat.d.ts +1 -0
- package/dist/cli/commands/chat.js +6 -1
- package/dist/cli/commands/deploy.d.ts +2 -0
- package/dist/cli/commands/deploy.js +78 -22
- package/dist/cli/commands/dev.js +11 -5
- package/dist/cli/commands/fire.js +3 -3
- package/dist/cli/commands/info.js +2 -2
- package/dist/cli/commands/invoke.js +2 -3
- package/dist/cli/commands/login.js +14 -25
- package/dist/cli/commands/models.js +1 -1
- package/dist/cli/commands/start.js +15 -8
- package/dist/cli/commands/tool.js +2 -1
- package/dist/cli/fail.d.ts +0 -2
- package/dist/cli/fail.js +1 -1
- package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
- package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
- package/dist/cli/kernel.d.ts +3 -2
- package/dist/cli/models-view.d.ts +21 -0
- package/dist/cli/models-view.js +66 -0
- package/dist/cli/program.js +79 -21
- package/dist/cli/serve.d.ts +38 -18
- package/dist/cli/serve.js +197 -39
- package/dist/cli/shared.d.ts +26 -6
- package/dist/cli/shared.js +163 -33
- package/dist/collect.d.ts +14 -3
- package/dist/collect.js +24 -0
- package/dist/core.d.ts +3 -1
- package/dist/core.js +2 -0
- package/dist/deploy/docker/plan.d.ts +2 -2
- package/dist/deploy/docker/plan.js +8 -6
- package/dist/deploy/fly/plan.d.ts +3 -1
- package/dist/deploy/fly/plan.js +12 -7
- package/dist/deploy/fly/run.d.ts +5 -4
- package/dist/deploy/fly/run.js +16 -5
- package/dist/deploy/preflight.d.ts +10 -4
- package/dist/deploy/preflight.js +53 -9
- package/dist/deploy/railway/plan.d.ts +3 -1
- package/dist/deploy/railway/plan.js +12 -4
- package/dist/deploy/railway/run.d.ts +5 -4
- package/dist/deploy/railway/run.js +15 -5
- package/dist/deploy/runner.d.ts +2 -1
- package/dist/deploy/secrets.d.ts +2 -1
- package/dist/deploy/secrets.js +23 -3
- package/dist/dev-supervisor.d.ts +0 -2
- package/dist/dev-supervisor.js +7 -3
- package/dist/engines/pi/channel.d.ts +22 -16
- package/dist/engines/pi/channel.js +90 -60
- package/dist/engines/pi/chat.d.ts +4 -16
- package/dist/engines/pi/chat.js +8 -261
- package/dist/engines/pi/config.d.ts +14 -9
- package/dist/engines/pi/config.js +33 -18
- package/dist/engines/pi/create.d.ts +22 -2
- package/dist/engines/pi/create.js +20 -14
- package/dist/engines/pi/harness.d.ts +49 -0
- package/dist/engines/pi/harness.js +90 -2
- package/dist/engines/pi/invoke.d.ts +55 -2
- package/dist/engines/pi/invoke.js +304 -19
- package/dist/engines/pi/login.d.ts +11 -0
- package/dist/engines/pi/login.js +16 -4
- package/dist/engines/pi/models.d.ts +56 -10
- package/dist/engines/pi/models.js +61 -23
- package/dist/engines/pi/session-builder.d.ts +16 -0
- package/dist/engines/pi/session-builder.js +308 -0
- package/dist/engines/pi/session-control.d.ts +50 -0
- package/dist/engines/pi/session-control.js +604 -0
- package/dist/engines/pi/sessions.d.ts +17 -2
- package/dist/engines/pi/sessions.js +9 -0
- package/dist/engines/pi/tool-context.d.ts +18 -11
- package/dist/engines/pi/tool-context.js +3 -9
- package/dist/engines/pi/tool.d.ts +6 -8
- package/dist/engines/pi/tool.js +6 -1
- package/dist/engines/pi/wake-tool.d.ts +0 -3
- package/dist/engines/pi/wake-tool.js +9 -7
- package/dist/engines/pi/workspace.d.ts +52 -0
- package/dist/engines/pi/workspace.js +72 -15
- package/dist/feishu.d.ts +1 -1
- package/dist/feishu.js +1 -1
- package/dist/host/node.d.ts +23 -6
- package/dist/host/node.js +5 -4
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/lark.d.ts +1 -1
- package/dist/lark.js +1 -1
- package/dist/observe.js +3 -0
- package/dist/pi.d.ts +5 -4
- package/dist/pi.js +2 -1
- package/dist/scaffold/add-channel.d.ts +19 -6
- package/dist/scaffold/add-channel.js +126 -17
- package/dist/scaffold/templates/fastagent.config.mjs +4 -3
- package/dist/schedule/wakeups.d.ts +0 -3
- package/dist/schedule/wakeups.js +1 -1
- package/dist/session-remote.d.ts +53 -0
- package/dist/session-remote.js +336 -0
- package/dist/session.d.ts +265 -0
- package/dist/session.js +37 -0
- package/dist/slack.d.ts +2 -0
- package/dist/slack.js +2 -0
- package/dist/tunnel.d.ts +7 -4
- package/dist/tunnel.js +21 -10
- package/package.json +19 -7
- package/dist/channels/feishu/seen.d.ts +0 -5
- package/dist/channels/feishu/seen.js +0 -47
- package/dist/cli-add-feishu.d.ts +0 -8
- package/dist/cli-models.d.ts +0 -11
- package/dist/cli-models.js +0 -20
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { log } from "../../log.js";
|
|
2
|
+
import { RETRY_NOTICE, THINKING_PLACEHOLDER, applyTurnEvent, composeTurnBody, createTurnView, defaultErrorMessage, humanizeToolName, revealedAnswer, toolLines, } from "../preview-kit.js";
|
|
3
|
+
import { chunkSlackMarkdown, chunkSlackText, isSlackNativeUnavailable, } from "./slack-api.js";
|
|
4
|
+
export { defaultErrorMessage };
|
|
5
|
+
const CLASSIC_UPDATE_INTERVAL_MS = 3_000;
|
|
6
|
+
const NATIVE_APPEND_INTERVAL_MS = 750;
|
|
7
|
+
const WORKING_STATUS = "is working on your request…";
|
|
8
|
+
const GENERIC_FAILURE = "⚠️ The response stream stopped unexpectedly. Please try again.";
|
|
9
|
+
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
10
|
+
/** LLM output uses standard Markdown, never Slack's notification control syntax. Keep explicit sends in
|
|
11
|
+
* the slack-send tool, where the Agent has to choose a side-effecting delivery action deliberately.
|
|
12
|
+
* The inner class excludes BOTH `<` and `>` on purpose: bounding each run by the next delimiter keeps
|
|
13
|
+
* the scan linear even on adversarial input like `(<!)^n` (a `>`-only bound would still be polynomial
|
|
14
|
+
* across the many `<` start positions), while still neutralizing every `<@…>` / `<!…>` control sequence
|
|
15
|
+
* (real Slack controls never contain a `<`). */
|
|
16
|
+
export function sanitizeSlackMarkdown(markdown) {
|
|
17
|
+
return markdown.replace(/<[@!][^<>]*>/g, (control) => `<${control.slice(1)}`);
|
|
18
|
+
}
|
|
19
|
+
function withDisclaimer(markdown, disclaimer) {
|
|
20
|
+
const body = markdown.trim() || "(no reply)";
|
|
21
|
+
return disclaimer === false || !disclaimer?.trim() ? body : `${body}\n\n_${disclaimer.trim()}_`;
|
|
22
|
+
}
|
|
23
|
+
async function settleClassic(api, target, previewTs, markdown, update = (ts, value) => api.updateMarkdown(target.channelId, ts, value)) {
|
|
24
|
+
if (markdown.trim() === "") {
|
|
25
|
+
if (previewTs)
|
|
26
|
+
await api.deleteMessage(target.channelId, previewTs).catch(() => { });
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
const [head, ...rest] = chunkSlackMarkdown(markdown);
|
|
30
|
+
if (previewTs && head !== undefined) {
|
|
31
|
+
try {
|
|
32
|
+
await update(previewTs, head);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
await api.deleteMessage(target.channelId, previewTs).catch(() => { });
|
|
36
|
+
await api.sendMarkdown(target, markdown);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
// The updated preview is authoritative. Never resend it if a continuation fails: that would
|
|
40
|
+
// duplicate any continuation that Slack already accepted.
|
|
41
|
+
for (const chunk of rest)
|
|
42
|
+
await api.postMarkdown(target, chunk);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
await api.sendMarkdown(target, markdown);
|
|
46
|
+
}
|
|
47
|
+
/** Settle a queue/drop/defer notice. These are authored plain strings, so the basic text API is enough. */
|
|
48
|
+
export async function settleSlackPreview(api, target, previewTs, text) {
|
|
49
|
+
if (previewTs) {
|
|
50
|
+
await api.updateMessage(target.channelId, previewTs, text);
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
await api.postMessage(target, text);
|
|
54
|
+
}
|
|
55
|
+
async function streamClassicSlackReply(events, api, target, formatError, initialPreviewTs, disclaimer, label) {
|
|
56
|
+
// Event → view-state reduction is the shared machine (preview-kit); this renderer owns mrkdwn
|
|
57
|
+
// sanitizing and delivery. Reasoning stays a static "Thinking…" here: raw chain-of-thought is not
|
|
58
|
+
// customer-facing on Slack, so the reducer accumulates it but this view never reads it.
|
|
59
|
+
const turn = createTurnView();
|
|
60
|
+
let previewTs = initialPreviewTs;
|
|
61
|
+
let previewAttempted = previewTs !== undefined;
|
|
62
|
+
let finalized = false;
|
|
63
|
+
let lastMutationAt = previewTs ? Date.now() : 0;
|
|
64
|
+
let lastSent = "";
|
|
65
|
+
let dirty = false;
|
|
66
|
+
let pumping = false;
|
|
67
|
+
let stopped = false;
|
|
68
|
+
let pumpDone;
|
|
69
|
+
let previewErrorLogged = false;
|
|
70
|
+
const view = () => composeTurnBody([
|
|
71
|
+
THINKING_PLACEHOLDER,
|
|
72
|
+
toolLines(turn),
|
|
73
|
+
turn.retrying ? RETRY_NOTICE : "",
|
|
74
|
+
sanitizeSlackMarkdown(revealedAnswer(turn, CLASSIC_UPDATE_INTERVAL_MS)),
|
|
75
|
+
]);
|
|
76
|
+
const waitForMutationSlot = async () => {
|
|
77
|
+
const remaining = lastMutationAt + CLASSIC_UPDATE_INTERVAL_MS - Date.now();
|
|
78
|
+
if (remaining > 0)
|
|
79
|
+
await wait(remaining);
|
|
80
|
+
};
|
|
81
|
+
const updateRateSafe = async (ts, markdown) => {
|
|
82
|
+
await waitForMutationSlot();
|
|
83
|
+
await api.updateMarkdown(target.channelId, ts, markdown);
|
|
84
|
+
lastMutationAt = Date.now();
|
|
85
|
+
};
|
|
86
|
+
const flushPreview = async () => {
|
|
87
|
+
const markdown = chunkSlackText(view())[0] ?? THINKING_PLACEHOLDER;
|
|
88
|
+
if (markdown === lastSent)
|
|
89
|
+
return;
|
|
90
|
+
if (previewTs) {
|
|
91
|
+
await updateRateSafe(previewTs, markdown);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
if (previewAttempted)
|
|
95
|
+
return;
|
|
96
|
+
previewAttempted = true;
|
|
97
|
+
previewTs = await api.postMarkdown(target, markdown);
|
|
98
|
+
lastMutationAt = Date.now();
|
|
99
|
+
}
|
|
100
|
+
lastSent = markdown;
|
|
101
|
+
};
|
|
102
|
+
const runPump = async () => {
|
|
103
|
+
pumping = true;
|
|
104
|
+
try {
|
|
105
|
+
while (dirty && !stopped) {
|
|
106
|
+
dirty = false;
|
|
107
|
+
try {
|
|
108
|
+
await flushPreview();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
if (!previewErrorLogged) {
|
|
112
|
+
previewErrorLogged = true;
|
|
113
|
+
log.warn(`${label} live preview failed (final reply still sends): ${String(error)}`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
pumping = false;
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
const touch = () => {
|
|
123
|
+
dirty = true;
|
|
124
|
+
if (!pumping)
|
|
125
|
+
pumpDone = runPump();
|
|
126
|
+
};
|
|
127
|
+
const finishPump = async () => {
|
|
128
|
+
stopped = true;
|
|
129
|
+
await pumpDone?.catch(() => { });
|
|
130
|
+
};
|
|
131
|
+
const finalize = async (markdown) => {
|
|
132
|
+
await settleClassic(api, target, previewTs, sanitizeSlackMarkdown(markdown), updateRateSafe);
|
|
133
|
+
};
|
|
134
|
+
try {
|
|
135
|
+
for await (const event of events) {
|
|
136
|
+
if (event.type === "completed") {
|
|
137
|
+
await finishPump();
|
|
138
|
+
finalized = true;
|
|
139
|
+
await finalize(withDisclaimer(turn.answer, disclaimer));
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (event.type === "failed") {
|
|
143
|
+
await finishPump();
|
|
144
|
+
finalized = true;
|
|
145
|
+
const notice = formatError({ details: event.details, retryable: event.retryable }) ?? "";
|
|
146
|
+
await finalize(notice).catch((error) => log.error(`${label} failed to deliver the agent-failure notice: ${String(error)}`));
|
|
147
|
+
throw new Error(`agent failed: ${event.details} (retryable=${event.retryable})`);
|
|
148
|
+
}
|
|
149
|
+
const changed = applyTurnEvent(turn, event);
|
|
150
|
+
// A young (hidden) answer must not trigger the first frame: unlike telegram/feishu, classic
|
|
151
|
+
// rendering never posts an upfront placeholder, so a text-only fast turn delivers ONE final
|
|
152
|
+
// post instead of placeholder → 3s-rate-limited edit. Thinking/tool activity still paints.
|
|
153
|
+
if (changed && (event.type !== "text" || revealedAnswer(turn, CLASSIC_UPDATE_INTERVAL_MS) !== ""))
|
|
154
|
+
touch();
|
|
155
|
+
}
|
|
156
|
+
throw new Error("stream ended without a terminal event");
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
await finishPump();
|
|
160
|
+
if (!finalized) {
|
|
161
|
+
const notice = formatError({ details: "the turn ended without completing", retryable: false }) ?? "";
|
|
162
|
+
await finalize(notice).catch((error) => log.error(`${label} failed to deliver the abnormal-turn notice: ${String(error)}`));
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async function streamNativeSlackReply(events, api, target, formatError, initialPreviewTs, threadTitle, disclaimer, label, taskDisplayMode) {
|
|
167
|
+
if (initialPreviewTs) {
|
|
168
|
+
await api
|
|
169
|
+
.deleteMessage(target.channelId, initialPreviewTs)
|
|
170
|
+
.catch((error) => log.warn(`${label} could not remove the compatibility queue notice before native streaming: ${String(error)}`));
|
|
171
|
+
}
|
|
172
|
+
if (target.channelId.startsWith("D")) {
|
|
173
|
+
await Promise.all([
|
|
174
|
+
api
|
|
175
|
+
.setThreadStatus(target, WORKING_STATUS)
|
|
176
|
+
.catch((error) => log.warn(`${label} could not set Slack Agent status: ${String(error)}`)),
|
|
177
|
+
threadTitle
|
|
178
|
+
? api
|
|
179
|
+
.setThreadTitle(target, threadTitle)
|
|
180
|
+
.catch((error) => log.warn(`${label} could not set Slack Agent thread title: ${String(error)}`))
|
|
181
|
+
: Promise.resolve(),
|
|
182
|
+
]);
|
|
183
|
+
}
|
|
184
|
+
let streamTs;
|
|
185
|
+
let retryStatusShown = false;
|
|
186
|
+
// DM Agent-status writes are fire-and-forget for the render loop, but must reach Slack in order:
|
|
187
|
+
// an out-of-order pair would leave a stale "retrying" line after progress (or after the final
|
|
188
|
+
// clear). One promise chain serializes them; each link swallows its own delivery error.
|
|
189
|
+
let statusChain = Promise.resolve();
|
|
190
|
+
const setStatus = (status) => {
|
|
191
|
+
statusChain = statusChain.then(() => api
|
|
192
|
+
.setThreadStatus(target, status)
|
|
193
|
+
.catch((error) => log.warn(`${label} could not set Slack Agent status: ${String(error)}`)));
|
|
194
|
+
};
|
|
195
|
+
const toolNames = new Map();
|
|
196
|
+
let pendingText = "";
|
|
197
|
+
let fullAnswer = "";
|
|
198
|
+
let textTimer;
|
|
199
|
+
let lastTextFlushAt = 0;
|
|
200
|
+
let operation = Promise.resolve();
|
|
201
|
+
let renderError;
|
|
202
|
+
let finalized = false;
|
|
203
|
+
const enqueue = (work) => {
|
|
204
|
+
operation = operation.then(async () => {
|
|
205
|
+
if (renderError !== undefined)
|
|
206
|
+
return;
|
|
207
|
+
try {
|
|
208
|
+
await work();
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
renderError = error;
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
};
|
|
215
|
+
const sendContent = async (content) => {
|
|
216
|
+
if (streamTs) {
|
|
217
|
+
await api.appendStream(target.channelId, streamTs, content);
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
streamTs = await api.startStream(target, content, taskDisplayMode);
|
|
221
|
+
}
|
|
222
|
+
};
|
|
223
|
+
const flushText = (final = false) => {
|
|
224
|
+
if (textTimer) {
|
|
225
|
+
clearTimeout(textTimer);
|
|
226
|
+
textTimer = undefined;
|
|
227
|
+
}
|
|
228
|
+
let value = pendingText;
|
|
229
|
+
pendingText = "";
|
|
230
|
+
if (!final) {
|
|
231
|
+
// Hold a possible Slack control token split across Agent deltas/flush windows until its closing
|
|
232
|
+
// `>` arrives. This prevents `<` + `!channel>` from bypassing the sanitizer.
|
|
233
|
+
const open = value.lastIndexOf("<");
|
|
234
|
+
if (open >= 0 && !value.slice(open).includes(">") && value.length - open <= 256) {
|
|
235
|
+
pendingText = value.slice(open);
|
|
236
|
+
value = value.slice(0, open);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (!value)
|
|
240
|
+
return;
|
|
241
|
+
lastTextFlushAt = Date.now();
|
|
242
|
+
for (const chunk of chunkSlackText(sanitizeSlackMarkdown(value))) {
|
|
243
|
+
enqueue(() => sendContent({ markdownText: chunk }));
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
const scheduleText = () => {
|
|
247
|
+
if (textTimer)
|
|
248
|
+
return;
|
|
249
|
+
const delay = Math.max(0, lastTextFlushAt + NATIVE_APPEND_INTERVAL_MS - Date.now());
|
|
250
|
+
textTimer = setTimeout(() => {
|
|
251
|
+
textTimer = undefined;
|
|
252
|
+
flushText();
|
|
253
|
+
}, delay);
|
|
254
|
+
};
|
|
255
|
+
const sendTask = (chunk) => {
|
|
256
|
+
flushText();
|
|
257
|
+
enqueue(() => sendContent({ chunks: [chunk] }));
|
|
258
|
+
};
|
|
259
|
+
const settleNative = async (terminalMarkdown) => {
|
|
260
|
+
flushText(true);
|
|
261
|
+
await operation;
|
|
262
|
+
const safeTerminal = sanitizeSlackMarkdown(terminalMarkdown);
|
|
263
|
+
if (renderError !== undefined) {
|
|
264
|
+
if (!streamTs && isSlackNativeUnavailable(renderError)) {
|
|
265
|
+
log.warn(`${label} native Slack stream was unavailable; delivering one compatibility Markdown reply`);
|
|
266
|
+
await api.sendMarkdown(target, safeTerminal);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
if (streamTs) {
|
|
270
|
+
await api.stopStream(target.channelId, streamTs, { markdownText: `\n\n${GENERIC_FAILURE}` }).catch(() => { });
|
|
271
|
+
}
|
|
272
|
+
throw renderError;
|
|
273
|
+
}
|
|
274
|
+
if (!streamTs)
|
|
275
|
+
streamTs = await api.startStream(target, { markdownText: safeTerminal }, taskDisplayMode);
|
|
276
|
+
await api.stopStream(target.channelId, streamTs);
|
|
277
|
+
};
|
|
278
|
+
try {
|
|
279
|
+
for await (const event of events) {
|
|
280
|
+
if (event.type !== "retrying" && retryStatusShown) {
|
|
281
|
+
// Progress after a retry notice: restore the normal working status so the stale line doesn't
|
|
282
|
+
// contradict a visibly streaming answer.
|
|
283
|
+
retryStatusShown = false;
|
|
284
|
+
setStatus(WORKING_STATUS);
|
|
285
|
+
}
|
|
286
|
+
if (event.type === "text") {
|
|
287
|
+
pendingText += event.delta;
|
|
288
|
+
fullAnswer += event.delta;
|
|
289
|
+
scheduleText();
|
|
290
|
+
}
|
|
291
|
+
else if (event.type === "thinking") {
|
|
292
|
+
// Slack's native loading status represents private reasoning without exposing it.
|
|
293
|
+
}
|
|
294
|
+
else if (event.type === "retrying") {
|
|
295
|
+
// A summarization retry backoff pauses the stream (~14s worst case). Channels have no per-run
|
|
296
|
+
// status surface in native mode; DMs get the explicit Agent status line, restored on progress.
|
|
297
|
+
if (target.channelId.startsWith("D")) {
|
|
298
|
+
retryStatusShown = true;
|
|
299
|
+
setStatus("hit a temporary problem — retrying…");
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
else if (event.type === "tool_started") {
|
|
303
|
+
const title = humanizeToolName(event.name);
|
|
304
|
+
toolNames.set(event.id, title);
|
|
305
|
+
sendTask({ type: "task_update", id: event.id, title, status: "in_progress" });
|
|
306
|
+
}
|
|
307
|
+
else if (event.type === "tool_ended") {
|
|
308
|
+
sendTask({
|
|
309
|
+
type: "task_update",
|
|
310
|
+
id: event.id,
|
|
311
|
+
title: toolNames.get(event.id) ?? "Tool",
|
|
312
|
+
status: event.isError ? "error" : "complete",
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
else if (event.type === "completed") {
|
|
316
|
+
finalized = true;
|
|
317
|
+
const finalAnswer = withDisclaimer(fullAnswer, disclaimer);
|
|
318
|
+
const footer = finalAnswer.slice(fullAnswer.trim().length);
|
|
319
|
+
if (footer)
|
|
320
|
+
pendingText += footer;
|
|
321
|
+
await settleNative(finalAnswer);
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
else if (event.type === "failed") {
|
|
325
|
+
finalized = true;
|
|
326
|
+
const notice = formatError({ details: event.details, retryable: event.retryable }) ?? "";
|
|
327
|
+
if (notice) {
|
|
328
|
+
pendingText += `${fullAnswer.trim() ? "\n\n" : ""}${notice}`;
|
|
329
|
+
fullAnswer += `${fullAnswer.trim() ? "\n\n" : ""}${notice}`;
|
|
330
|
+
}
|
|
331
|
+
await settleNative(fullAnswer.trim() || GENERIC_FAILURE).catch((error) => log.error(`${label} failed to deliver the agent-failure stream: ${String(error)}`));
|
|
332
|
+
throw new Error(`agent failed: ${event.details} (retryable=${event.retryable})`);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
throw new Error("stream ended without a terminal event");
|
|
336
|
+
}
|
|
337
|
+
finally {
|
|
338
|
+
if (textTimer)
|
|
339
|
+
clearTimeout(textTimer);
|
|
340
|
+
if (!finalized) {
|
|
341
|
+
pendingText += `${fullAnswer.trim() ? "\n\n" : ""}${GENERIC_FAILURE}`;
|
|
342
|
+
await settleNative(fullAnswer.trim() || GENERIC_FAILURE).catch((error) => log.error(`${label} failed to stop an abnormal Slack stream: ${String(error)}`));
|
|
343
|
+
}
|
|
344
|
+
if (target.channelId.startsWith("D")) {
|
|
345
|
+
setStatus("");
|
|
346
|
+
await statusChain;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
export async function streamSlackReply(events, api, target, formatError, options = {}) {
|
|
351
|
+
const { rendering = "native", initialPreviewTs, threadTitle, disclaimer, taskDisplay = "plan", label = "[slack]", } = options;
|
|
352
|
+
if (rendering === "native" && target.threadTs) {
|
|
353
|
+
return streamNativeSlackReply(events, api, target, formatError, initialPreviewTs, threadTitle, disclaimer, label, taskDisplay);
|
|
354
|
+
}
|
|
355
|
+
if (rendering === "native") {
|
|
356
|
+
log.info(`${label} native streaming needs a thread target — using the classic renderer for this turn`);
|
|
357
|
+
}
|
|
358
|
+
return streamClassicSlackReply(events, api, target, formatError, initialPreviewTs, disclaimer, label);
|
|
359
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { SlackApi } from "./slack-api.ts";
|
|
2
|
+
/** Normalize a Slack emoji name (strip surrounding colons, lowercase); return null if invalid. */
|
|
3
|
+
export declare function normalizeSlackEmojiName(value: string): string | null;
|
|
4
|
+
export interface SlackReactionEmojis {
|
|
5
|
+
processing: string;
|
|
6
|
+
completed: string;
|
|
7
|
+
}
|
|
8
|
+
/** Resolve the configured reaction emojis; `false` disables the ack, invalid names throw at construction. */
|
|
9
|
+
export declare function resolveReactionEmojis(reactionAck: false | {
|
|
10
|
+
processing?: string;
|
|
11
|
+
completed?: string;
|
|
12
|
+
}): SlackReactionEmojis | undefined;
|
|
13
|
+
export interface SlackReactionSession {
|
|
14
|
+
complete(): Promise<void>;
|
|
15
|
+
remove(): Promise<void>;
|
|
16
|
+
}
|
|
17
|
+
/** Add the processing emoji now; return a session to swap it for the completed emoji or remove it. */
|
|
18
|
+
export declare function startSlackReaction(args: {
|
|
19
|
+
api: Pick<SlackApi, "addReaction" | "removeReaction">;
|
|
20
|
+
channelId: string;
|
|
21
|
+
ts: string;
|
|
22
|
+
emojis: SlackReactionEmojis;
|
|
23
|
+
label: string;
|
|
24
|
+
}): Promise<SlackReactionSession>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/** Lightweight Slack emoji ack on the user's triggering message: 👀 while working, ✅ when done.
|
|
2
|
+
* Every failure is non-fatal — the turn's threaded reply is the real deliverable. */
|
|
3
|
+
import { log } from "../../log.js";
|
|
4
|
+
const EMOJI_RE = /^(?:[a-z0-9_+-]+)(?:::skin-tone-[2-6])?$/;
|
|
5
|
+
/** Normalize a Slack emoji name (strip surrounding colons, lowercase); return null if invalid. */
|
|
6
|
+
export function normalizeSlackEmojiName(value) {
|
|
7
|
+
const trimmed = value.trim().toLowerCase();
|
|
8
|
+
if (!trimmed)
|
|
9
|
+
return null;
|
|
10
|
+
const name = trimmed.startsWith(":") && trimmed.endsWith(":") ? trimmed.slice(1, -1) : trimmed;
|
|
11
|
+
return EMOJI_RE.test(name) ? name : null;
|
|
12
|
+
}
|
|
13
|
+
/** Resolve the configured reaction emojis; `false` disables the ack, invalid names throw at construction. */
|
|
14
|
+
export function resolveReactionEmojis(reactionAck) {
|
|
15
|
+
if (reactionAck === false)
|
|
16
|
+
return undefined;
|
|
17
|
+
const pick = (value, fallback, field) => {
|
|
18
|
+
if (value === undefined)
|
|
19
|
+
return fallback;
|
|
20
|
+
const name = normalizeSlackEmojiName(value);
|
|
21
|
+
if (!name)
|
|
22
|
+
throw new Error(`slackChannel reactionAck.${field} is not a valid Slack emoji name: "${value}"`);
|
|
23
|
+
return name;
|
|
24
|
+
};
|
|
25
|
+
return {
|
|
26
|
+
processing: pick(reactionAck.processing, "eyes", "processing"),
|
|
27
|
+
completed: pick(reactionAck.completed, "white_check_mark", "completed"),
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
const NO_REACTION = {
|
|
31
|
+
complete: async () => undefined,
|
|
32
|
+
remove: async () => undefined,
|
|
33
|
+
};
|
|
34
|
+
/** Add the processing emoji now; return a session to swap it for the completed emoji or remove it. */
|
|
35
|
+
export async function startSlackReaction(args) {
|
|
36
|
+
const { api, channelId, ts, emojis, label } = args;
|
|
37
|
+
try {
|
|
38
|
+
await api.addReaction(channelId, ts, emojis.processing);
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
log.warn(`${label} could not add the processing reaction (skipping ack): ${String(error)}`);
|
|
42
|
+
return NO_REACTION;
|
|
43
|
+
}
|
|
44
|
+
let active = true;
|
|
45
|
+
const removeProcessing = async () => {
|
|
46
|
+
if (!active)
|
|
47
|
+
return;
|
|
48
|
+
active = false;
|
|
49
|
+
await api
|
|
50
|
+
.removeReaction(channelId, ts, emojis.processing)
|
|
51
|
+
.catch((error) => log.warn(`${label} could not remove the processing reaction: ${String(error)}`));
|
|
52
|
+
};
|
|
53
|
+
return {
|
|
54
|
+
complete: async () => {
|
|
55
|
+
await removeProcessing();
|
|
56
|
+
await api
|
|
57
|
+
.addReaction(channelId, ts, emojis.completed)
|
|
58
|
+
.catch((error) => log.warn(`${label} could not add the completed reaction: ${String(error)}`));
|
|
59
|
+
},
|
|
60
|
+
remove: removeProcessing,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { RegistrationOutcome } from "../registration.ts";
|
|
2
|
+
export interface RegisterSlackWebhookOptions {
|
|
3
|
+
stateRoot: string;
|
|
4
|
+
log?: (message: string) => void;
|
|
5
|
+
healthTimeoutMs?: number;
|
|
6
|
+
apiBaseUrl?: string;
|
|
7
|
+
fetch?: typeof fetch;
|
|
8
|
+
}
|
|
9
|
+
/** Update one onboarded internal Slack app without sending its configuration credential to the host. */
|
|
10
|
+
export declare function registerSlackWebhook(baseUrl: string, options: RegisterSlackWebhookOptions): Promise<RegistrationOutcome>;
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { waitForHealth } from "../wait-health.js";
|
|
2
|
+
import { updateSlackAppManifest } from "./config-api.js";
|
|
3
|
+
import { buildSlackManifest } from "./manifest.js";
|
|
4
|
+
import { currentSlackConfigToken, readSlackOnboardingState } from "./onboarding-state.js";
|
|
5
|
+
/** Update one onboarded internal Slack app without sending its configuration credential to the host. */
|
|
6
|
+
export async function registerSlackWebhook(baseUrl, options) {
|
|
7
|
+
const note = options.log ?? ((message) => console.error(message));
|
|
8
|
+
const publicBaseUrl = baseUrl.replace(/\/$/, "");
|
|
9
|
+
let state;
|
|
10
|
+
try {
|
|
11
|
+
state = await readSlackOnboardingState(options.stateRoot);
|
|
12
|
+
}
|
|
13
|
+
catch (error) {
|
|
14
|
+
note(`[fastagent] slack: cannot read local onboarding state: ${String(error)}`);
|
|
15
|
+
return "failed";
|
|
16
|
+
}
|
|
17
|
+
if (!state?.appId || !state.installedAt) {
|
|
18
|
+
note(`[fastagent] slack: no completed local onboarding state on this machine — the config credential lives only where \`fastagent add slack\` ran. ` +
|
|
19
|
+
`Set Event Subscriptions → Request URL = ${publicBaseUrl}/slack manually in the Slack console, or re-run this command from the onboarding machine ` +
|
|
20
|
+
`(repair its expired/revoked tokens with \`fastagent add slack --replace-config\`)`);
|
|
21
|
+
return "manual";
|
|
22
|
+
}
|
|
23
|
+
const healthy = await waitForHealth(`${publicBaseUrl}/health`, options.healthTimeoutMs ?? 45_000, 500);
|
|
24
|
+
if (!healthy) {
|
|
25
|
+
note(`[fastagent] slack: ${publicBaseUrl}/health did not become reachable; Request URL was not changed`);
|
|
26
|
+
return "failed";
|
|
27
|
+
}
|
|
28
|
+
try {
|
|
29
|
+
const current = await currentSlackConfigToken(options.stateRoot, state, {
|
|
30
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
31
|
+
fetch: options.fetch,
|
|
32
|
+
});
|
|
33
|
+
await updateSlackAppManifest(current.token, current.state.appId, buildSlackManifest({
|
|
34
|
+
name: current.state.appName,
|
|
35
|
+
groupBehavior: current.state.groupBehavior,
|
|
36
|
+
requestUrl: `${publicBaseUrl}/slack`,
|
|
37
|
+
// Token-rotation manifests require at least one OAuth redirect URL even after installation.
|
|
38
|
+
// Actual reinstall flows replace this placeholder with their one-shot local setup callback.
|
|
39
|
+
redirectUrl: `${publicBaseUrl}/slack/oauth/callback`,
|
|
40
|
+
}), { apiBaseUrl: options.apiBaseUrl, fetch: options.fetch });
|
|
41
|
+
note(`[fastagent] slack: Event Subscriptions Request URL registered → ${publicBaseUrl}/slack`);
|
|
42
|
+
return "registered";
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
note(`[fastagent] slack: automatic Request URL registration failed: ${String(error)} — ` +
|
|
46
|
+
`re-run \`fastagent add slack --replace-config\` to repair the configuration tokens, or set ${publicBaseUrl}/slack in the Slack console`);
|
|
47
|
+
return "failed";
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { slackChannel } from "@fastagent-sh/fastagent/slack";
|
|
2
|
+
|
|
3
|
+
// Slack HTTP Events API channel. Setup:
|
|
4
|
+
// 1. Create a Slack app at https://api.slack.com/apps and add a bot user.
|
|
5
|
+
// 2. Bot Token Scopes: app_mentions:read, assistant:write, chat:write, im:history,
|
|
6
|
+
// files:read, files:write, channels:history, groups:history, and mpim:history. The explicit
|
|
7
|
+
// mention-only mode may omit the three group-history scopes.
|
|
8
|
+
// 3. Event Subscriptions: app_home_opened, app_context_changed, app_mention, message.im,
|
|
9
|
+
// message.channels, message.groups, and message.mpim. Mention-only may omit the last three.
|
|
10
|
+
// Set Request URL to https://<host>/slack.
|
|
11
|
+
// 4. Enable token rotation and install/reinstall the app after changing scopes. Rotating credentials
|
|
12
|
+
// are written by `fastagent add slack`; manual long-lived tokens may omit the four rotation fields.
|
|
13
|
+
export default slackChannel({
|
|
14
|
+
botToken: process.env.SLACK_BOT_TOKEN ?? "", // Bot User OAuth Token (xoxb-… or rotating xoxe.xoxb-…)
|
|
15
|
+
signingSecret: process.env.SLACK_SIGNING_SECRET ?? "", // Basic Information → App Credentials
|
|
16
|
+
botRefreshToken: process.env.SLACK_BOT_REFRESH_TOKEN || undefined,
|
|
17
|
+
clientId: process.env.SLACK_CLIENT_ID || undefined,
|
|
18
|
+
clientSecret: process.env.SLACK_CLIENT_SECRET || undefined,
|
|
19
|
+
botTokenExpiresAt: process.env.SLACK_BOT_TOKEN_EXPIRES_AT
|
|
20
|
+
? Number(process.env.SLACK_BOT_TOKEN_EXPIRES_AT)
|
|
21
|
+
: undefined,
|
|
22
|
+
groupBehavior: "context", // default; use "mentions" only for an explicit least-privilege setup
|
|
23
|
+
rendering: "native", // Slack Agent streams + task timeline; use "classic" only for compatibility
|
|
24
|
+
// taskDisplay: "plan", // native task-card layout: "plan" (default) | "timeline" | "dense"
|
|
25
|
+
// Optional per-reply footer, if your policy requires one: aiDisclaimer: "AI-generated; verify important information.",
|
|
26
|
+
// welcome: "Custom first-run DM greeting", // sent once on first DM open; false disables (default: a generic greeting)
|
|
27
|
+
// reactionAck: false, // disable the 👀→✅ ack on the user's message (default on; needs reactions:write)
|
|
28
|
+
// Direct and group asks default to independent sessions + Slack threads; opt out independently:
|
|
29
|
+
// directMessageSession: "continuous",
|
|
30
|
+
// groupMessageSession: "continuous",
|
|
31
|
+
// Dev/personal bot: surface raw errors. Remove this for a customer-facing bot; details remain in logs.
|
|
32
|
+
onError: (failed) => `⚠️ ${failed.details}`,
|
|
33
|
+
});
|