@fastagent-sh/fastagent 0.12.0 → 0.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +42 -36
  2. package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
  3. package/dist/channels/feishu/bootstrap-token.js +94 -0
  4. package/dist/channels/feishu/card.d.ts +32 -0
  5. package/dist/channels/feishu/card.js +66 -0
  6. package/dist/channels/feishu/cloud.d.ts +17 -0
  7. package/dist/channels/feishu/cloud.js +19 -0
  8. package/dist/channels/feishu/crypto.d.ts +13 -0
  9. package/dist/channels/feishu/crypto.js +41 -0
  10. package/dist/channels/feishu/feishu-api.d.ts +108 -0
  11. package/dist/channels/feishu/feishu-api.js +325 -0
  12. package/dist/channels/feishu/feishu.d.ts +36 -0
  13. package/dist/channels/feishu/feishu.js +359 -0
  14. package/dist/channels/feishu/invoke-turn.d.ts +59 -0
  15. package/dist/channels/feishu/invoke-turn.js +106 -0
  16. package/dist/channels/feishu/parse.d.ts +125 -0
  17. package/dist/channels/feishu/parse.js +175 -0
  18. package/dist/channels/feishu/preview.d.ts +36 -0
  19. package/dist/channels/feishu/preview.js +387 -0
  20. package/dist/channels/feishu/register-app.d.ts +70 -0
  21. package/dist/channels/feishu/register-app.js +141 -0
  22. package/dist/channels/feishu/register-webhook.d.ts +22 -0
  23. package/dist/channels/feishu/register-webhook.js +106 -0
  24. package/dist/channels/feishu/scaffold/channel.ts +34 -0
  25. package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
  26. package/dist/channels/feishu/seen.d.ts +5 -0
  27. package/dist/channels/feishu/seen.js +47 -0
  28. package/dist/channels/feishu/text.d.ts +13 -0
  29. package/dist/channels/feishu/text.js +63 -0
  30. package/dist/channels/lark/lark.d.ts +15 -0
  31. package/dist/channels/lark/lark.js +10 -0
  32. package/dist/channels/lark/onboard.d.ts +39 -0
  33. package/dist/channels/lark/onboard.js +58 -0
  34. package/dist/channels/lark/scaffold/channel.ts +32 -0
  35. package/dist/channels/lark/scaffold/lark-send.ts +87 -0
  36. package/dist/channels/registration.d.ts +15 -0
  37. package/dist/channels/registration.js +1 -0
  38. package/dist/channels/{telegram/state.js → state.js} +6 -4
  39. package/dist/channels/telegram/context-buffer.js +1 -1
  40. package/dist/channels/telegram/register-webhook.d.ts +4 -1
  41. package/dist/channels/telegram/register-webhook.js +17 -26
  42. package/dist/channels/telegram/telegram.js +2 -2
  43. package/dist/channels/telegram/turn-store.d.ts +8 -21
  44. package/dist/channels/telegram/turn-store.js +11 -130
  45. package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
  46. package/dist/channels/turn-store.d.ts +42 -0
  47. package/dist/channels/turn-store.js +139 -0
  48. package/dist/channels/wait-health.d.ts +6 -0
  49. package/dist/channels/wait-health.js +27 -0
  50. package/dist/cli-add-feishu.d.ts +8 -0
  51. package/dist/cli-add-feishu.js +223 -0
  52. package/dist/cli.js +68 -23
  53. package/dist/deploy/container.js +10 -6
  54. package/dist/deploy/fly/plan.d.ts +1 -1
  55. package/dist/deploy/fly/plan.js +15 -4
  56. package/dist/deploy/fly/run.d.ts +7 -4
  57. package/dist/deploy/fly/run.js +26 -5
  58. package/dist/deploy/railway/plan.d.ts +1 -1
  59. package/dist/deploy/railway/plan.js +17 -5
  60. package/dist/deploy/railway/run.d.ts +6 -3
  61. package/dist/deploy/railway/run.js +26 -4
  62. package/dist/deploy/registration-gate.d.ts +20 -0
  63. package/dist/deploy/registration-gate.js +20 -0
  64. package/dist/deploy/secrets.d.ts +10 -9
  65. package/dist/deploy/secrets.js +15 -14
  66. package/dist/dev-supervisor.js +2 -1
  67. package/dist/engines/pi/chat.js +3 -3
  68. package/dist/engines/pi/create.d.ts +0 -1
  69. package/dist/engines/pi/create.js +8 -7
  70. package/dist/feishu.d.ts +2 -0
  71. package/dist/feishu.js +2 -0
  72. package/dist/lark.d.ts +3 -0
  73. package/dist/lark.js +3 -0
  74. package/dist/open-url.d.ts +2 -0
  75. package/dist/open-url.js +6 -0
  76. package/dist/scaffold/add-channel.d.ts +9 -5
  77. package/dist/scaffold/add-channel.js +73 -7
  78. package/dist/tunnel.d.ts +9 -6
  79. package/dist/tunnel.js +48 -31
  80. package/package.json +19 -8
  81. /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
  82. /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
