@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,20 @@
1
+ /**
2
+ * Buffered consumption helper (caller-side, SPEC §7): reduce an AgentEvent stream to a final value,
3
+ * encoding the terminal discipline (failed → throw, missing terminal → error). Streaming consumers
4
+ * for-await themselves.
5
+ */
6
+ import type { AgentEvent, Json } from "./agent.ts";
7
+ /** Exception form of a failed event (thrown by collect). Carries the failed event's fields verbatim, so
8
+ * a buffered consumer can branch on `code` (SPEC §8 failure subdivision) just like a streaming one. */
9
+ export declare class AgentFailure extends Error {
10
+ readonly details: string;
11
+ readonly retryable: boolean;
12
+ /** Machine-readable failure subdivision (SPEC §8), when the engine set one. */
13
+ readonly code?: string;
14
+ constructor(details: string, retryable: boolean, code?: string);
15
+ }
16
+ export interface CollectResult {
17
+ text: string;
18
+ data?: Json;
19
+ }
20
+ export declare function collect(events: AsyncIterable<AgentEvent>): Promise<CollectResult>;
@@ -0,0 +1,27 @@
1
+ /** Exception form of a failed event (thrown by collect). Carries the failed event's fields verbatim, so
2
+ * a buffered consumer can branch on `code` (SPEC §8 failure subdivision) just like a streaming one. */
3
+ export class AgentFailure extends Error {
4
+ details;
5
+ retryable;
6
+ /** Machine-readable failure subdivision (SPEC §8), when the engine set one. */
7
+ code;
8
+ constructor(details, retryable, code) {
9
+ super(details);
10
+ this.name = "AgentFailure";
11
+ this.details = details;
12
+ this.retryable = retryable;
13
+ this.code = code;
14
+ }
15
+ }
16
+ export async function collect(events) {
17
+ let text = "";
18
+ for await (const e of events) {
19
+ if (e.type === "text")
20
+ text += e.delta;
21
+ else if (e.type === "completed")
22
+ return { text, data: e.data };
23
+ else if (e.type === "failed")
24
+ throw new AgentFailure(e.details, e.retryable, e.code);
25
+ }
26
+ throw new Error("stream ended without a terminal event"); // violates SPEC MUST 1
27
+ }
package/dist/core.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ export type { Agent, AgentEvent, ImageRef, Json, Prompt, Scope } from "./agent.ts";
2
+ export { collect, AgentFailure, type CollectResult } from "./collect.ts";
3
+ export type { ModuleLoadFailure } from "./loader.ts";
4
+ export { createInvokeHandler, nodeListener } from "./channels/http.ts";
5
+ export { readBodyCapped } from "./channels/body.ts";
6
+ export { text, textHeaders } from "./channels/respond.ts";
7
+ export { type ChannelContext, type ChannelHandler, type ChannelModule, type Routes, router, serveNode, } from "./host/node.ts";
8
+ export { defineSchedule, type LoadedSchedule, type Schedule } from "./schedule/schedule.ts";
9
+ export { discoverScheduleFiles, loadSchedules } from "./schedule/discover.ts";
10
+ export { createScheduler, scheduleSession, type Scheduler, type SchedulerOptions } from "./schedule/scheduler.ts";
package/dist/core.js ADDED
@@ -0,0 +1,8 @@
1
+ export { collect, AgentFailure } from "./collect.js";
2
+ export { createInvokeHandler, nodeListener } from "./channels/http.js";
3
+ export { readBodyCapped } from "./channels/body.js";
4
+ export { text, textHeaders } from "./channels/respond.js";
5
+ export { router, serveNode, } from "./host/node.js";
6
+ export { defineSchedule } from "./schedule/schedule.js";
7
+ export { discoverScheduleFiles, loadSchedules } from "./schedule/discover.js";
8
+ export { createScheduler, scheduleSession } from "./schedule/scheduler.js";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The portable container — Dockerfile + .dockerignore, host-neutral. Every deploy target ships these
3
+ * unchanged; only the host config (fly.toml / railway.json) and the CLI sequence differ. The image
4
+ * runs `fastagent start /app` on `$PORT` with state under `$FASTAGENT_STATE_DIR` — standard container
5
+ * conventions, no host coupling. Extracted from the Fly plan when Railway became the second target.
6
+ */
7
+ export interface Artifact {
8
+ path: string;
9
+ content: string;
10
+ }
11
+ /** Fixed first-line prefix of a fastagent-generated Dockerfile — the ONE source both the generator (the
12
+ * `head` below) and the "is this ours?" check (cli deploy's kept-Dockerfile apt warn) share, so they
13
+ * can't drift into detecting a marker the generator no longer writes. */
14
+ export declare const GENERATED_DOCKERFILE_MARKER = "# Generated by `fastagent deploy`";
15
+ /** The ONE predicate for "did fastagent generate this Dockerfile?" — a generated file opens with the
16
+ * marker, so a hand-written one (or a file where the marker was dropped to take ownership) reads false.
17
+ * Both the "keep vs flag-as-stale" decision and the apt warning share this; a split would let them
18
+ * disagree on the same file (e.g. a marker not at byte 0). */
19
+ export declare function isGeneratedDockerfile(content: string): boolean;
20
+ export interface ContainerInput {
21
+ /** Whether the workspace has a package.json (a code workspace); else a pure markdown/skills agent. */
22
+ hasPackageJson: boolean;
23
+ /** The package manager the generated image targets: `bun` (packageManager: bun / a bun lockfile) gets an
24
+ * `oven/bun` base + `bun install` + `bun run fastagent`; otherwise npm on `node:22-slim`. */
25
+ runtime: "node" | "bun";
26
+ /** Whether the runtime's lockfile exists (package-lock.json for npm, bun.lock/bun.lockb for bun) — a
27
+ * frozen/ci install needs it; without it the build resolves versions at build time (not reproducible). */
28
+ hasLockfile: boolean;
29
+ /** For `runtime: "bun"`, the `FROM oven/bun:<tag>` version (from packageManager `bun@x`), else `"1"`. */
30
+ bunVersion?: string;
31
+ /** This fastagent version, to PIN the global install on the markdown path (reproducible redeploys). */
32
+ version: string;
33
+ /** Extra apt packages (fastagent.config deploy.apt) baked in for the agent's tools — git, ripgrep, …. */
34
+ apt?: string[];
35
+ /**
36
+ * Repo-as-workspace layout: the agent kit's subdirectory relative to the repo root (e.g. "agent" —
37
+ * `config.agentDir` resolved). When set, the image bakes the WHOLE repo as the agent's cwd (§11 bake
38
+ * decision), installs the KIT's deps (not the host's — the agent installs those in its workspace at
39
+ * runtime when its job needs them), ships `.git` (write-back via commit/push needs it), and the
40
+ * artifacts are namespaced under the kit so they never collide with the host repo's own
41
+ * Dockerfile/.dockerignore. The runtime facts (hasPackageJson/runtime/hasLockfile) describe the KIT.
42
+ */
43
+ kitDir?: string;
44
+ }
45
+ /**
46
+ * The Dockerfile + ignore artifacts — spread into any host's artifact list. Kit layout ({@link
47
+ * ContainerInput.kitDir}): the Dockerfile is namespaced under the kit (`agent/Dockerfile`) so it never
48
+ * collides with the host repo's own. The ignore ships in TWO forms because context packing is
49
+ * host-CLI-owned and inconsistent: (1) a ROOT `.dockerignore` — the only form flyctl/railway's own
50
+ * context packers reliably read (kept if the host already has one — preflight then warns specifically
51
+ * about a .git exclude / missing recursive node_modules) — and (2) a
52
+ * per-Dockerfile `agent/Dockerfile.dockerignore` for plain docker/buildx builds. Whether `.git`
53
+ * actually reaches the box is host-CLI-dependent (some strip it from the upload regardless of any
54
+ * ignore file) — the runbook's write-back note carries the runtime-clone fallback.
55
+ */
56
+ export declare function containerArtifacts(input: ContainerInput): Artifact[];
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The portable container — Dockerfile + .dockerignore, host-neutral. Every deploy target ships these
3
+ * unchanged; only the host config (fly.toml / railway.json) and the CLI sequence differ. The image
4
+ * runs `fastagent start /app` on `$PORT` with state under `$FASTAGENT_STATE_DIR` — standard container
5
+ * conventions, no host coupling. Extracted from the Fly plan when Railway became the second target.
6
+ */
7
+ /** Fixed first-line prefix of a fastagent-generated Dockerfile — the ONE source both the generator (the
8
+ * `head` below) and the "is this ours?" check (cli deploy's kept-Dockerfile apt warn) share, so they
9
+ * can't drift into detecting a marker the generator no longer writes. */
10
+ export const GENERATED_DOCKERFILE_MARKER = "# Generated by `fastagent deploy`";
11
+ /** The ONE predicate for "did fastagent generate this Dockerfile?" — a generated file opens with the
12
+ * marker, so a hand-written one (or a file where the marker was dropped to take ownership) reads false.
13
+ * Both the "keep vs flag-as-stale" decision and the apt warning share this; a split would let them
14
+ * disagree on the same file (e.g. a marker not at byte 0). */
15
+ export function isGeneratedDockerfile(content) {
16
+ return content.startsWith(GENERATED_DOCKERFILE_MARKER);
17
+ }
18
+ /** The repo-as-workspace Dockerfile: bake the repo as cwd, install the kit's deps, run from the kit. */
19
+ function kitDockerfile(input, kit) {
20
+ const apt = aptLayer(input.apt);
21
+ if (!input.hasPackageJson) {
22
+ // Markdown-only kit: no deps to install — the pinned global CLI serves the repo directly.
23
+ return `${GENERATED_DOCKERFILE_MARKER}. Repo-as-workspace: the whole repo is the agent's cwd; the kit lives in ${kit}/.
24
+ FROM node:22-slim
25
+ ${apt}WORKDIR /app
26
+ RUN npm i -g @fastagent-sh/fastagent@${input.version}
27
+ COPY . .
28
+ CMD ["fastagent", "start", "/app"]
29
+ `;
30
+ }
31
+ const isBun = input.runtime === "bun";
32
+ const base = isBun ? `oven/bun:${input.bunVersion ?? "1"}` : "node:22-slim";
33
+ const head = `${GENERATED_DOCKERFILE_MARKER}. Repo-as-workspace: the whole repo is the agent's cwd; the kit lives in ${kit}/.
34
+ # Only the KIT's deps are installed — the host repo's own deps are the agent's runtime concern (it can
35
+ # install them in its workspace when its job needs them). .git IS shipped: write-back needs it.
36
+ FROM ${base}
37
+ ${apt}WORKDIR /app
38
+ `;
39
+ if (isBun) {
40
+ const install = input.hasLockfile ? "bun install --frozen-lockfile" : "bun install";
41
+ return `${head}COPY ${kit}/package.json ${kit}/bun.lock* ./${kit}/
42
+ RUN cd ${kit} && ${install}
43
+ COPY . .
44
+ CMD ["sh", "-c", "cd ${kit} && bun run fastagent start /app"]
45
+ `;
46
+ }
47
+ const install = input.hasLockfile ? "npm ci" : "npm install";
48
+ return `${head}COPY ${kit}/package.json ${kit}/package-lock.json* ./${kit}/
49
+ RUN cd ${kit} && ${install}
50
+ COPY . .
51
+ CMD ["./${kit}/node_modules/.bin/fastagent", "start", "/app"]
52
+ `;
53
+ }
54
+ /** The apt layer (cached right after FROM). Debian default repos only. */
55
+ function aptLayer(packages) {
56
+ return packages && packages.length > 0
57
+ ? `RUN apt-get update \\
58
+ && apt-get install -y --no-install-recommends ${packages.join(" ")} \\
59
+ && rm -rf /var/lib/apt/lists/*
60
+ `
61
+ : "";
62
+ }
63
+ function dockerfile(input) {
64
+ // apt layer right after FROM (cached across code changes): the agent's tools may shell out to git etc.,
65
+ // which node:22-slim lacks. Debian default repos only — a package needing a custom repo (gh) or a
66
+ // different base is the operator's own Dockerfile (kept if present). deploy.apt is package-name-validated.
67
+ const apt = aptLayer(input.apt);
68
+ // No package.json → pure markdown/skills agent: install the pinned CLI GLOBALLY and run `fastagent`
69
+ // from PATH. That needs npm + a global bin, which live on node:22-slim, NOT on oven/bun — so this path
70
+ // pins node:22-slim regardless of input.runtime (a stray bun lockfile with no package.json would
71
+ // otherwise select a bun base here and crash the `npm i -g` build). input.runtime is meaningful only
72
+ // for a code workspace, so the bun-vs-node base below is scoped to that path.
73
+ if (!input.hasPackageJson) {
74
+ return `${GENERATED_DOCKERFILE_MARKER}. The directory IS the agent — no build step.
75
+ # npm-based (a markdown/skills agent installs the pinned CLI globally; node:22-slim has npm).
76
+ FROM node:22-slim
77
+ ${apt}WORKDIR /app
78
+ RUN npm i -g @fastagent-sh/fastagent@${input.version}
79
+ COPY . .
80
+ CMD ["fastagent", "start", "/app"]
81
+ `;
82
+ }
83
+ const isBun = input.runtime === "bun";
84
+ const base = isBun ? `oven/bun:${input.bunVersion ?? "1"}` : "node:22-slim";
85
+ const note = isBun
86
+ ? "# Bun-based (packageManager: bun / a bun lockfile detected). fastagent runs under Bun (bun run)."
87
+ : "# npm-based — for pnpm/yarn, adapt the install line + the lockfile COPY (corepack enable, etc.).";
88
+ const head = `${GENERATED_DOCKERFILE_MARKER}. The directory IS the agent — no build step.
89
+ ${note}
90
+ FROM ${base}
91
+ ${apt}WORKDIR /app
92
+ `;
93
+ // Install ALL deps (no --omit=dev / --production): a repo-as-agent (e.g. an Astro site it operates on)
94
+ // needs its full toolchain — the build/check tools that live in devDependencies — to do its work, and
95
+ // we can't tell a repo-as-agent from a purpose-built workspace, so the safe default keeps everything.
96
+ if (isBun) {
97
+ // `--frozen-lockfile` needs bun.lock and hard-fails without it; fall back to a plain `bun install`
98
+ // (resolves at build time — not reproducible; the CLI warns to commit the lockfile).
99
+ // `bun run` executes the LOCAL node_modules/.bin entry only — never the registry. A bare
100
+ // `bunx fastagent` would fall back to installing the npm package named `fastagent`, which is an
101
+ // unrelated third-party package (ours is the scoped @fastagent-sh/fastagent).
102
+ const install = input.hasLockfile ? "bun install --frozen-lockfile" : "bun install";
103
+ return `${head}COPY package.json bun.lock* ./
104
+ RUN ${install}
105
+ COPY . .
106
+ CMD ["bun", "run", "fastagent", "start", "/app"]
107
+ `;
108
+ }
109
+ // `npm ci` requires a lockfile and hard-fails without one (a common `init --no-install` workspace);
110
+ // fall back to `npm install` when there is none so the build never breaks. Caveat: `npm install`
111
+ // resolves caret ranges at build time, so THIS branch is NOT reproducible — unlike the pinned
112
+ // `npm ci` (lockfile) and pinned global (markdown) paths. The CLI warns to commit a lockfile.
113
+ // The explicit local bin, not `npx fastagent`: npx falls back to fetching the npm package named
114
+ // `fastagent` when the dep is absent — an unrelated third-party package (ours is scoped). The local
115
+ // path fails fast and visibly instead.
116
+ const install = input.hasLockfile ? "npm ci" : "npm install";
117
+ return `${head}COPY package.json package-lock.json* ./
118
+ RUN ${install}
119
+ COPY . .
120
+ CMD ["./node_modules/.bin/fastagent", "start", "/app"]
121
+ `;
122
+ }
123
+ const DOCKERIGNORE = `node_modules
124
+ .fastagent
125
+ .env
126
+ .env.*
127
+ !.env.example
128
+ *.log
129
+ # .git is excluded to keep the image small. If your agent runs git on its OWN history
130
+ # (git log/blame over the repo it ships in), delete the next line so that history is in the image.
131
+ .git
132
+ `;
133
+ /** The kit-layout ignore: `.git` is deliberately NOT excluded (write-back wants it — though whether it
134
+ * survives is host-CLI-dependent; see containerArtifacts). Patterns are RECURSIVE (`**​/`) on purpose —
135
+ * dockerignore patterns are root-anchored (unlike .gitignore), so a bare `node_modules` would NOT
136
+ * exclude `agent/node_modules`: the build machine's kit deps (macOS binaries!) would be uploaded by
137
+ * `COPY . .` and clobber the image's freshly-installed linux ones. */
138
+ const KIT_DOCKERIGNORE = `**/node_modules
139
+ **/.fastagent
140
+ **/.env
141
+ **/.env.*
142
+ !**/.env.example
143
+ **/*.log
144
+ # .git is DELIBERATELY shipped: the agent's write-back (commit/push) needs the repo's history+remote.
145
+ `;
146
+ /**
147
+ * The Dockerfile + ignore artifacts — spread into any host's artifact list. Kit layout ({@link
148
+ * ContainerInput.kitDir}): the Dockerfile is namespaced under the kit (`agent/Dockerfile`) so it never
149
+ * collides with the host repo's own. The ignore ships in TWO forms because context packing is
150
+ * host-CLI-owned and inconsistent: (1) a ROOT `.dockerignore` — the only form flyctl/railway's own
151
+ * context packers reliably read (kept if the host already has one — preflight then warns specifically
152
+ * about a .git exclude / missing recursive node_modules) — and (2) a
153
+ * per-Dockerfile `agent/Dockerfile.dockerignore` for plain docker/buildx builds. Whether `.git`
154
+ * actually reaches the box is host-CLI-dependent (some strip it from the upload regardless of any
155
+ * ignore file) — the runbook's write-back note carries the runtime-clone fallback.
156
+ */
157
+ export function containerArtifacts(input) {
158
+ if (input.kitDir) {
159
+ return [
160
+ { path: `${input.kitDir}/Dockerfile`, content: kitDockerfile(input, input.kitDir) },
161
+ { path: ".dockerignore", content: KIT_DOCKERIGNORE },
162
+ { path: `${input.kitDir}/Dockerfile.dockerignore`, content: KIT_DOCKERIGNORE },
163
+ ];
164
+ }
165
+ return [
166
+ { path: "Dockerfile", content: dockerfile(input) },
167
+ { path: ".dockerignore", content: DOCKERIGNORE },
168
+ ];
169
+ }
@@ -0,0 +1,65 @@
1
+ /**
2
+ * `fastagent deploy fly` — the Fly.io deploy PLAN, computed from the resolved definition. Pure: facts
3
+ * in, artifact contents + an ordered runbook out; the CLI (deploy side effects live there) writes the
4
+ * files and prints the runbook.
5
+ *
6
+ * fastagent owns the two ends only it can know — generate definition-aware artifacts (state root →
7
+ * volume, autostop tuned to the turn model, the exact secret list) and the post-deploy webhook step —
8
+ * and GUIDES the middle (flyctl app/volume/secrets/deploy) as a precise, values-resolved runbook. By
9
+ * default a coding agent (or you) runs flyctl from that runbook; `deploy fly --run` drives it from the
10
+ * CLI instead. This module stays pure either way — it produces the plan, never runs flyctl. The runbook
11
+ * is a FIRST-deploy sequence: `apps`/`volumes create` are one-time (marked so — re-running would make a
12
+ * second volume, the state split it warns against); a redeploy is `fly deploy` alone.
13
+ *
14
+ * autostop = "suspend": the machine snapshots and suspends when idle (Fly Proxy sees inbound load 0),
15
+ * resumes on the next webhook in ~hundreds of ms. A long turn interrupted by an idle-suspend whose
16
+ * snapshot is discarded replays on the next start (the Telegram L1 turn store) — at-least-once, the
17
+ * documented floor. State on the /data volume survives stop/suspend on the same machine.
18
+ */
19
+ import type { ChannelKind } from "../../scaffold/add-channel.ts";
20
+ import { type Artifact, type ContainerInput } from "../container.ts";
21
+ export interface FlyPlanInput extends ContainerInput {
22
+ /** Fly app name — globally unique, lowercase; the CLI sanitizes it from the dir basename. */
23
+ appName: string;
24
+ /** The port the app listens on (config.http.port ?? 8787); fly.toml routes to it. */
25
+ port: number;
26
+ /**
27
+ * What satisfies model auth locally ({@link probeAuthSource}): an env-var name (`OPENAI_API_KEY`),
28
+ * `"OAuth"`/`"stored credential"` (a local login the server can't use), or undefined (unconfigured).
29
+ */
30
+ modelAuth: string | undefined;
31
+ /** Channels discovered in the workspace — each contributes its required secrets + webhook step. */
32
+ channels: ChannelKind[];
33
+ /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
34
+ extraSecrets?: string[];
35
+ /** `auto_stop_machines` — `"suspend"` (default, fast resume) or `"stop"` (cold start). CLI `--stop`. */
36
+ autostop: "suspend" | "stop";
37
+ /** Allow scaling to zero when idle (default true → `min_machines_running=0`). CLI `--no-scale-to-zero`
38
+ * forces one machine up; a github channel forces it too (fire-and-forget turns have no replay). */
39
+ scaleToZero: boolean;
40
+ /** Time triggers present (schedules/ or selfSchedule) — forces one machine up: cron/wake has no
41
+ * external wake-up, so a scaled-to-zero box would sleep through them. */
42
+ hasTimeTriggers: boolean;
43
+ }
44
+ export interface FlyPlan {
45
+ /** fly.toml / Dockerfile / .dockerignore — written by the CLI (skipped if present unless --force). */
46
+ artifacts: Artifact[];
47
+ /** The ordered, values-resolved deploy runbook — printed to stdout for the coding agent to execute. */
48
+ runbook: string[];
49
+ }
50
+ /** Compute the Fly deploy plan from the resolved definition. */
51
+ export declare function planFlyDeploy(input: FlyPlanInput): FlyPlan;
52
+ /**
53
+ * The `app` name from an existing fly.toml's `app = "…"` line, or undefined if absent — the KEEP-mode
54
+ * single source (a user who renamed the app is not overridden by the basename guess). Accepts TOML's
55
+ * double OR single quotes; anything else (or no `app`) is undefined → the caller falls back to basename.
56
+ */
57
+ export declare function parseFlyAppName(toml: string): string | undefined;
58
+ /** The `primary_region` from a fly.toml, or undefined — `--run` passes it to `fly volumes create` so the
59
+ * volume lands in the machine's region (fly.toml is the single source; see {@link parseFlyAppName}). */
60
+ export declare function parseFlyRegion(toml: string): string | undefined;
61
+ /** The `min_machines_running` from a fly.toml, or undefined — the KEEP-mode check reads it so a kept
62
+ * file that still scales to zero can be warned about when time triggers (schedules/wake) exist. */
63
+ export declare function parseFlyMinMachines(toml: string): number | undefined;
64
+ /** Sanitize a directory basename into a Fly app name: lowercase, [a-z0-9-], must start with a letter. */
65
+ export declare function toFlyAppName(basename: string): string;
@@ -0,0 +1,142 @@
1
+ import { containerArtifacts } from "../container.js";
2
+ import { isEnvKey, requiredSecrets } from "../secrets.js";
3
+ function flyToml(appName, port, hasGithub, autostop, scaleToZero, hasTimeTriggers) {
4
+ // min_machines_running: 1 (keep one up) when a github channel is present, TIME triggers exist, OR the
5
+ // operator opted out of scale-to-zero. GitHub's is a SAFETY default — its fire-and-forget turns have no
6
+ // replay, so scaling to zero could drop an in-flight review. Time triggers (schedules/wake) have no
7
+ // external wake-up at all — a scaled-to-zero box sleeps through the cron instant. Reason-tagged so the
8
+ // comment is honest.
9
+ const min = hasGithub
10
+ ? ` min_machines_running = 1 # github turns have no replay — don't scale to zero (an in-flight review would be lost)`
11
+ : hasTimeTriggers
12
+ ? ` min_machines_running = 1 # schedules/wake-ups need a running machine (no external wake-up for a cron instant)`
13
+ : !scaleToZero
14
+ ? ` min_machines_running = 1 # kept running (--no-scale-to-zero)`
15
+ : ` min_machines_running = 0 # scale to zero`;
16
+ const stopLine = autostop === "stop"
17
+ ? ` auto_stop_machines = "stop" # stop on idle (cold start on the next webhook)`
18
+ : ` auto_stop_machines = "suspend" # suspend on idle (fast resume on the next webhook)`;
19
+ return `# Generated by \`fastagent deploy fly\`. Edit freely — it is not regenerated unless you pass --force.
20
+ app = "${appName}"
21
+ primary_region = "iad" # set your region (list: \`fly platform regions\`)
22
+
23
+ [build]
24
+
25
+ [env]
26
+ FASTAGENT_STATE_DIR = "/data" # the ONE machine-state root — auth, sessions, channel state
27
+ PORT = "${port}"
28
+
29
+ [http_service]
30
+ internal_port = ${port}
31
+ force_https = true
32
+ ${stopLine}
33
+ auto_start_machines = true
34
+ ${min}
35
+
36
+ [mounts]
37
+ source = "data"
38
+ destination = "/data" # FASTAGENT_STATE_DIR — persists across stop/suspend/redeploy
39
+
40
+ [[vm]]
41
+ size = "shared-cpu-1x"
42
+ memory = "512mb" # suspend is not recommended above 2 GB
43
+ `;
44
+ }
45
+ /** Compute the Fly deploy plan from the resolved definition. */
46
+ export function planFlyDeploy(input) {
47
+ const { appName, port, modelAuth, channels, kitDir } = input;
48
+ // Kit layout: every artifact is namespaced under the kit (agent/fly.toml, agent/Dockerfile) so the
49
+ // host repo's own deploy files are never touched; the runbook passes explicit -c/--dockerfile flags
50
+ // (unambiguous across flyctl versions — no reliance on config-relative path resolution).
51
+ const flyTomlPath = kitDir ? `${kitDir}/fly.toml` : "fly.toml";
52
+ const artifacts = [
53
+ {
54
+ path: flyTomlPath,
55
+ content: flyToml(appName, port, channels.includes("github"), input.autostop, input.scaleToZero, input.hasTimeTriggers),
56
+ },
57
+ ...containerArtifacts(input),
58
+ ];
59
+ // The exact secret list the deployed machine needs, computed from the definition (host-neutral): the
60
+ // model key (when local auth is an env key) + every discovered channel's secrets. Names + hints as
61
+ // COMMENT lines (a `#` inside a `\`-continued command would break the shell), then one flat, executable
62
+ // `fly secrets set` the coding agent fills — `<value>` placeholders, never inline comments.
63
+ const secrets = requiredSecrets(modelAuth, channels, input.extraSecrets);
64
+ const deployCmd = kitDir
65
+ ? `fly deploy . --config ${kitDir}/fly.toml --dockerfile ${kitDir}/Dockerfile --app ${appName}`
66
+ : `fly deploy --app ${appName}`;
67
+ const runbook = [
68
+ `# Deploy "${appName}" to Fly.io. ${flyTomlPath} / Dockerfile(.dockerignore) are generated above.`,
69
+ `# Prereqs: flyctl installed (https://fly.io/docs/flyctl/install) and \`fly auth login\`.`,
70
+ ``,
71
+ `# One-time setup (skip on a redeploy — a second run makes a SECOND app/volume, splitting state).`,
72
+ `# Fly app names are GLOBALLY unique: if this fails as taken, set a unique "app" in fly.toml and`,
73
+ `# re-run \`fastagent deploy fly\` — the runbook follows fly.toml's app name.`,
74
+ `fly apps create ${appName}`,
75
+ `# volume persists FASTAGENT_STATE_DIR=/data (sessions, auth, channel state) across stop/suspend/redeploy.`,
76
+ `# <region> MUST equal primary_region in fly.toml (a volume in another region can't mount) — fly.toml`,
77
+ `# is the single source for the region; skip this if the volume exists (fly volumes list --app ${appName}):`,
78
+ `fly volumes create data --app ${appName} --region <region> --size 1`,
79
+ ];
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
+ }
83
+ if (kitDir) {
84
+ 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:`);
85
+ }
86
+ runbook.push(deployCmd);
87
+ if (kitDir) {
88
+ runbook.push(``, `# Write-back mechanics: git ships in the image and GH_TOKEN-style creds ride config.deploy.secrets;`, `# the POLICY (push vs PR, identity, remote) lives in persona.md. CAVEAT — whether .git survives is`, `# host-CLI-dependent (some context packers strip it from the upload): verify \`git status\` on the`, `# box after the first deploy; if it is missing, have the agent \`git clone\` its repo in the`, `# workspace instead (same token). Un-pushed changes never survive a redeploy — the image is a`, `# snapshot; durability lives in git.`);
89
+ }
90
+ // Model-auth guidance: an env key becomes a secret above. Otherwise the plan can't read the local
91
+ // credential's VALUE to set as a secret — true for OAuth AND a stored API key (both are
92
+ // `AuthResult.source` non-env labels), so the wording doesn't prejudge whether it's migratable.
93
+ if (!isEnvKey(modelAuth)) {
94
+ runbook.push(``, modelAuth === undefined
95
+ ? `# Model auth: none found at the local auth path — a global \`fastagent login\` isn't read here; pass --auth-path <file> (e.g. ~/.fastagent/auth.json), or \`--run\` carries it automatically.`
96
+ : `# Model auth: your local auth is "${modelAuth}" — the plan can't read its value to set as a secret.`, `# Set your provider API key as a Fly secret (fly secrets set KEY=...), OR place auth.json on the /data volume.`);
97
+ }
98
+ // The fastagent-only post-step: point each channel's webhook at the live URL. Only fastagent knows the routes.
99
+ // The URLs below assume each channel's DEFAULT route key (POST /telegram, POST /webhook). Reading the
100
+ // real key would mean executing the channel factory (getMe, state-dir creation) — wrong at plan time
101
+ // — so the runbook states the assumption instead of silently printing a stale path for remapped glue.
102
+ const post = [];
103
+ if (channels.includes("telegram")) {
104
+ post.push(`# After deploy — register the Telegram webhook. The path assumes the default route (POST /telegram);`, `# if you remapped it in channels/telegram.ts, use your path. secret_token MUST equal TELEGRAM_SECRET_TOKEN:`, `curl "https://api.telegram.org/bot<TELEGRAM_BOT_TOKEN>/setWebhook" \\`, ` -d url=https://${appName}.fly.dev/telegram -d secret_token=<TELEGRAM_SECRET_TOKEN>`);
105
+ }
106
+ if (channels.includes("github")) {
107
+ 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
+ }
109
+ if (post.length > 0)
110
+ runbook.push(``, ...post);
111
+ // Single-machine tier: state lives on ONE volume tied to ONE machine. Scaling to multiple machines
112
+ // splits state (each gets its own volume) — that needs a shared/external backend, not this recipe.
113
+ runbook.push(``, `# Keep this a SINGLE machine: the /data volume (and all state on it) is tied to one machine.`, `# Multiple machines would each get their own volume and split sessions/turns — don't scale past 1.`);
114
+ return { artifacts, runbook };
115
+ }
116
+ /**
117
+ * The `app` name from an existing fly.toml's `app = "…"` line, or undefined if absent — the KEEP-mode
118
+ * single source (a user who renamed the app is not overridden by the basename guess). Accepts TOML's
119
+ * double OR single quotes; anything else (or no `app`) is undefined → the caller falls back to basename.
120
+ */
121
+ export function parseFlyAppName(toml) {
122
+ return toml.match(/^\s*app\s*=\s*["']([^"']+)["']/m)?.[1];
123
+ }
124
+ /** The `primary_region` from a fly.toml, or undefined — `--run` passes it to `fly volumes create` so the
125
+ * volume lands in the machine's region (fly.toml is the single source; see {@link parseFlyAppName}). */
126
+ export function parseFlyRegion(toml) {
127
+ return toml.match(/^\s*primary_region\s*=\s*["']([^"']+)["']/m)?.[1];
128
+ }
129
+ /** The `min_machines_running` from a fly.toml, or undefined — the KEEP-mode check reads it so a kept
130
+ * file that still scales to zero can be warned about when time triggers (schedules/wake) exist. */
131
+ export function parseFlyMinMachines(toml) {
132
+ const m = toml.match(/^\s*min_machines_running\s*=\s*(\d+)/m)?.[1];
133
+ return m === undefined ? undefined : Number(m);
134
+ }
135
+ /** Sanitize a directory basename into a Fly app name: lowercase, [a-z0-9-], must start with a letter. */
136
+ export function toFlyAppName(basename) {
137
+ const slug = basename
138
+ .toLowerCase()
139
+ .replace(/[^a-z0-9]+/g, "-")
140
+ .replace(/^-+|-+$/g, "");
141
+ return /^[a-z]/.test(slug) ? slug : `app-${slug || "agent"}`;
142
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * `fastagent deploy fly --run` — drive flyctl to completion. The middle of the deploy (app / volume /
3
+ * secrets / deploy) that the plain runbook hands to the operator; `--run` executes it instead, so a
4
+ * coding agent runs ONE command. Idempotent (app/volume check-then-act; channel secrets come from the
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
7
+ * exit, so the agent clears the gate and re-runs. A `generate` channel secret absent from `.env` is a
8
+ * gate too (`missingSecrets`), not a silent mint — fill it in `.env` (use the random string that
9
+ * `add <channel>` prints).
10
+ *
11
+ * flyctl is behind the shared {@link CliRunner} seam — production spawns `fly`, tests inject a fake that
12
+ * records the command sequence and scripts outputs. That seam is the benchmark: the agent's journey
13
+ * encoded as an asserted command sequence + gate behavior, validated without a real Fly account.
14
+ *
15
+ * Non-interactive incantations the agent would otherwise get wrong: `--remote-only` (build on Fly's
16
+ * builders — no local Docker in a sandbox), `--yes` (no prompts), `--ha=false` + the mounted volume
17
+ * (one machine, the single-machine tier). Secrets go in via `secrets import` over stdin, so values
18
+ * never land in argv/process listings.
19
+ */
20
+ import type { ChannelKind } from "../../scaffold/add-channel.ts";
21
+ import type { CliRunner } from "../runner.ts";
22
+ /**
23
+ * The bytes to seed to the auth file, or undefined to leave it alone — the pure core of `start`'s
24
+ * FASTAGENT_AUTH_SEED materialization. ABSENT-ONLY by design: a present file (a refreshed volume copy)
25
+ * is never overwritten by the stale seed, so a box that ran its own OAuth refresh is not rolled back.
26
+ */
27
+ export declare function authSeedBytes(seed: string | undefined, fileExists: boolean): Buffer | undefined;
28
+ export interface FlyRunPlan {
29
+ appName: string;
30
+ region: string;
31
+ /** `KEY=value` secrets to set on Fly: model key (env auth) or `FASTAGENT_AUTH_SEED` (file auth) +
32
+ * channel secrets. Set via stdin, never argv. */
33
+ secrets: Record<string, string>;
34
+ /** Required secret names with NO local value — the run gates on these before any side effect. */
35
+ missingSecrets: string[];
36
+ channels: ChannelKind[];
37
+ /** fly.toml path passed to `fly deploy -c` (relative to the run cwd = the workspace dir). */
38
+ flyConfig: string;
39
+ }
40
+ /** Done, or a gate the operator must clear before re-running (printed + non-zero exit by the CLI). */
41
+ export type FlyRunOutcome = {
42
+ ok: true;
43
+ } | {
44
+ ok: false;
45
+ gate: string;
46
+ };
47
+ /**
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.
50
+ */
51
+ export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<void>): Promise<FlyRunOutcome>;