@fastagent-sh/fastagent 0.15.0 → 0.16.1

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 (181) hide show
  1. package/README.md +14 -4
  2. package/dist/bind.d.ts +34 -0
  3. package/dist/bind.js +74 -0
  4. package/dist/channels/agentcore-limits.d.ts +7 -0
  5. package/dist/channels/agentcore-limits.js +9 -0
  6. package/dist/channels/agentcore-state.d.ts +83 -0
  7. package/dist/channels/agentcore-state.js +266 -0
  8. package/dist/channels/agentcore.d.ts +98 -0
  9. package/dist/channels/agentcore.js +260 -0
  10. package/dist/channels/busy.d.ts +23 -0
  11. package/dist/channels/busy.js +53 -0
  12. package/dist/channels/context-buffer.d.ts +5 -0
  13. package/dist/channels/context-buffer.js +5 -0
  14. package/dist/channels/feishu/context-buffer.d.ts +13 -8
  15. package/dist/channels/feishu/context-buffer.js +60 -6
  16. package/dist/channels/feishu/feishu-api.js +4 -1
  17. package/dist/channels/feishu/feishu.d.ts +0 -12
  18. package/dist/channels/feishu/feishu.js +143 -54
  19. package/dist/channels/feishu/invoke-turn.js +35 -18
  20. package/dist/channels/feishu/model.d.ts +0 -1
  21. package/dist/channels/feishu/normalize.js +0 -1
  22. package/dist/channels/feishu/parse.d.ts +21 -7
  23. package/dist/channels/feishu/parse.js +24 -7
  24. package/dist/channels/feishu/preview.js +3 -2
  25. package/dist/channels/feishu/scaffold/channel.ts +9 -8
  26. package/dist/channels/feishu/scaffold/feishu-send.ts +6 -4
  27. package/dist/channels/feishu/setup-mode.d.ts +30 -1
  28. package/dist/channels/feishu/setup-mode.js +27 -1
  29. package/dist/channels/github/github.js +8 -1
  30. package/dist/channels/http.js +1 -1
  31. package/dist/channels/lark/scaffold/channel.ts +9 -8
  32. package/dist/channels/lark/scaffold/lark-send.ts +6 -4
  33. package/dist/channels/preview-kit.d.ts +7 -1
  34. package/dist/channels/preview-kit.js +3 -2
  35. package/dist/channels/slack/parse.d.ts +16 -1
  36. package/dist/channels/slack/parse.js +46 -3
  37. package/dist/channels/slack/preview.d.ts +1 -2
  38. package/dist/channels/slack/preview.js +68 -24
  39. package/dist/channels/slack/scaffold/channel.ts +5 -5
  40. package/dist/channels/slack/slack-api.d.ts +3 -23
  41. package/dist/channels/slack/slack-api.js +6 -22
  42. package/dist/channels/slack/slack.d.ts +13 -20
  43. package/dist/channels/slack/slack.js +95 -50
  44. package/dist/channels/state.d.ts +11 -4
  45. package/dist/channels/state.js +19 -12
  46. package/dist/channels/tasks.d.ts +0 -6
  47. package/dist/channels/tasks.js +16 -1
  48. package/dist/channels/telegram/parse.d.ts +0 -7
  49. package/dist/channels/telegram/parse.js +4 -2
  50. package/dist/channels/telegram/scaffold/telegram-send.ts +6 -3
  51. package/dist/channels/telegram/telegram.js +1 -1
  52. package/dist/channels/text.d.ts +14 -0
  53. package/dist/channels/text.js +14 -0
  54. package/dist/channels/thread-participants.d.ts +21 -0
  55. package/dist/channels/thread-participants.js +132 -0
  56. package/dist/channels/turn-queue.js +7 -0
  57. package/dist/cli/add-feishu.d.ts +7 -4
  58. package/dist/cli/add-feishu.js +57 -37
  59. package/dist/cli/add-slack.d.ts +2 -1
  60. package/dist/cli/add-slack.js +6 -11
  61. package/dist/cli/commands/add.js +50 -51
  62. package/dist/cli/commands/attach.js +8 -4
  63. package/dist/cli/commands/chat.js +8 -8
  64. package/dist/cli/commands/deploy.d.ts +14 -1
  65. package/dist/cli/commands/deploy.js +330 -78
  66. package/dist/cli/commands/dev.d.ts +1 -0
  67. package/dist/cli/commands/dev.js +38 -25
  68. package/dist/cli/commands/fire.js +15 -16
  69. package/dist/cli/commands/info.js +36 -29
  70. package/dist/cli/commands/init.d.ts +1 -1
  71. package/dist/cli/commands/init.js +65 -53
  72. package/dist/cli/commands/invoke.js +9 -6
  73. package/dist/cli/commands/login.js +35 -21
  74. package/dist/cli/commands/schedule.js +6 -8
  75. package/dist/cli/commands/start.d.ts +1 -0
  76. package/dist/cli/commands/start.js +101 -37
  77. package/dist/cli/commands/tool.js +30 -18
  78. package/dist/cli/fail.d.ts +17 -0
  79. package/dist/cli/fail.js +24 -0
  80. package/dist/cli/program.js +66 -36
  81. package/dist/cli/serve.d.ts +52 -8
  82. package/dist/cli/serve.js +133 -32
  83. package/dist/cli/shared.d.ts +21 -2
  84. package/dist/cli/shared.js +44 -18
  85. package/dist/deploy/agentcore/plan.d.ts +117 -0
  86. package/dist/deploy/agentcore/plan.js +721 -0
  87. package/dist/deploy/agentcore/run.d.ts +73 -0
  88. package/dist/deploy/agentcore/run.js +412 -0
  89. package/dist/deploy/agentcore/zip.d.ts +17 -0
  90. package/dist/deploy/agentcore/zip.js +68 -0
  91. package/dist/deploy/container.d.ts +26 -25
  92. package/dist/deploy/container.js +93 -89
  93. package/dist/deploy/docker/plan.d.ts +1 -1
  94. package/dist/deploy/docker/plan.js +12 -17
  95. package/dist/deploy/fly/plan.d.ts +2 -0
  96. package/dist/deploy/fly/plan.js +27 -19
  97. package/dist/deploy/fly/run.d.ts +12 -1
  98. package/dist/deploy/fly/run.js +36 -2
  99. package/dist/deploy/preflight.d.ts +11 -5
  100. package/dist/deploy/preflight.js +235 -65
  101. package/dist/deploy/railway/plan.d.ts +7 -0
  102. package/dist/deploy/railway/plan.js +41 -16
  103. package/dist/deploy/railway/run.d.ts +8 -1
  104. package/dist/deploy/railway/run.js +7 -2
  105. package/dist/deploy/runner.d.ts +5 -2
  106. package/dist/deploy/runner.js +9 -3
  107. package/dist/dev-supervisor.d.ts +11 -8
  108. package/dist/dev-supervisor.js +53 -51
  109. package/dist/engines/pi/auth.d.ts +8 -7
  110. package/dist/engines/pi/auth.js +12 -10
  111. package/dist/engines/pi/channel.d.ts +1 -1
  112. package/dist/engines/pi/channel.js +5 -5
  113. package/dist/engines/pi/chat.js +2 -2
  114. package/dist/engines/pi/config.d.ts +12 -48
  115. package/dist/engines/pi/config.js +29 -110
  116. package/dist/engines/pi/create.d.ts +32 -24
  117. package/dist/engines/pi/create.js +47 -23
  118. package/dist/engines/pi/definition.d.ts +7 -26
  119. package/dist/engines/pi/definition.js +8 -54
  120. package/dist/engines/pi/harness.d.ts +19 -5
  121. package/dist/engines/pi/harness.js +3 -5
  122. package/dist/engines/pi/login.d.ts +1 -1
  123. package/dist/engines/pi/models.d.ts +3 -3
  124. package/dist/engines/pi/models.js +1 -1
  125. package/dist/engines/pi/{workspace.d.ts → open.d.ts} +31 -25
  126. package/dist/engines/pi/{workspace.js → open.js} +27 -29
  127. package/dist/engines/pi/read-image.d.ts +4 -0
  128. package/dist/engines/pi/read-image.js +62 -0
  129. package/dist/engines/pi/search-tools.d.ts +6 -4
  130. package/dist/engines/pi/search-tools.js +3 -1
  131. package/dist/engines/pi/session-builder.d.ts +2 -2
  132. package/dist/engines/pi/session-builder.js +18 -13
  133. package/dist/engines/pi/tool.d.ts +13 -5
  134. package/dist/engines/pi/tool.js +4 -0
  135. package/dist/engines/pi/wake-tool.d.ts +3 -3
  136. package/dist/env.d.ts +16 -4
  137. package/dist/env.js +43 -5
  138. package/dist/host/node.d.ts +4 -2
  139. package/dist/host/node.js +2 -1
  140. package/dist/loader.d.ts +2 -2
  141. package/dist/loader.js +3 -3
  142. package/dist/log.d.ts +1 -1
  143. package/dist/log.js +1 -1
  144. package/dist/paths.d.ts +138 -0
  145. package/dist/paths.js +326 -0
  146. package/dist/pi.d.ts +2 -2
  147. package/dist/pi.js +2 -2
  148. package/dist/runtime.d.ts +7 -5
  149. package/dist/runtime.js +2 -2
  150. package/dist/scaffold/add-channel.d.ts +7 -3
  151. package/dist/scaffold/add-channel.js +55 -29
  152. package/dist/scaffold/init.d.ts +32 -41
  153. package/dist/scaffold/init.js +161 -185
  154. package/dist/scaffold/templates/env.example +15 -6
  155. package/dist/scaffold/templates/fastagent.config.mjs +1 -1
  156. package/dist/scaffold/templates/gitignore +14 -6
  157. package/dist/scaffold/templates/persona.md +4 -2
  158. package/dist/scaffold/templates/secrets.gitignore +5 -0
  159. package/dist/scaffold/templates.d.ts +1 -7
  160. package/dist/scaffold/templates.js +3 -25
  161. package/dist/scaffold/vendor-skill.d.ts +2 -2
  162. package/dist/scaffold/vendor-skill.js +13 -13
  163. package/dist/schedule/discover.js +4 -4
  164. package/dist/schedule/scheduler.d.ts +40 -1
  165. package/dist/schedule/scheduler.js +89 -56
  166. package/dist/schedule/state.js +1 -1
  167. package/dist/schedule/wake-alarm.d.ts +47 -0
  168. package/dist/schedule/wake-alarm.js +136 -0
  169. package/dist/schedule/wakeups.d.ts +1 -0
  170. package/dist/schedule/wakeups.js +18 -0
  171. package/dist/tunnel.d.ts +3 -3
  172. package/dist/tunnel.js +7 -7
  173. package/package.json +7 -4
  174. package/dist/channels/feishu/owned-threads.d.ts +0 -7
  175. package/dist/channels/feishu/owned-threads.js +0 -47
  176. package/dist/channels/slack/owned-threads.d.ts +0 -6
  177. package/dist/channels/slack/owned-threads.js +0 -43
  178. package/dist/scaffold/templates/gitignore.agentdir-root +0 -5
  179. package/dist/scaffold/templates/gitignore.kit +0 -2
  180. package/dist/workspace.d.ts +0 -9
  181. 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
  });