@@ -0,0 +1,139 @@
1
+ /**
2
+ * Durable turn intent: the L1 half of durable execution (the L2 exactly-once / deterministic
3
+ * step-replay layer is the K-axis backend — docs/design/core.md §11). Persists an accepted turn BEFORE
4
+ * the webhook 200 (pre-ACK, like the telegram context-buffer) and removes it when the turn ENDS — the
5
+ * runner's `finally`: a completed turn OR any caught error both remove it. Precisely, a completed turn's
6
+ * removal fires at the `completed` event (the session is committed), which is BEFORE the reply reaches
7
+ * the chat platform: so L1 recovers the ACKed-but-un-COMPLETED window, not un-DELIVERED. A crash in
8
+ * that narrow tail (completed, session-committed, but the message not yet sent) leaves the answer in the
9
+ * session history undelivered and is deliberately NOT replayed — replaying a session-committed turn
10
+ * would double-append it; the asker re-asks (and sees the prior answer in history). L1's scope is the
11
+ * INTERRUPTED-run window (the `finally` never runs); a caught error is NOT retried here — a `failed`
12
+ * event already told the user, and a transport throw is dropped exactly like the pre-L1 in-memory queue
13
+ * did (replaying it could double-send). Only an interrupted run leaves the record on disk — and
14
+ * "interrupted" is not just a rare crash: `runStart` has no graceful drain (cli.ts), so a SIGTERM exits
15
+ * mid-turn too, i.e. EVERY rolling deploy that catches an in-flight turn. Recovery re-enqueues it next start.
16
+ *
17
+ * This recovers the ACKed-but-unfinished window the in-memory turn-queue drops (turn-queue.ts). Weigh
18
+ * the trade before trusting it: the alternative (dropping the turn) fails VISIBLY and self-corrects
19
+ * (the turn vanished, the asker re-asks); replay's sharpest cost is the opposite — invisible. It is
20
+ * at-least-once, not exactly-once:
21
+ * - PRIMARY cost: replay re-runs the WHOLE turn, so every external side effect happens AGAIN — re-sent
22
+ * messages, re-fired tool actions — and nobody may notice (unlike the visible loss it replaces).
23
+ * And the trigger is not rare (above): it fires on every deploy that interrupts an in-flight turn,
24
+ * not just on crashes. So "safe only if the turn's tools are idempotent" is a bar to judge against
25
+ * DEPLOY frequency: it holds for a Q&A bot, and is the gate for adding side-effecting tools. (A
26
+ * mid-stream interruption also leaves an orphan "💭 Thinking…" preview — cosmetic.)
27
+ * - The pre-ACK window overlaps webhook redelivery: a crash AFTER the persist but BEFORE the 200
28
+ * means recovery replays the turn AND the platform (never-ACKed) redelivers the same event — same
29
+ * id, and unless the channel dedups accepted ids, the turn can run twice. Exactly-once (a persisted
30
+ * delivery key) is L2.
31
+ *
32
+ * Channel-neutral, generic over the record shape: the channel supplies its own record type (everything
33
+ * its runner needs to re-execute the turn), the shape validator (state files are an IO boundary), the
34
+ * arrival ordering for recovery, and its log label. telegram/turn-store.ts and the Feishu channel are the
35
+ * two consumers.
36
+ *
37
+ * ponytail: at-least-once with a per-turn EXECUTION ceiling. A poison turn that deterministically
38
+ * crashes the process would replay forever under a container restart policy. The counter is bumped at
39
+ * `startAttempt` — when a turn is about to RUN, not per restart cycle — so it counts a turn's OWN
40
+ * execution attempts: `recover()` re-enqueues every surviving turn without touching its count, and a
41
+ * poison turn at a session's head is dropped on its own N+1th run WITHOUT penalizing the never-run turns
42
+ * queued behind it (they keep their full budget and get their turn once the poison one is gone). On drop,
43
+ * the runner notifies the asker (the chain's end must get a signal — a log line the user can't see isn't).
44
+ * The bump is the ONE post-ACK write that fails CLOSED (not best-effort): if it can't be persisted the
45
+ * turn is DEFERRED — skipped this cycle and left on disk to replay on the next start — rather than run
46
+ * untracked, because an unpersisted count lets recover() re-run a poison turn every restart (the ceiling
47
+ * would never advance on disk). It replays on the next START — a restart is required; disk recovery alone
48
+ * does not re-run a deferred turn — and the asker is NOT told (a transient system degrade, not a user-
49
+ * actionable failure — telling them "ask again" would double-answer on that replay).
50
+ * Single-process, single-writer: same durability model as state.ts (crash-safe via atomic rename;
51
+ * power-loss is best-effort — no fsync, consistent with the rest of the channel's state).
52
+ */
53
+ import { log } from "../log.js";
54
+ import { loadStateFile, saveStateFile } from "./state.js";
55
+ export function createTurnStore(path, opts) {
56
+ const { label, isRecord, order } = opts;
57
+ const load = () => {
58
+ const raw = loadStateFile(path);
59
+ if (raw === undefined)
60
+ return new Map();
61
+ if (typeof raw === "object" && raw !== null && !Array.isArray(raw) && Object.values(raw).every(isRecord)) {
62
+ return new Map(Object.entries(raw));
63
+ }
64
+ log.warn(`${label} unexpected shape in ${path} — starting with no pending turns`);
65
+ return new Map();
66
+ };
67
+ const turns = load();
68
+ const persist = () => saveStateFile(path, Object.fromEntries(turns));
69
+ // Post-ACK writes (remove, startAttempt) must not abort a turn: log a failed write, never throw.
70
+ const persistBestEffort = (what) => {
71
+ try {
72
+ persist();
73
+ }
74
+ catch (e) {
75
+ log.error(`${label} turn-store ${what} write failed post-ACK: ${String(e)}`);
76
+ }
77
+ };
78
+ return {
79
+ add(rec) {
80
+ // Idempotent on re-add: a redelivery (pre-ACK crash → never-ACKed event replayed) re-submits an id
81
+ // already in the store. Skip it — the intent is already durable, and overwriting would RESET this
82
+ // turn's execution count, handing a poison turn a fresh ceiling budget.
83
+ if (turns.has(rec.id))
84
+ return;
85
+ turns.set(rec.id, rec);
86
+ try {
87
+ persist(); // pre-ACK: a throw becomes the webhook's 500 and the platform redelivers
88
+ }
89
+ catch (e) {
90
+ // Roll the memory back so it matches disk (mirrors context-buffer.push): otherwise the phantom
91
+ // entry makes the redelivery's `add` short-circuit on `turns.has` — running the turn with its
92
+ // intent never persisted, defeating this module — and gets flushed later by an unrelated persist.
93
+ turns.delete(rec.id);
94
+ throw e;
95
+ }
96
+ },
97
+ remove(id) {
98
+ if (turns.delete(id))
99
+ persistBestEffort("remove (a restart may replay an answered turn)");
100
+ },
101
+ recover() {
102
+ // The channel's arrival order, applied explicitly rather than leaning on JS object-key enumeration
103
+ // happening to survive the load's JSON round-trip.
104
+ return [...turns.values()].sort(order);
105
+ },
106
+ startAttempt(id, maxAttempts) {
107
+ const rec = turns.get(id);
108
+ if (!rec)
109
+ return "run"; // no record — run untracked (a redelivery double-run whose first run removed it)
110
+ const attempts = rec.attempts + 1;
111
+ if (attempts > maxAttempts) {
112
+ // State the fact, not a cause the counter can't prove: a turn killed mid-run every time bumps
113
+ // this whether IT poisoned the process or a deploy/OOM took it down each time.
114
+ log.error(`${label} dropping turn ${id} after starting ${rec.attempts} time(s) without finishing ` +
115
+ `(session=${rec.session}) — it may be crashing the process, or was killed mid-run each time; notifying the asker`);
116
+ turns.delete(id);
117
+ persistBestEffort("drop");
118
+ return "exceeded";
119
+ }
120
+ turns.set(id, { ...rec, attempts });
121
+ try {
122
+ persist();
123
+ }
124
+ catch (e) {
125
+ // The bump MUST be durable, unlike remove/drop: if it isn't, a restart's recover() reads the old
126
+ // count and RE-RUNS this turn — a poison turn would re-execute forever (the ceiling never advances
127
+ // on disk). Fail closed: roll the memory bump back (match disk) and DEFER — the record stays on
128
+ // disk with its old count and replays on the next start (a restart is required; disk recovery
129
+ // alone won't re-run it). Not a drop: under disk failure the removal couldn't persist anyway.
130
+ // The runner skips silently (no notify).
131
+ turns.set(id, rec);
132
+ log.error(`${label} cannot persist turn ${id}'s attempt count — deferring it to the next start rather ` +
133
+ `than run it untracked (session=${rec.session}): ${String(e)}`);
134
+ return "defer";
135
+ }
136
+ return "run";
137
+ },
138
+ };
139
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Poll `healthUrl` until it responds 200, or the timeout elapses. Any error (not routable yet, DNS not
3
+ * settled, connection refused) is a "keep waiting", not a failure — that IS the readiness signal. Each
4
+ * probe has its own short timeout so one slow attempt can't eat the whole budget.
5
+ */
6
+ export declare function waitForHealth(healthUrl: string, timeoutMs: number, intervalMs: number): Promise<boolean>;
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Readiness probe shared by the webhook registrars (telegram setWebhook, lark config PATCH): both
3
+ * platforms VERIFY the URL at registration time, and a fresh deploy's container or a fresh tunnel's
4
+ * DNS is not routable for some seconds — registering before the server actually serves would fail.
5
+ * Tracking real readiness (not a fixed timer) is what fixes that race.
6
+ */
7
+ import { setTimeout as sleep } from "node:timers/promises";
8
+ /**
9
+ * Poll `healthUrl` until it responds 200, or the timeout elapses. Any error (not routable yet, DNS not
10
+ * settled, connection refused) is a "keep waiting", not a failure — that IS the readiness signal. Each
11
+ * probe has its own short timeout so one slow attempt can't eat the whole budget.
12
+ */
13
+ export async function waitForHealth(healthUrl, timeoutMs, intervalMs) {
14
+ const deadline = Date.now() + timeoutMs;
15
+ for (;;) {
16
+ try {
17
+ if ((await fetch(healthUrl, { signal: AbortSignal.timeout(5000) })).ok)
18
+ return true;
19
+ }
20
+ catch {
21
+ /* not routable yet — keep polling until the deadline */
22
+ }
23
+ if (Date.now() >= deadline)
24
+ return false;
25
+ await sleep(intervalMs);
26
+ }
27
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Create or resume the platform app behind `add feishu` / `add lark`. Returns credentials for the
3
+ * caller's generic .env write (the guided Lark path), or undefined when nothing remains to write —
4
+ * the feishu path persists its own two credential stages internally (the App ID/Secret boundary is
5
+ * irreversible and must not wait for the caller). Throws on refusal (a committable .env, a
6
+ * non-interactive lark run); the caller surfaces that as a startup failure.
7
+ */
8
+ export declare function onboardFeishuCloudApp(target: string, kind: "feishu" | "lark", envIgnored: boolean): Promise<Record<string, string> | undefined>;
@@ -0,0 +1,223 @@
1
+ /**
2
+ * `fastagent add feishu|lark` app onboarding — the cloud-facing half of runAdd, kept out of cli.ts
3
+ * (which self-executes on import), mirroring cli-models.ts/cli-auth.ts. The POLICY already lives in
4
+ * testable modules (register-app.ts, bootstrap-token.ts, lark/onboard.ts); this layer is the terminal
5
+ * wiring: clack prompts, .env staging, browser opens, progress lines.
6
+ *
7
+ * Feishu (scan-to-create): the device flow creates the app; App ID/Secret are persisted at the
8
+ * irreversible creation boundary, then the Verification Token is captured over a throwaway tunnel and
9
+ * persisted as a second stage — a re-run RESUMES the app instead of minting another. Lark
10
+ * (guided-console): open the unbound launcher, validate entered credentials, probe the same
11
+ * webhook/token bootstrap, and fall back to a manual token prompt on the definitive config-route 404.
12
+ */
13
+ import { readFile } from "node:fs/promises";
14
+ import { join } from "node:path";
15
+ import { isCancel, log as clackLog, password, text as clackText } from "@clack/prompts";
16
+ import { bootstrapFeishuVerificationToken } from "./channels/feishu/bootstrap-token.js";
17
+ import { cloudFor } from "./channels/feishu/cloud.js";
18
+ import { createFeishuApi, isFeishuConfigApiMissing, isTransientFeishuRegistrationError, } from "./channels/feishu/feishu-api.js";
19
+ import { registerFeishuApp } from "./channels/feishu/register-app.js";
20
+ import { onboardLarkApp } from "./channels/lark/onboard.js";
21
+ import { parseEnvContent } from "./env.js";
22
+ import { openExternalUrl } from "./open-url.js";
23
+ import { appendChannelDotEnv } from "./scaffold/add-channel.js";
24
+ import { startCloudflareTunnel } from "./tunnel.js";
25
+ /**
26
+ * Create or resume the platform app behind `add feishu` / `add lark`. Returns credentials for the
27
+ * caller's generic .env write (the guided Lark path), or undefined when nothing remains to write —
28
+ * the feishu path persists its own two credential stages internally (the App ID/Secret boundary is
29
+ * irreversible and must not wait for the caller). Throws on refusal (a committable .env, a
30
+ * non-interactive lark run); the caller surfaces that as a startup failure.
31
+ */
32
+ export async function onboardFeishuCloudApp(target, kind, envIgnored) {
33
+ const { envPrefix, apiBase, capabilities } = cloudFor(kind);
34
+ // The CLI must never materialize a real credential into a committable file — refuse, don't warn.
35
+ if (!envIgnored) {
36
+ throw new Error(kind === "feishu"
37
+ ? "`add feishu` creates an app and writes real credentials to .env — add .env to .gitignore/.fastagentignore first, then re-run"
38
+ : "`add lark` writes real app credentials to .env — add .env to .gitignore/.fastagentignore first, then re-run");
39
+ }
40
+ const existing = await activeDotEnvValues(target, [
41
+ `${envPrefix}_APP_ID`,
42
+ `${envPrefix}_APP_SECRET`,
43
+ `${envPrefix}_VERIFICATION_TOKEN`,
44
+ ]);
45
+ if (Object.keys(existing).length === 3) {
46
+ console.error(`[fastagent] ${envPrefix}_APP_ID/SECRET/VERIFICATION_TOKEN already set in .env — keeping them`);
47
+ return undefined;
48
+ }
49
+ if (capabilities.appCreation === "scan-to-create") {
50
+ await createFeishuAppFlow(target, existing);
51
+ return undefined;
52
+ }
53
+ // guided-console (lark): the intl cloud cannot complete the bound device flow — collect + validate.
54
+ if (!(process.stdin.isTTY && process.stdout.isTTY)) {
55
+ throw new Error("`add lark` needs an interactive terminal to onboard the Lark app credentials — re-run it in a terminal");
56
+ }
57
+ return onboardLarkApp({
58
+ openUrl: openExternalUrl,
59
+ note: (message) => clackLog.info(message),
60
+ async prompt(message, opts) {
61
+ const result = opts?.hidden ? await password({ message }) : await clackText({ message });
62
+ return isCancel(result) ? undefined : result;
63
+ },
64
+ }, {
65
+ existing,
66
+ verifyCredentials: async (appId, appSecret) => {
67
+ await createFeishuApi({ kind: "lark", baseUrl: apiBase, appId, appSecret }).verifyCredentials();
68
+ console.error(`[fastagent] Lark App ID / Secret verified`);
69
+ },
70
+ bootstrapWebhook: async (appId, appSecret) => {
71
+ const api = createFeishuApi({ kind: "lark", baseUrl: apiBase, appId, appSecret });
72
+ console.error(`[fastagent] trying Lark's webhook-mode + Verification-Token bootstrap (temporary tunnel)…`);
73
+ try {
74
+ const token = await bootstrapFeishuVerificationToken({
75
+ api,
76
+ appId,
77
+ kind: "lark",
78
+ startTunnel: (port) => startCloudflareTunnel(port),
79
+ onTunnelReady: (url) => console.error(`[fastagent] temporary tunnel ready → ${url}; registering webhook mode now…`),
80
+ onPatchRetry: ({ error, attempt, attempts, retryMs }) => console.error(`[fastagent] Lark could not validate the fresh tunnel yet (${String(error)}); retrying PATCH ${attempt + 1}/${attempts} in ${Math.round(retryMs / 1000)}s…`),
81
+ // A route-level 404 is definitive, not edge weather: fall back immediately. Retry only
82
+ // actual edge/network weather; scope/auth/config failures remain immediate.
83
+ shouldRetryPatch: (error) => !isFeishuConfigApiMissing(error) && isTransientFeishuRegistrationError(error),
84
+ });
85
+ console.error(`[fastagent] Lark Verification Token captured; Subscription mode changed to webhook in the app draft`);
86
+ return { token };
87
+ }
88
+ catch (error) {
89
+ if (!isFeishuConfigApiMissing(error))
90
+ throw error;
91
+ const manualReason = "This Lark app returned HTTP 404 for the application-config API, so automatic mode/token bootstrap is unavailable.";
92
+ console.error(`[fastagent] ${manualReason}`);
93
+ return { manualReason };
94
+ }
95
+ },
96
+ });
97
+ }
98
+ /**
99
+ * The scan-to-create flow `add feishu` runs by default. The device-authorization grant
100
+ * creates a pre-configured agent app (bot capability, messaging scopes, event subscriptions) when the
101
+ * user confirms a link in the app, and hands back the credentials; App ID/Secret are persisted at that
102
+ * irreversible boundary before the platform-generated Verification Token is captured from the
103
+ * registration challenge (bootstrap-token.ts). The Token is persisted as a second stage, so .env is
104
+ * complete before the one remaining version-publish action. The event Request URL is NOT left pointing at the throwaway
105
+ * tunnel for long: `dev --tunnel` / `deploy --run` re-register it against the live URL.
106
+ *
107
+ * Feishu is the reference cloud and the only kind that runs this BOUND device flow. Lark is an explicit
108
+ * compatibility profile: its lagging control plane uses the unbound launcher + guided credentials,
109
+ * then probes the canonical token/mode bootstrap with a manual fallback.
110
+ */
111
+ async function createFeishuAppFlow(target, existing) {
112
+ const { apiBase } = cloudFor("feishu");
113
+ let appId = existing.FEISHU_APP_ID;
114
+ let appSecret = existing.FEISHU_APP_SECRET;
115
+ if (appId && appSecret) {
116
+ console.error(`[fastagent] resuming Feishu app ${appId} from .env to capture its missing Verification Token`);
117
+ }
118
+ else {
119
+ console.error(`[fastagent] creating the Feishu app (confirm in the app)…`);
120
+ const app = await registerFeishuApp({
121
+ name: "{user}'s agent", // the platform expands {user} to the confirming user's name; editable on the page
122
+ desc: "Served by fastagent",
123
+ // The agent template alone is not enough to SERVE: the v7 config PATCH (webhook auto-registration
124
+ // in `dev --tunnel` / `deploy --run`) demands application:application:patch, and the app must
125
+ // subscribe the receive event. Addons merge both onto the confirm page — no manual app setup.
126
+ addons: {
127
+ scopes: { tenant: ["application:application:patch"] },
128
+ events: { items: { tenant: ["im.message.receive_v1"] } },
129
+ },
130
+ onVerificationUrl: ({ url, expiresInS }) => {
131
+ console.error(`\n Opening the confirmation link in your browser (or open it in Feishu / render it as a QR code) — valid for ${Math.round(expiresInS / 60)} minutes:\n\n ${url}\n\n waiting for confirmation… (keep this running — the credentials are delivered here)`);
132
+ openExternalUrl(url); // best-effort, like `login` — the URL above is the fallback
133
+ },
134
+ });
135
+ console.error(`[fastagent] app created: ${app.appId}${app.tenantBrand ? ` (${app.tenantBrand} tenant)` : ""}`);
136
+ // A cross-brand confirmation should be impossible (each confirm page refuses the other brand's
137
+ // code) — but if the platform ever reports one, the credentials would land in the WRONG kind's env
138
+ // namespace and serve the wrong cloud. Fail visibly instead of writing them.
139
+ if (app.tenantBrand && app.tenantBrand !== "feishu") {
140
+ throw new Error(`the confirming account is a ${app.tenantBrand} tenant, but this is \`add feishu\` — run \`fastagent add ${app.tenantBrand}\` instead`);
141
+ }
142
+ appId = app.appId;
143
+ appSecret = app.appSecret;
144
+ // IRREVERSIBLE BOUNDARY: the remote app now exists and its one-time Secret is in memory. Persist
145
+ // both before any config read, temporary tunnel, or Token bootstrap can be interrupted. Partial old
146
+ // lines are overwritten because these newly-minted credentials are authoritative as one pair.
147
+ await appendChannelDotEnv(target, "feishu", {
148
+ FEISHU_APP_ID: appId,
149
+ FEISHU_APP_SECRET: appSecret,
150
+ // A Token from a partial OLD credential set belongs to another App. Clear it at the same
151
+ // boundary; successful bootstrap below replaces the empty line with this App's Token.
152
+ FEISHU_VERIFICATION_TOKEN: "",
153
+ }, ["FEISHU_APP_ID", "FEISHU_APP_SECRET", "FEISHU_VERIFICATION_TOKEN"]);
154
+ console.error(`[fastagent] wrote FEISHU_APP_ID, FEISHU_APP_SECRET to .env before Token bootstrap`);
155
+ }
156
+ // The webhook channel authenticates plaintext events by the platform-generated Verification Token.
157
+ // Try the cheap read first (the v6 detail MAY someday return `encryption`), then the real path: the
158
+ // token's only programmatic delivery is the url_verification challenge during registration — capture
159
+ // it over a throwaway tunnel (bootstrap-token.ts). Failing both is a one-line manual copy; the staged
160
+ // ID/Secret pair makes a re-run resume this App rather than mint another one.
161
+ const tokenVar = "FEISHU_VERIFICATION_TOKEN";
162
+ const api = createFeishuApi({ baseUrl: apiBase, appId, appSecret });
163
+ let token;
164
+ let webhookModeChanged = false;
165
+ try {
166
+ const cfg = await api.getAppConfig(appId);
167
+ token = cfg.verificationToken;
168
+ }
169
+ catch {
170
+ /* the read surface is best-effort — the bootstrap below is the real path */
171
+ }
172
+ if (!token) {
173
+ console.error(`[fastagent] capturing the Verification Token — a throwaway webhook registration delivers it (spinning up a temporary tunnel; can take a few minutes on a slow edge)…`);
174
+ try {
175
+ token = await bootstrapFeishuVerificationToken({
176
+ api,
177
+ appId,
178
+ startTunnel: (port) => startCloudflareTunnel(port),
179
+ });
180
+ webhookModeChanged = true;
181
+ console.error(`[fastagent] Verification Token captured`);
182
+ }
183
+ catch (e) {
184
+ // Transient tunnel weather is the usual cause. Do NOT suggest re-running `add feishu` as a new
185
+ // scan: the staged pair makes the re-run resume THIS app; manual copy completes it too.
186
+ console.error(`[fastagent] warn: could not capture the Verification Token: ${String(e)} — usually a transient tunnel issue; finish this app with the manual copy below`);
187
+ }
188
+ }
189
+ if (token) {
190
+ // Persist the second credential stage immediately too — opening the publish page and generic
191
+ // scaffold finalization happen only after the complete runtime credential set is durable.
192
+ const staged = await appendChannelDotEnv(target, "feishu", { [tokenVar]: token }, [tokenVar]);
193
+ console.error(`[fastagent] wrote ${staged.written.join(", ")} to .env`);
194
+ }
195
+ else {
196
+ console.error(`[fastagent] copy it manually: developer console → Events & Callbacks → Encryption Strategy → Verification Token → ${tokenVar} in .env`);
197
+ }
198
+ if (webhookModeChanged) {
199
+ // The bootstrap's PATCH flipped event mode in the DRAFT. It takes effect only after a version
200
+ // publish, which has no API; later dev/deploy runs change only the Request URL immediately.
201
+ const versionUrl = `${apiBase}/app/${appId}/version`;
202
+ console.error(`[fastagent] one console click remains: CREATE + PUBLISH a version (self-approved) — the switch to webhook mode takes effect on publish. Opening ${versionUrl}`);
203
+ openExternalUrl(versionUrl);
204
+ }
205
+ }
206
+ /** Active run-root `.env` values for the requested names — decided by THE .env parser, so this
207
+ * check can never disagree with what `loadEnvFile` reads. Empty/commented values are absent. */
208
+ async function activeDotEnvValues(dir, names) {
209
+ let content;
210
+ try {
211
+ content = await readFile(join(dir, ".env"), "utf8");
212
+ }
213
+ catch (e) {
214
+ if (e.code === "ENOENT")
215
+ return {};
216
+ throw e;
217
+ }
218
+ const parsed = parseEnvContent(content);
219
+ return Object.fromEntries(names.flatMap((name) => {
220
+ const value = parsed.get(name)?.trim();
221
+ return value ? [[name, value]] : [];
222
+ }));
223
+ }
package/dist/cli.js CHANGED
@@ -13,6 +13,7 @@ import { logAgentLoop } from "./observe.js";
13
13
  import { log, setLogLevel } from "./log.js";
14
14
  import { loadDotEnv } from "./env.js";
15
15
  import { installProxyFetch } from "./proxy.js";
16
+ import { openExternalUrl } from "./open-url.js";
16
17
  import { createInvokeHandler } from "./channels/http.js";
17
18
  import { text } from "./channels/respond.js";
18
19
  import { parseRouteKey, router, serveNode } from "./host/node.js";
@@ -44,6 +45,8 @@ import { deployRailwayRun } from "./deploy/railway/run.js";
44
45
  import { authSeedBytes, deployFlyRun } from "./deploy/fly/run.js";
45
46
  import { spawnRunner } from "./deploy/runner.js";
46
47
  import { assembleSecrets } from "./deploy/secrets.js";
48
+ import { registerFeishuWebhook } from "./channels/feishu/register-webhook.js";
49
+ import { onboardFeishuCloudApp } from "./cli-add-feishu.js";
47
50
  import { registerTelegramWebhook } from "./channels/telegram/register-webhook.js";
48
51
  import { loadSchedules } from "./schedule/discover.js";
49
52
  import { readRuns } from "./schedule/audit.js";
@@ -64,7 +67,7 @@ function usage(code) {
64
67
  fastagent dev [dir] [--port N] [--model provider/modelId] [--auth-path file] [--no-watch] [--tunnel]
65
68
  fastagent chat [dir] [--model provider/modelId]
66
69
  fastagent start [dir] [--port N] [--model provider/modelId] [--sessions-dir dir] [--auth-path file] [--tunnel]
67
- fastagent add github | telegram | skill <source> [dir]
70
+ fastagent add github | telegram | feishu | lark | skill <source> [dir]
68
71
  fastagent deploy fly|railway [dir] [--run] [--force] [--stop] [--no-scale-to-zero] [--into-linked]
69
72
  fastagent login [provider] [--auth-path file]
70
73
  fastagent --version
@@ -75,7 +78,7 @@ function usage(code) {
75
78
  disable). Files the agent writes as work product never trigger a restart.
76
79
  model precedence: --model > FASTAGENT_MODEL > fastagent.config.ts
77
80
  --tunnel expose it on a public HTTPS URL via a Cloudflare quick tunnel (needs cloudflared)
78
- and auto-register the webhook channels (telegram setWebhook; github prints the URL)
81
+ and auto-register the webhook channels (telegram, feishu, lark; github prints the URL)
79
82
  chat open the SAME assembled agent in pi's interactive TUI (the real harness, not a
80
83
  crude REPL) — to try it locally before serving. Same model/tool/skill resolution
81
84
  as dev; pi handles login, sessions, and /resume natively.
@@ -123,8 +126,16 @@ function usage(code) {
123
126
  point it at ~/.fastagent/auth.json to share one credential across projects)
124
127
  --tunnel same as dev: a public HTTPS URL + auto-registered webhooks, for hosting a bot from
125
128
  your own box without deploying (the quick-tunnel URL is ephemeral, not for production)
126
- add github | telegram: scaffold channels/<kind>.ts — third-party adapter glue with the policy
127
- to edit (github maps events in on(); telegram routes in the optional route()).
129
+ add github | telegram | feishu | lark: scaffold channels/<kind>.ts — third-party adapter glue
130
+ with the policy to edit (github maps events in on(); telegram/feishu/lark route in the
131
+ optional route()). Feishu (open.feishu.cn) is the canonical implementation; Lark international
132
+ (open.larksuite.com) is its compatibility profile with degraded control-plane setup.
133
+ feishu also CREATES + configures the platform app (confirm a link in the app — the platform's
134
+ "scan to create" flow; one version-publish action remains) and writes credentials to .env;
135
+ a persisted ID/Secret pair resumes missing-Token setup instead of creating another app. lark
136
+ opens the intl developer console only for a new/partial pair, validates App ID/Secret, then
137
+ probes Feishu's webhook-mode + Token automation; an explicit
138
+ config-route 404 falls back to a hidden Token prompt + manual mode/URL setup.
128
139
  skill <source>: vendor an Agent Skills skill into skills/<name>/ (git ref owner/repo/path, a
129
140
  local path, or a bare name from ~/.agents/skills; --update re-fetches, review with git diff)
130
141
  deploy fly|railway [dir]: generate host config + Dockerfile/.dockerignore from the definition and
@@ -164,6 +175,7 @@ const { positionals, values } = parseArgs({
164
175
  "agent-dir": { type: "string" },
165
176
  "no-watch": { type: "boolean" },
166
177
  tunnel: { type: "boolean" },
178
+ "create-app": { type: "boolean" },
167
179
  update: { type: "boolean" },
168
180
  force: { type: "boolean" },
169
181
  stop: { type: "boolean" },
@@ -575,18 +587,39 @@ async function runAdd() {
575
587
  process.exit(1);
576
588
  }
577
589
  const channelKind = kind;
590
+ // App creation is not a flag — it is what `add feishu` IS (the scan-to-create flow is the default
591
+ // and only path there). The retired --create-app spelling gets a pointer, not silence.
592
+ if (values["create-app"]) {
593
+ if (channelKind === "feishu") {
594
+ console.error(`[fastagent] note: --create-app is retired — \`add feishu\` creates the app by default`);
595
+ }
596
+ else if (channelKind === "lark") {
597
+ failStartup(new Error("--create-app is retired — `add lark` now opens the developer console and guides credential setup by default"));
598
+ }
599
+ else {
600
+ failStartup(new Error("--create-app is retired — app creation is the default behavior of `add feishu`"));
601
+ }
602
+ }
578
603
  // The channel (glue + companion tool) is agent surface — it lands in agentDir (config.agentDir, or
579
604
  // target when flat), the same place dev/start discover channels/. .env(.example) and the secret
580
605
  // hygiene stay at the run root, where .env is actually read.
581
606
  const { config: addConfig } = await loadConfig(target).catch(failStartup);
582
607
  const channelHome = resolveAgentDir(target, addConfig);
583
- // Preconditions before the write, so a refusal is side-effect-free.
608
+ // Preconditions before the write, so a refusal is side-effect-free. feishu/lark are exceptions:
609
+ // their add is scaffold + ONBOARD THE APP, so an existing scaffold skips the write and continues (a
610
+ // failed or cancelled scan/paste flow must be re-runnable without hand-deleting glue); never touch it.
611
+ const file = join(channelHome, "channels", `${channelKind}.ts`);
584
612
  if (await channelExists(channelHome, channelKind).catch(failStartup)) {
585
- failStartup(new Error(`${relative(target, join(channelHome, "channels", `${channelKind}.ts`))} already exists — edit it, or remove it to re-scaffold`));
613
+ if (channelKind !== "feishu" && channelKind !== "lark") {
614
+ failStartup(new Error(`${relative(target, file)} already exists — edit it, or remove it to re-scaffold`));
615
+ }
616
+ console.error(`[fastagent] ${relative(target, file)} already exists — keeping it`);
617
+ }
618
+ else {
619
+ await assertChannelReady(channelHome).catch(failStartup);
620
+ await scaffoldChannel(channelHome, channelKind).catch(failStartup);
621
+ console.error(`[fastagent] created ${relative(target, file)}`);
586
622
  }
587
- await assertChannelReady(channelHome).catch(failStartup);
588
- const file = await scaffoldChannel(channelHome, channelKind).catch(failStartup);
589
- console.error(`[fastagent] created ${relative(target, file)}`);
590
623
  if (await appendChannelEnv(target, channelKind).catch(failStartup)) {
591
624
  console.error(`[fastagent] added ${channelKind} env vars to .env.example`);
592
625
  }
@@ -598,11 +631,21 @@ async function runAdd() {
598
631
  if (!envIgnored) {
599
632
  console.error(`[fastagent] warn: .env is not gitignored — a deploy that copies the directory would ship a secret placed there; add .env to .gitignore/.fastagentignore, or use a real env var`);
600
633
  }
634
+ // `add feishu`/`add lark` = scaffold + CREATE OR RESUME the app (cli-add-feishu.ts): feishu persists
635
+ // its irreversible App ID/Secret boundary internally; lark returns guided credentials for the
636
+ // generic .env write below.
637
+ let created;
638
+ if (channelKind === "feishu" || channelKind === "lark") {
639
+ created = await onboardFeishuCloudApp(target, channelKind, envIgnored).catch(failStartup);
640
+ }
601
641
  const { env, steps } = channelSetup(channelKind);
602
642
  const generated = Object.fromEntries(env.filter((e) => e.generate).map((e) => [e.name, randomBytes(24).toString("hex")]));
603
643
  // Kind-neutral: every channel's generated secrets get the same treatment (github's webhook secret is
604
- // the same class of value as telegram's).
605
- const dotEnv = envIgnored ? await appendChannelDotEnv(target, channelKind, generated).catch(failStartup) : undefined;
644
+ // the same class of value as telegram's); guided Lark credentials ride the same write as overwrites.
645
+ // Feishu's irreversible credentials were already staged inside cli-add-feishu.ts before bootstrap.
646
+ const dotEnv = envIgnored
647
+ ? await appendChannelDotEnv(target, channelKind, { ...generated, ...created }, Object.keys(created ?? {})).catch(failStartup)
648
+ : undefined;
606
649
  if (dotEnv && dotEnv.written.length > 0) {
607
650
  console.error(`[fastagent] wrote ${dotEnv.written.join(", ")} to .env`);
608
651
  }
@@ -617,11 +660,12 @@ async function runAdd() {
617
660
  if (dotEnv?.written.includes(e.name)) {
618
661
  // Written, but its hint may still carry an action (github: paste the same value into the webhook
619
662
  // UI) — keep the variable visible instead of silently absorbing it.
620
- console.error(` ${e.name} — generated and written to .env # ${e.hint}`);
663
+ console.error(` ${e.name} — ${e.generate ? "generated and " : ""}written to .env # ${e.hint}`);
621
664
  continue;
622
665
  }
623
666
  const value = e.generate ? `=${generated[e.name]}` : "";
624
- console.error(` set ${e.name}${value} in .env${envIgnored ? " (gitignored)" : ""} # ${e.hint}`);
667
+ const action = e.required ? "set" : "optionally set";
668
+ console.error(` ${action} ${e.name}${value} in .env${envIgnored ? " (gitignored)" : ""} # ${e.hint}`);
625
669
  }
626
670
  // Steps carry `{channel}`/`{tools}` path placeholders (their filenames are the scaffold's private
627
671
  // knowledge) — resolve them to the real workspace-relative locations (agentDir-aware) here.
@@ -629,7 +673,12 @@ async function runAdd() {
629
673
  for (const s of steps) {
630
674
  console.error(` ${s.replace("{channel}", relative(target, file)).replace("{tools}", `${kitPrefix}tools`)}`);
631
675
  }
632
- console.error(` fastagent dev --tunnel # serve locally + a public URL, auto-registering the webhook`);
676
+ if (channelKind !== "lark") {
677
+ console.error(` fastagent dev --tunnel # serve locally + a public URL, auto-registering the webhook`);
678
+ }
679
+ // The app-creation flow leaves keep-alive sockets behind (platform API fetches, the throwaway tunnel's
680
+ // health probes) that would hold the event loop open for a while — the work is done, exit crisply.
681
+ process.exit(0);
633
682
  }
634
683
  /** `fastagent add skill <source> [dir]`: vendor an Agent Skills skill into <dir>/skills/<name>/. */
635
684
  async function runAddSkill() {
@@ -681,6 +730,7 @@ async function runDeploy() {
681
730
  process.exit(1);
682
731
  }
683
732
  loadDotEnv(target); // a custom provider/tool may read a key at config load
733
+ installProxyFetch(); // post-deploy channel API calls must honor HTTP(S)_PROXY under Node
684
734
  const { config } = await loadConfig(target).catch(failStartup);
685
735
  const modelSpec = resolveModelSpec(values.model, config);
686
736
  // The host-neutral pre-flight (model-travel gate, channel discovery, model-auth probe, container facts +
@@ -853,7 +903,7 @@ async function runDeployFly(params) {
853
903
  console.error(`[fastagent] deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`);
854
904
  process.exit(1);
855
905
  }
856
- const outcome = await deployFlyRun({ appName, region, secrets, missingSecrets, channels, flyConfig: "fly.toml" }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl));
906
+ const outcome = await deployFlyRun({ appName, region, secrets, missingSecrets, channels, flyConfig: "fly.toml" }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind));
857
907
  if (!outcome.ok) {
858
908
  console.error(`[fastagent] deploy stopped: ${outcome.gate}`);
859
909
  process.exit(1);
@@ -887,7 +937,7 @@ async function runDeployRailway(params) {
887
937
  console.error(`[fastagent] deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`);
888
938
  process.exit(1);
889
939
  }
890
- const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked: !!values["into-linked"] }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl));
940
+ const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked: !!values["into-linked"] }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl), (baseUrl, kind) => registerFeishuWebhook(baseUrl, kind));
891
941
  if (!outcome.ok) {
892
942
  console.error(`[fastagent] deploy stopped: ${outcome.gate}`);
893
943
  process.exit(1);
@@ -931,11 +981,6 @@ async function runLogin() {
931
981
  console.error(`[fastagent] logged in to ${result.provider} (${result.method}) — saved to ${authPath}`);
932
982
  process.exit(0); // the undici proxy agent's keep-alive sockets would otherwise hold the event loop open
933
983
  }
934
- /** Best-effort open a URL in the default browser; failure is fine (the URL is always printed too). */
935
- function openBrowser(url) {
936
- const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
937
- spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).on("error", () => { });
938
- }
939
984
  /** Login terminal IO via @clack/prompts: a searchable list once long, a hidden prompt for keys. */
