@fastagent-sh/fastagent 0.15.0 → 0.16.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 +13 -3
- package/dist/channels/agentcore-limits.d.ts +7 -0
- package/dist/channels/agentcore-limits.js +9 -0
- package/dist/channels/agentcore-state.d.ts +83 -0
- package/dist/channels/agentcore-state.js +258 -0
- package/dist/channels/agentcore.d.ts +98 -0
- package/dist/channels/agentcore.js +260 -0
- package/dist/channels/busy.d.ts +23 -0
- package/dist/channels/busy.js +53 -0
- package/dist/channels/context-buffer.d.ts +5 -0
- package/dist/channels/context-buffer.js +5 -0
- package/dist/channels/feishu/context-buffer.d.ts +13 -8
- package/dist/channels/feishu/context-buffer.js +60 -6
- package/dist/channels/feishu/feishu-api.js +4 -1
- package/dist/channels/feishu/feishu.d.ts +0 -12
- package/dist/channels/feishu/feishu.js +143 -54
- package/dist/channels/feishu/invoke-turn.js +35 -18
- package/dist/channels/feishu/model.d.ts +0 -1
- package/dist/channels/feishu/normalize.js +0 -1
- package/dist/channels/feishu/parse.d.ts +21 -7
- package/dist/channels/feishu/parse.js +24 -7
- package/dist/channels/feishu/preview.js +3 -2
- package/dist/channels/feishu/scaffold/channel.ts +9 -8
- package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
- package/dist/channels/feishu/setup-mode.d.ts +30 -1
- package/dist/channels/feishu/setup-mode.js +27 -1
- package/dist/channels/github/github.js +8 -1
- package/dist/channels/http.js +1 -1
- package/dist/channels/lark/scaffold/channel.ts +9 -8
- package/dist/channels/lark/scaffold/lark-send.ts +6 -4
- package/dist/channels/preview-kit.d.ts +7 -1
- package/dist/channels/preview-kit.js +3 -2
- package/dist/channels/slack/parse.d.ts +16 -1
- package/dist/channels/slack/parse.js +46 -3
- package/dist/channels/slack/preview.d.ts +1 -2
- package/dist/channels/slack/preview.js +68 -24
- package/dist/channels/slack/scaffold/channel.ts +5 -5
- package/dist/channels/slack/slack-api.d.ts +3 -23
- package/dist/channels/slack/slack-api.js +6 -22
- package/dist/channels/slack/slack.d.ts +13 -20
- package/dist/channels/slack/slack.js +95 -50
- package/dist/channels/state.d.ts +11 -4
- package/dist/channels/state.js +19 -12
- package/dist/channels/tasks.d.ts +0 -6
- package/dist/channels/tasks.js +16 -1
- package/dist/channels/telegram/parse.d.ts +0 -7
- package/dist/channels/telegram/parse.js +4 -2
- package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
- package/dist/channels/telegram/telegram.js +1 -1
- package/dist/channels/text.d.ts +14 -0
- package/dist/channels/text.js +14 -0
- package/dist/channels/thread-participants.d.ts +21 -0
- package/dist/channels/thread-participants.js +132 -0
- package/dist/channels/turn-queue.js +7 -0
- package/dist/cli/add-feishu.d.ts +7 -4
- package/dist/cli/add-feishu.js +57 -37
- package/dist/cli/add-slack.d.ts +2 -1
- package/dist/cli/add-slack.js +6 -11
- package/dist/cli/commands/add.js +50 -51
- package/dist/cli/commands/attach.js +8 -4
- package/dist/cli/commands/chat.js +8 -8
- package/dist/cli/commands/deploy.d.ts +1 -1
- package/dist/cli/commands/deploy.js +323 -77
- package/dist/cli/commands/dev.js +24 -22
- package/dist/cli/commands/fire.js +15 -16
- package/dist/cli/commands/info.js +36 -29
- package/dist/cli/commands/init.d.ts +1 -1
- package/dist/cli/commands/init.js +65 -53
- package/dist/cli/commands/invoke.js +9 -6
- package/dist/cli/commands/login.js +35 -21
- package/dist/cli/commands/schedule.js +6 -8
- package/dist/cli/commands/start.js +93 -37
- package/dist/cli/commands/tool.js +22 -18
- package/dist/cli/fail.d.ts +17 -0
- package/dist/cli/fail.js +24 -0
- package/dist/cli/program.js +57 -36
- package/dist/cli/serve.d.ts +26 -6
- package/dist/cli/serve.js +62 -15
- package/dist/cli/shared.d.ts +15 -2
- package/dist/cli/shared.js +30 -18
- package/dist/deploy/agentcore/plan.d.ts +117 -0
- package/dist/deploy/agentcore/plan.js +721 -0
- package/dist/deploy/agentcore/run.d.ts +73 -0
- package/dist/deploy/agentcore/run.js +412 -0
- package/dist/deploy/agentcore/zip.d.ts +17 -0
- package/dist/deploy/agentcore/zip.js +68 -0
- package/dist/deploy/container.d.ts +26 -25
- package/dist/deploy/container.js +93 -89
- package/dist/deploy/docker/plan.d.ts +1 -1
- package/dist/deploy/docker/plan.js +12 -17
- package/dist/deploy/fly/plan.d.ts +2 -0
- package/dist/deploy/fly/plan.js +27 -19
- package/dist/deploy/fly/run.d.ts +12 -1
- package/dist/deploy/fly/run.js +36 -2
- package/dist/deploy/preflight.d.ts +11 -5
- package/dist/deploy/preflight.js +217 -65
- package/dist/deploy/railway/plan.d.ts +7 -0
- package/dist/deploy/railway/plan.js +41 -16
- package/dist/deploy/railway/run.d.ts +8 -1
- package/dist/deploy/railway/run.js +7 -2
- package/dist/deploy/runner.d.ts +5 -2
- package/dist/deploy/runner.js +9 -3
- package/dist/dev-supervisor.d.ts +11 -8
- package/dist/dev-supervisor.js +53 -51
- package/dist/engines/pi/auth.d.ts +8 -7
- package/dist/engines/pi/auth.js +12 -10
- package/dist/engines/pi/channel.d.ts +1 -1
- package/dist/engines/pi/channel.js +5 -5
- package/dist/engines/pi/chat.js +2 -2
- package/dist/engines/pi/config.d.ts +6 -46
- package/dist/engines/pi/config.js +21 -108
- package/dist/engines/pi/create.d.ts +8 -8
- package/dist/engines/pi/create.js +13 -12
- package/dist/engines/pi/definition.d.ts +7 -26
- package/dist/engines/pi/definition.js +8 -54
- package/dist/engines/pi/login.d.ts +1 -1
- package/dist/engines/pi/models.d.ts +3 -3
- package/dist/engines/pi/models.js +1 -1
- package/dist/engines/pi/{workspace.d.ts → open.d.ts} +29 -23
- package/dist/engines/pi/{workspace.js → open.js} +27 -29
- package/dist/engines/pi/session-builder.d.ts +2 -2
- package/dist/engines/pi/session-builder.js +11 -11
- package/dist/engines/pi/tool.js +4 -0
- package/dist/env.d.ts +16 -4
- package/dist/env.js +43 -5
- package/dist/host/node.d.ts +2 -2
- package/dist/loader.d.ts +2 -2
- package/dist/loader.js +3 -3
- package/dist/log.d.ts +1 -1
- package/dist/log.js +1 -1
- package/dist/paths.d.ts +138 -0
- package/dist/paths.js +326 -0
- package/dist/pi.d.ts +1 -1
- package/dist/pi.js +2 -2
- package/dist/runtime.d.ts +7 -5
- package/dist/runtime.js +2 -2
- package/dist/scaffold/add-channel.d.ts +7 -3
- package/dist/scaffold/add-channel.js +55 -29
- package/dist/scaffold/init.d.ts +32 -41
- package/dist/scaffold/init.js +161 -185
- package/dist/scaffold/templates/env.example +15 -6
- package/dist/scaffold/templates/fastagent.config.mjs +1 -1
- package/dist/scaffold/templates/gitignore +14 -6
- package/dist/scaffold/templates/persona.md +4 -2
- package/dist/scaffold/templates/secrets.gitignore +5 -0
- package/dist/scaffold/templates.d.ts +1 -7
- package/dist/scaffold/templates.js +3 -25
- package/dist/scaffold/vendor-skill.d.ts +2 -2
- package/dist/scaffold/vendor-skill.js +13 -13
- package/dist/schedule/discover.js +4 -4
- package/dist/schedule/scheduler.d.ts +40 -1
- package/dist/schedule/scheduler.js +89 -56
- package/dist/schedule/state.js +1 -1
- package/dist/schedule/wake-alarm.d.ts +47 -0
- package/dist/schedule/wake-alarm.js +136 -0
- package/dist/schedule/wakeups.d.ts +1 -0
- package/dist/schedule/wakeups.js +18 -0
- package/dist/tunnel.d.ts +3 -3
- package/dist/tunnel.js +7 -7
- package/package.json +4 -1
- package/dist/channels/feishu/owned-threads.d.ts +0 -7
- package/dist/channels/feishu/owned-threads.js +0 -47
- package/dist/channels/slack/owned-threads.d.ts +0 -6
- package/dist/channels/slack/owned-threads.js +0 -43
- package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
- package/dist/scaffold/templates/gitignore.kit +0 -2
- package/dist/workspace.d.ts +0 -9
- package/dist/workspace.js +0 -45
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SHARED: who the agent has HEARD in a group thread — the sole input to the participant model's summon
|
|
3
|
+
* rule. **The derivation lives in docs/design/participant-model.md §3** (why the rule is defined over
|
|
4
|
+
* observation rather than the thread's true membership, what the weaker claim costs in both
|
|
5
|
+
* directions, and why recording is never gated on configuration). Repeating it here would mean two
|
|
6
|
+
* copies to keep true; what follows is only what a caller of this module must not get wrong.
|
|
7
|
+
*
|
|
8
|
+
* - **The key MUST be the string the session uses.** A record here is a claim about that session's
|
|
9
|
+
* memory, so the two cannot be keyed independently — including the channel brand
|
|
10
|
+
* (`feishu:<chat>:<thread>`, `slack:<team>:<channel>:<thread_ts>`). This file is already per-channel
|
|
11
|
+
* and would not need the prefix, but SESSION ids share one namespace across every channel in a
|
|
12
|
+
* deployment, so dropping it would key participation to a session that is not the one that answered.
|
|
13
|
+
* - **Write both halves under one condition**, and gate that condition on STRUCTURAL facts only (is
|
|
14
|
+
* this a group? a thread? a human speaking?) — never on configuration, which changes while records
|
|
15
|
+
* outlive the change.
|
|
16
|
+
* - **Observations only accumulate.** Nothing here sheds: no platform signals that someone stopped
|
|
17
|
+
* taking part, and the error directions are not symmetric — over-counting humans makes the agent ask
|
|
18
|
+
* to be named, under-counting makes it speak into a crowd.
|
|
19
|
+
* - Keyed by `thread_id`, never a reply-chain root: Feishu's `root_id` moves with the chain, so it
|
|
20
|
+
* cannot identify a side conversation at all.
|
|
21
|
+
*/
|
|
22
|
+
import { log } from "../log.js";
|
|
23
|
+
import { loadStateFile, saveStateFile } from "./state.js";
|
|
24
|
+
/** Cap on remembered threads. Losing one costs a mention to re-enter that thread, so an unbounded file
|
|
25
|
+
* buys little — and a merge that carries new information rewrites the whole map synchronously, so the
|
|
26
|
+
* map's size is the cost of every such write. What keeps that bounded is that only NEW information
|
|
27
|
+
* writes at all (a repeat speaker, or any message once MAX_HUMANS is reached, returns before
|
|
28
|
+
* persisting), and that the map stays small enough for state.ts's premise to hold — these writes land
|
|
29
|
+
* synchronously on the acceptance path, so the whole file is the cost of each one. A thousand records
|
|
30
|
+
* is tens of KB, and the records that matter (threads the agent takes part in) are far fewer than
|
|
31
|
+
* that; the dominant traffic is bystanders, which is what the eviction policy below is aimed at.
|
|
32
|
+
*
|
|
33
|
+
* Eviction prefers BYSTANDER threads — ones the agent has only listened to. They are written on the
|
|
34
|
+
* same path and vastly outnumber the rest (every thread in every visible channel), yet losing one
|
|
35
|
+
* costs nothing: the summon rule refuses a thread the agent has not spoken in anyway, so the record
|
|
36
|
+
* would have to be rebuilt by the mention that admits it. Evicting purely by age would let this
|
|
37
|
+
* traffic push out the threads the agent is actively serving, silently reverting them to
|
|
38
|
+
* mention-only. This is also what makes it safe to record threads no rule currently reads (those
|
|
39
|
+
* behind a custom route, or under a posture whose summon rule is off — see the header). */
|
|
40
|
+
const MAX_THREADS = 1000;
|
|
41
|
+
/** Cap on remembered humans per thread. The rule only asks "have I heard a second one?", so two is
|
|
42
|
+
* already the whole answer and anything beyond it is weight nothing reads. */
|
|
43
|
+
const MAX_HUMANS = 2;
|
|
44
|
+
function isStoredParticipation(value) {
|
|
45
|
+
const record = value;
|
|
46
|
+
return (Array.isArray(record?.humans) &&
|
|
47
|
+
record.humans.every((human) => typeof human === "string") &&
|
|
48
|
+
typeof record.agentSpoke === "boolean");
|
|
49
|
+
}
|
|
50
|
+
export function createThreadParticipants(path, label) {
|
|
51
|
+
const raw = loadStateFile(path);
|
|
52
|
+
const records = new Map();
|
|
53
|
+
if (raw !== undefined) {
|
|
54
|
+
if (typeof raw === "object" &&
|
|
55
|
+
raw !== null &&
|
|
56
|
+
!Array.isArray(raw) &&
|
|
57
|
+
Object.values(raw).every(isStoredParticipation)) {
|
|
58
|
+
for (const [key, record] of Object.entries(raw)) {
|
|
59
|
+
records.set(key, { humans: record.humans, agentSpoke: record.agentSpoke });
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
else {
|
|
63
|
+
log.warn(`${label} unexpected shape in ${path} — starting with no thread participation`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
admitsBareMessage(key) {
|
|
68
|
+
const heard = records.get(key);
|
|
69
|
+
return heard?.agentSpoke === true && heard.humans.length <= 1;
|
|
70
|
+
},
|
|
71
|
+
merge(key, heard) {
|
|
72
|
+
const previous = records.get(key);
|
|
73
|
+
const humans = new Set(previous?.humans ?? []);
|
|
74
|
+
for (const human of heard.humans ?? []) {
|
|
75
|
+
if (humans.size >= MAX_HUMANS)
|
|
76
|
+
break;
|
|
77
|
+
humans.add(human);
|
|
78
|
+
}
|
|
79
|
+
const next = {
|
|
80
|
+
humans: [...humans],
|
|
81
|
+
agentSpoke: (previous?.agentSpoke ?? false) || (heard.agentSpoke ?? false),
|
|
82
|
+
};
|
|
83
|
+
// `humans` starts from `previous` and only grows, so equal size IS set equality here.
|
|
84
|
+
const unchanged = previous !== undefined &&
|
|
85
|
+
previous.agentSpoke === next.agentSpoke &&
|
|
86
|
+
previous.humans.length === next.humans.length;
|
|
87
|
+
// Re-insert so insertion order is "least recently TOUCHED first" — including when nothing
|
|
88
|
+
// changed. A thread in its steady state (the agent answers, the same person keeps talking) stops
|
|
89
|
+
// carrying new information and would otherwise never refresh its position again, leaving the
|
|
90
|
+
// thread being served right now at the head of the eviction order.
|
|
91
|
+
//
|
|
92
|
+
// PROCESS-LOCAL: a touch alone never writes, and such a thread has nothing left to write (it has
|
|
93
|
+
// reached MAX_HUMANS), so the refreshed order survives only until restart. That is the right
|
|
94
|
+
// trade for a cache — persisting recency would mean a whole-map write per message — and the
|
|
95
|
+
// consequence is bounded: after a restart, eviction order among participant threads is the order
|
|
96
|
+
// they last carried new information.
|
|
97
|
+
records.delete(key);
|
|
98
|
+
records.set(key, next);
|
|
99
|
+
if (unchanged)
|
|
100
|
+
return;
|
|
101
|
+
while (records.size > MAX_THREADS) {
|
|
102
|
+
// Oldest bystander first; only when every record is a thread the agent takes part in does age
|
|
103
|
+
// alone decide. NEVER the key just merged: it is the most recently touched record, so evicting
|
|
104
|
+
// it contradicts the recency policy re-established above — and worse, a channel that records a
|
|
105
|
+
// thread's humans and its own participation in two steps would then write the second half onto
|
|
106
|
+
// an empty record, producing "answered here, heard nobody" and a permanent bare-message admit.
|
|
107
|
+
let evict;
|
|
108
|
+
for (const [candidate, record] of records) {
|
|
109
|
+
if (candidate === key)
|
|
110
|
+
continue;
|
|
111
|
+
if (!record.agentSpoke) {
|
|
112
|
+
evict = candidate;
|
|
113
|
+
break;
|
|
114
|
+
}
|
|
115
|
+
evict ??= candidate;
|
|
116
|
+
}
|
|
117
|
+
if (evict === undefined)
|
|
118
|
+
break;
|
|
119
|
+
records.delete(evict);
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
saveStateFile(path, Object.fromEntries(records));
|
|
123
|
+
}
|
|
124
|
+
catch (error) {
|
|
125
|
+
// Cache only: memory stays correct for this process, and the whole map is rewritten on the
|
|
126
|
+
// next successful merge — so a failed write costs durability only until then, and a thread
|
|
127
|
+
// whose record is lost simply needs a mention to re-enter.
|
|
128
|
+
log.warn(`${label} could not persist thread participation ${path}: ${String(error)}`);
|
|
129
|
+
}
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
* backend — an external queue with distributed locking (SPEC §11) — not this in-memory queue.
|
|
12
12
|
*/
|
|
13
13
|
import { log } from "../log.js";
|
|
14
|
+
import { beginWork } from "./busy.js";
|
|
14
15
|
export function createTurnQueue(opts) {
|
|
15
16
|
const { label, run, onQueuedBehind } = opts;
|
|
16
17
|
// Per-session serial chains: a second turn for the same session waits its turn (FIFO) instead of
|
|
@@ -20,6 +21,11 @@ export function createTurnQueue(opts) {
|
|
|
20
21
|
accept(rec) {
|
|
21
22
|
if (chains.has(rec.session))
|
|
22
23
|
onQueuedBehind?.(rec);
|
|
24
|
+
// Process-wide busy signal (busy.ts): an accepted turn counts as in-flight work from acceptance
|
|
25
|
+
// until its run settles — serving surfaces that must not idle mid-turn (the AgentCore adapter's
|
|
26
|
+
// /ping) read it. Counted here, not in run(), so a QUEUED turn (accepted, waiting on the chain)
|
|
27
|
+
// already reads as busy.
|
|
28
|
+
const workDone = beginWork();
|
|
23
29
|
const prev = chains.get(rec.session) ?? Promise.resolve();
|
|
24
30
|
const task = async () => {
|
|
25
31
|
try {
|
|
@@ -32,6 +38,7 @@ export function createTurnQueue(opts) {
|
|
|
32
38
|
const next = prev.then(task, task); // run after this session's previous turn, in arrival order
|
|
33
39
|
chains.set(rec.session, next);
|
|
34
40
|
void next.finally(() => {
|
|
41
|
+
workDone();
|
|
35
42
|
if (chains.get(rec.session) === next)
|
|
36
43
|
chains.delete(rec.session); // drop the entry when drained
|
|
37
44
|
});
|
package/dist/cli/add-feishu.d.ts
CHANGED
|
@@ -18,10 +18,13 @@ export declare function configureGroupBehavior(input: {
|
|
|
18
18
|
openUrl?: (url: string) => void;
|
|
19
19
|
}): Promise<GroupBehaviorSetup>;
|
|
20
20
|
/**
|
|
21
|
-
* Create or resume the platform app behind `add feishu` / `add lark`.
|
|
21
|
+
* Create or resume the platform app behind `add feishu` / `add lark`. `target` is the AGENT DIR;
|
|
22
|
+
* credentials land in its `.env` — whose real path is {@link dotEnvPath}, printed rather than spelled,
|
|
23
|
+
* because `FASTAGENT_SECRETS_DIR` moves it and a hardcoded `.secrets/.env` would name a file this run
|
|
24
|
+
* did not write. Returns credentials for the
|
|
22
25
|
* caller's generic .env write (the guided Lark path), or undefined when nothing remains to write —
|
|
23
26
|
* the feishu path persists its own two credential stages internally (the App ID/Secret boundary is
|
|
24
|
-
* irreversible and must not wait for the caller). Throws on refusal (a
|
|
25
|
-
*
|
|
27
|
+
* irreversible and must not wait for the caller). Throws on refusal (a non-interactive lark run);
|
|
28
|
+
* the caller surfaces that as a startup failure.
|
|
26
29
|
*/
|
|
27
|
-
export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "lark",
|
|
30
|
+
export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "lark", ingress?: FeishuSubscriptionMode, groupBehavior?: GroupBehaviorChoice): Promise<Record<string, string> | undefined>;
|
package/dist/cli/add-feishu.js
CHANGED
|
@@ -10,15 +10,14 @@
|
|
|
10
10
|
* for WebSocket or probes the same webhook/token bootstrap with the config-route-404 manual fallback.
|
|
11
11
|
*/
|
|
12
12
|
import { readFile } from "node:fs/promises";
|
|
13
|
-
import { join } from "node:path";
|
|
14
13
|
import { isCancel, log as clackLog, password, text as clackText } from "@clack/prompts";
|
|
15
14
|
import { bootstrapFeishuVerificationToken } from "../channels/feishu/bootstrap-token.js";
|
|
16
|
-
import { FEISHU_GROUP_CONTEXT_SCOPE, } from "../channels/feishu/setup-mode.js";
|
|
15
|
+
import { FEISHU_GROUP_CONTEXT_SCOPE, FEISHU_CONTEXT_ONBOARDING_SCOPES, FEISHU_MESSAGE_READ_SCOPE, scopeSatisfied, } from "../channels/feishu/setup-mode.js";
|
|
17
16
|
import { cloudFor } from "../channels/feishu/cloud.js";
|
|
18
17
|
import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError, } from "../channels/feishu/feishu-api.js";
|
|
19
18
|
import { registerFeishuApp } from "../channels/feishu/register-app.js";
|
|
20
19
|
import { onboardLarkApp } from "../channels/lark/onboard.js";
|
|
21
|
-
import { parseEnvContent } from "../env.js";
|
|
20
|
+
import { dotEnvPath, parseEnvContent } from "../env.js";
|
|
22
21
|
import { openExternalUrl } from "../open-url.js";
|
|
23
22
|
import { appendChannelDotEnv } from "../scaffold/add-channel.js";
|
|
24
23
|
import { startCloudflareTunnel } from "../tunnel.js";
|
|
@@ -36,7 +35,17 @@ export async function configureGroupBehavior(input) {
|
|
|
36
35
|
inspected = false;
|
|
37
36
|
scopes = [];
|
|
38
37
|
}
|
|
39
|
-
const
|
|
38
|
+
const granted = (name) => scopes.some((scope) => scope.name === name && scope.grantStatus === 1 && (scope.type === undefined || scope.type === "tenant"));
|
|
39
|
+
// Same type filter as `granted`: a user-type entry is not the tenant scope this path needs, so
|
|
40
|
+
// treating one as "on the app" would report an approval that can never arrive and skip the PATCH
|
|
41
|
+
// that would actually add it.
|
|
42
|
+
const onApp = (name) => scopes.some((scope) => scope.name === name && (scope.type === undefined || scope.type === "tenant"));
|
|
43
|
+
// Both halves of the recommended path: delivery (the platform pushes un-mentioned group messages)
|
|
44
|
+
// and reading a quoted message (so a thread's opening ask carries what it replies to).
|
|
45
|
+
// The read capability has two spellings and `im:message` is the superset, so an app holding it needs
|
|
46
|
+
// nothing added — asking anyway would cost the author a second tenant-admin approval round.
|
|
47
|
+
const missing = FEISHU_CONTEXT_ONBOARDING_SCOPES.filter((entry) => !scopeSatisfied(entry, granted));
|
|
48
|
+
const missingNames = missing.map((entry) => entry.request);
|
|
40
49
|
if (behavior === "mentions") {
|
|
41
50
|
if (!inspected) {
|
|
42
51
|
const permissionUrl = `${apiBase}/app/${encodeURIComponent(appId)}/permission`;
|
|
@@ -44,60 +53,70 @@ export async function configureGroupBehavior(input) {
|
|
|
44
53
|
openUrl(permissionUrl);
|
|
45
54
|
return { publishReady: false };
|
|
46
55
|
}
|
|
47
|
-
if (
|
|
56
|
+
if (granted(FEISHU_GROUP_CONTEXT_SCOPE)) {
|
|
48
57
|
const permissionUrl = `${apiBase}/app/${encodeURIComponent(appId)}/permission`;
|
|
49
58
|
note(`[fastagent] warn: mention-only was selected, but ${FEISHU_GROUP_CONTEXT_SCOPE} is already granted — remove it before publishing a new version to restore least-privilege platform delivery. Opening ${permissionUrl}`);
|
|
50
59
|
openUrl(permissionUrl);
|
|
51
60
|
return { publishReady: false };
|
|
52
61
|
}
|
|
53
|
-
note(`[fastagent] group behavior: mention-only — bare
|
|
62
|
+
note(`[fastagent] group behavior: mention-only — bare replies in the Agent's threads and group context buffering are disabled. ` +
|
|
63
|
+
`Add ${FEISHU_MESSAGE_READ_SCOPE} by hand if you want an @mention to carry the message it quotes`);
|
|
54
64
|
return { publishReady: true };
|
|
55
65
|
}
|
|
56
66
|
note(`[fastagent] group behavior: context-aware (recommended) — ${kind} will deliver all group messages; ` +
|
|
57
|
-
`FastAgent invokes @Agent
|
|
58
|
-
|
|
59
|
-
|
|
67
|
+
`FastAgent invokes @Agent, answers bare replies in threads it takes part in, and durably buffers ` +
|
|
68
|
+
`other discussion. ${FEISHU_GROUP_CONTEXT_SCOPE} (delivery) is required; ${FEISHU_MESSAGE_READ_SCOPE} ` +
|
|
69
|
+
`(reading a quoted message) is requested with it — without it a quoted message degrades to a marker`);
|
|
70
|
+
if (missing.length === 0) {
|
|
71
|
+
note(`[fastagent] ${FEISHU_CONTEXT_ONBOARDING_SCOPES.map((entry) => entry.request).join(" + ")} are already granted`);
|
|
60
72
|
return { publishReady: true };
|
|
61
73
|
}
|
|
62
74
|
const permissionUrl = `${apiBase}/app/${encodeURIComponent(appId)}/permission`;
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
// A missing scope is in one of two states, and they need different actions: already on the app but
|
|
76
|
+
// not yet approved (nothing to add — wait for the admin), or absent from the draft entirely.
|
|
77
|
+
// Same superset rule as `missing`: a draft already requesting `im:message` is awaiting approval, not
|
|
78
|
+
// missing something to add.
|
|
79
|
+
const awaitingApproval = missing.filter((entry) => scopeSatisfied(entry, onApp)).map((entry) => entry.request);
|
|
80
|
+
const toRequest = missing.filter((entry) => !scopeSatisfied(entry, onApp)).map((entry) => entry.request);
|
|
81
|
+
if (toRequest.length === 0) {
|
|
82
|
+
note(`[fastagent] ${awaitingApproval.join(" + ")} awaiting approval — complete tenant-admin approval before publishing. Opening ${permissionUrl}`);
|
|
65
83
|
openUrl(permissionUrl);
|
|
66
84
|
return { publishReady: false };
|
|
67
85
|
}
|
|
68
86
|
if (!explicit) {
|
|
69
87
|
// Defaulted, not chosen: report the gap and how to opt in, but leave the app's requested
|
|
70
88
|
// permission set untouched (a scripted re-run must not silently escalate a mention-only app).
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
89
|
+
// Name what is ACTUALLY missing: the common upgrade has the delivery scope already granted and
|
|
90
|
+
// only the read scope absent, and pointing at a granted permission sends the author looking in
|
|
91
|
+
// the wrong place.
|
|
92
|
+
note(`[fastagent] ${missingNames.join(" + ")} not granted (or could not be verified) — group behavior was ` +
|
|
93
|
+
`defaulted, so nothing was requested. Re-run with --group-behavior context to add ${missing.length > 1 ? "them" : "it"} ` +
|
|
94
|
+
`to the app draft, or --group-behavior mentions to stay least-privilege: ${permissionUrl}`);
|
|
74
95
|
return { publishReady: false };
|
|
75
96
|
}
|
|
76
97
|
try {
|
|
77
|
-
await api.addAppScopes(appId,
|
|
78
|
-
note(`[fastagent] added ${
|
|
98
|
+
await api.addAppScopes(appId, toRequest);
|
|
99
|
+
note(`[fastagent] added ${toRequest.join(" + ")} to the app draft — complete tenant-admin approval before publishing. Opening ${permissionUrl}`);
|
|
79
100
|
}
|
|
80
101
|
catch (error) {
|
|
81
|
-
note(`[fastagent] warn: could not add ${
|
|
102
|
+
note(`[fastagent] warn: could not add ${toRequest.join(" + ")} automatically: ${String(error)} — add it manually before publishing. Opening ${permissionUrl}`);
|
|
82
103
|
}
|
|
83
104
|
openUrl(permissionUrl);
|
|
84
105
|
return { publishReady: false };
|
|
85
106
|
}
|
|
86
107
|
/**
|
|
87
|
-
* Create or resume the platform app behind `add feishu` / `add lark`.
|
|
108
|
+
* Create or resume the platform app behind `add feishu` / `add lark`. `target` is the AGENT DIR;
|
|
109
|
+
* credentials land in its `.env` — whose real path is {@link dotEnvPath}, printed rather than spelled,
|
|
110
|
+
* because `FASTAGENT_SECRETS_DIR` moves it and a hardcoded `.secrets/.env` would name a file this run
|
|
111
|
+
* did not write. Returns credentials for the
|
|
88
112
|
* caller's generic .env write (the guided Lark path), or undefined when nothing remains to write —
|
|
89
113
|
* the feishu path persists its own two credential stages internally (the App ID/Secret boundary is
|
|
90
|
-
* irreversible and must not wait for the caller). Throws on refusal (a
|
|
91
|
-
*
|
|
114
|
+
* irreversible and must not wait for the caller). Throws on refusal (a non-interactive lark run);
|
|
115
|
+
* the caller surfaces that as a startup failure.
|
|
92
116
|
*/
|
|
93
|
-
export async function onboardFeishuCloudApp(target, kind,
|
|
117
|
+
export async function onboardFeishuCloudApp(target, kind, ingress = "webhook", groupBehavior = { behavior: "context", explicit: false }) {
|
|
118
|
+
const env = dotEnvPath(target); // the file actually written — never the default spelling
|
|
94
119
|
const { envPrefix, apiBase, capabilities } = cloudFor(kind);
|
|
95
|
-
// The CLI must never materialize a real credential into a committable file — refuse, don't warn.
|
|
96
|
-
if (!envIgnored) {
|
|
97
|
-
throw new Error(kind === "feishu"
|
|
98
|
-
? "`add feishu` creates an app and writes real credentials to .env — add .env to .gitignore/.fastagentignore first, then re-run"
|
|
99
|
-
: "`add lark` writes real app credentials to .env — add .env to .gitignore/.fastagentignore first, then re-run");
|
|
100
|
-
}
|
|
101
120
|
const requiredNames = [
|
|
102
121
|
`${envPrefix}_APP_ID`,
|
|
103
122
|
`${envPrefix}_APP_SECRET`,
|
|
@@ -105,7 +124,7 @@ export async function onboardFeishuCloudApp(target, kind, envIgnored, ingress =
|
|
|
105
124
|
];
|
|
106
125
|
const existing = await activeDotEnvValues(target, requiredNames);
|
|
107
126
|
if (Object.keys(existing).length === requiredNames.length) {
|
|
108
|
-
console.error(`[fastagent] ${requiredNames.join("/")} already set in
|
|
127
|
+
console.error(`[fastagent] ${requiredNames.join("/")} already set in ${env} — keeping them`);
|
|
109
128
|
// WebSocket still needs its console mode/publish guidance. A complete webhook can skip the rest of
|
|
110
129
|
// onboarding, but group visibility must still be inspected/configured on every explicit re-run.
|
|
111
130
|
if (ingress === "webhook") {
|
|
@@ -200,13 +219,14 @@ export async function onboardFeishuCloudApp(target, kind, envIgnored, ingress =
|
|
|
200
219
|
* then probes the canonical token/mode bootstrap with a manual fallback.
|
|
201
220
|
*/
|
|
202
221
|
async function createFeishuAppFlow(target, existing, ingress, groupBehavior) {
|
|
222
|
+
const env = dotEnvPath(target); // the file actually written — never the default spelling
|
|
203
223
|
const { apiBase } = cloudFor("feishu");
|
|
204
224
|
let appId = existing.FEISHU_APP_ID;
|
|
205
225
|
let appSecret = existing.FEISHU_APP_SECRET;
|
|
206
226
|
if (appId && appSecret) {
|
|
207
227
|
console.error(ingress === "webhook"
|
|
208
|
-
? `[fastagent] resuming Feishu app ${appId} from
|
|
209
|
-
: `[fastagent] reusing Feishu app ${appId} from
|
|
228
|
+
? `[fastagent] resuming Feishu app ${appId} from ${env} to capture its missing Verification Token`
|
|
229
|
+
: `[fastagent] reusing Feishu app ${appId} from ${env} for WebSocket ingress`);
|
|
210
230
|
}
|
|
211
231
|
else {
|
|
212
232
|
console.error(`[fastagent] creating the Feishu app (confirm in the app)…`);
|
|
@@ -247,8 +267,8 @@ async function createFeishuAppFlow(target, existing, ingress, groupBehavior) {
|
|
|
247
267
|
};
|
|
248
268
|
await appendChannelDotEnv(target, "feishu", staged, Object.keys(staged), ingress);
|
|
249
269
|
console.error(ingress === "webhook"
|
|
250
|
-
? `[fastagent] wrote FEISHU_APP_ID, FEISHU_APP_SECRET to
|
|
251
|
-
: `[fastagent] wrote FEISHU_APP_ID, FEISHU_APP_SECRET to
|
|
270
|
+
? `[fastagent] wrote FEISHU_APP_ID, FEISHU_APP_SECRET to ${env} before Token bootstrap`
|
|
271
|
+
: `[fastagent] wrote FEISHU_APP_ID, FEISHU_APP_SECRET to ${env}`);
|
|
252
272
|
}
|
|
253
273
|
const groupSetup = await configureGroupBehavior({
|
|
254
274
|
kind: "feishu",
|
|
@@ -308,10 +328,10 @@ async function createFeishuAppFlow(target, existing, ingress, groupBehavior) {
|
|
|
308
328
|
// Persist the second credential stage immediately too — opening the publish page and generic
|
|
309
329
|
// scaffold finalization happen only after the complete runtime credential set is durable.
|
|
310
330
|
const staged = await appendChannelDotEnv(target, "feishu", { [tokenVar]: token }, [tokenVar]);
|
|
311
|
-
console.error(`[fastagent] wrote ${staged.written.join(", ")} to
|
|
331
|
+
console.error(`[fastagent] wrote ${staged.written.join(", ")} to ${env}`);
|
|
312
332
|
}
|
|
313
333
|
else {
|
|
314
|
-
console.error(`[fastagent] copy it manually: developer console → Events & Callbacks → Encryption Strategy → Verification Token → ${tokenVar} in
|
|
334
|
+
console.error(`[fastagent] copy it manually: developer console → Events & Callbacks → Encryption Strategy → Verification Token → ${tokenVar} in ${env}`);
|
|
315
335
|
}
|
|
316
336
|
if (webhookModeChanged) {
|
|
317
337
|
// The bootstrap's PATCH flipped event mode in the DRAFT. It takes effect only after a version
|
|
@@ -326,12 +346,12 @@ async function createFeishuAppFlow(target, existing, ingress, groupBehavior) {
|
|
|
326
346
|
}
|
|
327
347
|
}
|
|
328
348
|
}
|
|
329
|
-
/** Active
|
|
330
|
-
* check can never disagree with what `loadEnvFile` reads. Empty/commented values are absent. */
|
|
349
|
+
/** Active agent `.env` (`.secrets/.env`) values for the requested names — decided by THE .env
|
|
350
|
+
* parser, so this check can never disagree with what `loadEnvFile` reads. Empty/commented values are absent. */
|
|
331
351
|
async function activeDotEnvValues(dir, names) {
|
|
332
352
|
let content;
|
|
333
353
|
try {
|
|
334
|
-
content = await readFile(
|
|
354
|
+
content = await readFile(dotEnvPath(dir), "utf8");
|
|
335
355
|
}
|
|
336
356
|
catch (e) {
|
|
337
357
|
if (e.code === "ENOENT")
|
package/dist/cli/add-slack.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type GroupBehaviorChoice } from "../scaffold/add-channel.ts";
|
|
2
2
|
/** Interactive single-workspace internal-app creation + installation. Safe to re-run after interruption. */
|
|
3
3
|
export declare function onboardSlackInternalApp(input: {
|
|
4
|
+
/** The AGENT DIR — credentials land in its `.env` ({@link dotEnvPath}: `FASTAGENT_SECRETS_DIR` moves
|
|
5
|
+
* it, so messages print the resolved path rather than the default spelling). */
|
|
4
6
|
target: string;
|
|
5
7
|
stateRoot: string;
|
|
6
|
-
envIgnored: boolean;
|
|
7
8
|
groupBehavior: GroupBehaviorChoice;
|
|
8
9
|
/** `--replace-config`: go straight to replacing the local App Configuration token pair. */
|
|
9
10
|
replaceConfig?: boolean;
|
package/dist/cli/add-slack.js
CHANGED
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { basename
|
|
2
|
+
import { basename } from "node:path";
|
|
3
3
|
import { isCancel, log as clackLog, password, select, text as clackText } from "@clack/prompts";
|
|
4
4
|
import { waitForHealth } from "../channels/wait-health.js";
|
|
5
|
-
import {
|
|
6
|
-
import { parseEnvContent } from "../env.js";
|
|
5
|
+
import { dotEnvPath, parseEnvContent } from "../env.js";
|
|
7
6
|
import { openExternalUrl } from "../open-url.js";
|
|
8
7
|
import { installProxyFetch } from "../proxy.js";
|
|
9
8
|
import { appendChannelDotEnv } from "../scaffold/add-channel.js";
|
|
@@ -24,15 +23,10 @@ async function promptValue(message, hidden = false, initialValue) {
|
|
|
24
23
|
/** Interactive single-workspace internal-app creation + installation. Safe to re-run after interruption. */
|
|
25
24
|
export async function onboardSlackInternalApp(input) {
|
|
26
25
|
installProxyFetch();
|
|
27
|
-
if (!input.envIgnored) {
|
|
28
|
-
throw new Error("`add slack` onboarding creates an app and writes real credentials to .env — " +
|
|
29
|
-
"add .env to .gitignore/.fastagentignore first, then re-run");
|
|
30
|
-
}
|
|
31
26
|
if (!(process.stdin.isTTY && process.stdout.isTTY)) {
|
|
32
27
|
throw new Error("`add slack` needs an interactive terminal for internal-app creation and OAuth — " +
|
|
33
28
|
"re-run in a terminal, or pass --no-onboard to scaffold only");
|
|
34
29
|
}
|
|
35
|
-
await ensureStateRootSelfIgnored(input.target, input.stateRoot);
|
|
36
30
|
let state = await readSlackOnboardingState(input.stateRoot);
|
|
37
31
|
const resumed = state !== undefined;
|
|
38
32
|
if (input.replaceConfig && !state) {
|
|
@@ -40,7 +34,7 @@ export async function onboardSlackInternalApp(input) {
|
|
|
40
34
|
"Run `fastagent add slack` to onboard, or update the Request URL manually in the Slack console");
|
|
41
35
|
}
|
|
42
36
|
if (state?.installedAt) {
|
|
43
|
-
const env = await readFile(
|
|
37
|
+
const env = await readFile(dotEnvPath(input.target), "utf8")
|
|
44
38
|
.then(parseEnvContent)
|
|
45
39
|
.catch((error) => {
|
|
46
40
|
if (error.code === "ENOENT")
|
|
@@ -56,7 +50,8 @@ export async function onboardSlackInternalApp(input) {
|
|
|
56
50
|
"SLACK_SIGNING_SECRET",
|
|
57
51
|
].filter((name) => !((process.env[name] ?? env.get(name))?.trim() ?? ""));
|
|
58
52
|
if (missingRuntime.length > 0) {
|
|
59
|
-
throw new Error(`Slack app ${state.appId ?? "(unknown)"} is installed but
|
|
53
|
+
throw new Error(`Slack app ${state.appId ?? "(unknown)"} is installed but ${dotEnvPath(input.target)} is missing ` +
|
|
54
|
+
`${missingRuntime.join(", ")} — ` +
|
|
60
55
|
"restore them from the Slack app console, or delete the app + onboarding state and create a new one");
|
|
61
56
|
}
|
|
62
57
|
if (input.groupBehavior.explicit && state.groupBehavior !== input.groupBehavior.behavior) {
|
|
@@ -193,7 +188,7 @@ export async function onboardSlackInternalApp(input) {
|
|
|
193
188
|
}
|
|
194
189
|
},
|
|
195
190
|
});
|
|
196
|
-
console.error(
|
|
191
|
+
console.error(`[fastagent] Slack app installed; rotating bot credentials and Signing Secret written to ${dotEnvPath(input.target)}`);
|
|
197
192
|
console.error(`[fastagent] run \`fastagent dev --tunnel\` next — FastAgent will rotate the config token and ` +
|
|
198
193
|
"replace the temporary Events API URL automatically");
|
|
199
194
|
}
|