@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
@@ -28,7 +28,7 @@ export interface FlyPlanInput extends ContainerInput {
28
28
  * `"OAuth"`/`"stored credential"` (a local login the server can't use), or undefined (unconfigured).
29
29
  */
30
30
  modelAuth: string | undefined;
31
- /** Channels discovered in the workspace — each contributes its required secrets + webhook step. */
31
+ /** Channels discovered in the workspace — each contributes its secret metadata + webhook step. */
32
32
  channels: ChannelKind[];
33
33
  /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
34
34
  extraSecrets?: string[];
@@ -1,5 +1,5 @@
1
1
  import { containerArtifacts } from "../container.js";
2
- import { isEnvKey, requiredSecrets } from "../secrets.js";
2
+ import { deploymentSecrets, isEnvKey } from "../secrets.js";
3
3
  function flyToml(appName, port, hasGithub, autostop, scaleToZero, hasTimeTriggers) {
4
4
  // min_machines_running: 1 (keep one up) when a github channel is present, TIME triggers exist, OR the
5
5
  // operator opted out of scale-to-zero. GitHub's is a SAFETY default — its fire-and-forget turns have no
@@ -60,7 +60,9 @@ export function planFlyDeploy(input) {
60
60
  // model key (when local auth is an env key) + every discovered channel's secrets. Names + hints as
61
61
  // COMMENT lines (a `#` inside a `\`-continued command would break the shell), then one flat, executable
62
62
  // `fly secrets set` the coding agent fills — `<value>` placeholders, never inline comments.
63
- const secrets = requiredSecrets(modelAuth, channels, input.extraSecrets);
63
+ const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets);
64
+ const requiredSecrets = secrets.filter((secret) => secret.required);
65
+ const optionalSecrets = secrets.filter((secret) => !secret.required);
64
66
  const deployCmd = kitDir
65
67
  ? `fly deploy . --config ${kitDir}/fly.toml --dockerfile ${kitDir}/Dockerfile --app ${appName}`
66
68
  : `fly deploy --app ${appName}`;
@@ -77,8 +79,11 @@ export function planFlyDeploy(input) {
77
79
  `# is the single source for the region; skip this if the volume exists (fly volumes list --app ${appName}):`,
78
80
  `fly volumes create data --app ${appName} --region <region> --size 1`,
79
81
  ];
80
- if (secrets.length > 0) {
81
- runbook.push(``, `# Secrets (replace each <value>):`, ...secrets.map((s) => `# ${s.name}: ${s.hint}`), `fly secrets set --app ${appName} ${secrets.map((s) => `${s.name}=<value>`).join(" ")}`);
82
+ if (requiredSecrets.length > 0) {
83
+ runbook.push(``, `# Required secrets (replace each <value>):`, ...requiredSecrets.map((s) => `# ${s.name}: ${s.hint}`), `fly secrets set --app ${appName} ${requiredSecrets.map((s) => `${s.name}=<value>`).join(" ")}`);
84
+ }
85
+ if (optionalSecrets.length > 0) {
86
+ runbook.push(``, `# Optional secrets — set only when the matching feature is configured:`, ...optionalSecrets.map((s) => `# ${s.name}: ${s.hint}`), `# fly secrets set --app ${appName} ${optionalSecrets.map((s) => `${s.name}=<value>`).join(" ")}`);
82
87
  }
83
88
  if (kitDir) {
84
89
  runbook.push(``, `# Repo-as-workspace: the build context is the REPO ROOT (the whole repo is the agent's cwd); the`, `# config/Dockerfile live under ${kitDir}/ so they never collide with the repo's own deploy files.`, `# Run this from the repo root:`);
@@ -106,6 +111,12 @@ export function planFlyDeploy(input) {
106
111
  if (channels.includes("github")) {
107
112
  post.push(`# After deploy — set the GitHub webhook (repo Settings → Webhooks). Path assumes the default route`, `# (POST /webhook); if you remapped it in channels/github.ts, use your path:`, `# Payload URL = https://${appName}.fly.dev/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
108
113
  }
114
+ for (const kind of ["feishu", "lark"]) {
115
+ if (!channels.includes(kind))
116
+ continue;
117
+ const label = kind === "feishu" ? "Feishu" : "Lark";
118
+ post.push(`# After deploy — set the ${label} event Request URL (developer console → Events & Callbacks).`, `# Path assumes the default route (POST /${kind}); the app must be RUNNING when you save (the console`, `# verifies the URL with a challenge):`, `# Request URL = https://${appName}.fly.dev/${kind}`);
119
+ }
109
120
  if (post.length > 0)
110
121
  runbook.push(``, ...post);
111
122
  // Single-machine tier: state lives on ONE volume tied to ONE machine. Scaling to multiple machines
@@ -3,7 +3,7 @@
3
3
  * secrets / deploy) that the plain runbook hands to the operator; `--run` executes it instead, so a
4
4
  * coding agent runs ONE command. Idempotent (app/volume check-then-act; channel secrets come from the
5
5
  * local env — NOT minted — so a re-run sets the same values) and resumable: it STOPS at a human gate
6
- * (not logged in, a missing secret value, a taken app name) with one actionable line and a non-zero
6
+ * (not logged in, a missing secret value, a taken app name, a failed webhook registration) with one actionable line and a non-zero
7
7
  * exit, so the agent clears the gate and re-runs. A `generate` channel secret absent from `.env` is a
8
8
  * gate too (`missingSecrets`), not a silent mint — fill it in `.env` (use the random string that
9
9
  * `add <channel>` prints).
@@ -17,6 +17,7 @@
17
17
  * (one machine, the single-machine tier). Secrets go in via `secrets import` over stdin, so values
18
18
  * never land in argv/process listings.
19
19
  */
20
+ import type { RegistrationOutcome } from "../../channels/registration.ts";
20
21
  import type { ChannelKind } from "../../scaffold/add-channel.ts";
21
22
  import type { CliRunner } from "../runner.ts";
22
23
  /**
@@ -45,7 +46,9 @@ export type FlyRunOutcome = {
45
46
  gate: string;
46
47
  };
47
48
  /**
48
- * Run the deploy through `fly`. `log` reports progress; `registerTelegram(baseUrl)` performs the
49
- * post-deploy webhook step (the CLI passes its telegram registrar). Every gate is fail-visible.
49
+ * Run the deploy through `fly`. `log` reports progress; `registerTelegram(baseUrl)` /
50
+ * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
51
+ * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
52
+ * instruction is printed. Every gate is fail-visible.
50
53
  */
51
- export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<void>): Promise<FlyRunOutcome>;
54
+ export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>): Promise<FlyRunOutcome>;
@@ -1,3 +1,4 @@
1
+ import { registrationGate } from "../registration-gate.js";
1
2
  /**
2
3
  * The bytes to seed to the auth file, or undefined to leave it alone — the pure core of `start`'s
3
4
  * FASTAGENT_AUTH_SEED materialization. ABSENT-ONLY by design: a present file (a refreshed volume copy)
@@ -18,10 +19,12 @@ function listHasName(stdout, name) {
18
19
  }
19
20
  }
20
21
  /**
21
- * Run the deploy through `fly`. `log` reports progress; `registerTelegram(baseUrl)` performs the
22
- * post-deploy webhook step (the CLI passes its telegram registrar). Every gate is fail-visible.
22
+ * Run the deploy through `fly`. `log` reports progress; `registerTelegram(baseUrl)` /
23
+ * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
24
+ * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
25
+ * instruction is printed. Every gate is fail-visible.
23
26
  */
24
- export async function deployFlyRun(plan, fly, log, registerTelegram) {
27
+ export async function deployFlyRun(plan, fly, log, registerTelegram, registerFeishu) {
25
28
  const gate = (g) => ({ ok: false, gate: g });
26
29
  // 1. Auth is the one gate a coding agent can't clear itself (browser OAuth). `whoami` succeeds with
27
30
  // either an interactive login or FLY_API_TOKEN, so one check covers both.
@@ -77,13 +80,31 @@ export async function deployFlyRun(plan, fly, log, registerTelegram) {
77
80
  return gate("`fly deploy` failed — see the flyctl output above; fix and re-run");
78
81
  }
79
82
  // 7. Post-deploy webhook — telegram end-to-end (fastagent has the token + the live URL); github is a
80
- // repo-settings step only a human can do.
83
+ // repo-settings step only a human can do. Gate policy is the shared registration-gate kernel
84
+ // (registrars report facts, it owns the policy); all channels are attempted first.
85
+ const reg = registrationGate(log, "re-run to retry registration (steps already done are skipped)");
81
86
  if (plan.channels.includes("telegram")) {
82
87
  log("registering telegram webhook…");
83
- await registerTelegram(`https://${plan.appName}.fly.dev`);
88
+ reg.track("telegram", await registerTelegram(`https://${plan.appName}.fly.dev`));
84
89
  }
85
90
  if (plan.channels.includes("github")) {
86
91
  log(`github: set the webhook in the repo (Settings → Webhooks) → https://${plan.appName}.fly.dev/webhook`);
92
+ reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
87
93
  }
94
+ for (const kind of ["feishu", "lark"]) {
95
+ if (!plan.channels.includes(kind))
96
+ continue;
97
+ if (registerFeishu) {
98
+ log(`registering ${kind} event URL…`);
99
+ reg.track(kind, await registerFeishu(`https://${plan.appName}.fly.dev`, kind));
100
+ }
101
+ else {
102
+ log(`${kind}: set the event Request URL in the developer console (Events & Callbacks) → https://${plan.appName}.fly.dev/${kind} (the app must be running when you save)`);
103
+ reg.track(kind, "manual"); // no registrar wired — the console step above is the operator's
104
+ }
105
+ }
106
+ const registrationGateMsg = reg.gate();
107
+ if (registrationGateMsg)
108
+ return gate(registrationGateMsg);
88
109
  return { ok: true };
89
110
  }
@@ -29,7 +29,7 @@ export interface RailwayPlanInput extends ContainerInput {
29
29
  serviceName: string;
30
30
  /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
31
31
  modelAuth: string | undefined;
32
- /** Channels discovered in the workspace — each contributes its required secrets + webhook step. */
32
+ /** Channels discovered in the workspace — each contributes its secret metadata + webhook step. */
33
33
  channels: ChannelKind[];
34
34
  /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
35
35
  extraSecrets?: string[];
@@ -1,5 +1,5 @@
1
1
  import { containerArtifacts } from "../container.js";
2
- import { isEnvKey, requiredSecrets } from "../secrets.js";
2
+ import { deploymentSecrets, isEnvKey } from "../secrets.js";
3
3
  /** State root = the volume mount path, kept in lockstep. `/data` matches the Fly recipe. */
4
4
  const MOUNT = "/data";
5
5
  /** railway.json — build/deploy only (Railway's config-as-code scope). No env/volume/sleeping here: those
@@ -23,7 +23,9 @@ export function planRailwayDeploy(input) {
23
23
  { path: configPath, content: railwayJson(input.kitDir) },
24
24
  ...containerArtifacts(input),
25
25
  ];
26
- const secrets = requiredSecrets(modelAuth, channels, input.extraSecrets);
26
+ const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets);
27
+ const requiredSecrets = secrets.filter((secret) => secret.required);
28
+ const optionalSecrets = secrets.filter((secret) => !secret.required);
27
29
  // Order matters, not cosmetics: `railway init` creates a PROJECT with no service, but the volume and
28
30
  // variables are service-scoped and `railway up` deploys THE service — so the service must exist first
29
31
  // (`railway add --service`), and variables must be set BEFORE the first `up` or the box boots without a
@@ -51,8 +53,11 @@ export function planRailwayDeploy(input) {
51
53
  `# Variables — set BEFORE the first deploy so the box boots with them. Railway injects PORT itself.`,
52
54
  `railway variables set FASTAGENT_STATE_DIR=${MOUNT}`,
53
55
  ];
54
- if (secrets.length > 0) {
55
- runbook.push(`# ${secrets.map((s) => `${s.name}: ${s.hint}`).join("\n# ")}`, `railway variables set ${secrets.map((s) => `${s.name}=<value>`).join(" ")}`);
56
+ if (requiredSecrets.length > 0) {
57
+ runbook.push(`# Required secrets:`, `# ${requiredSecrets.map((s) => `${s.name}: ${s.hint}`).join("\n# ")}`, `railway variables set ${requiredSecrets.map((s) => `${s.name}=<value>`).join(" ")}`);
58
+ }
59
+ if (optionalSecrets.length > 0) {
60
+ runbook.push(`# Optional secrets — set only when the matching feature is configured:`, `# ${optionalSecrets.map((s) => `${s.name}: ${s.hint}`).join("\n# ")}`, `# railway variables set ${optionalSecrets.map((s) => `${s.name}=<value>`).join(" ")}`);
56
61
  }
57
62
  // Model-auth guidance: an env key becomes a variable above. Otherwise the plan can't read the local
58
63
  // credential's value (OAuth or a stored key) to set it — same wording discipline as the Fly plan.
@@ -70,7 +75,8 @@ export function planRailwayDeploy(input) {
70
75
  }
71
76
  // The public URL is minted, not deterministic (unlike Fly's <app>.fly.dev) — ONE mint step, then each
72
77
  // channel's webhook uses that domain (mint once even when both channels are present).
73
- if (channels.includes("telegram") || channels.includes("github")) {
78
+ const hasFeishuCloudChannel = ["feishu", "lark"].some((kind) => channels.includes(kind));
79
+ if (channels.includes("telegram") || channels.includes("github") || hasFeishuCloudChannel) {
74
80
  runbook.push(``, `# Public URL — Railway mints a *.up.railway.app domain (NOT deterministic). Generate it, then read`, `# the printed https URL and use it as <your-domain> in the webhook step(s) below:`, `railway domain`);
75
81
  }
76
82
  if (channels.includes("telegram")) {
@@ -79,6 +85,12 @@ export function planRailwayDeploy(input) {
79
85
  if (channels.includes("github")) {
80
86
  runbook.push(`# Set the GitHub webhook (repo Settings → Webhooks). Default route POST /webhook; if you remapped it`, `# in channels/github.ts, use your path:`, `# Payload URL = https://<your-domain>/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
81
87
  }
88
+ for (const kind of ["feishu", "lark"]) {
89
+ if (!channels.includes(kind))
90
+ continue;
91
+ const label = kind === "feishu" ? "Feishu" : "Lark";
92
+ runbook.push(`# Set the ${label} event Request URL (developer console → Events & Callbacks). Default route`, `# POST /${kind}; the service must be RUNNING when you save (the console verifies with a challenge):`, `# Request URL = https://<your-domain>/${kind}`);
93
+ }
82
94
  // Scale-to-zero: App Sleeping is dashboard-only (no CLI/API) — a manual step, not a generated setting.
83
95
  // A github channel should NOT enable it: fire-and-forget reviews have no replay (unlike Telegram's L1
84
96
  // turn store), so a sleep mid-review would drop it — the same floor the Fly plan enforces via config.
@@ -23,6 +23,7 @@
23
23
  * no bulk stdin import like Fly's `secrets import`). Auth needs an ACCOUNT credential (login or
24
24
  * `RAILWAY_API_KEY`), not a project token: `init` creates a project that a project token can't predate.
25
25
  */
26
+ import type { RegistrationOutcome } from "../../channels/registration.ts";
26
27
  import type { ChannelKind } from "../../scaffold/add-channel.ts";
27
28
  import type { CliRunner } from "../runner.ts";
28
29
  export interface RailwayRunPlan {
@@ -75,7 +76,9 @@ export declare function parseDomainUrl(stdout: string): string | undefined;
75
76
  */
76
77
  export declare function parseHasVolume(stdout: string, mountPath: string): boolean;
77
78
  /**
78
- * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` performs the
79
- * post-deploy webhook step (the CLI passes its telegram registrar). Every gate is fail-visible.
79
+ * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` /
80
+ * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
81
+ * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
82
+ * instruction is printed. Every gate is fail-visible.
80
83
  */
81
- export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<void>): Promise<RailwayRunOutcome>;
84
+ export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>): Promise<RailwayRunOutcome>;
@@ -1,3 +1,4 @@
1
+ import { registrationGate } from "../registration-gate.js";
1
2
  /**
2
3
  * Whether `railway status --json` shows a linked project: non-empty stdout. Unlinked prints its message
3
4
  * to stderr and leaves stdout EMPTY (the exit code is 0 either way, so it can't be the signal). ANY
@@ -61,10 +62,12 @@ export function parseHasVolume(stdout, mountPath) {
61
62
  return jsonStrings(stdout).includes(mountPath);
62
63
  }
63
64
  /**
64
- * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` performs the
65
- * post-deploy webhook step (the CLI passes its telegram registrar). Every gate is fail-visible.
65
+ * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` /
66
+ * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
67
+ * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
68
+ * instruction is printed. Every gate is fail-visible.
66
69
  */
67
- export async function deployRailwayRun(plan, railway, log, registerTelegram) {
70
+ export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu) {
68
71
  const gate = (g) => ({ ok: false, gate: g });
69
72
  // Every --service below targets plan.name — the name this tool gives BOTH the project and the service
70
73
  // (`init --name` + `add --service`). On a fresh create they match; on `--into-linked` into a hand-made
@@ -165,12 +168,31 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram) {
165
168
  if (!url) {
166
169
  return gate("couldn't read a domain from `railway domain` — run `railway domain` manually, then set any webhook");
167
170
  }
171
+ // 7. Post-deploy webhook — gate policy is the shared registration-gate kernel (registrars report
172
+ // facts, it owns the policy); all channels are attempted first.
173
+ const reg = registrationGate(log, "re-run with --into-linked to retry registration");
168
174
  if (plan.channels.includes("telegram")) {
169
175
  log("registering telegram webhook…");
170
- await registerTelegram(url);
176
+ reg.track("telegram", await registerTelegram(url));
171
177
  }
172
178
  if (plan.channels.includes("github")) {
173
179
  log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
180
+ reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
181
+ }
182
+ for (const kind of ["feishu", "lark"]) {
183
+ if (!plan.channels.includes(kind))
184
+ continue;
185
+ if (registerFeishu) {
186
+ log(`registering ${kind} event URL…`);
187
+ reg.track(kind, await registerFeishu(url, kind));
188
+ }
189
+ else {
190
+ log(`${kind}: set the event Request URL in the developer console (Events & Callbacks) → ${url}/${kind} (the service must be running when you save)`);
191
+ reg.track(kind, "manual"); // no registrar wired — the console step above is the operator's
192
+ }
174
193
  }
194
+ const registrationGateMsg = reg.gate();
195
+ if (registrationGateMsg)
196
+ return gate(registrationGateMsg);
175
197
  return { ok: true, url };
176
198
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Host-NEUTRAL step-7 gate policy, shared by the host runners: the registrars report facts
3
+ * ({@link RegistrationOutcome}), this module owns what to do about them.
4
+ *
5
+ * - "failed" gates — exit 0 would tell a coding agent "done" while the deployed agent cannot receive
6
+ * messages, and the runners' earlier steps are idempotent so a re-run just retries registration.
7
+ * - "manual" does NOT gate — re-running can never change it (an unclearable gate would spin a coding
8
+ * agent forever) — but is re-surfaced after all registrar output (and before any gate message) so
9
+ * the operator cannot miss it under the registrar's earlier log lines.
10
+ *
11
+ * The runners attempt ALL channels first (one failure doesn't skip the rest), then apply this policy
12
+ * once. Only the retry remediation differs per host (`retryHint`).
13
+ */
14
+ import type { RegistrationOutcome } from "../channels/registration.ts";
15
+ export declare function registrationGate(log: (msg: string) => void, retryHint: string): {
16
+ track: (kind: string, outcome: RegistrationOutcome) => void;
17
+ /** Logs the manual notices; returns the gate message (composes with cli.ts's "deploy stopped:"
18
+ * prefix — it leads with the failure, not "the deploy succeeded"), or undefined for no gate. */
19
+ gate: () => string | undefined;
20
+ };
@@ -0,0 +1,20 @@
1
+ export function registrationGate(log, retryHint) {
2
+ const unregistered = [];
3
+ const manual = [];
4
+ return {
5
+ track(kind, outcome) {
6
+ if (outcome === "failed")
7
+ unregistered.push(kind);
8
+ if (outcome === "manual")
9
+ manual.push(kind);
10
+ },
11
+ gate() {
12
+ for (const kind of manual) {
13
+ log(`${kind}: webhook registration needs a one-time manual step — see the instructions above`);
14
+ }
15
+ if (unregistered.length === 0)
16
+ return undefined;
17
+ return `webhook registration failed for: ${unregistered.join(", ")} — the app itself deployed; fix the error above, then ${retryHint}`;
18
+ },
19
+ };
20
+ }
@@ -1,8 +1,7 @@
1
1
  /**
2
- * The secret set a deployed agent needs, computed from the definition — host-neutral. WHAT secrets are
3
- * required (the model key when local auth is an env key + every discovered channel's secrets) is the
4
- * same on every target; only the SET command differs (`fly secrets import` vs `railway variables set`).
5
- * The runbooks list these NAMES; `--run` (per host) reads their VALUES from the local env.
2
+ * The secret set a deployed agent needs, computed from the definition — host-neutral. Required values
3
+ * gate every target; optional channel values travel only when configured. Only the SET command differs
4
+ * (`fly secrets import` vs `railway variables set`). The runbooks list both classes; `--run` reads local values.
6
5
  */
7
6
  import { type ChannelKind } from "../scaffold/add-channel.ts";
8
7
  /**
@@ -13,13 +12,15 @@ import { type ChannelKind } from "../scaffold/add-channel.ts";
13
12
  */
14
13
  export declare function isEnvKey(source: string | undefined): source is string;
15
14
  /**
16
- * The required secret NAMES + hints for a runbook: the model key (when local auth is an env key) plus
17
- * every discovered channel's secrets. An OAuth/stored login has no env key here it carries as
15
+ * Secret NAMES + hints for a runbook: the model key (when local auth is an env key), discovered channel
16
+ * secrets, and config extras. Channel metadata keeps optional values visible without presenting them as
17
+ * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
18
18
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
19
19
  */
20
- export declare function requiredSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
20
+ export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
21
21
  name: string;
22
22
  hint: string;
23
+ required: boolean;
23
24
  }[];
24
25
  /**
25
26
  * Assemble the secret VALUES a `--run` deploy sets on the host, from the local credential + channels —
@@ -32,8 +33,8 @@ export declare function requiredSecrets(modelAuth: string | undefined, channels:
32
33
  *
33
34
  * Channel secrets come from the local env only — NEVER minted. A random mint would be wrong for a
34
35
  * human-shared secret (github's webhook secret must match the value the operator enters in the repo,
35
- * which a silent mint never surfaces) and would rotate every run (breaking idempotency). Absent
36
- * `missingSecrets`, same as the plain runbook's operator-filled placeholders.
36
+ * which a silent mint never surfaces) and would rotate every run (breaking idempotency). An absent
37
+ * required value enters `missingSecrets`; an absent optional value is simply omitted.
37
38
  */
38
39
  export declare function assembleSecrets(input: {
39
40
  modelAuth: string | undefined;
@@ -1,8 +1,7 @@
1
1
  /**
2
- * The secret set a deployed agent needs, computed from the definition — host-neutral. WHAT secrets are
3
- * required (the model key when local auth is an env key + every discovered channel's secrets) is the
4
- * same on every target; only the SET command differs (`fly secrets import` vs `railway variables set`).
5
- * The runbooks list these NAMES; `--run` (per host) reads their VALUES from the local env.
2
+ * The secret set a deployed agent needs, computed from the definition — host-neutral. Required values
3
+ * gate every target; optional channel values travel only when configured. Only the SET command differs
4
+ * (`fly secrets import` vs `railway variables set`). The runbooks list both classes; `--run` reads local values.
6
5
  */
7
6
  import { channelSetup } from "../scaffold/add-channel.js";
8
7
  /**
@@ -15,22 +14,23 @@ export function isEnvKey(source) {
15
14
  return source !== undefined && /^[A-Z][A-Z0-9_]*$/.test(source);
16
15
  }
17
16
  /**
18
- * The required secret NAMES + hints for a runbook: the model key (when local auth is an env key) plus
19
- * every discovered channel's secrets. An OAuth/stored login has no env key here it carries as
17
+ * Secret NAMES + hints for a runbook: the model key (when local auth is an env key), discovered channel
18
+ * secrets, and config extras. Channel metadata keeps optional values visible without presenting them as
19
+ * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
20
20
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
21
21
  */
22
- export function requiredSecrets(modelAuth, channels, extraSecrets = []) {
22
+ export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
23
23
  const secrets = [];
24
24
  if (isEnvKey(modelAuth))
25
- secrets.push({ name: modelAuth, hint: "your model provider key" });
25
+ secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
26
26
  for (const kind of channels) {
27
27
  for (const e of channelSetup(kind).env)
28
- secrets.push({ name: e.name, hint: e.hint });
28
+ secrets.push({ name: e.name, hint: e.hint, required: e.required });
29
29
  }
30
30
  // Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
31
31
  for (const name of extraSecrets) {
32
32
  if (!secrets.some((s) => s.name === name)) {
33
- secrets.push({ name, hint: "declared in fastagent.config deploy.secrets" });
33
+ secrets.push({ name, hint: "declared in fastagent.config deploy.secrets", required: true });
34
34
  }
35
35
  }
36
36
  return secrets;
@@ -46,8 +46,8 @@ export function requiredSecrets(modelAuth, channels, extraSecrets = []) {
46
46
  *
47
47
  * Channel secrets come from the local env only — NEVER minted. A random mint would be wrong for a
48
48
  * human-shared secret (github's webhook secret must match the value the operator enters in the repo,
49
- * which a silent mint never surfaces) and would rotate every run (breaking idempotency). Absent
50
- * `missingSecrets`, same as the plain runbook's operator-filled placeholders.
49
+ * which a silent mint never surfaces) and would rotate every run (breaking idempotency). An absent
50
+ * required value enters `missingSecrets`; an absent optional value is simply omitted.
51
51
  */
52
52
  export function assembleSecrets(input) {
53
53
  const secrets = {};
@@ -70,9 +70,10 @@ export function assembleSecrets(input) {
70
70
  for (const e of channelSetup(kind).env) {
71
71
  const v = input.env[e.name];
72
72
  if (v)
73
- secrets[e.name] = v;
74
- else
73
+ secrets[e.name] = v; // optional channel values travel when configured
74
+ else if (e.required) {
75
75
  missingSecrets.push(e.name); // operator-provided (in .env); a human-shared secret can't be minted
76
+ }
76
77
  }
77
78
  }
78
79
  for (const name of input.extraSecrets ?? []) {
@@ -16,6 +16,7 @@ import { watch as watchTree } from "chokidar";
16
16
  import { loadConfig, resolveAgentDir } from "./engines/pi/config.js";
17
17
  import { log } from "./log.js";
18
18
  import { installProxyFetch } from "./proxy.js";
19
+ import { openExternalUrl } from "./open-url.js";
19
20
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
20
21
  /** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
21
22
  export const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
@@ -99,7 +100,7 @@ export async function runDevSupervisor(dir, options = {}) {
99
100
  void startCloudflareTunnel(m.port).then((t) => {
100
101
  if (t) {
101
102
  tunnel = t;
102
- void announceWebhooks(agentDir, t.url);
103
+ void announceWebhooks(agentDir, t.url, { openUrl: openExternalUrl });
103
104
  }
104
105
  });
105
106
  }
@@ -7,7 +7,7 @@
7
7
  * AGENTS.md up to the repo root and discover skills from pi's own global dirs). So fastagent's
8
8
  * assembly is INJECTED into pi's session:
9
9
  * - prompt → systemPromptOverride = base + instructions ONLY; pi appends the skill section and env
10
- * (date/cwd) itself (including them here would duplicate both).
10
+ * (cwd) itself (including it here would duplicate it).
11
11
  * - skills → skillsOverride (fastagent's skills, for the section + invocation).
12
12
  * - tools → default coding tools by NAME (pi rebuilds them cwd-bound for rich rendering) +
13
13
  * fastagent's custom tools via pi's customTools path (so they survive /new, /resume, fork).
@@ -68,8 +68,8 @@ sessionManager) {
68
68
  parameters: t.parameters,
69
69
  execute: (id, params, signal) => t.execute(id, params, signal),
70
70
  }));
71
- // base + instructions ONLY — pi appends the skill section and env (date/cwd) itself (including
72
- // them here would duplicate both).
71
+ // base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
72
+ // them here would duplicate them).
73
73
  const systemPrompt = assembleSystemPrompt({
74
74
  base: piBasePrompt({ tools, persona: definition.persona }),
75
75
  contextFiles: definition.contextFiles,
@@ -46,7 +46,6 @@ export interface AssembleSystemPromptOptions {
46
46
  /** ③ Skills for the <available_skills> listing. */
47
47
  skills?: Skill[];
48
48
  /** ④ Env context, caller-provided (keeps this function pure). Omitted = segment omitted. */
49
- date?: string;
50
49
  cwd?: string;
51
50
  }
52
51
  export declare function assembleSystemPrompt(options: AssembleSystemPromptOptions): string;
@@ -54,10 +54,14 @@ export async function resolveWorkspaceTools(config, agentDir, cwd = agentDir) {
54
54
  //
55
55
  // systemPrompt = ① base (engine asset; a persona.md persona overrides its identity line)
56
56
  // + ② project context (AGENTS.md files via pi's loadProjectContextFiles, <project_context>-wrapped)
57
- // + ③ skills listing + ④ env context (date/cwd)
57
+ // + ③ skills listing + ④ env context (cwd)
58
58
  //
59
- // AGENTS.md ≠ system prompt. Pure functions: segment ④ inputs (date/cwd) are caller-provided, so the
60
- // same inputs always produce the same prompt (testable, reproducible).
59
+ // AGENTS.md ≠ system prompt. Pure functions: segment ④ input (cwd) is caller-provided, so the
60
+ // same inputs always produce the same prompt (testable, reproducible). No date: a date line would
61
+ // invalidate the provider prompt cache (a prefix cache) for every session at each day boundary —
62
+ // channel sessions routinely live for weeks (pi ≥0.80.7 dropped it from its default prompt for the
63
+ // same reason). The model gets the date when it needs it: `bash date`, and the wake tool takes
64
+ // relative delays ("30m") / cron — never an absolute now-derived instant.
61
65
  /**
62
66
  * The pi engine's base prompt (segment ①), mirroring pi-coding-agent's default path with two
63
67
  * deviations: the pi-TUI docs section is dropped (those paths don't exist in deployments), and the
@@ -96,8 +100,6 @@ export function assembleSystemPrompt(options) {
96
100
  if (options.skills && options.skills.length > 0) {
97
101
  prompt += `\n${formatSkillsForSystemPrompt(options.skills)}\n`;
98
102
  }
99
- if (options.date)
100
- prompt += `\nCurrent date: ${options.date}`;
101
103
  if (options.cwd)
102
104
  prompt += `\nCurrent working directory: ${options.cwd}`;
103
105
  return prompt;
@@ -185,7 +187,7 @@ export async function createPiAgentFromDefinition(dir, options) {
185
187
  // The directory is the agent, LIVE: re-read the definition on every invoke, so AGENTS.md/skills
186
188
  // edits (the author's, or the agent's own self-modification) take effect on the next turn with
187
189
  // no process restart — restarts are reserved for code (tools/channels/config, module cache).
188
- // One read yields prompt AND skills (they can never diverge), `date` is the turn's date, and the
190
+ // One read yields prompt AND skills (they can never diverge), and the
189
191
  // fs cost is a few reads against a model call. Broken edits stay visible: a throw-class problem
190
192
  // (unreadable AGENTS.md) fails that turn's invoke, and the loader's NON-fatal findings (bad
191
193
  // SKILL.md frontmatter, name collisions — returned as data, not thrown) are warned the moment
@@ -207,7 +209,6 @@ export async function createPiAgentFromDefinition(dir, options) {
207
209
  // ② project context: AGENTS.md files (agentDir + cwd-ancestor walk) via loadProjectContextFiles.
208
210
  contextFiles: def.contextFiles,
209
211
  skills: def.skills,
210
- date: new Date().toISOString().slice(0, 10),
211
212
  cwd: env.cwd,
212
213
  }),
213
214
  skills: def.skills,
@@ -0,0 +1,2 @@
1
+ /** `@fastagent-sh/fastagent/feishu` — the canonical Feishu (open.feishu.cn) bot-channel surface. */
2
+ export { feishuChannel, defaultFeishuRoute, feishuEnvelope, type FeishuChannelOptions, type FeishuMessageEvent, type FeishuMessage, type FeishuRoute, type FeishuFailure, } from "./channels/feishu/feishu.ts";
package/dist/feishu.js ADDED
@@ -0,0 +1,2 @@
1
+ /** `@fastagent-sh/fastagent/feishu` — the canonical Feishu (open.feishu.cn) bot-channel surface. */
2
+ export { feishuChannel, defaultFeishuRoute, feishuEnvelope, } from "./channels/feishu/feishu.js";
package/dist/lark.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ /** `@fastagent-sh/fastagent/lark` — the Lark-international compatibility surface over the canonical
2
+ * Feishu engine. Feishu tenants use `@fastagent-sh/fastagent/feishu`. */
3
+ export { larkChannel, defaultLarkRoute, larkEnvelope, type LarkChannelOptions, type LarkMessageEvent, type LarkMessage, type LarkRoute, type LarkFailure, } from "./channels/lark/lark.ts";
package/dist/lark.js ADDED
@@ -0,0 +1,3 @@
1
+ /** `@fastagent-sh/fastagent/lark` — the Lark-international compatibility surface over the canonical
2
+ * Feishu engine. Feishu tenants use `@fastagent-sh/fastagent/feishu`. */
3
+ export { larkChannel, defaultLarkRoute, larkEnvelope, } from "./channels/lark/lark.js";
@@ -0,0 +1,2 @@
1
+ /** Best-effort open a URL in the default browser. Callers must also print the URL for headless use. */
2
+ export declare function openExternalUrl(url: string): void;
@@ -0,0 +1,6 @@
1
+ import { spawn } from "node:child_process";
2
+ /** Best-effort open a URL in the default browser. Callers must also print the URL for headless use. */
3
+ export function openExternalUrl(url) {
4
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
5
+ spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).on("error", () => { });
6
+ }
@@ -1,8 +1,10 @@
1
- export type ChannelKind = "github" | "telegram";
1
+ export type ChannelKind = "github" | "telegram" | "feishu" | "lark";
2
2
  /** An env var a scaffolded channel reads. `generate` = a random-string secret the CLI can pre-fill. */
3
3
  export interface ChannelEnv {
4
4
  name: string;
5
5
  hint: string;
6
+ /** Required for the channel to run. Optional values are deployed when present but never gate deploy. */
7
+ required: boolean;
6
8
  generate?: boolean;
7
9
  }
8
10
  /** The channel kinds `fastagent add <kind>` can scaffold. */
@@ -26,11 +28,13 @@ export interface DotEnvWriteResult {
26
28
  }
27
29
  /**
28
30
  * Append generated channel secrets to the run-root `.env` (never `.env.example`) after the CLI has
29
- * verified that `.env` is gitignored. Existing non-empty values are kept. Manual values (e.g.
30
- * TELEGRAM_BOT_TOKEN from BotFather) are added only as commented placeholders, so the file is ready to
31
- * edit while no fake secret is committed to the user's mental model.
31
+ * verified that `.env` is gitignored. Existing non-empty values are kept EXCEPT the names listed in
32
+ * `overwrite`: those are authoritative (e.g. the credentials of an app `add feishu` JUST minted —
33
+ * skipping them for a stale value would silently discard a fresh, unrecoverable secret). Manual values
34
+ * (e.g. TELEGRAM_BOT_TOKEN from BotFather) are added only as commented placeholders, so the file is
35
+ * ready to edit while no fake secret is committed to the user's mental model.
32
36
  */
33
- export declare function appendChannelDotEnv(dir: string, kind: ChannelKind, generated: Record<string, string>): Promise<DotEnvWriteResult>;
37
+ export declare function appendChannelDotEnv(dir: string, kind: ChannelKind, generated: Record<string, string>, overwrite?: readonly string[]): Promise<DotEnvWriteResult>;
34
38
  /** Whether a channel file already exists — checked before any mutation, so a no-clobber re-add is side-effect-free. */
35
39
  export declare function channelExists(dir: string, kind: ChannelKind): Promise<boolean>;
36
40
  /**