940
985
  function terminalLoginIO() {
941
986
  return {
@@ -950,7 +995,7 @@ function terminalLoginIO() {
950
995
  return isCancel(r) ? undefined : r;
951
996
  },
952
997
  note: (message) => clackLog.info(message),
953
- openUrl: openBrowser,
998
+ openUrl: openExternalUrl,
954
999
  };
955
1000
  }
956
1001
  /**
@@ -1118,7 +1163,7 @@ function maybeTunnel(workspaceDir, boundPort) {
1118
1163
  void startCloudflareTunnel(boundPort).then((t) => {
1119
1164
  if (!t)
1120
1165
  return;
1121
- void announceWebhooks(workspaceDir, t.url);
1166
+ void announceWebhooks(workspaceDir, t.url, { openUrl: openExternalUrl });
1122
1167
  // Single-process (start / --no-watch): close the tunnel on exit (watch mode's supervisor owns its own).
1123
1168
  const cleanup = () => {
1124
1169
  t.close();
@@ -120,12 +120,16 @@ COPY . .
120
120
  CMD ["./node_modules/.bin/fastagent", "start", "/app"]
121
121
  `;
122
122
  }
123
- const DOCKERIGNORE = `node_modules
124
- .fastagent
125
- .env
126
- .env.*
127
- !.env.example
128
- *.log
123
+ /** Patterns are RECURSIVE (`**​/`) on purpose — dockerignore patterns are root-anchored (unlike
124
+ * .gitignore), and a repo-as-agent can hold nested projects: a bare `node_modules` would upload their
125
+ * build-machine deps (macOS binaries!) and a bare `.env` would bake their secrets into the image.
126
+ * `.git` stays root-anchored: nested projects' own `.git` ships (the agent's write-back needs it). */
127
+ const DOCKERIGNORE = `**/node_modules
128
+ **/.fastagent
129
+ **/.env
130
+ **/.env.*
131
+ !**/.env.example
132
+ **/*.log
129
133
  # .git is excluded to keep the image small. If your agent runs git on its OWN history
130
134
  # (git log/blame over the repo it ships in), delete the next line so that history is in the image.
131
135
  .git