@fastagent-sh/fastagent 0.12.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 (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/dist/agent.d.ts +72 -0
  4. package/dist/agent.js +14 -0
  5. package/dist/channels/body.d.ts +9 -0
  6. package/dist/channels/body.js +29 -0
  7. package/dist/channels/github/github.d.ts +31 -0
  8. package/dist/channels/github/github.js +80 -0
  9. package/dist/channels/github/scaffold/channel.ts +25 -0
  10. package/dist/channels/http.d.ts +13 -0
  11. package/dist/channels/http.js +161 -0
  12. package/dist/channels/respond.d.ts +5 -0
  13. package/dist/channels/respond.js +3 -0
  14. package/dist/channels/telegram/context-buffer.d.ts +59 -0
  15. package/dist/channels/telegram/context-buffer.js +139 -0
  16. package/dist/channels/telegram/invoke-turn.d.ts +53 -0
  17. package/dist/channels/telegram/invoke-turn.js +127 -0
  18. package/dist/channels/telegram/parse.d.ts +160 -0
  19. package/dist/channels/telegram/parse.js +153 -0
  20. package/dist/channels/telegram/preview.d.ts +22 -0
  21. package/dist/channels/telegram/preview.js +266 -0
  22. package/dist/channels/telegram/register-webhook.d.ts +13 -0
  23. package/dist/channels/telegram/register-webhook.js +73 -0
  24. package/dist/channels/telegram/scaffold/channel.ts +25 -0
  25. package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
  26. package/dist/channels/telegram/state.d.ts +9 -0
  27. package/dist/channels/telegram/state.js +58 -0
  28. package/dist/channels/telegram/telegram-api.d.ts +77 -0
  29. package/dist/channels/telegram/telegram-api.js +345 -0
  30. package/dist/channels/telegram/telegram.d.ts +35 -0
  31. package/dist/channels/telegram/telegram.js +352 -0
  32. package/dist/channels/telegram/turn-queue.d.ts +21 -0
  33. package/dist/channels/telegram/turn-queue.js +47 -0
  34. package/dist/channels/telegram/turn-store.d.ts +38 -0
  35. package/dist/channels/telegram/turn-store.js +150 -0
  36. package/dist/cli-auth.d.ts +24 -0
  37. package/dist/cli-auth.js +30 -0
  38. package/dist/cli-models.d.ts +11 -0
  39. package/dist/cli-models.js +20 -0
  40. package/dist/cli.d.ts +2 -0
  41. package/dist/cli.js +1288 -0
  42. package/dist/collect.d.ts +20 -0
  43. package/dist/collect.js +27 -0
  44. package/dist/core.d.ts +10 -0
  45. package/dist/core.js +8 -0
  46. package/dist/deploy/container.d.ts +56 -0
  47. package/dist/deploy/container.js +169 -0
  48. package/dist/deploy/fly/plan.d.ts +65 -0
  49. package/dist/deploy/fly/plan.js +142 -0
  50. package/dist/deploy/fly/run.d.ts +51 -0
  51. package/dist/deploy/fly/run.js +89 -0
  52. package/dist/deploy/preflight.d.ts +59 -0
  53. package/dist/deploy/preflight.js +191 -0
  54. package/dist/deploy/railway/plan.d.ts +48 -0
  55. package/dist/deploy/railway/plan.js +91 -0
  56. package/dist/deploy/railway/run.d.ts +81 -0
  57. package/dist/deploy/railway/run.js +176 -0
  58. package/dist/deploy/runner.d.ts +18 -0
  59. package/dist/deploy/runner.js +25 -0
  60. package/dist/deploy/secrets.d.ts +49 -0
  61. package/dist/deploy/secrets.js +88 -0
  62. package/dist/dev-supervisor.d.ts +14 -0
  63. package/dist/dev-supervisor.js +158 -0
  64. package/dist/engines/pi/auth.d.ts +17 -0
  65. package/dist/engines/pi/auth.js +118 -0
  66. package/dist/engines/pi/channel.d.ts +31 -0
  67. package/dist/engines/pi/channel.js +113 -0
  68. package/dist/engines/pi/chat.d.ts +19 -0
  69. package/dist/engines/pi/chat.js +200 -0
  70. package/dist/engines/pi/config.d.ts +110 -0
  71. package/dist/engines/pi/config.js +285 -0
  72. package/dist/engines/pi/create.d.ts +127 -0
  73. package/dist/engines/pi/create.js +222 -0
  74. package/dist/engines/pi/definition.d.ts +72 -0
  75. package/dist/engines/pi/definition.js +129 -0
  76. package/dist/engines/pi/harness.d.ts +50 -0
  77. package/dist/engines/pi/harness.js +41 -0
  78. package/dist/engines/pi/invoke.d.ts +28 -0
  79. package/dist/engines/pi/invoke.js +298 -0
  80. package/dist/engines/pi/login.d.ts +51 -0
  81. package/dist/engines/pi/login.js +125 -0
  82. package/dist/engines/pi/models.d.ts +41 -0
  83. package/dist/engines/pi/models.js +76 -0
  84. package/dist/engines/pi/report.d.ts +13 -0
  85. package/dist/engines/pi/report.js +21 -0
  86. package/dist/engines/pi/sessions.d.ts +15 -0
  87. package/dist/engines/pi/sessions.js +130 -0
  88. package/dist/engines/pi/tool-context.d.ts +17 -0
  89. package/dist/engines/pi/tool-context.js +13 -0
  90. package/dist/engines/pi/tool.d.ts +45 -0
  91. package/dist/engines/pi/tool.js +85 -0
  92. package/dist/engines/pi/wake-tool.d.ts +20 -0
  93. package/dist/engines/pi/wake-tool.js +110 -0
  94. package/dist/engines/pi/workspace.d.ts +53 -0
  95. package/dist/engines/pi/workspace.js +70 -0
  96. package/dist/env.d.ts +24 -0
  97. package/dist/env.js +57 -0
  98. package/dist/github.d.ts +3 -0
  99. package/dist/github.js +2 -0
  100. package/dist/host/node.d.ts +41 -0
  101. package/dist/host/node.js +50 -0
  102. package/dist/index.d.ts +2 -0
  103. package/dist/index.js +5 -0
  104. package/dist/invoke-stream.d.ts +10 -0
  105. package/dist/invoke-stream.js +26 -0
  106. package/dist/loader.d.ts +38 -0
  107. package/dist/loader.js +69 -0
  108. package/dist/log.d.ts +26 -0
  109. package/dist/log.js +51 -0
  110. package/dist/observe.d.ts +12 -0
  111. package/dist/observe.js +44 -0
  112. package/dist/pi.d.ts +16 -0
  113. package/dist/pi.js +12 -0
  114. package/dist/proxy.d.ts +9 -0
  115. package/dist/proxy.js +23 -0
  116. package/dist/runtime.d.ts +26 -0
  117. package/dist/runtime.js +29 -0
  118. package/dist/scaffold/add-channel.d.ts +46 -0
  119. package/dist/scaffold/add-channel.js +227 -0
  120. package/dist/scaffold/init.d.ts +54 -0
  121. package/dist/scaffold/init.js +249 -0
  122. package/dist/scaffold/templates/env.example +24 -0
  123. package/dist/scaffold/templates/fastagent.config.mjs +19 -0
  124. package/dist/scaffold/templates/gitignore +8 -0
  125. package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
  126. package/dist/scaffold/templates/gitignore.kit +2 -0
  127. package/dist/scaffold/templates/persona.md +10 -0
  128. package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
  129. package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
  130. package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
  131. package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
  132. package/dist/scaffold/templates.d.ts +17 -0
  133. package/dist/scaffold/templates.js +56 -0
  134. package/dist/scaffold/vendor-skill.d.ts +24 -0
  135. package/dist/scaffold/vendor-skill.js +157 -0
  136. package/dist/schedule/audit.d.ts +18 -0
  137. package/dist/schedule/audit.js +57 -0
  138. package/dist/schedule/cron.d.ts +7 -0
  139. package/dist/schedule/cron.js +33 -0
  140. package/dist/schedule/discover.d.ts +17 -0
  141. package/dist/schedule/discover.js +64 -0
  142. package/dist/schedule/schedule.d.ts +35 -0
  143. package/dist/schedule/schedule.js +23 -0
  144. package/dist/schedule/scheduler.d.ts +36 -0
  145. package/dist/schedule/scheduler.js +253 -0
  146. package/dist/schedule/state.d.ts +14 -0
  147. package/dist/schedule/state.js +56 -0
  148. package/dist/schedule/wakeups.d.ts +82 -0
  149. package/dist/schedule/wakeups.js +196 -0
  150. package/dist/telegram.d.ts +2 -0
  151. package/dist/telegram.js +2 -0
  152. package/dist/tunnel.d.ts +31 -0
  153. package/dist/tunnel.js +139 -0
  154. package/dist/version.d.ts +5 -0
  155. package/dist/version.js +13 -0
  156. package/dist/workspace.d.ts +9 -0
  157. package/dist/workspace.js +45 -0
  158. package/package.json +97 -0
@@ -0,0 +1,176 @@
1
+ /**
2
+ * Whether `railway status --json` shows a linked project: non-empty stdout. Unlinked prints its message
3
+ * to stderr and leaves stdout EMPTY (the exit code is 0 either way, so it can't be the signal). ANY
4
+ * non-empty output — parseable or not — counts as linked, so an unreadable shape is refused, never
5
+ * mistaken for unlinked (which would `init` a duplicate). Verified against CLI 5.15.0.
6
+ */
7
+ export function isLinked(stdout) {
8
+ return stdout.trim() !== "";
9
+ }
10
+ /** The linked project's name for the gate message, or undefined if it can't be read (still linked —
11
+ * `railway status --json` puts `name` at the top level on 5.15.0). */
12
+ export function linkedName(stdout) {
13
+ try {
14
+ const v = JSON.parse(stdout);
15
+ if (typeof v.name === "string")
16
+ return v.name;
17
+ }
18
+ catch {
19
+ /* non-JSON but non-empty → still linked, just no name to show */
20
+ }
21
+ return undefined;
22
+ }
23
+ /** Every string leaf of a parsed JSON value — the shape-agnostic scan both readers below share: Railway's
24
+ * `--json` field names aren't guaranteed, so we match on VALUES, not paths. `stdout` that isn't JSON
25
+ * (an empty/errored query) yields []. */
26
+ function jsonStrings(stdout) {
27
+ const walk = (v) => typeof v === "string"
28
+ ? [v]
29
+ : Array.isArray(v)
30
+ ? v.flatMap(walk)
31
+ : v && typeof v === "object"
32
+ ? Object.values(v).flatMap(walk)
33
+ : [];
34
+ try {
35
+ return walk(JSON.parse(stdout));
36
+ }
37
+ catch {
38
+ return [];
39
+ }
40
+ }
41
+ /**
42
+ * The first Railway-provided domain as an https URL, or undefined if none is present — reads `railway
43
+ * domain --json` without assuming exact field names: any string value carrying a `*.railway.app` host
44
+ * wins (unanchored, so a scheme/port/path around it still yields the bare host). Undefined → the CLI
45
+ * gates (register manually).
46
+ */
47
+ export function parseDomainUrl(stdout) {
48
+ for (const s of jsonStrings(stdout)) {
49
+ const host = s.match(/[a-z0-9-]+(?:\.[a-z0-9-]+)*\.railway\.app/i)?.[0];
50
+ if (host)
51
+ return `https://${host}`;
52
+ }
53
+ return undefined;
54
+ }
55
+ /**
56
+ * Whether `railway volume list --json` shows a volume at `mountPath` — shape-agnostic, like
57
+ * {@link parseDomainUrl}. Drives check-then-act so a half-provisioned project (service created, volume
58
+ * add failed) self-heals on re-run instead of silently deploying with no volume.
59
+ */
60
+ export function parseHasVolume(stdout, mountPath) {
61
+ return jsonStrings(stdout).includes(mountPath);
62
+ }
63
+ /**
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.
66
+ */
67
+ export async function deployRailwayRun(plan, railway, log, registerTelegram) {
68
+ const gate = (g) => ({ ok: false, gate: g });
69
+ // Every --service below targets plan.name — the name this tool gives BOTH the project and the service
70
+ // (`init --name` + `add --service`). On a fresh create they match; on `--into-linked` into a hand-made
71
+ // project whose service is named differently, the FIRST --service command (`variables set`) gates —
72
+ // and it's ordered before the volume (which has no --service), so the mismatch fails visibly with no
73
+ // side effect. (Pre-checking the name means walking status's nested multi-service shape — not worth it.)
74
+ const svc = ["--service", plan.name];
75
+ // 1. Auth needs an ACCOUNT credential (browser login or RAILWAY_API_KEY) — a project token can't
76
+ // predate the project `init` creates. `whoami` succeeds with either.
77
+ if ((await railway(["whoami"], { capture: true })).code !== 0) {
78
+ return gate("not logged in to Railway — run `railway login`, or set RAILWAY_API_KEY (an account token), then re-run");
79
+ }
80
+ // 2. Gate missing required secret VALUES before any side effect (no half-created infra).
81
+ if (plan.missingSecrets.length > 0) {
82
+ return gate(`no local value for: ${plan.missingSecrets.join(", ")} — set them in .env (or the environment) and re-run`);
83
+ }
84
+ // 3. Linked? `--run` provisions a project and only runs on an UNLINKED directory, so it can never deploy
85
+ // into a project it didn't create. A linked directory is REFUSED (naming the project so the operator
86
+ // sees what it is) UNLESS they pass --into-linked to provision INTO that project deliberately.
87
+ // Unlinked → create. No ownership is tracked — a pre-existing link is never assumed ours; the only
88
+ // "yes, this project" signal is the explicit --into-linked. Any non-empty status counts as linked (an
89
+ // unreadable shape is refused, not mistaken for unlinked, which would `init` a duplicate).
90
+ const status = await railway(["status", "--json"], { capture: true });
91
+ if (isLinked(status.stdout)) {
92
+ if (!plan.intoLinked) {
93
+ const name = linkedName(status.stdout);
94
+ return gate(`this directory is already linked to Railway project ${name ? `"${name}"` : "(name unreadable)"}. ` +
95
+ "`--run` provisions a NEW project and only runs on an unlinked directory — it won't deploy into an " +
96
+ "unrelated one. To redeploy an already-provisioned agent, run `railway up`. To provision the agent " +
97
+ "INTO this project, re-run with --into-linked. To start fresh, `railway unlink` first.");
98
+ }
99
+ log(`provisioning into linked project ${linkedName(status.stdout) ?? plan.name} (--into-linked)`);
100
+ }
101
+ else {
102
+ // --into-linked means "provision into the project this dir is linked to" — but it isn't linked. Don't
103
+ // swallow the flag silently: the operator expected an existing project; say we're creating a fresh one.
104
+ if (plan.intoLinked) {
105
+ log("warn: --into-linked was passed but this directory isn't linked to any project — creating a fresh one");
106
+ }
107
+ log(`creating project ${plan.name}…`);
108
+ if ((await railway(["init", "--name", plan.name])).code !== 0) {
109
+ return gate("`railway init` failed — if you have multiple workspaces, run it once interactively (or pass --workspace) to pick one, then re-run");
110
+ }
111
+ // Create + link the service (init makes only a project); it precedes the volume, which has no
112
+ // --service flag and rides the linked service.
113
+ log(`creating service ${plan.name}…`);
114
+ if ((await railway(["add", "--service", plan.name])).code !== 0) {
115
+ // Precise recovery, not "fix and re-run": init already created + linked the project, so a plain
116
+ // re-run hits the linked-gate, and --into-linked SKIPS `add` and then fails at the volume (no
117
+ // service to ride). The clean paths are to create the service by hand then --into-linked, or unlink.
118
+ return gate(`\`railway add --service\` failed — \`railway init\` already created the project (this directory is now ` +
119
+ `linked) but not the service. Finish with \`railway add --service ${plan.name}\` here, then re-run with ` +
120
+ `--into-linked; or \`railway unlink\` to detach and start fresh.`);
121
+ }
122
+ }
123
+ // 3b. Variables — set BEFORE the volume/deploy. This is deliberately the FIRST `--service` command: on
124
+ // an --into-linked into a hand-made project whose service name ≠ plan.name, it gates HERE, before the
125
+ // volume (which has no --service and would otherwise attach to that service) — so the mismatch fails
126
+ // visibly with NO side effect. State root on argv (not secret); secrets one-per-`set --stdin` (value
127
+ // on stdin, never argv). Idempotent. (Order vs the volume is free — both just need to precede `up`.)
128
+ log(`setting FASTAGENT_STATE_DIR + ${Object.keys(plan.secrets).length} secret(s)…`);
129
+ if ((await railway(["variables", "set", `FASTAGENT_STATE_DIR=${plan.mountPath}`, ...svc])).code !== 0) {
130
+ return gate("`railway variables set` failed — see the railway output above");
131
+ }
132
+ for (const [k, v] of Object.entries(plan.secrets)) {
133
+ if ((await railway(["variables", "set", k, "--stdin", ...svc], { input: v })).code !== 0) {
134
+ return gate(`\`railway variables set ${k}\` failed — see the railway output above`);
135
+ }
136
+ }
137
+ // 3c. Volume — check-then-act, ALWAYS (like Fly's per-resource skip-if-present). An --into-linked into an
138
+ // existing project may already have it; a first deploy that failed after this step has it, one that
139
+ // failed before needs it. Verify + add so we never deploy with NO persistence (FASTAGENT_STATE_DIR
140
+ // =/data would be empty every redeploy — silent state loss). Runs after variables so a mismatched
141
+ // service (see above) has already gated — this step won't attach a volume to the wrong service.
142
+ const vols = await railway(["volume", "list", "--json"], { capture: true });
143
+ if (parseHasVolume(vols.stdout, plan.mountPath)) {
144
+ log(`volume at ${plan.mountPath} exists — skipping`);
145
+ }
146
+ else {
147
+ log(`creating volume at ${plan.mountPath}…`);
148
+ if ((await railway(["volume", "add", "--mount-path", plan.mountPath])).code !== 0) {
149
+ return gate("`railway volume add` failed — see the railway output above");
150
+ }
151
+ }
152
+ // 5. Deploy — CI mode streams build logs then exits (no interactive attach). Build runs on Railway.
153
+ log("deploying (railway up)…");
154
+ if ((await railway(["up", "--ci", ...svc])).code !== 0) {
155
+ return gate("`railway up` failed — see the railway output above; fix and re-run");
156
+ }
157
+ // 6. Public domain — a Railway service isn't reachable until one is minted (unlike Fly's deterministic
158
+ // <app>.fly.dev), so every deploy needs it (the /invoke + /health surface), not just webhook channels.
159
+ // The top-level `railway domain` (no subcommand; --json/--service flags are fine) is the long-standing
160
+ // core command: it returns the service domain, minting one if absent. We do NOT pre-check with the
161
+ // `domain list` SUBCOMMAND — it's newer, and on a CLI without it `railway domain list` falls through
162
+ // to the [DOMAIN] positional and registers a bogus custom domain named "list" (destructive). Version-robust.
163
+ log("getting the public domain…");
164
+ const url = parseDomainUrl((await railway(["domain", "--json", ...svc], { capture: true })).stdout);
165
+ if (!url) {
166
+ return gate("couldn't read a domain from `railway domain` — run `railway domain` manually, then set any webhook");
167
+ }
168
+ if (plan.channels.includes("telegram")) {
169
+ log("registering telegram webhook…");
170
+ await registerTelegram(url);
171
+ }
172
+ if (plan.channels.includes("github")) {
173
+ log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
174
+ }
175
+ return { ok: true, url };
176
+ }
@@ -0,0 +1,18 @@
1
+ export interface RunResult {
2
+ code: number;
3
+ /** Captured stdout (for `--json` queries); empty when the command streamed to the terminal. The CLI's
4
+ * stderr is always inherited straight to the terminal, so it is not a field here. */
5
+ stdout: string;
6
+ }
7
+ /** Run `bin args`: `capture` collects stdout (for `--json` queries), else the command streams to the
8
+ * terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv). */
9
+ export type CliRunner = (args: string[], opts?: {
10
+ capture?: boolean;
11
+ input?: string;
12
+ }) => Promise<RunResult>;
13
+ /**
14
+ * Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the
15
+ * agent). stderr is always inherited to the terminal; stdout is inherited unless `capture`. A spawn
16
+ * ENOENT (the CLI not on PATH) resolves to code 127 so the caller can gate with an install hint.
17
+ */
18
+ export declare function spawnRunner(bin: string, cwd: string): CliRunner;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The host-CLI dispatcher seam, shared by every `deploy <host> --run` driver (fly/run.ts, railway/run.ts).
3
+ * WHAT it does — run a CLI, optionally capture stdout or feed stdin — is identical across hosts; only the
4
+ * binary and the command sequence differ. Tests inject a fake recorder; production spawns the real CLI.
5
+ */
6
+ import { spawn } from "node:child_process";
7
+ /**
8
+ * Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the
9
+ * agent). stderr is always inherited to the terminal; stdout is inherited unless `capture`. A spawn
10
+ * ENOENT (the CLI not on PATH) resolves to code 127 so the caller can gate with an install hint.
11
+ */
12
+ export function spawnRunner(bin, cwd) {
13
+ return (args, opts) => new Promise((res) => {
14
+ const child = spawn(bin, args, {
15
+ cwd,
16
+ stdio: [opts?.input ? "pipe" : "inherit", opts?.capture ? "pipe" : "inherit", "inherit"],
17
+ });
18
+ let out = "";
19
+ child.stdout?.on("data", (d) => (out += String(d)));
20
+ if (opts?.input)
21
+ child.stdin?.end(opts.input);
22
+ child.on("close", (code) => res({ code: code ?? 1, stdout: out }));
23
+ child.on("error", () => res({ code: 127, stdout: "" })); // ENOENT: bin not on PATH
24
+ });
25
+ }
@@ -0,0 +1,49 @@
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.
6
+ */
7
+ import { type ChannelKind } from "../scaffold/add-channel.ts";
8
+ /**
9
+ * Is this local auth source an env-var API key (→ becomes a deploy secret) vs OAuth / stored / none?
10
+ * Positive match on the UPPER_SNAKE env-var naming shape, NOT a negative exclude of today's sentinel
11
+ * labels: a new non-env `AuthResult.source` (e.g. `keychain`) then degrades to guidance, never a fake
12
+ * `keychain=<value>` secret — the two modules don't couple through an exhaustive string list.
13
+ */
14
+ export declare function isEnvKey(source: string | undefined): source is string;
15
+ /**
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
18
+ * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
19
+ */
20
+ export declare function requiredSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
21
+ name: string;
22
+ hint: string;
23
+ }[];
24
+ /**
25
+ * Assemble the secret VALUES a `--run` deploy sets on the host, from the local credential + channels —
26
+ * pure, host-neutral (Fly sets them via `fly secrets import`, Railway via `railway variables set`), so
27
+ * the security-sensitive key wiring is testable once. The model credential travels one of two ways: an
28
+ * env-key auth as its own secret (value from `env`), OR an OAuth/stored login (no plaintext key) as
29
+ * `FASTAGENT_AUTH_SEED` (base64 auth.json) which `start` materializes on first boot. `needsModelCredential`
30
+ * (neither present) is a DISTINCT signal: its remediation is `fastagent login`, not the `.env` one that
31
+ * `missingSecrets` (real secret NAMES with no value) carries.
32
+ *
33
+ * Channel secrets come from the local env only — NEVER minted. A random mint would be wrong for a
34
+ * 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.
37
+ */
38
+ export declare function assembleSecrets(input: {
39
+ modelAuth: string | undefined;
40
+ authFile: Buffer | undefined;
41
+ channels: ChannelKind[];
42
+ /** Extra secret env-var names from `fastagent.config` deploy.secrets — carried like channel secrets. */
43
+ extraSecrets?: string[];
44
+ env: NodeJS.ProcessEnv;
45
+ }): {
46
+ secrets: Record<string, string>;
47
+ missingSecrets: string[];
48
+ needsModelCredential: boolean;
49
+ };
@@ -0,0 +1,88 @@
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.
6
+ */
7
+ import { channelSetup } from "../scaffold/add-channel.js";
8
+ /**
9
+ * Is this local auth source an env-var API key (→ becomes a deploy secret) vs OAuth / stored / none?
10
+ * Positive match on the UPPER_SNAKE env-var naming shape, NOT a negative exclude of today's sentinel
11
+ * labels: a new non-env `AuthResult.source` (e.g. `keychain`) then degrades to guidance, never a fake
12
+ * `keychain=<value>` secret — the two modules don't couple through an exhaustive string list.
13
+ */
14
+ export function isEnvKey(source) {
15
+ return source !== undefined && /^[A-Z][A-Z0-9_]*$/.test(source);
16
+ }
17
+ /**
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
20
+ * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
21
+ */
22
+ export function requiredSecrets(modelAuth, channels, extraSecrets = []) {
23
+ const secrets = [];
24
+ if (isEnvKey(modelAuth))
25
+ secrets.push({ name: modelAuth, hint: "your model provider key" });
26
+ for (const kind of channels) {
27
+ for (const e of channelSetup(kind).env)
28
+ secrets.push({ name: e.name, hint: e.hint });
29
+ }
30
+ // Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
31
+ for (const name of extraSecrets) {
32
+ if (!secrets.some((s) => s.name === name)) {
33
+ secrets.push({ name, hint: "declared in fastagent.config deploy.secrets" });
34
+ }
35
+ }
36
+ return secrets;
37
+ }
38
+ /**
39
+ * Assemble the secret VALUES a `--run` deploy sets on the host, from the local credential + channels —
40
+ * pure, host-neutral (Fly sets them via `fly secrets import`, Railway via `railway variables set`), so
41
+ * the security-sensitive key wiring is testable once. The model credential travels one of two ways: an
42
+ * env-key auth as its own secret (value from `env`), OR an OAuth/stored login (no plaintext key) as
43
+ * `FASTAGENT_AUTH_SEED` (base64 auth.json) which `start` materializes on first boot. `needsModelCredential`
44
+ * (neither present) is a DISTINCT signal: its remediation is `fastagent login`, not the `.env` one that
45
+ * `missingSecrets` (real secret NAMES with no value) carries.
46
+ *
47
+ * Channel secrets come from the local env only — NEVER minted. A random mint would be wrong for a
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.
51
+ */
52
+ export function assembleSecrets(input) {
53
+ const secrets = {};
54
+ const missingSecrets = [];
55
+ let needsModelCredential = false;
56
+ if (isEnvKey(input.modelAuth)) {
57
+ const v = input.env[input.modelAuth];
58
+ if (v)
59
+ secrets[input.modelAuth] = v;
60
+ else
61
+ missingSecrets.push(input.modelAuth); // an env-key name with no value — `.env` remediation fits
62
+ }
63
+ else if (input.authFile) {
64
+ secrets.FASTAGENT_AUTH_SEED = input.authFile.toString("base64");
65
+ }
66
+ else {
67
+ needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
68
+ }
69
+ for (const kind of input.channels) {
70
+ for (const e of channelSetup(kind).env) {
71
+ const v = input.env[e.name];
72
+ if (v)
73
+ secrets[e.name] = v;
74
+ else
75
+ missingSecrets.push(e.name); // operator-provided (in .env); a human-shared secret can't be minted
76
+ }
77
+ }
78
+ for (const name of input.extraSecrets ?? []) {
79
+ if (name in secrets || missingSecrets.includes(name))
80
+ continue; // already covered by model/channel — no dup
81
+ const v = input.env[name];
82
+ if (v)
83
+ secrets[name] = v;
84
+ else
85
+ missingSecrets.push(name); // declared in config but no local value — same .env remediation
86
+ }
87
+ return { secrets, missingSecrets, needsModelCredential };
88
+ }
@@ -0,0 +1,14 @@
1
+ /** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
2
+ export declare const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
3
+ /**
4
+ * chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
5
+ * the whole subtree, so everything outside the allowlist — .fastagent state, node_modules, .git, and
6
+ * any file/dir the agent writes as work product — costs no watchers and triggers no restarts.
7
+ * Helper code imported from OUTSIDE tools//channels/ is out of scope by design (keep it under
8
+ * tools/, or restart manually) — the startup log names the watched set.
9
+ */
10
+ export declare function devWatchIgnored(dir: string, agentDir: string): (path: string) => boolean;
11
+ /** Spawn the dev worker and restart it on workspace edits; supervise its lifecycle until the process exits. */
12
+ export declare function runDevSupervisor(dir: string, options?: {
13
+ tunnel?: boolean;
14
+ }): Promise<void>;
@@ -0,0 +1,158 @@
1
+ /**
2
+ * The `fastagent dev` process supervisor: re-spawn the CLI as a worker (`FASTAGENT_DEV_WORKER=1`) and
3
+ * restart it on debounced edits to the workspace's CODE inputs. Each restart is a fresh process
4
+ * (always-latest, no stale module cache). The supervisor never exits on a bad edit — the worker fails
5
+ * loudly and it waits for the next save.
6
+ *
7
+ * Watch scope is deliberately narrow: only inputs whose changes REQUIRE a new process — imported
8
+ * code (tools/, channels/), fastagent.config.*, package.json, .env. The definition (AGENTS.md,
9
+ * persona.md, skills/) is re-read per invoke by the directory rung, so its edits go live on the next turn with no
10
+ * restart — and, critically, an agent that writes files into its own workspace (its normal work
11
+ * product, including editing its own AGENTS.md) never has its in-flight turn killed by the watcher.
12
+ */
13
+ import { spawn } from "node:child_process";
14
+ import { relative, sep } from "node:path";
15
+ import { watch as watchTree } from "chokidar";
16
+ import { loadConfig, resolveAgentDir } from "./engines/pi/config.js";
17
+ import { log } from "./log.js";
18
+ import { installProxyFetch } from "./proxy.js";
19
+ import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
20
+ /** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
21
+ export const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
22
+ /**
23
+ * chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
24
+ * the whole subtree, so everything outside the allowlist — .fastagent state, node_modules, .git, and
25
+ * any file/dir the agent writes as work product — costs no watchers and triggers no restarts.
26
+ * Helper code imported from OUTSIDE tools//channels/ is out of scope by design (keep it under
27
+ * tools/, or restart manually) — the startup log names the watched set.
28
+ */
29
+ export function devWatchIgnored(dir, agentDir) {
30
+ return (path) => {
31
+ if (path === dir || path === agentDir)
32
+ return false; // the roots themselves must not be pruned
33
+ // Never prune a directory on the path from the watch root down to agentDir, so chokidar can descend
34
+ // into `agentDir/tools` even when agentDir is a subdir (config.agentDir = "./agent").
35
+ if (agentDir.startsWith(path + sep))
36
+ return false;
37
+ const rel = relative(dir, path);
38
+ // Run-root (cwd) inputs: .env + fastagent.config.* live where config lives, not in agentDir.
39
+ if (rel === ".env")
40
+ return false;
41
+ if (/^fastagent\.config\.[cm]?[jt]s$/.test(rel))
42
+ return false;
43
+ // Agent code inputs live in agentDir: tools/, channels/, schedules/ (loaded once per worker — a
44
+ // restart is their only re-read), package.json (its own deps). Everything else under agentDir
45
+ // (skills/, persona.md, AGENTS.md) is live-read — pruned, no restart.
46
+ const relAgent = relative(agentDir, path);
47
+ if (!relAgent.startsWith("..")) {
48
+ const [head] = relAgent.split(sep);
49
+ if (head === "tools" || head === "channels" || head === "schedules")
50
+ return false;
51
+ if (relAgent === "package.json")
52
+ return false;
53
+ }
54
+ return true;
55
+ };
56
+ }
57
+ /** Spawn the dev worker and restart it on workspace edits; supervise its lifecycle until the process exits. */
58
+ export async function runDevSupervisor(dir, options = {}) {
59
+ // The watch root is `dir` (cwd); tools/channels the restart-watch cares about live in agentDir. On a
60
+ // config error, default agentDir=dir and let the spawned worker surface the real error (fail-visibly).
61
+ // agentDir is assumed STATIC for the dev session: the supervisor computes it once here and each spawned
62
+ // worker recomputes its own from the same config — config validation guarantees it stays under `dir`
63
+ // (so the watch scope is always right); a config edit that changes agentDir mid-session (rare) is out
64
+ // of scope for watch-scope re-sync (it triggers a worker restart regardless).
65
+ // A genuine config error (not just a missing agentDir key) — debug-log it here so the silence is not
66
+ // total before the spawned worker crash-loops and surfaces the real message; default agentDir=dir.
67
+ const config = await loadConfig(dir)
68
+ .then((r) => r.config)
69
+ .catch((err) => {
70
+ log.debug(`[fastagent] dev: config load failed while resolving agentDir (worker will report): ${String(err)}`);
71
+ return {};
72
+ });
73
+ const agentDir = resolveAgentDir(dir, config);
74
+ let worker;
75
+ let reloadPending = false;
76
+ let everServed = false; // has any worker successfully bound (sent `ready`) yet?
77
+ let timer;
78
+ // The supervisor owns the tunnel so the public URL survives worker reloads (a fresh tunnel per save
79
+ // would mean a new URL + re-registering the webhook on every edit).
80
+ let tunnel;
81
+ // The supervisor itself calls the channel webhook APIs (setWebhook) when announcing the tunnel, so
82
+ // it needs the proxy too (workers install their own). A region-blocked api.telegram.org fails otherwise.
83
+ if (options.tunnel)
84
+ installProxyFetch();
85
+ const spawnWorker = () => {
86
+ // ipc fd so the worker can signal readiness once it binds; stdio otherwise inherited.
87
+ // biome-ignore lint/style/noNonNullAssertion: argv[1] is always the script path under a node entry
88
+ const w = spawn(process.execPath, [process.argv[1], ...process.argv.slice(2)], {
89
+ stdio: ["inherit", "inherit", "inherit", "ipc"],
90
+ env: { ...process.env, FASTAGENT_DEV_WORKER: "1" },
91
+ });
92
+ worker = w;
93
+ w.on("message", (m) => {
94
+ if (m?.type !== "ready")
95
+ return;
96
+ everServed = true;
97
+ // Start the tunnel once, on the first worker that binds; reuse it across reloads.
98
+ if (options.tunnel && !tunnel && typeof m.port === "number") {
99
+ void startCloudflareTunnel(m.port).then((t) => {
100
+ if (t) {
101
+ tunnel = t;
102
+ void announceWebhooks(agentDir, t.url);
103
+ }
104
+ });
105
+ }
106
+ });
107
+ w.on("exit", (code, signal) => {
108
+ if (worker !== w)
109
+ return; // already superseded
110
+ worker = undefined;
111
+ if (reloadPending) {
112
+ reloadPending = false;
113
+ spawnWorker(); // restart requested: the old worker has exited, so the port is free
114
+ }
115
+ else if (!everServed) {
116
+ // Failed BEFORE ever serving — a non-editable startup failure (bad flag, EADDRINUSE, broken
117
+ // initial workspace) that saving cannot fix. Propagate the exit code (the worker already
118
+ // printed the error via inherited stdio).
119
+ process.exit(code ?? 1);
120
+ }
121
+ else {
122
+ // A worker that HAD been serving stopped (broken edit or crash). Fixable; wait for the next save.
123
+ log.warn(`[fastagent] dev stopped (worker exited: ${signal ?? code}) — save a change to retry`);
124
+ }
125
+ });
126
+ };
127
+ const triggerReload = () => {
128
+ log.info(`[fastagent] change detected — restarting…`);
129
+ if (worker) {
130
+ reloadPending = true;
131
+ worker.kill("SIGTERM"); // the exit handler respawns once the port is released
132
+ }
133
+ else {
134
+ spawnWorker(); // worker was down (broken edit) — retry now
135
+ }
136
+ };
137
+ // chokidar gives reliable cross-platform recursion + structural ignore that native fs.watch
138
+ // cannot; devWatchIgnored (above) narrows the scope to the process-bound code inputs.
139
+ const watcher = watchTree(dir, {
140
+ ignoreInitial: true, // the startup scan is not a change
141
+ ignored: devWatchIgnored(dir, agentDir),
142
+ });
143
+ watcher.on("all", () => {
144
+ clearTimeout(timer);
145
+ timer = setTimeout(triggerReload, 200);
146
+ });
147
+ watcher.on("error", (error) => log.warn(`[fastagent] file watching error (${error.message}); some edits may need a manual restart`));
148
+ log.info(`[fastagent] watching ${WATCHED_HINT} — code edits restart the dev worker (--no-watch to disable); AGENTS.md/persona.md/skills edits go live next turn without a restart`);
149
+ const shutdown = () => {
150
+ worker?.kill("SIGTERM");
151
+ tunnel?.close();
152
+ void watcher.close();
153
+ process.exit(0);
154
+ };
155
+ process.on("SIGINT", shutdown);
156
+ process.on("SIGTERM", shutdown);
157
+ spawnWorker();
158
+ }
@@ -0,0 +1,17 @@
1
+ import type { CredentialStore } from "@earendil-works/pi-ai";
2
+ /**
3
+ * The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`). The project-level default is
4
+ * `<dir>/.fastagent/auth.json` (computed by the opener and by `fastagent login`); this is only the
5
+ * `loginFlow()` PROGRAMMATIC fallback (when a caller omits `authPath`) and the path to point
6
+ * `--auth-path`/`FASTAGENT_AUTH_PATH` at to deliberately share ONE credential file across projects
7
+ * (safe — one file, one lock-serialized refresh lifecycle). The `fastagent login` CLI is project-
8
+ * level by default, never this.
9
+ */
10
+ export declare const GLOBAL_AUTH_PATH: string;
11
+ export interface FastagentAuthOptions {
12
+ /** Sink for non-fatal auth anomalies (unreadable/corrupt file). Defaults to the process logger (warn). */
13
+ warn?: (message: string) => void;
14
+ }
15
+ /** A read-write `CredentialStore` backed by the given credentials file (default {@link GLOBAL_AUTH_PATH};
16
+ * the directory opener passes the project-level `<dir>/.fastagent/auth.json`). */
17
+ export declare function fastagentCredentialStore(authPath?: string, options?: FastagentAuthOptions): CredentialStore;