@@ -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`. Returns credentials for the
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 committable .env, a
25
- * non-interactive lark run); the caller surfaces that as a startup failure.
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", envIgnored: boolean, ingress?: FeishuSubscriptionMode, groupBehavior?: GroupBehaviorChoice): Promise<Record<string, string> | undefined>;
30
+ export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "lark", ingress?: FeishuSubscriptionMode, groupBehavior?: GroupBehaviorChoice): Promise<Record<string, string> | undefined>;
@@ -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 groupScope = scopes.find((scope) => scope.name === FEISHU_GROUP_CONTEXT_SCOPE && (scope.type === undefined || scope.type === "tenant"));
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 (groupScope?.grantStatus === 1) {
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 managed-thread replies and group context buffering are disabled`);
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 + bare managed-thread replies and durably buffers other discussion`);
58
- if (groupScope?.grantStatus === 1) {
59
- note(`[fastagent] ${FEISHU_GROUP_CONTEXT_SCOPE} is already granted`);
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
- if (groupScope) {
64
- note(`[fastagent] ${FEISHU_GROUP_CONTEXT_SCOPE} is awaiting approvalcomplete tenant-admin approval before publishing. Opening ${permissionUrl}`);
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
- note(`[fastagent] ${FEISHU_GROUP_CONTEXT_SCOPE} is not granted (or could not be verified) group behavior was ` +
72
- `defaulted, so it was not requested. Re-run with --group-behavior context to add it to the app draft, ` +
73
- `or --group-behavior mentions to stay least-privilege: ${permissionUrl}`);
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, [FEISHU_GROUP_CONTEXT_SCOPE]);
78
- note(`[fastagent] added ${FEISHU_GROUP_CONTEXT_SCOPE} to the app draft — complete tenant-admin approval before publishing. Opening ${permissionUrl}`);
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 ${FEISHU_GROUP_CONTEXT_SCOPE} automatically: ${String(error)} — add it manually before publishing. Opening ${permissionUrl}`);
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`. Returns credentials for the
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 committable .env, a
91
- * non-interactive lark run); the caller surfaces that as a startup failure.
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, envIgnored, ingress = "webhook", groupBehavior = { behavior: "context", explicit: false }) {
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 .env — keeping them`);
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 .env to capture its missing Verification Token`
209
- : `[fastagent] reusing Feishu app ${appId} from .env for WebSocket ingress`);
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 .env before Token bootstrap`
251
- : `[fastagent] wrote FEISHU_APP_ID, FEISHU_APP_SECRET to .env`);
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 .env`);
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 .env`);
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 run-root `.env` values for the requested names — decided by THE .env parser, so this
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(join(dir, ".env"), "utf8");
354
+ content = await readFile(dotEnvPath(dir), "utf8");
335
355
  }
336
356
  catch (e) {
337
357
  if (e.code === "ENOENT")
@@ -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;
@@ -1,9 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { basename, join } from "node:path";
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 { ensureStateRootSelfIgnored } from "../engines/pi/definition.js";
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(join(input.target, ".env"), "utf8")
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 .env is missing ${missingRuntime.join(", ")} ` +
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("[fastagent] Slack app installed; rotating bot credentials and Signing Secret written to .env");
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
  }