@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.
- package/LICENSE +21 -0
- package/README.md +214 -0
- package/dist/agent.d.ts +72 -0
- package/dist/agent.js +14 -0
- package/dist/channels/body.d.ts +9 -0
- package/dist/channels/body.js +29 -0
- package/dist/channels/github/github.d.ts +31 -0
- package/dist/channels/github/github.js +80 -0
- package/dist/channels/github/scaffold/channel.ts +25 -0
- package/dist/channels/http.d.ts +13 -0
- package/dist/channels/http.js +161 -0
- package/dist/channels/respond.d.ts +5 -0
- package/dist/channels/respond.js +3 -0
- package/dist/channels/telegram/context-buffer.d.ts +59 -0
- package/dist/channels/telegram/context-buffer.js +139 -0
- package/dist/channels/telegram/invoke-turn.d.ts +53 -0
- package/dist/channels/telegram/invoke-turn.js +127 -0
- package/dist/channels/telegram/parse.d.ts +160 -0
- package/dist/channels/telegram/parse.js +153 -0
- package/dist/channels/telegram/preview.d.ts +22 -0
- package/dist/channels/telegram/preview.js +266 -0
- package/dist/channels/telegram/register-webhook.d.ts +13 -0
- package/dist/channels/telegram/register-webhook.js +73 -0
- package/dist/channels/telegram/scaffold/channel.ts +25 -0
- package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
- package/dist/channels/telegram/state.d.ts +9 -0
- package/dist/channels/telegram/state.js +58 -0
- package/dist/channels/telegram/telegram-api.d.ts +77 -0
- package/dist/channels/telegram/telegram-api.js +345 -0
- package/dist/channels/telegram/telegram.d.ts +35 -0
- package/dist/channels/telegram/telegram.js +352 -0
- package/dist/channels/telegram/turn-queue.d.ts +21 -0
- package/dist/channels/telegram/turn-queue.js +47 -0
- package/dist/channels/telegram/turn-store.d.ts +38 -0
- package/dist/channels/telegram/turn-store.js +150 -0
- package/dist/cli-auth.d.ts +24 -0
- package/dist/cli-auth.js +30 -0
- package/dist/cli-models.d.ts +11 -0
- package/dist/cli-models.js +20 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +1288 -0
- package/dist/collect.d.ts +20 -0
- package/dist/collect.js +27 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +8 -0
- package/dist/deploy/container.d.ts +56 -0
- package/dist/deploy/container.js +169 -0
- package/dist/deploy/fly/plan.d.ts +65 -0
- package/dist/deploy/fly/plan.js +142 -0
- package/dist/deploy/fly/run.d.ts +51 -0
- package/dist/deploy/fly/run.js +89 -0
- package/dist/deploy/preflight.d.ts +59 -0
- package/dist/deploy/preflight.js +191 -0
- package/dist/deploy/railway/plan.d.ts +48 -0
- package/dist/deploy/railway/plan.js +91 -0
- package/dist/deploy/railway/run.d.ts +81 -0
- package/dist/deploy/railway/run.js +176 -0
- package/dist/deploy/runner.d.ts +18 -0
- package/dist/deploy/runner.js +25 -0
- package/dist/deploy/secrets.d.ts +49 -0
- package/dist/deploy/secrets.js +88 -0
- package/dist/dev-supervisor.d.ts +14 -0
- package/dist/dev-supervisor.js +158 -0
- package/dist/engines/pi/auth.d.ts +17 -0
- package/dist/engines/pi/auth.js +118 -0
- package/dist/engines/pi/channel.d.ts +31 -0
- package/dist/engines/pi/channel.js +113 -0
- package/dist/engines/pi/chat.d.ts +19 -0
- package/dist/engines/pi/chat.js +200 -0
- package/dist/engines/pi/config.d.ts +110 -0
- package/dist/engines/pi/config.js +285 -0
- package/dist/engines/pi/create.d.ts +127 -0
- package/dist/engines/pi/create.js +222 -0
- package/dist/engines/pi/definition.d.ts +72 -0
- package/dist/engines/pi/definition.js +129 -0
- package/dist/engines/pi/harness.d.ts +50 -0
- package/dist/engines/pi/harness.js +41 -0
- package/dist/engines/pi/invoke.d.ts +28 -0
- package/dist/engines/pi/invoke.js +298 -0
- package/dist/engines/pi/login.d.ts +51 -0
- package/dist/engines/pi/login.js +125 -0
- package/dist/engines/pi/models.d.ts +41 -0
- package/dist/engines/pi/models.js +76 -0
- package/dist/engines/pi/report.d.ts +13 -0
- package/dist/engines/pi/report.js +21 -0
- package/dist/engines/pi/sessions.d.ts +15 -0
- package/dist/engines/pi/sessions.js +130 -0
- package/dist/engines/pi/tool-context.d.ts +17 -0
- package/dist/engines/pi/tool-context.js +13 -0
- package/dist/engines/pi/tool.d.ts +45 -0
- package/dist/engines/pi/tool.js +85 -0
- package/dist/engines/pi/wake-tool.d.ts +20 -0
- package/dist/engines/pi/wake-tool.js +110 -0
- package/dist/engines/pi/workspace.d.ts +53 -0
- package/dist/engines/pi/workspace.js +70 -0
- package/dist/env.d.ts +24 -0
- package/dist/env.js +57 -0
- package/dist/github.d.ts +3 -0
- package/dist/github.js +2 -0
- package/dist/host/node.d.ts +41 -0
- package/dist/host/node.js +50 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -0
- package/dist/invoke-stream.d.ts +10 -0
- package/dist/invoke-stream.js +26 -0
- package/dist/loader.d.ts +38 -0
- package/dist/loader.js +69 -0
- package/dist/log.d.ts +26 -0
- package/dist/log.js +51 -0
- package/dist/observe.d.ts +12 -0
- package/dist/observe.js +44 -0
- package/dist/pi.d.ts +16 -0
- package/dist/pi.js +12 -0
- package/dist/proxy.d.ts +9 -0
- package/dist/proxy.js +23 -0
- package/dist/runtime.d.ts +26 -0
- package/dist/runtime.js +29 -0
- package/dist/scaffold/add-channel.d.ts +46 -0
- package/dist/scaffold/add-channel.js +227 -0
- package/dist/scaffold/init.d.ts +54 -0
- package/dist/scaffold/init.js +249 -0
- package/dist/scaffold/templates/env.example +24 -0
- package/dist/scaffold/templates/fastagent.config.mjs +19 -0
- package/dist/scaffold/templates/gitignore +8 -0
- package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
- package/dist/scaffold/templates/gitignore.kit +2 -0
- package/dist/scaffold/templates/persona.md +10 -0
- package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
- package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
- package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
- package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
- package/dist/scaffold/templates.d.ts +17 -0
- package/dist/scaffold/templates.js +56 -0
- package/dist/scaffold/vendor-skill.d.ts +24 -0
- package/dist/scaffold/vendor-skill.js +157 -0
- package/dist/schedule/audit.d.ts +18 -0
- package/dist/schedule/audit.js +57 -0
- package/dist/schedule/cron.d.ts +7 -0
- package/dist/schedule/cron.js +33 -0
- package/dist/schedule/discover.d.ts +17 -0
- package/dist/schedule/discover.js +64 -0
- package/dist/schedule/schedule.d.ts +35 -0
- package/dist/schedule/schedule.js +23 -0
- package/dist/schedule/scheduler.d.ts +36 -0
- package/dist/schedule/scheduler.js +253 -0
- package/dist/schedule/state.d.ts +14 -0
- package/dist/schedule/state.js +56 -0
- package/dist/schedule/wakeups.d.ts +82 -0
- package/dist/schedule/wakeups.js +196 -0
- package/dist/telegram.d.ts +2 -0
- package/dist/telegram.js +2 -0
- package/dist/tunnel.d.ts +31 -0
- package/dist/tunnel.js +139 -0
- package/dist/version.d.ts +5 -0
- package/dist/version.js +13 -0
- package/dist/workspace.d.ts +9 -0
- package/dist/workspace.js +45 -0
- package/package.json +97 -0
package/dist/cli.js
ADDED
|
@@ -0,0 +1,1288 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* fastagent CLI — the product entry point and consumer of fastagent.config.ts. Process-level side
|
|
4
|
+
* effects (proxy dispatcher, .env loading) belong here.
|
|
5
|
+
*/
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
import { randomBytes, randomUUID } from "node:crypto";
|
|
8
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
9
|
+
import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
10
|
+
import { autocomplete, isCancel, log as clackLog, password, select, text as clackText } from "@clack/prompts";
|
|
11
|
+
import { parseArgs } from "node:util";
|
|
12
|
+
import { logAgentLoop } from "./observe.js";
|
|
13
|
+
import { log, setLogLevel } from "./log.js";
|
|
14
|
+
import { loadDotEnv } from "./env.js";
|
|
15
|
+
import { installProxyFetch } from "./proxy.js";
|
|
16
|
+
import { createInvokeHandler } from "./channels/http.js";
|
|
17
|
+
import { text } from "./channels/respond.js";
|
|
18
|
+
import { parseRouteKey, router, serveNode } from "./host/node.js";
|
|
19
|
+
import { runDevSupervisor } from "./dev-supervisor.js";
|
|
20
|
+
import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
|
|
21
|
+
import { discoverChannelFiles, loadChannels } from "./engines/pi/channel.js";
|
|
22
|
+
import { detectRuntime, readPackageJson } from "./runtime.js";
|
|
23
|
+
import { fastagentVersion } from "./version.js";
|
|
24
|
+
import { defaultAuthPath, defaultSessionsDir, resolveStateRoot, isValidPort, listModels, loadConfig, resolveAgentDir, resolveAuthPath, resolveAuthPathOverride, resolveModelSpec, resolveSessionsDirOverride, rewriteConfigModel, } from "./engines/pi/config.js";
|
|
25
|
+
import { formatModelsCommand } from "./cli-models.js";
|
|
26
|
+
import { formatAuthReport } from "./cli-auth.js";
|
|
27
|
+
import { fastagentCredentialStore } from "./engines/pi/auth.js";
|
|
28
|
+
import { loginFlow } from "./engines/pi/login.js";
|
|
29
|
+
import { configuredModelSpecs, createPiModels, probeAuthSource } from "./engines/pi/models.js";
|
|
30
|
+
import { ensureStateRootSelfIgnored, isUnderDir, loadAgentDefinition } from "./engines/pi/definition.js";
|
|
31
|
+
import { loadRootIgnore } from "./workspace.js";
|
|
32
|
+
import { runInvokeStream } from "./invoke-stream.js";
|
|
33
|
+
import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "./engines/pi/report.js";
|
|
34
|
+
import { createPiAgentFromWorkspace } from "./engines/pi/workspace.js";
|
|
35
|
+
import { resolveWorkspaceTools } from "./engines/pi/create.js";
|
|
36
|
+
import { CHANNEL_KINDS, appendChannelDotEnv, appendChannelEnv, assertChannelReady, channelExists, channelSetup, scaffoldChannel, } from "./scaffold/add-channel.js";
|
|
37
|
+
import { detectHostSignals, exists, nextStepCd, scaffoldWorkspace } from "./scaffold/init.js";
|
|
38
|
+
import { vendorSkill } from "./scaffold/vendor-skill.js";
|
|
39
|
+
import { isGeneratedDockerfile } from "./deploy/container.js";
|
|
40
|
+
import { parseFlyAppName, parseFlyMinMachines, parseFlyRegion, planFlyDeploy, toFlyAppName, } from "./deploy/fly/plan.js";
|
|
41
|
+
import { preflightDeploy } from "./deploy/preflight.js";
|
|
42
|
+
import { planRailwayDeploy } from "./deploy/railway/plan.js";
|
|
43
|
+
import { deployRailwayRun } from "./deploy/railway/run.js";
|
|
44
|
+
import { authSeedBytes, deployFlyRun } from "./deploy/fly/run.js";
|
|
45
|
+
import { spawnRunner } from "./deploy/runner.js";
|
|
46
|
+
import { assembleSecrets } from "./deploy/secrets.js";
|
|
47
|
+
import { registerTelegramWebhook } from "./channels/telegram/register-webhook.js";
|
|
48
|
+
import { loadSchedules } from "./schedule/discover.js";
|
|
49
|
+
import { readRuns } from "./schedule/audit.js";
|
|
50
|
+
import { nextRun } from "./schedule/cron.js";
|
|
51
|
+
import { listWakeups, removeWakeup } from "./schedule/wakeups.js";
|
|
52
|
+
import { createScheduler, scheduleSession } from "./schedule/scheduler.js";
|
|
53
|
+
function usage(code) {
|
|
54
|
+
console.error(`usage:
|
|
55
|
+
fastagent init [dir] [--minimal] [--no-install] [--flat] [--agent-dir <name>]
|
|
56
|
+
fastagent models [search]
|
|
57
|
+
fastagent info [dir] [--json] [--auth-path file]
|
|
58
|
+
fastagent tool <name> '<json-args>' [dir]
|
|
59
|
+
fastagent invoke <message> [dir] [--model provider/modelId] [--auth-path file]
|
|
60
|
+
fastagent fire <name> [dir] [--model provider/modelId] [--auth-path file]
|
|
61
|
+
fastagent schedule history <name> [dir] [--json]
|
|
62
|
+
fastagent schedule list [dir] [--json]
|
|
63
|
+
fastagent schedule cancel <id> [dir]
|
|
64
|
+
fastagent dev [dir] [--port N] [--model provider/modelId] [--auth-path file] [--no-watch] [--tunnel]
|
|
65
|
+
fastagent chat [dir] [--model provider/modelId]
|
|
66
|
+
fastagent start [dir] [--port N] [--model provider/modelId] [--sessions-dir dir] [--auth-path file] [--tunnel]
|
|
67
|
+
fastagent add github | telegram | skill <source> [dir]
|
|
68
|
+
fastagent deploy fly|railway [dir] [--run] [--force] [--stop] [--no-scale-to-zero] [--into-linked]
|
|
69
|
+
fastagent login [provider] [--auth-path file]
|
|
70
|
+
fastagent --version
|
|
71
|
+
|
|
72
|
+
dev assemble the agent in dir (default .) and serve a local HTTP channel. persona.md/AGENTS.md/
|
|
73
|
+
skills are re-read every turn (edits go live next turn); edits to code inputs — tools/,
|
|
74
|
+
channels/, fastagent.config.*, package.json, .env — restart the worker (--no-watch to
|
|
75
|
+
disable). Files the agent writes as work product never trigger a restart.
|
|
76
|
+
model precedence: --model > FASTAGENT_MODEL > fastagent.config.ts
|
|
77
|
+
--tunnel expose it on a public HTTPS URL via a Cloudflare quick tunnel (needs cloudflared)
|
|
78
|
+
and auto-register the webhook channels (telegram setWebhook; github prints the URL)
|
|
79
|
+
chat open the SAME assembled agent in pi's interactive TUI (the real harness, not a
|
|
80
|
+
crude REPL) — to try it locally before serving. Same model/tool/skill resolution
|
|
81
|
+
as dev; pi handles login, sessions, and /resume natively.
|
|
82
|
+
init scaffold a runnable agent in dir (default .) and run npm install. Default is a
|
|
83
|
+
self-iterating agent: persona.md (its identity), a writing-great-skills example skill, a
|
|
84
|
+
fetch-url code tool, config, package.json, .gitignore. Never overwrites existing files; an
|
|
85
|
+
existing AGENTS.md is kept as project context. Layout: flat by default ("a directory is an
|
|
86
|
+
agent"); when an existing toolchain/deploy claims the directory (tsconfig/framework config,
|
|
87
|
+
a non-JS build manifest like go.mod/pyproject.toml/Cargo.toml, Dockerfile/fly/railway, or
|
|
88
|
+
occupied tools/, channels/, or skills/), the kit goes into ./agent
|
|
89
|
+
and config.agentDir points there — the reason is printed, no prompt.
|
|
90
|
+
--minimal persona.md + the example skill + config only (no code tool / package.json)
|
|
91
|
+
--no-install scaffold everything but skip npm install
|
|
92
|
+
--flat force the flat layout (skip detection)
|
|
93
|
+
--agent-dir <name> force the kit into ./<name>
|
|
94
|
+
models list the available "provider/modelId" specs ([search] filters by substring; use one with
|
|
95
|
+
--model or in the config).
|
|
96
|
+
info print what dir (default .) ASSEMBLES into — model, persona, context files (AGENTS.md), skills, tools (+ collisions),
|
|
97
|
+
channels, sessions, load diagnostics — WITHOUT serving. Read-only (never creates sessions /
|
|
98
|
+
writes .gitignore); an unset model is reported, not fatal. --json for CI. Run it first when
|
|
99
|
+
something looks off.
|
|
100
|
+
tool run one tool (from tools/ or config.tools) directly with JSON args — no model, no
|
|
101
|
+
server, no tokens. Fast feedback while authoring: fastagent tool add '{"a":2,"b":3}'
|
|
102
|
+
invoke run ONE turn against the assembled agent and exit — no server, no TUI. The reply streams
|
|
103
|
+
to stdout, tool/diagnostics to stderr, a failed turn exits non-zero. The all-agent
|
|
104
|
+
counterpart of tool, for CI smoke and quick checks. Same model resolution as dev.
|
|
105
|
+
fire run ONE schedule's turn immediately (authoring loop, like invoke) — fires schedules/<name>.ts
|
|
106
|
+
now without waiting for its cron. Reply→stdout; does NOT advance the schedule's fire state.
|
|
107
|
+
schedule history <name> print the run audit for a schedule (or "wake" for self-scheduled wake-ups):
|
|
108
|
+
when each run fired, completed/failed/deferred, duration, and the reply/error — the answer to
|
|
109
|
+
"did last night's run silently fail?". --json for the full records (complete reply text).
|
|
110
|
+
schedule list everything that will fire: static schedules (next instant) + pending wake-ups.
|
|
111
|
+
schedule cancel <id> remove a pending wake-up — the operator's kill switch for a runaway recurring
|
|
112
|
+
wake (the agent's own is the \`unwake\` tool).
|
|
113
|
+
start run the agent in dir (default .) in production posture — the SAME assembly as dev
|
|
114
|
+
(your directory is the agent), just no file-watching. No build step: start reads the
|
|
115
|
+
definition directly; model/http come from fastagent.config.ts (frozen by git).
|
|
116
|
+
model precedence: --model > FASTAGENT_MODEL > fastagent.config.ts
|
|
117
|
+
port precedence: --port > PORT env > fastagent.config.ts http.port > 8787
|
|
118
|
+
state: FASTAGENT_STATE_DIR > <dir>/.fastagent — the ONE machine-state root (auth,
|
|
119
|
+
sessions, channel state all derive from it); point it at a mounted volume so a
|
|
120
|
+
redeploy that replaces the directory never wipes state
|
|
121
|
+
sessions: --sessions-dir > FASTAGENT_SESSIONS_DIR > <state>/sessions
|
|
122
|
+
auth: --auth-path > FASTAGENT_AUTH_PATH > <state>/auth.json (project-level;
|
|
123
|
+
point it at ~/.fastagent/auth.json to share one credential across projects)
|
|
124
|
+
--tunnel same as dev: a public HTTPS URL + auto-registered webhooks, for hosting a bot from
|
|
125
|
+
your own box without deploying (the quick-tunnel URL is ephemeral, not for production)
|
|
126
|
+
add github | telegram: scaffold channels/<kind>.ts — third-party adapter glue with the policy
|
|
127
|
+
to edit (github maps events in on(); telegram routes in the optional route()).
|
|
128
|
+
skill <source>: vendor an Agent Skills skill into skills/<name>/ (git ref owner/repo/path, a
|
|
129
|
+
local path, or a bare name from ~/.agents/skills; --update re-fetches, review with git diff)
|
|
130
|
+
deploy fly|railway [dir]: generate host config + Dockerfile/.dockerignore from the definition and
|
|
131
|
+
print an ordered deploy runbook + the post-deploy webhook step. Does not run the host CLI — a
|
|
132
|
+
coding agent (or you) executes the runbook. fly: fly.toml (autostop=suspend, state→volume).
|
|
133
|
+
railway: railway.json (healthcheck /health); its volume/variables/App-Sleeping are dashboard/
|
|
134
|
+
CLI steps the runbook states (see the runbook).
|
|
135
|
+
--run drive the host CLI to completion: app/service + volume + secrets + deploy +
|
|
136
|
+
telegram webhook (railway also mints the public domain). Carries your local
|
|
137
|
+
credential (env key or the OAuth auth.json) to the box. Stops at a gate (not
|
|
138
|
+
logged in, a missing secret) with one actionable line; needs flyctl / the
|
|
139
|
+
railway CLI. Without it: prints the runbook.
|
|
140
|
+
--into-linked (railway --run) provision INTO the project this dir is already linked to (skip
|
|
141
|
+
create). By default --run only creates on an unlinked dir and refuses a
|
|
142
|
+
pre-existing link (could be unrelated/production) — this is the explicit opt-in.
|
|
143
|
+
A routine redeploy of an already-provisioned agent is just 'railway up'.
|
|
144
|
+
--stop (fly only) autostop by stopping (cold start) instead of suspending (fast resume)
|
|
145
|
+
--no-scale-to-zero (fly only) keep one machine running when idle (min_machines_running=1)
|
|
146
|
+
--force overwrite existing host config/Dockerfile/.dockerignore (else kept)
|
|
147
|
+
login authenticate a model provider into the project-level <state root>/auth.json — default
|
|
148
|
+
<cwd>/.fastagent/auth.json (root: FASTAGENT_STATE_DIR; file: --auth-path / FASTAGENT_AUTH_PATH;
|
|
149
|
+
run from $HOME for the global ~/.fastagent/auth.json): pick
|
|
150
|
+
a method (subscription/OAuth or API key), then a provider that offers it (configured status
|
|
151
|
+
shown). [provider] takes the method from what that provider supports, asked only when both.`);
|
|
152
|
+
process.exit(code);
|
|
153
|
+
}
|
|
154
|
+
const { positionals, values } = parseArgs({
|
|
155
|
+
allowPositionals: true,
|
|
156
|
+
options: {
|
|
157
|
+
port: { type: "string" },
|
|
158
|
+
model: { type: "string" },
|
|
159
|
+
"sessions-dir": { type: "string" },
|
|
160
|
+
"auth-path": { type: "string" },
|
|
161
|
+
minimal: { type: "boolean" },
|
|
162
|
+
"no-install": { type: "boolean" },
|
|
163
|
+
flat: { type: "boolean" },
|
|
164
|
+
"agent-dir": { type: "string" },
|
|
165
|
+
"no-watch": { type: "boolean" },
|
|
166
|
+
tunnel: { type: "boolean" },
|
|
167
|
+
update: { type: "boolean" },
|
|
168
|
+
force: { type: "boolean" },
|
|
169
|
+
stop: { type: "boolean" },
|
|
170
|
+
"no-scale-to-zero": { type: "boolean" },
|
|
171
|
+
run: { type: "boolean" },
|
|
172
|
+
"into-linked": { type: "boolean" },
|
|
173
|
+
json: { type: "boolean" },
|
|
174
|
+
help: { type: "boolean", short: "h" },
|
|
175
|
+
version: { type: "boolean", short: "v" },
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
if (values.version) {
|
|
179
|
+
console.log(await fastagentVersion());
|
|
180
|
+
process.exit(0);
|
|
181
|
+
}
|
|
182
|
+
if (values.help)
|
|
183
|
+
usage(0);
|
|
184
|
+
const [command, dirArg] = positionals;
|
|
185
|
+
const dir = resolve(dirArg ?? ".");
|
|
186
|
+
if (command === "init")
|
|
187
|
+
await runInit();
|
|
188
|
+
else if (command === "models")
|
|
189
|
+
runModels();
|
|
190
|
+
else if (command === "tool")
|
|
191
|
+
await runTool();
|
|
192
|
+
else if (command === "invoke")
|
|
193
|
+
await runInvoke();
|
|
194
|
+
else if (command === "info")
|
|
195
|
+
await runInfo();
|
|
196
|
+
else if (command === "dev")
|
|
197
|
+
await runDev();
|
|
198
|
+
else if (command === "chat")
|
|
199
|
+
await runChat();
|
|
200
|
+
else if (command === "start")
|
|
201
|
+
await runStart();
|
|
202
|
+
else if (command === "add")
|
|
203
|
+
await runAdd();
|
|
204
|
+
else if (command === "deploy")
|
|
205
|
+
await runDeploy();
|
|
206
|
+
else if (command === "fire")
|
|
207
|
+
await runFire();
|
|
208
|
+
else if (command === "schedule")
|
|
209
|
+
await runScheduleCmd();
|
|
210
|
+
else if (command === "login")
|
|
211
|
+
await runLogin();
|
|
212
|
+
else
|
|
213
|
+
usage(1);
|
|
214
|
+
/** `fastagent models [search]`: print every registered "provider/modelId"; `[search]` filters by substring. */
|
|
215
|
+
function runModels() {
|
|
216
|
+
const { lines, error } = formatModelsCommand(listModels(createPiModels()), positionals[1]);
|
|
217
|
+
for (const spec of lines)
|
|
218
|
+
console.log(spec);
|
|
219
|
+
if (error)
|
|
220
|
+
console.error(error);
|
|
221
|
+
}
|
|
222
|
+
/** `fastagent tool <name> '<json>' [dir]`: run one tool's body directly with JSON args — no model. */
|
|
223
|
+
async function runTool() {
|
|
224
|
+
const name = positionals[1];
|
|
225
|
+
const argsJson = positionals[2] ?? "{}";
|
|
226
|
+
const toolDir = resolve(positionals[3] ?? ".");
|
|
227
|
+
if (!name) {
|
|
228
|
+
console.error(`usage: fastagent tool <name> '<json-args>' [dir]`);
|
|
229
|
+
process.exit(1);
|
|
230
|
+
}
|
|
231
|
+
loadDotEnv(toolDir); // a tool may read a key from .env
|
|
232
|
+
const { config } = await loadConfig(toolDir).catch(failStartup);
|
|
233
|
+
// The same tool set dev/start mount (defaults + config.tools + discovered, deduped), so the runner
|
|
234
|
+
// exercises exactly what gets served — a shadowed tool is surfaced, not silently run. Resolve agentDir
|
|
235
|
+
// like the openers so `fastagent tool` finds the SAME tools/ as dev/start when config.agentDir is set.
|
|
236
|
+
const agentDir = resolveAgentDir(toolDir, config);
|
|
237
|
+
const { tools, toolCollisions, toolFailures } = await resolveWorkspaceTools(config, agentDir, toolDir).catch(failStartup);
|
|
238
|
+
for (const c of toolCollisions) {
|
|
239
|
+
console.error(`[fastagent] warn: tool "${c.name}" (${c.source}) is shadowed by a default/config tool — not mounted`);
|
|
240
|
+
}
|
|
241
|
+
reportModuleLoadFailures(toolFailures);
|
|
242
|
+
const tool = tools.find((t) => t.name === name);
|
|
243
|
+
if (!tool) {
|
|
244
|
+
console.error(`unknown tool "${name}". available: ${tools.map((t) => t.name).join(", ") || "(none)"}`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
let args;
|
|
248
|
+
try {
|
|
249
|
+
args = JSON.parse(argsJson);
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
console.error(`invalid JSON args: ${argsJson}`);
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
const result = await tool.execute(`cli-${name}`, args).catch(failStartup);
|
|
256
|
+
const out = result?.details !== undefined
|
|
257
|
+
? result.details
|
|
258
|
+
: (result?.content ?? []).map((c) => ("text" in c ? c.text : "")).join("");
|
|
259
|
+
console.log(typeof out === "string" ? out : JSON.stringify(out, null, 2));
|
|
260
|
+
}
|
|
261
|
+
/** `fastagent invoke <message> [dir]`: run ONE turn against the assembled agent, then exit. */
|
|
262
|
+
async function runInvoke() {
|
|
263
|
+
const message = positionals[1];
|
|
264
|
+
if (!message) {
|
|
265
|
+
console.error(`usage: fastagent invoke <message> [dir]`);
|
|
266
|
+
process.exit(2);
|
|
267
|
+
}
|
|
268
|
+
const invokeDir = resolve(positionals[2] ?? ".");
|
|
269
|
+
loadDotEnv(invokeDir);
|
|
270
|
+
installProxyFetch();
|
|
271
|
+
await resolveFirstRunModel(invokeDir);
|
|
272
|
+
const { agent, modelSpec, authPath } = await createPiAgentFromWorkspace(invokeDir, {
|
|
273
|
+
model: values.model,
|
|
274
|
+
authPath: resolveAuthPathOverride(values["auth-path"]),
|
|
275
|
+
}).catch(failStartup);
|
|
276
|
+
console.error(`[fastagent] invoke: ${invokeDir} (${modelSpec})`);
|
|
277
|
+
await reportAuth(modelSpec, authPath);
|
|
278
|
+
// Fresh session per invoke (one-shot, no resume). runInvokeStream maps events→IO: reply→stdout,
|
|
279
|
+
// tool/failure→stderr, exit 1 iff the turn failed (so CI can gate on it).
|
|
280
|
+
const exitCode = await runInvokeStream(agent.invoke({ session: randomUUID() }, { text: message }), (text) => process.stdout.write(text), (line) => console.error(line));
|
|
281
|
+
process.stdout.write("\n");
|
|
282
|
+
// Always exit explicitly: the undici proxy agent's keep-alive sockets would otherwise hold the
|
|
283
|
+
// event loop open after a successful one-shot turn.
|
|
284
|
+
process.exit(exitCode);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* `fastagent fire <name> [dir]`: run ONE schedule's turn immediately — the authoring loop for schedules
|
|
288
|
+
* (like `invoke` is for a prompt). Fires `schedules/<name>.ts` now, without waiting for its cron, using
|
|
289
|
+
* the schedule's stable session (faithful to the served behavior). Does NOT advance the schedule's fire
|
|
290
|
+
* state — a test run must never make the scheduler skip the real next run.
|
|
291
|
+
*/
|
|
292
|
+
async function runFire() {
|
|
293
|
+
const name = positionals[1];
|
|
294
|
+
if (!name) {
|
|
295
|
+
console.error(`usage: fastagent fire <name> [dir]`);
|
|
296
|
+
process.exit(2);
|
|
297
|
+
}
|
|
298
|
+
const fireDir = resolve(positionals[2] ?? ".");
|
|
299
|
+
loadDotEnv(fireDir);
|
|
300
|
+
installProxyFetch();
|
|
301
|
+
await resolveFirstRunModel(fireDir);
|
|
302
|
+
// Schedules are agent surface — discover them where dev/start/`schedule list` do (agentDir), not the
|
|
303
|
+
// run root, so `fire` sees the same set the scheduler serves in the kit layout.
|
|
304
|
+
const { config: fireConfig } = await loadConfig(fireDir).catch(failStartup);
|
|
305
|
+
const fireAgentDir = resolveAgentDir(fireDir, fireConfig);
|
|
306
|
+
const { schedules, failures } = await loadSchedules(fireAgentDir).catch(failStartup);
|
|
307
|
+
reportModuleLoadFailures(failures);
|
|
308
|
+
const schedule = schedules.find((s) => s.name === name);
|
|
309
|
+
if (!schedule) {
|
|
310
|
+
// Name the discovery path in the kit layout: a schedule misplaced at the run root should read as
|
|
311
|
+
// "wrong place", not "broken file".
|
|
312
|
+
const looked = fireAgentDir === fireDir ? "" : ` (looked in ${relative(fireDir, fireAgentDir).split(sep).join("/")}/schedules)`;
|
|
313
|
+
console.error(`unknown schedule "${name}"${looked}. available: ${schedules.map((s) => s.name).join(", ") || "(none)"}`);
|
|
314
|
+
process.exit(1);
|
|
315
|
+
}
|
|
316
|
+
const { agent, modelSpec, authPath } = await createPiAgentFromWorkspace(fireDir, {
|
|
317
|
+
model: values.model,
|
|
318
|
+
authPath: resolveAuthPathOverride(values["auth-path"]),
|
|
319
|
+
}).catch(failStartup);
|
|
320
|
+
console.error(`[fastagent] fire: ${name} (${modelSpec})`);
|
|
321
|
+
await reportAuth(modelSpec, authPath);
|
|
322
|
+
const exitCode = await runInvokeStream(agent.invoke({ session: scheduleSession(name) }, { text: schedule.prompt }), (text) => process.stdout.write(text), (line) => console.error(line));
|
|
323
|
+
process.stdout.write("\n");
|
|
324
|
+
process.exit(exitCode);
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* `fastagent schedule history <name> [dir]`: print the run audit for one schedule (or "wake") — fired
|
|
328
|
+
* time, outcome, duration, reply/error. Read-only (reads `<stateRoot>/schedule/runs.jsonl`); the answer
|
|
329
|
+
* to "did last night's run silently fail?". Text mode previews the reply/error; --json is the full record.
|
|
330
|
+
*/
|
|
331
|
+
async function runScheduleCmd() {
|
|
332
|
+
const sub = positionals[1];
|
|
333
|
+
if (sub === "list")
|
|
334
|
+
return runScheduleList();
|
|
335
|
+
if (sub === "cancel")
|
|
336
|
+
return runScheduleCancel();
|
|
337
|
+
const name = positionals[2];
|
|
338
|
+
if (sub !== "history" || !name) {
|
|
339
|
+
console.error(`usage: fastagent schedule history <name> | list | cancel <id> [dir] [--json]`);
|
|
340
|
+
process.exit(2);
|
|
341
|
+
}
|
|
342
|
+
const target = resolve(positionals[3] ?? ".");
|
|
343
|
+
loadDotEnv(target); // FASTAGENT_STATE_DIR may live in .env — read the SAME state root the scheduler wrote
|
|
344
|
+
const runs = readRuns(resolveStateRoot(target), name);
|
|
345
|
+
if (values.json) {
|
|
346
|
+
console.log(JSON.stringify(runs, null, 2));
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (runs.length === 0) {
|
|
350
|
+
console.error(`no recorded runs for "${name}" (state: ${resolveStateRoot(target)})`);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
// The question is "did LAST NIGHT's run fail?" — so text mode tails the most recent runs (chronological
|
|
354
|
+
// within the tail); --json above returns the full history.
|
|
355
|
+
const TAIL = 20;
|
|
356
|
+
const shown = runs.slice(-TAIL);
|
|
357
|
+
if (runs.length > shown.length) {
|
|
358
|
+
console.error(`(showing the last ${shown.length} of ${runs.length} runs — --json for all)`);
|
|
359
|
+
}
|
|
360
|
+
for (const r of shown) {
|
|
361
|
+
const detail = r.error ?? r.reply ?? "";
|
|
362
|
+
const preview = detail.replace(/\s+/g, " ").slice(0, 100);
|
|
363
|
+
console.log(`${r.firedAt} ${r.outcome.padEnd(9)} ${String(r.ms).padStart(6)}ms ${preview}`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
/** `fastagent schedule list [dir]`: everything that will fire — BOTH producers: the static `schedules/`
|
|
367
|
+
* files (with their next instant) and the agent's pending self-scheduled wake-ups. Read-only. */
|
|
368
|
+
async function runScheduleList() {
|
|
369
|
+
const target = resolve(positionals[2] ?? ".");
|
|
370
|
+
loadDotEnv(target);
|
|
371
|
+
const { config } = await loadConfig(target).catch(failStartup);
|
|
372
|
+
const agentDir = resolveAgentDir(target, config);
|
|
373
|
+
const { schedules, failures } = await loadSchedules(agentDir).catch(failStartup);
|
|
374
|
+
reportModuleLoadFailures(failures);
|
|
375
|
+
const wakeups = listWakeups(resolveStateRoot(target));
|
|
376
|
+
if (values.json) {
|
|
377
|
+
console.log(JSON.stringify({
|
|
378
|
+
schedules: schedules.map((s) => ({ ...s, next: nextRun(s.cron, s.tz, new Date())?.toISOString() })),
|
|
379
|
+
wakeups,
|
|
380
|
+
}, null, 2));
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
if (schedules.length === 0 && wakeups.length === 0) {
|
|
384
|
+
console.error(`nothing scheduled — no schedules/ files, no pending wake-ups (state: ${resolveStateRoot(target)})`);
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
for (const s of schedules) {
|
|
388
|
+
const next = nextRun(s.cron, s.tz, new Date())?.toISOString() ?? "(never)";
|
|
389
|
+
console.log(`schedule ${s.name.padEnd(20)} ${next} cron ${s.cron}${s.tz ? ` ${s.tz}` : ""}`);
|
|
390
|
+
}
|
|
391
|
+
for (const w of wakeups) {
|
|
392
|
+
const kind = w.cron ? `cron ${w.cron}${w.tz ? ` ${w.tz}` : ""}` : "one-shot";
|
|
393
|
+
console.log(`wake ${w.id} ${w.fireAt} ${kind} session=${w.session} ${w.prompt.slice(0, 60)}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
/** `fastagent schedule cancel <id> [dir]`: remove a pending wake-up — the operator's kill switch (the
|
|
397
|
+
* agent's own is the `unwake` tool). Unlike unwake it is NOT session-scoped: the operator owns the box. */
|
|
398
|
+
function runScheduleCancel() {
|
|
399
|
+
const id = positionals[2];
|
|
400
|
+
if (!id) {
|
|
401
|
+
console.error(`usage: fastagent schedule cancel <id> [dir]`);
|
|
402
|
+
process.exit(2);
|
|
403
|
+
}
|
|
404
|
+
const target = resolve(positionals[3] ?? ".");
|
|
405
|
+
loadDotEnv(target);
|
|
406
|
+
if (removeWakeup(resolveStateRoot(target), id)) {
|
|
407
|
+
// ponytail: the store's load→save is lock-free — a serving scheduler's claim-advance can race this
|
|
408
|
+
// write (window = ms around each fire). Tell the operator to verify; a lockfile/CAS is the upgrade
|
|
409
|
+
// path if it ever bites.
|
|
410
|
+
console.error(`[fastagent] cancelled wake-up ${id} — if a server is running, verify with \`fastagent schedule list\``);
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
console.error(`no pending wake-up ${id} (state: ${resolveStateRoot(target)}) — \`fastagent schedule list\` shows ids`);
|
|
414
|
+
process.exit(1);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
/** `fastagent info [dir] [--json]`: print what the directory ASSEMBLES into, WITHOUT booting a server. Read-only. */
|
|
418
|
+
async function runInfo() {
|
|
419
|
+
loadDotEnv(dir); // skills/tools may read env at load time
|
|
420
|
+
const { config, path: configPath } = await loadConfig(dir).catch(failStartup);
|
|
421
|
+
const modelSpec = resolveModelSpec(values.model, config);
|
|
422
|
+
// dir = the run root (cwd, whose AGENTS.md is ② context); the agent's own surface lives in agentDir.
|
|
423
|
+
const agentDir = resolveAgentDir(dir, config);
|
|
424
|
+
const definition = await loadAgentDefinition(agentDir, { cwd: dir }).catch(failStartup);
|
|
425
|
+
// A tool that fails to load, for any reason (a missing dep, a top-level throw, or just not being a
|
|
426
|
+
// tool), is isolated the same way everywhere (G2): info, dev, AND start report it and keep going with
|
|
427
|
+
// the tools that loaded. The `error`/`.catch` below only fires for a whole-load fault (an unreadable
|
|
428
|
+
// tools/ dir), not a single bad file.
|
|
429
|
+
const tools = await resolveWorkspaceTools(config, agentDir, dir)
|
|
430
|
+
.then((r) => ({
|
|
431
|
+
names: r.toolNames,
|
|
432
|
+
collisions: r.toolCollisions,
|
|
433
|
+
failures: r.toolFailures,
|
|
434
|
+
error: undefined,
|
|
435
|
+
}))
|
|
436
|
+
.catch((e) => ({ names: [], collisions: [], failures: [], error: e.message }));
|
|
437
|
+
const channels = await discoverChannelFiles(agentDir).catch(failStartup);
|
|
438
|
+
// Loaded (imported + validated), not just discovered: info's job is "fix only what it reports", so a
|
|
439
|
+
// broken schedule file (bad cron/tz, failed import) must show up HERE, not first at dev/start — and
|
|
440
|
+
// loading is what makes the next fire instant printable. Consistent with tools (info imports those too).
|
|
441
|
+
const sched = await loadSchedules(agentDir).catch(failStartup);
|
|
442
|
+
const schedules = sched.schedules.map((s) => ({
|
|
443
|
+
name: s.name,
|
|
444
|
+
cron: s.cron,
|
|
445
|
+
tz: s.tz ?? null,
|
|
446
|
+
next: nextRun(s.cron, s.tz, new Date())?.toISOString() ?? null,
|
|
447
|
+
}));
|
|
448
|
+
// The default sessions/auth paths WITHOUT creating anything (info is read-only; dev/start mkdir/login
|
|
449
|
+
// create them, info must not).
|
|
450
|
+
const stateRoot = resolveStateRoot(dir);
|
|
451
|
+
const sessionsDir = resolveSessionsDirOverride(values["sessions-dir"]) ?? defaultSessionsDir(stateRoot);
|
|
452
|
+
const authPath = resolveAuthPathOverride(values["auth-path"]) ?? defaultAuthPath(stateRoot);
|
|
453
|
+
if (values.json) {
|
|
454
|
+
console.log(JSON.stringify({
|
|
455
|
+
dir,
|
|
456
|
+
agentDir,
|
|
457
|
+
configPath: configPath ?? null,
|
|
458
|
+
model: modelSpec ?? null,
|
|
459
|
+
context: definition.contextFiles.map((f) => f.path),
|
|
460
|
+
persona: definition.persona !== undefined,
|
|
461
|
+
skills: definition.skills.map((skill) => ({ name: skill.name, description: skill.description })),
|
|
462
|
+
tools: tools.names,
|
|
463
|
+
toolError: tools.error ?? null,
|
|
464
|
+
channels,
|
|
465
|
+
schedules,
|
|
466
|
+
scheduleFailures: sched.failures,
|
|
467
|
+
selfSchedule: config.selfSchedule ?? false,
|
|
468
|
+
stateRoot,
|
|
469
|
+
sessionsDir,
|
|
470
|
+
authPath,
|
|
471
|
+
diagnostics: definition.diagnostics,
|
|
472
|
+
skillCollisions: definition.collisions,
|
|
473
|
+
toolCollisions: tools.collisions,
|
|
474
|
+
toolFailures: tools.failures,
|
|
475
|
+
}, null, 2));
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
console.log(`dir: ${dir}`);
|
|
479
|
+
if (agentDir !== dir)
|
|
480
|
+
console.log(`agent: ${agentDir}`);
|
|
481
|
+
console.log(`config: ${configPath ?? "(none)"}`);
|
|
482
|
+
console.log(`model: ${modelSpec ?? "(not set — pass --model, set FASTAGENT_MODEL, or config.model)"}`);
|
|
483
|
+
console.log(`context: ${definition.contextFiles.map((f) => f.path).join(", ") || "(none)"}`);
|
|
484
|
+
console.log(`persona: ${definition.persona ? "persona.md" : "(none)"}`);
|
|
485
|
+
console.log(`skills: ${definition.skills.map((skill) => skill.name).join(", ") || "(none)"}`);
|
|
486
|
+
console.log(`tools: ${tools.error ? "(could not load — see warning below)" : tools.names.join(", ") || "(none)"}`);
|
|
487
|
+
console.log(`channels: ${channels.join(", ") || "(none)"}`);
|
|
488
|
+
console.log(`schedules: ${schedules.map((s) => `${s.name} (next ${s.next ?? "never"})`).join(", ") || "(none)"}`);
|
|
489
|
+
console.log(`selfSchedule: ${config.selfSchedule ? "on (mounts the wake tool when serving)" : "off"}`);
|
|
490
|
+
console.log(`state: ${stateRoot}`);
|
|
491
|
+
console.log(`sessions: ${sessionsDir}`);
|
|
492
|
+
console.log(`auth: ${authPath}`);
|
|
493
|
+
reportToolCollisions(tools.collisions);
|
|
494
|
+
reportModuleLoadFailures(tools.failures);
|
|
495
|
+
reportModuleLoadFailures(sched.failures);
|
|
496
|
+
if (tools.error)
|
|
497
|
+
log.warn(`[fastagent] ${tools.error}`);
|
|
498
|
+
reportDefinitionWarnings(definition.collisions, definition.diagnostics);
|
|
499
|
+
}
|
|
500
|
+
async function runInit() {
|
|
501
|
+
const minimal = values.minimal ?? false;
|
|
502
|
+
// Layout: flags force; otherwise the jurisdiction rule decides (see detectHostSignals) and the reason
|
|
503
|
+
// is printed. Deliberately no prompt — non-interactive executors (coding agents) get a deterministic
|
|
504
|
+
// default they can read and override.
|
|
505
|
+
if (values.flat && values["agent-dir"])
|
|
506
|
+
failStartup(new Error(`--flat and --agent-dir conflict — pick one`));
|
|
507
|
+
let agentDir;
|
|
508
|
+
let signals = [];
|
|
509
|
+
if (values["agent-dir"]) {
|
|
510
|
+
// Same containment contract loadConfig enforces on config.agentDir: an escaping value would write
|
|
511
|
+
// the kit outside the workspace AND produce a config that can never load — refuse up front.
|
|
512
|
+
// POSIX-normalized: this lands verbatim in the generated config (agentDir: "./a/b") and the persona
|
|
513
|
+
// locator note — a Windows `relative()` would write backslashes into both.
|
|
514
|
+
const rel = relative(dir, resolve(dir, values["agent-dir"])).split(sep).join("/");
|
|
515
|
+
if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
|
|
516
|
+
failStartup(new Error(`--agent-dir ("${values["agent-dir"]}") must be a subdirectory of ${dir}`));
|
|
517
|
+
}
|
|
518
|
+
agentDir = `./${rel}`;
|
|
519
|
+
}
|
|
520
|
+
else if (!values.flat) {
|
|
521
|
+
signals = await detectHostSignals(dir).catch(failStartup);
|
|
522
|
+
if (signals.length > 0)
|
|
523
|
+
agentDir = "./agent";
|
|
524
|
+
}
|
|
525
|
+
const { complete, created, skipped, patched, intoNonEmpty, warnings } = await scaffoldWorkspace(dir, {
|
|
526
|
+
minimal,
|
|
527
|
+
agentDir,
|
|
528
|
+
}).catch(failStartup);
|
|
529
|
+
// The layout reason prints only once the scaffold actually happened — an "already a workspace" refusal
|
|
530
|
+
// must not be preceded by an announced decision that then never takes place.
|
|
531
|
+
if (signals.length > 0) {
|
|
532
|
+
console.error(`[fastagent] found ${signals.join(", ")} — an existing toolchain/deploy claims this directory, so the agent kit goes into ./agent (its own namespace; config.agentDir points there). cwd stays this directory. Override: --flat`);
|
|
533
|
+
}
|
|
534
|
+
console.error(`[fastagent] initialized ${dir}${complete ? "" : " (minimal)"}${agentDir ? ` — agent kit in ${agentDir}` : ""}`);
|
|
535
|
+
if (created.length > 0)
|
|
536
|
+
console.error(` created: ${created.join(", ")}`);
|
|
537
|
+
if (skipped.length > 0)
|
|
538
|
+
console.error(` kept existing: ${skipped.join(", ")}`);
|
|
539
|
+
if (patched.length > 0)
|
|
540
|
+
console.error(` updated: ${patched.join(", ")} (missing fastagent excludes appended)`);
|
|
541
|
+
if (intoNonEmpty && !agentDir) {
|
|
542
|
+
console.error(` note: scaffolded flat into a non-empty directory (nothing claims it — the directory is the agent); use --agent-dir <name> to put the kit in a subdir instead`);
|
|
543
|
+
}
|
|
544
|
+
for (const w of warnings)
|
|
545
|
+
console.error(`[fastagent] warn: ${w}`);
|
|
546
|
+
// Install deps only for a complete agent whose package.json we just wrote (a kept one is not ours).
|
|
547
|
+
// The manifest lives with the kit (agentDir when set), so the install runs there — never against a
|
|
548
|
+
// host repo's own package.json.
|
|
549
|
+
const kitDir = resolve(dir, agentDir ?? ".");
|
|
550
|
+
const willInstall = complete && !values["no-install"] && created.includes(join(agentDir ?? ".", "package.json"));
|
|
551
|
+
let installFailed = false;
|
|
552
|
+
if (willInstall) {
|
|
553
|
+
console.error(`[fastagent] installing dependencies (npm install${agentDir ? ` in ${agentDir}` : ""})…`);
|
|
554
|
+
installFailed = (await npmInstall(kitDir)) !== 0;
|
|
555
|
+
if (installFailed)
|
|
556
|
+
console.error(`[fastagent] warn: npm install failed — run it manually in ${kitDir} before \`fastagent dev\``);
|
|
557
|
+
}
|
|
558
|
+
console.error(` next steps:`);
|
|
559
|
+
const cdTarget = nextStepCd(process.cwd(), dir);
|
|
560
|
+
if (cdTarget)
|
|
561
|
+
console.error(` cd ${cdTarget}`);
|
|
562
|
+
if (complete && (values["no-install"] || installFailed))
|
|
563
|
+
console.error(` ${agentDir ? `(cd ${agentDir} && npm install)` : "npm install"}`);
|
|
564
|
+
console.error(` fastagent dev # serve locally and iterate`);
|
|
565
|
+
console.error(` fastagent add skill <owner/repo/path> # vendor more skills from GitHub`);
|
|
566
|
+
}
|
|
567
|
+
/** `fastagent add <channel> [dir]`: scaffold `channels/<kind>.ts` — the adapter import plus a starter `on()`. */
|
|
568
|
+
async function runAdd() {
|
|
569
|
+
const kind = positionals[1];
|
|
570
|
+
if (kind === "skill")
|
|
571
|
+
return runAddSkill();
|
|
572
|
+
const target = resolve(positionals[2] ?? ".");
|
|
573
|
+
if (!CHANNEL_KINDS.includes(kind)) {
|
|
574
|
+
console.error(`usage: fastagent add ${CHANNEL_KINDS.join(" | ")} [dir] | fastagent add skill <source> [dir]`);
|
|
575
|
+
process.exit(1);
|
|
576
|
+
}
|
|
577
|
+
const channelKind = kind;
|
|
578
|
+
// The channel (glue + companion tool) is agent surface — it lands in agentDir (config.agentDir, or
|
|
579
|
+
// target when flat), the same place dev/start discover channels/. .env(.example) and the secret
|
|
580
|
+
// hygiene stay at the run root, where .env is actually read.
|
|
581
|
+
const { config: addConfig } = await loadConfig(target).catch(failStartup);
|
|
582
|
+
const channelHome = resolveAgentDir(target, addConfig);
|
|
583
|
+
// Preconditions before the write, so a refusal is side-effect-free.
|
|
584
|
+
if (await channelExists(channelHome, channelKind).catch(failStartup)) {
|
|
585
|
+
failStartup(new Error(`${relative(target, join(channelHome, "channels", `${channelKind}.ts`))} already exists — edit it, or remove it to re-scaffold`));
|
|
586
|
+
}
|
|
587
|
+
await assertChannelReady(channelHome).catch(failStartup);
|
|
588
|
+
const file = await scaffoldChannel(channelHome, channelKind).catch(failStartup);
|
|
589
|
+
console.error(`[fastagent] created ${relative(target, file)}`);
|
|
590
|
+
if (await appendChannelEnv(target, channelKind).catch(failStartup)) {
|
|
591
|
+
console.error(`[fastagent] added ${channelKind} env vars to .env.example`);
|
|
592
|
+
}
|
|
593
|
+
// Secret hygiene: a channel's GENERATED secret (a random string the user contributes nothing to) is
|
|
594
|
+
// written into `.env` — but only when `.env` is already gitignored: the CLI must never materialize a
|
|
595
|
+
// secret into a committable file. Warn, not refuse, when it is exposed — channel glue may read a real
|
|
596
|
+
// env var instead.
|
|
597
|
+
const envIgnored = (await loadRootIgnore(target).catch(failStartup))?.ignores(".env") ?? false;
|
|
598
|
+
if (!envIgnored) {
|
|
599
|
+
console.error(`[fastagent] warn: .env is not gitignored — a deploy that copies the directory would ship a secret placed there; add .env to .gitignore/.fastagentignore, or use a real env var`);
|
|
600
|
+
}
|
|
601
|
+
const { env, steps } = channelSetup(channelKind);
|
|
602
|
+
const generated = Object.fromEntries(env.filter((e) => e.generate).map((e) => [e.name, randomBytes(24).toString("hex")]));
|
|
603
|
+
// Kind-neutral: every channel's generated secrets get the same treatment (github's webhook secret is
|
|
604
|
+
// the same class of value as telegram's).
|
|
605
|
+
const dotEnv = envIgnored ? await appendChannelDotEnv(target, channelKind, generated).catch(failStartup) : undefined;
|
|
606
|
+
if (dotEnv && dotEnv.written.length > 0) {
|
|
607
|
+
console.error(`[fastagent] wrote ${dotEnv.written.join(", ")} to .env`);
|
|
608
|
+
}
|
|
609
|
+
const install = detectRuntime(channelHome, await readPackageJson(channelHome)).runtime === "bun" ? "bun install" : "npm install";
|
|
610
|
+
// The kit's manifest lives in channelHome (agentDir when set) — point the install there, not the run root.
|
|
611
|
+
const installCmd = channelHome === target ? install : `(cd ${relative(target, channelHome)} && ${install})`;
|
|
612
|
+
console.error(` next steps:`);
|
|
613
|
+
console.error(` ${installCmd} # if @fastagent-sh/fastagent is not installed yet`);
|
|
614
|
+
for (const e of env) {
|
|
615
|
+
if (dotEnv?.alreadySet.includes(e.name))
|
|
616
|
+
continue; // the user already has it — nothing to do
|
|
617
|
+
if (dotEnv?.written.includes(e.name)) {
|
|
618
|
+
// Written, but its hint may still carry an action (github: paste the same value into the webhook
|
|
619
|
+
// UI) — keep the variable visible instead of silently absorbing it.
|
|
620
|
+
console.error(` ${e.name} — generated and written to .env # ${e.hint}`);
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
const value = e.generate ? `=${generated[e.name]}` : "";
|
|
624
|
+
console.error(` set ${e.name}${value} in .env${envIgnored ? " (gitignored)" : ""} # ${e.hint}`);
|
|
625
|
+
}
|
|
626
|
+
// Steps carry `{channel}`/`{tools}` path placeholders (their filenames are the scaffold's private
|
|
627
|
+
// knowledge) — resolve them to the real workspace-relative locations (agentDir-aware) here.
|
|
628
|
+
const kitPrefix = channelHome === target ? "" : `${relative(target, channelHome)}/`;
|
|
629
|
+
for (const s of steps) {
|
|
630
|
+
console.error(` ${s.replace("{channel}", relative(target, file)).replace("{tools}", `${kitPrefix}tools`)}`);
|
|
631
|
+
}
|
|
632
|
+
console.error(` fastagent dev --tunnel # serve locally + a public URL, auto-registering the webhook`);
|
|
633
|
+
}
|
|
634
|
+
/** `fastagent add skill <source> [dir]`: vendor an Agent Skills skill into <dir>/skills/<name>/. */
|
|
635
|
+
async function runAddSkill() {
|
|
636
|
+
const source = positionals[2];
|
|
637
|
+
const target = resolve(positionals[3] ?? ".");
|
|
638
|
+
if (!source) {
|
|
639
|
+
console.error(`add a skill — two ways:\n` +
|
|
640
|
+
` 1. write your own (vibe): create skills/<name>/SKILL.md with name + description\n` +
|
|
641
|
+
` frontmatter; it's auto-discovered. No command needed — this is the common path.\n` +
|
|
642
|
+
` 2. vendor an existing Agent Skills skill (copied in, git-tracked):\n` +
|
|
643
|
+
` fastagent add skill <source> [dir]\n` +
|
|
644
|
+
` source: a git ref (owner/repo/path, github default), a local path (./x, /abs), or a\n` +
|
|
645
|
+
` bare name found in your global skill dirs (~/.agents/skills, ~/.pi/agent/skills)\n` +
|
|
646
|
+
` --update overwrites an existing skill (re-fetch from source); review with git diff`);
|
|
647
|
+
process.exit(1);
|
|
648
|
+
}
|
|
649
|
+
// Skills are agent surface — vendored into agentDir/skills (config.agentDir, or target when flat).
|
|
650
|
+
const { config: skillConfig } = await loadConfig(target).catch(failStartup);
|
|
651
|
+
const skillHome = resolveAgentDir(target, skillConfig);
|
|
652
|
+
const { name, description, dest, hasScripts, diagnostics, overwritten } = await vendorSkill(skillHome, source, {
|
|
653
|
+
update: values.update ?? false,
|
|
654
|
+
}).catch(failStartup);
|
|
655
|
+
console.error(`[fastagent] ${overwritten ? "updated" : "vendored"} skill "${name}" → ${dest}/`);
|
|
656
|
+
if (overwritten)
|
|
657
|
+
console.error(` overwrote it — \`git diff ${dest}\` to review, \`git checkout ${dest}\` to revert`);
|
|
658
|
+
if (description)
|
|
659
|
+
console.error(` ${description.length > 100 ? `${description.slice(0, 100)}…` : description}`);
|
|
660
|
+
for (const d of diagnostics)
|
|
661
|
+
console.error(` warn: ${d.message}`);
|
|
662
|
+
if (hasScripts) {
|
|
663
|
+
console.error(` warn: this skill ships scripts/ (executable code that runs in your agent) — review it before deploying`);
|
|
664
|
+
}
|
|
665
|
+
console.error(` next: mention "${name}" in persona.md so the model knows when to use it; then \`fastagent dev\``);
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* `fastagent deploy <host> [dir]`: generate host artifacts from the resolved definition and print an
|
|
669
|
+
* ordered deploy runbook. Host-scoped (`fly` | `railway` — the extension seam). It does NOT run the
|
|
670
|
+
* host CLI: fastagent owns the two ends it uniquely knows (definition-aware artifacts; the post-deploy
|
|
671
|
+
* webhook step), and hands the middle to a coding agent (or human) as a precise, values-resolved
|
|
672
|
+
* runbook. The pre-flight (config/model/channels/container facts) is host-neutral; the host branch adds
|
|
673
|
+
* its config file + runbook. Read-only on the definition; the only writes are the generated artifacts
|
|
674
|
+
* (never clobbered without --force). `--run` drives the host CLI instead of printing.
|
|
675
|
+
*/
|
|
676
|
+
async function runDeploy() {
|
|
677
|
+
const host = positionals[1];
|
|
678
|
+
const target = resolve(positionals[2] ?? ".");
|
|
679
|
+
if (host !== "fly" && host !== "railway") {
|
|
680
|
+
console.error(`usage: fastagent deploy <fly|railway> [dir]`);
|
|
681
|
+
process.exit(1);
|
|
682
|
+
}
|
|
683
|
+
loadDotEnv(target); // a custom provider/tool may read a key at config load
|
|
684
|
+
const { config } = await loadConfig(target).catch(failStartup);
|
|
685
|
+
const modelSpec = resolveModelSpec(values.model, config);
|
|
686
|
+
// The host-neutral pre-flight (model-travel gate, channel discovery, model-auth probe, container facts +
|
|
687
|
+
// their warnings) lives in deploy/preflight.ts — testable in isolation. The CLI prints its messages and
|
|
688
|
+
// stops on its gate; the host branch below adds only the host-specific artifacts + runbook + run drive.
|
|
689
|
+
const pre = await preflightDeploy({
|
|
690
|
+
target,
|
|
691
|
+
agentDir: resolveAgentDir(target, config),
|
|
692
|
+
config,
|
|
693
|
+
modelSpec,
|
|
694
|
+
run: !!values.run,
|
|
695
|
+
force: !!values.force,
|
|
696
|
+
authPathOverride: resolveAuthPathOverride(values["auth-path"]),
|
|
697
|
+
}).catch(failStartup);
|
|
698
|
+
if (!pre.ok) {
|
|
699
|
+
console.error(`[fastagent] deploy stopped: ${pre.gate}`);
|
|
700
|
+
process.exit(1);
|
|
701
|
+
}
|
|
702
|
+
for (const m of pre.messages)
|
|
703
|
+
console.error(`[fastagent] ${m.level}: ${m.text}`);
|
|
704
|
+
const { channels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets } = pre;
|
|
705
|
+
// Railway: thin config file, scale-to-zero is a manual dashboard step, the URL is minted (see
|
|
706
|
+
// planRailwayDeploy). --run drives the railway CLI to completion; otherwise print the runbook.
|
|
707
|
+
if (host === "railway") {
|
|
708
|
+
if (values.stop || values["no-scale-to-zero"]) {
|
|
709
|
+
console.error(`[fastagent] warn: --stop/--no-scale-to-zero are Fly-only — Railway's App Sleeping is a dashboard toggle ` +
|
|
710
|
+
`(the runbook states the manual step).`);
|
|
711
|
+
}
|
|
712
|
+
// Railway service names are project-scoped (not globally unique like a Fly app); slug the dir
|
|
713
|
+
// basename so a name with spaces/odd chars can't break the `railway add --service <name>` command.
|
|
714
|
+
const serviceName = basename(target)
|
|
715
|
+
.replace(/[^a-zA-Z0-9-]+/g, "-")
|
|
716
|
+
.replace(/^-+|-+$/g, "") || "agent";
|
|
717
|
+
const plan = planRailwayDeploy({ serviceName, modelAuth, channels, extraSecrets, hasTimeTriggers, ...container });
|
|
718
|
+
await writeArtifacts(target, plan.artifacts, { neverForce: container.kitDir ? [".dockerignore"] : [] });
|
|
719
|
+
if (values.run)
|
|
720
|
+
return runDeployRailway({ target, name: serviceName, modelAuth, authPath, channels, extraSecrets });
|
|
721
|
+
console.log(plan.runbook.join("\n"));
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
// host === "fly".
|
|
725
|
+
if (values["into-linked"]) {
|
|
726
|
+
console.error(`[fastagent] warn: --into-linked is railway-only (fly --run is idempotent — it reuses an existing app/volume)`);
|
|
727
|
+
}
|
|
728
|
+
// The replay floor that makes scale-to-zero safe is Telegram-only (its L1 turn store). GitHub turns
|
|
729
|
+
// are fire-and-forget (no replay), so the generated fly.toml keeps one machine running for them —
|
|
730
|
+
// a note, not a warn, since the plan already did the safe thing (definition-aware autostop).
|
|
731
|
+
if (channels.includes("github")) {
|
|
732
|
+
console.error(`[fastagent] note: github turns have no replay — the generated fly.toml uses min_machines_running=1 ` +
|
|
733
|
+
`(no scale-to-zero) so autostop can't drop an in-flight review. Set it to 0 to accept that trade.`);
|
|
734
|
+
}
|
|
735
|
+
// Two consistent modes. KEEP (no --force): an existing fly.toml is authoritative — not rewritten,
|
|
736
|
+
// and the runbook reads its `app=` (Fly app names are globally unique, so the basename guess may be
|
|
737
|
+
// taken and the user renamed it). --force: the template is authoritative — the WHOLE fly.toml resets
|
|
738
|
+
// (app→basename, region→iad, vm→defaults), so we do NOT round-trip `app` and warn that hand edits go.
|
|
739
|
+
// Kit layout: fly.toml lives under the kit (agent/fly.toml) — the host repo's own fly.toml (if any)
|
|
740
|
+
// belongs to the host's product deploy and is never read or written here.
|
|
741
|
+
const flyTomlPath = container.kitDir ? join(target, container.kitDir, "fly.toml") : join(target, "fly.toml");
|
|
742
|
+
const flyTomlExists = await exists(flyTomlPath);
|
|
743
|
+
const keptApp = flyTomlExists && !values.force ? parseFlyAppName(await readFile(flyTomlPath, "utf8")) : undefined;
|
|
744
|
+
const appName = keptApp ?? toFlyAppName(basename(target));
|
|
745
|
+
if (keptApp)
|
|
746
|
+
console.error(`[fastagent] app: ${keptApp} (from fly.toml)`);
|
|
747
|
+
if (flyTomlExists && values.force) {
|
|
748
|
+
console.error(`[fastagent] warn: --force resets fly.toml to defaults (app, region, vm) — re-apply any hand edits`);
|
|
749
|
+
}
|
|
750
|
+
// Autostop flags shape the GENERATED fly.toml only. In KEEP mode (fly.toml exists, no --force) it is
|
|
751
|
+
// not rewritten, so the flags would silently do nothing — surface that instead of a confusing no-op.
|
|
752
|
+
if (flyTomlExists && !values.force && (values.stop || values["no-scale-to-zero"])) {
|
|
753
|
+
console.error(`[fastagent] warn: --stop/--no-scale-to-zero only shape a freshly generated fly.toml — yours exists and ` +
|
|
754
|
+
`was kept. Edit auto_stop_machines/min_machines_running in fly.toml, or pass --force to regenerate.`);
|
|
755
|
+
}
|
|
756
|
+
// KEEP mode + time triggers: the kept fly.toml may still scale to zero — which would sleep through every
|
|
757
|
+
// cron instant / wake-up. The generated plan can't fix a kept file, so surface it instead of the preflight
|
|
758
|
+
// note silently not applying (the author who deployed FIRST and added schedules LATER hits exactly this).
|
|
759
|
+
// Under `--run` this is a GATE (same discipline as the model-travel gate): a full deploy whose schedules
|
|
760
|
+
// silently never fire is worse than a crash-loop — nothing fails visibly when a cron instant passes on a
|
|
761
|
+
// sleeping machine, and unlike github's min=0 there is no legitimate trade to accept here.
|
|
762
|
+
if (flyTomlExists && !values.force && hasTimeTriggers) {
|
|
763
|
+
const min = parseFlyMinMachines(await readFile(flyTomlPath, "utf8"));
|
|
764
|
+
if ((min ?? 0) === 0) {
|
|
765
|
+
// undefined = the line is absent — Fly's platform default for min_machines_running is 0, so a
|
|
766
|
+
// hand-written fly.toml without the line scales to zero exactly like an explicit 0.
|
|
767
|
+
const msg = `your kept fly.toml scales to zero (min_machines_running = ${min ?? "absent → platform default 0"}), but ` +
|
|
768
|
+
`schedules/self-scheduling need a running machine (no external wake-up). Set min_machines_running = 1, ` +
|
|
769
|
+
`or pass --force to regenerate.`;
|
|
770
|
+
if (values.run) {
|
|
771
|
+
console.error(`[fastagent] deploy stopped: ${msg}`);
|
|
772
|
+
process.exit(1);
|
|
773
|
+
}
|
|
774
|
+
console.error(`[fastagent] warn: ${msg}`);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
const plan = planFlyDeploy({
|
|
778
|
+
appName,
|
|
779
|
+
port,
|
|
780
|
+
modelAuth,
|
|
781
|
+
channels,
|
|
782
|
+
extraSecrets,
|
|
783
|
+
hasTimeTriggers,
|
|
784
|
+
...container,
|
|
785
|
+
autostop: values.stop ? "stop" : "suspend",
|
|
786
|
+
scaleToZero: !values["no-scale-to-zero"],
|
|
787
|
+
});
|
|
788
|
+
await writeArtifacts(target, plan.artifacts, { neverForce: container.kitDir ? [".dockerignore"] : [] });
|
|
789
|
+
if (values.run)
|
|
790
|
+
return runDeployFly({ target, appName, modelAuth, authPath, channels, flyTomlPath, extraSecrets });
|
|
791
|
+
console.log(plan.runbook.join("\n"));
|
|
792
|
+
}
|
|
793
|
+
/**
|
|
794
|
+
* Write each generated artifact. An existing file is KEPT unless --force — deploy NEVER clobbers a file
|
|
795
|
+
* without it (no silent data loss). A Dockerfile WE generated is derived from the current config, so a KEPT
|
|
796
|
+
* one that no longer matches what deploy would generate now (a changed deploy.apt, a new lockfile, a bumped
|
|
797
|
+
* version — OR the user's own edits, which we can't tell apart) is flagged stale so the drift is visible;
|
|
798
|
+
* --force regenerates it. A hand-written Dockerfile / a hand-written .dockerignore / fly.toml's app+region
|
|
799
|
+
* state are just kept.
|
|
800
|
+
*/
|
|
801
|
+
async function writeArtifacts(target, artifacts, options = {}) {
|
|
802
|
+
for (const a of artifacts) {
|
|
803
|
+
const abs = join(target, a.path);
|
|
804
|
+
// Host-owned paths (the root .dockerignore in the agentDir layout): --force means "MY generated
|
|
805
|
+
// artifact is authoritative", which never licenses clobbering the HOST's file — keep it always.
|
|
806
|
+
if (options.neverForce?.includes(a.path) && (await exists(abs))) {
|
|
807
|
+
console.error(`[fastagent] kept ${a.path} — the host repo's own file (never overwritten, even with --force); ` +
|
|
808
|
+
`see the preflight warnings for what it must contain`);
|
|
809
|
+
continue;
|
|
810
|
+
}
|
|
811
|
+
if (!values.force && (await exists(abs))) {
|
|
812
|
+
const existing = await readFile(abs, "utf8");
|
|
813
|
+
if (a.path.endsWith("Dockerfile") && isGeneratedDockerfile(existing) && existing !== a.content) {
|
|
814
|
+
console.error(`[fastagent] kept ${a.path} — it no longer matches what deploy would generate (config changed, or ` +
|
|
815
|
+
`you edited it); pass --force to regenerate.`);
|
|
816
|
+
}
|
|
817
|
+
else {
|
|
818
|
+
console.error(`[fastagent] kept ${a.path} (exists — pass --force to overwrite)`);
|
|
819
|
+
}
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
await mkdir(dirname(abs), { recursive: true }); // kit-layout artifacts live under agent/
|
|
823
|
+
await writeFile(abs, a.content);
|
|
824
|
+
console.error(`[fastagent] wrote ${a.path}`);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* `deploy fly --run`: drive flyctl to completion (idempotent, resumable). Gathers the secret VALUES
|
|
829
|
+
* from the local env — the model key (env auth) or the whole auth.json as a `FASTAGENT_AUTH_SEED` seed
|
|
830
|
+
* (OAuth/stored auth: the deployed box materializes it onto the /data volume on first boot, so a
|
|
831
|
+
* personal deploy runs on the SAME subscription) plus channel secrets — then runs the flyctl steps
|
|
832
|
+
* behind the shared {@link spawnRunner} seam (spawned `fly`, cwd = the workspace so the build context is the agent).
|
|
833
|
+
*/
|
|
834
|
+
async function runDeployFly(params) {
|
|
835
|
+
const { target, appName, modelAuth, authPath, channels, flyTomlPath, extraSecrets } = params;
|
|
836
|
+
const fly = spawnRunner("fly", target);
|
|
837
|
+
// Fail fast if flyctl is absent (spawn ENOENT → 127), with the install link — not a confusing auth gate.
|
|
838
|
+
if ((await fly(["version"], { capture: true })).code === 127) {
|
|
839
|
+
console.error(`[fastagent] flyctl not found — install it: https://fly.io/docs/flyctl/install, then re-run`);
|
|
840
|
+
process.exit(1);
|
|
841
|
+
}
|
|
842
|
+
const region = parseFlyRegion(await readFile(flyTomlPath, "utf8")) ?? "iad";
|
|
843
|
+
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
844
|
+
modelAuth,
|
|
845
|
+
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
846
|
+
channels,
|
|
847
|
+
extraSecrets,
|
|
848
|
+
env: process.env,
|
|
849
|
+
});
|
|
850
|
+
// Model credential has its OWN remediation (login), distinct from a missing secret's (.env) — gate it
|
|
851
|
+
// here, not through missingSecrets, so the message isn't a contradictory mash of both.
|
|
852
|
+
if (needsModelCredential) {
|
|
853
|
+
console.error(`[fastagent] deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`);
|
|
854
|
+
process.exit(1);
|
|
855
|
+
}
|
|
856
|
+
const outcome = await deployFlyRun({ appName, region, secrets, missingSecrets, channels, flyConfig: "fly.toml" }, fly, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl));
|
|
857
|
+
if (!outcome.ok) {
|
|
858
|
+
console.error(`[fastagent] deploy stopped: ${outcome.gate}`);
|
|
859
|
+
process.exit(1);
|
|
860
|
+
}
|
|
861
|
+
console.error(`[fastagent] deployed → https://${appName}.fly.dev`);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* `deploy railway --run`: drive the railway CLI to completion. Mirrors {@link runDeployFly} — same
|
|
865
|
+
* credential carry (env key OR the OAuth auth.json as `FASTAGENT_AUTH_SEED`) via {@link assembleSecrets},
|
|
866
|
+
* same runner seam (spawned `railway`, cwd = the workspace so `railway up`'s upload is the agent). The
|
|
867
|
+
* Railway-specific sequence (linked-check → init/add/volume when fresh → variables → up → domain →
|
|
868
|
+
* webhook) lives in {@link deployRailwayRun}; see there for why Railway differs from Fly.
|
|
869
|
+
*/
|
|
870
|
+
async function runDeployRailway(params) {
|
|
871
|
+
const { target, name, modelAuth, authPath, channels, extraSecrets } = params;
|
|
872
|
+
const railway = spawnRunner("railway", target);
|
|
873
|
+
// Fail fast if the railway CLI is absent (spawn ENOENT → 127), with the install link.
|
|
874
|
+
if ((await railway(["--version"], { capture: true })).code === 127) {
|
|
875
|
+
console.error(`[fastagent] railway CLI not found — install it: https://docs.railway.com/guides/cli, then re-run`);
|
|
876
|
+
process.exit(1);
|
|
877
|
+
}
|
|
878
|
+
const { secrets, missingSecrets, needsModelCredential } = assembleSecrets({
|
|
879
|
+
modelAuth,
|
|
880
|
+
authFile: (await exists(authPath)) ? await readFile(authPath) : undefined,
|
|
881
|
+
channels,
|
|
882
|
+
extraSecrets,
|
|
883
|
+
env: process.env,
|
|
884
|
+
});
|
|
885
|
+
// Model credential has its OWN remediation (login), distinct from a missing secret's (.env).
|
|
886
|
+
if (needsModelCredential) {
|
|
887
|
+
console.error(`[fastagent] deploy stopped: no model credential — run \`fastagent login\`, or set a provider API key in .env, then re-run`);
|
|
888
|
+
process.exit(1);
|
|
889
|
+
}
|
|
890
|
+
const outcome = await deployRailwayRun({ name, mountPath: "/data", secrets, missingSecrets, channels, intoLinked: !!values["into-linked"] }, railway, (m) => console.error(`[fastagent] ${m}`), (baseUrl) => registerTelegramWebhook(baseUrl));
|
|
891
|
+
if (!outcome.ok) {
|
|
892
|
+
console.error(`[fastagent] deploy stopped: ${outcome.gate}`);
|
|
893
|
+
process.exit(1);
|
|
894
|
+
}
|
|
895
|
+
console.error(`[fastagent] deployed → ${outcome.url}`);
|
|
896
|
+
}
|
|
897
|
+
/**
|
|
898
|
+
* `fastagent login [provider]`: authenticate a model provider into the project-level auth file
|
|
899
|
+
* (`<cwd>/.fastagent/auth.json`) by default, or `--auth-path`/`FASTAGENT_AUTH_PATH`. The positional is
|
|
900
|
+
* the PROVIDER (not a dir), so the project is cwd — `cd` into your agent before logging in (running it
|
|
901
|
+
* from $HOME writes the global `~/.fastagent/auth.json`).
|
|
902
|
+
*
|
|
903
|
+
* Creates and self-ignores `<cwd>/.fastagent/` (the credential's gitignored home) BEFORE the auth flow,
|
|
904
|
+
* so the secret can never land untracked — a flow that then fails (bad provider, abort) leaves that
|
|
905
|
+
* empty state dir behind, by design (no secret without its `.gitignore`). Skipped for the HOME-global dir.
|
|
906
|
+
*/
|
|
907
|
+
async function runLogin() {
|
|
908
|
+
const loginDir = process.cwd();
|
|
909
|
+
loadDotEnv(loginDir); // FASTAGENT_AUTH_PATH / a proxy (HTTPS_PROXY) may be configured in the project .env
|
|
910
|
+
installProxyFetch(); // the OAuth token exchange must go through HTTPS_PROXY (region-locked providers)
|
|
911
|
+
const stateRoot = resolveStateRoot(loginDir);
|
|
912
|
+
const authPath = resolveAuthPathOverride(values["auth-path"]) ?? defaultAuthPath(stateRoot);
|
|
913
|
+
// login is the command that CREATES the credential file, so the leak guard binds HERE too (not only
|
|
914
|
+
// in the opener): on an adapted project dir, a `login` before the first dev/start would otherwise
|
|
915
|
+
// leave the secret untracked-but-committable. Unlike the opener (which populates the WHOLE root, so
|
|
916
|
+
// it always self-ignores an in-tree root), login writes ONLY auth.json — so guard iff the credential
|
|
917
|
+
// actually lands under the in-tree root. An external `--auth-path`/`FASTAGENT_AUTH_PATH` writes
|
|
918
|
+
// nothing in-tree (don't create an empty `.fastagent`); the guard also skips the HOME-global root.
|
|
919
|
+
if (isUnderDir(authPath, stateRoot))
|
|
920
|
+
await ensureStateRootSelfIgnored(loginDir, stateRoot);
|
|
921
|
+
// login is inherently interactive — loginFlow renders provider/method menus and opens a browser (or
|
|
922
|
+
// prompts for a key). In a non-TTY (a pipe, CI, a coding-agent shell) the menu can't receive keystrokes
|
|
923
|
+
// and would hang. Fail fast with the reason instead of stalling on an unanswerable prompt. (After the
|
|
924
|
+
// secret-hygiene self-ignore above, which is cheap prep, so a later terminal login is safe.)
|
|
925
|
+
if (!isInteractive()) {
|
|
926
|
+
console.error(`[fastagent] login is interactive (it shows a menu and opens a browser) — run it in a terminal, not a pipe/CI`);
|
|
927
|
+
process.exit(1);
|
|
928
|
+
}
|
|
929
|
+
const io = terminalLoginIO();
|
|
930
|
+
const result = await loginFlow(io, { provider: positionals[1], authPath }).catch(failStartup);
|
|
931
|
+
console.error(`[fastagent] logged in to ${result.provider} (${result.method}) — saved to ${authPath}`);
|
|
932
|
+
process.exit(0); // the undici proxy agent's keep-alive sockets would otherwise hold the event loop open
|
|
933
|
+
}
|
|
934
|
+
/** Best-effort open a URL in the default browser; failure is fine (the URL is always printed too). */
|
|
935
|
+
function openBrowser(url) {
|
|
936
|
+
const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "start" : "xdg-open";
|
|
937
|
+
spawn(cmd, [url], { stdio: "ignore", detached: true, shell: process.platform === "win32" }).on("error", () => { });
|
|
938
|
+
}
|
|
939
|
+
/** Login terminal IO via @clack/prompts: a searchable list once long, a hidden prompt for keys. */
|
|
940
|
+
function terminalLoginIO() {
|
|
941
|
+
return {
|
|
942
|
+
async select(message, options) {
|
|
943
|
+
const r = await (options.length > 7 ? autocomplete : select)({ message, options });
|
|
944
|
+
return isCancel(r) ? undefined : r;
|
|
945
|
+
},
|
|
946
|
+
async prompt(message, opts) {
|
|
947
|
+
const r = opts?.hidden
|
|
948
|
+
? await password({ message, signal: opts.signal })
|
|
949
|
+
: await clackText({ message, signal: opts?.signal });
|
|
950
|
+
return isCancel(r) ? undefined : r;
|
|
951
|
+
},
|
|
952
|
+
note: (message) => clackLog.info(message),
|
|
953
|
+
openUrl: openBrowser,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Materialize `FASTAGENT_AUTH_SEED` (base64 of an auth.json, set by `deploy fly --run`) onto the
|
|
958
|
+
* writable state root ONCE — only when the seed is set AND the auth file is absent, so a refreshed
|
|
959
|
+
* volume copy is never clobbered by the stale seed. Lets a deploy carry the operator's local
|
|
960
|
+
* OAuth/API credential so the box runs on the SAME subscription. No-op locally (the seed is unset).
|
|
961
|
+
*/
|
|
962
|
+
async function maybeSeedAuth(authPath) {
|
|
963
|
+
const bytes = authSeedBytes(process.env.FASTAGENT_AUTH_SEED, await exists(authPath));
|
|
964
|
+
if (!bytes)
|
|
965
|
+
return;
|
|
966
|
+
await mkdir(dirname(authPath), { recursive: true });
|
|
967
|
+
await writeFile(authPath, bytes);
|
|
968
|
+
log.info(`[fastagent] seeded ${authPath} from FASTAGENT_AUTH_SEED (first boot)`);
|
|
969
|
+
}
|
|
970
|
+
/** Run `npm install` in `cwd` (inherit stdio). Returns the exit code. */
|
|
971
|
+
function npmInstall(cwd) {
|
|
972
|
+
return new Promise((resolveCode) => {
|
|
973
|
+
const child = spawn("npm", ["install"], { cwd, stdio: "inherit" });
|
|
974
|
+
child.on("close", (code) => resolveCode(code ?? 1));
|
|
975
|
+
child.on("error", () => resolveCode(1));
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
/**
|
|
979
|
+
* Parse + range-check a port string (CLI flag or env). Empty/whitespace is "not set" → undefined, so
|
|
980
|
+
* the `??` chain falls through instead of binding port 0 (`Number("")` is 0). A non-decimal or
|
|
981
|
+
* out-of-range value is an argument error → exit 1.
|
|
982
|
+
*/
|
|
983
|
+
function parsePort(value, source) {
|
|
984
|
+
if (value === undefined)
|
|
985
|
+
return undefined;
|
|
986
|
+
const trimmed = value.trim();
|
|
987
|
+
if (trimmed === "")
|
|
988
|
+
return undefined;
|
|
989
|
+
if (!/^\d+$/.test(trimmed) || !isValidPort(Number(trimmed))) {
|
|
990
|
+
console.error(`invalid ${source} "${value}": must be an integer 0-65535`);
|
|
991
|
+
process.exit(1);
|
|
992
|
+
}
|
|
993
|
+
return Number(trimmed);
|
|
994
|
+
}
|
|
995
|
+
/** Report which source provides the model's credentials, surfacing a remediation hint at startup. Non-blocking. */
|
|
996
|
+
async function reportAuth(modelSpec, authPath) {
|
|
997
|
+
const provider = modelSpec.slice(0, modelSpec.indexOf("/"));
|
|
998
|
+
const source = await probeAuthSource(createPiModels({ authPath }), modelSpec);
|
|
999
|
+
// Only when nothing satisfies auth do we read the store (refresh-FREE) to tell "nothing stored" from
|
|
1000
|
+
// "stored but unusable" — see formatAuthReport for why. store.read warns on a corrupt file itself.
|
|
1001
|
+
const stored = source === undefined
|
|
1002
|
+
? await fastagentCredentialStore(authPath)
|
|
1003
|
+
.read(provider)
|
|
1004
|
+
.catch(() => undefined)
|
|
1005
|
+
: undefined;
|
|
1006
|
+
const report = formatAuthReport(provider, authPath, source, stored);
|
|
1007
|
+
log.info(`[fastagent] ${report.line}`);
|
|
1008
|
+
if (report.warn)
|
|
1009
|
+
log.warn(`[fastagent] ${report.warn}`);
|
|
1010
|
+
}
|
|
1011
|
+
/** Both stdin and stdout are a terminal — the precondition for an interactive prompt. */
|
|
1012
|
+
function isInteractive() {
|
|
1013
|
+
return Boolean(process.stdin.isTTY && process.stdout.isTTY);
|
|
1014
|
+
}
|
|
1015
|
+
/**
|
|
1016
|
+
* First-run model resolution for the serving commands. When no model is set (flag/env/config), and
|
|
1017
|
+
* we're on a TTY, pick one from the providers the user is logged into and persist the choice. A no-op
|
|
1018
|
+
* when a model is already set; on a non-TTY (CI/deploy) or with nothing configured it stays silent and
|
|
1019
|
+
* lets the opener raise its clear "missing model" error. The pick is exported to FASTAGENT_MODEL so a
|
|
1020
|
+
* spawned `dev` worker inherits it, and best-effort written back to the config so the next run is quiet.
|
|
1021
|
+
*/
|
|
1022
|
+
async function resolveFirstRunModel(workspaceDir) {
|
|
1023
|
+
const { config, path: configPath } = await loadConfig(workspaceDir).catch(failStartup);
|
|
1024
|
+
if (resolveModelSpec(values.model, config))
|
|
1025
|
+
return; // already set (flag > FASTAGENT_MODEL > config)
|
|
1026
|
+
if (!isInteractive())
|
|
1027
|
+
return; // CI/deploy: the opener throws the actionable missing-model error
|
|
1028
|
+
const authPath = resolveAuthPath(workspaceDir, values["auth-path"]);
|
|
1029
|
+
let specs;
|
|
1030
|
+
try {
|
|
1031
|
+
specs = await configuredModelSpecs(createPiModels({ authPath }));
|
|
1032
|
+
}
|
|
1033
|
+
catch (error) {
|
|
1034
|
+
// Enumerating providers/auth threw — a system fault (a corrupt auth store, a throwing provider),
|
|
1035
|
+
// NOT "not logged in". Surface it instead of masking it as the login hint; the opener then still
|
|
1036
|
+
// raises the clear missing-model error.
|
|
1037
|
+
log.warn(`[fastagent] could not list configured models: ${error.message}`);
|
|
1038
|
+
return;
|
|
1039
|
+
}
|
|
1040
|
+
if (specs.length === 0) {
|
|
1041
|
+
log.warn(`[fastagent] no model set and no authenticated provider — run \`fastagent login\`, then \`fastagent dev\``);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
1044
|
+
const r = await (specs.length > 7 ? autocomplete : select)({
|
|
1045
|
+
message: "Choose a model for this agent",
|
|
1046
|
+
options: specs.map((s) => ({ value: s, label: s })),
|
|
1047
|
+
});
|
|
1048
|
+
if (isCancel(r))
|
|
1049
|
+
return; // cancelled: let the opener report the missing model
|
|
1050
|
+
const chosen = r;
|
|
1051
|
+
process.env.FASTAGENT_MODEL = chosen; // this process + any spawned dev worker inherits it
|
|
1052
|
+
await persistModelChoice(workspaceDir, configPath, chosen);
|
|
1053
|
+
}
|
|
1054
|
+
/**
|
|
1055
|
+
* Best-effort persist the picked model so the next run does not prompt. Only rewrites the commented
|
|
1056
|
+
* `model:` placeholder the scaffold writes (or an existing `model:` line); anything else (zero-config,
|
|
1057
|
+
* a hand-shaped config) is left untouched with a printed hint. Never throws — persistence is a convenience.
|
|
1058
|
+
*/
|
|
1059
|
+
async function persistModelChoice(workspaceDir, configPath, spec) {
|
|
1060
|
+
const hint = () => console.error(`[fastagent] using ${spec} for this run; set \`model: ${JSON.stringify(spec)}\` in your config to persist`);
|
|
1061
|
+
if (!configPath)
|
|
1062
|
+
return hint();
|
|
1063
|
+
try {
|
|
1064
|
+
const replaced = rewriteConfigModel(await readFile(configPath, "utf8"), spec);
|
|
1065
|
+
if (!replaced)
|
|
1066
|
+
return hint();
|
|
1067
|
+
await writeFile(configPath, replaced);
|
|
1068
|
+
console.error(`[fastagent] saved model ${JSON.stringify(spec)} to ${relative(workspaceDir, configPath)}`);
|
|
1069
|
+
}
|
|
1070
|
+
catch {
|
|
1071
|
+
hint();
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
/** The agents/skills/tools/collisions report lines. */
|
|
1075
|
+
function reportAgentsSkillsTools(a) {
|
|
1076
|
+
log.info(`[fastagent] context: ${a.definition.contextFiles.map((f) => f.path).join(", ") || "(none)"}`);
|
|
1077
|
+
if (a.definition.persona)
|
|
1078
|
+
log.info(`[fastagent] persona: persona.md`);
|
|
1079
|
+
log.info(`[fastagent] skills: ${a.definition.skills.map((s) => s.name).join(", ") || "(none)"}`);
|
|
1080
|
+
if (a.toolNames.length > 0)
|
|
1081
|
+
log.info(`[fastagent] tools: ${a.toolNames.join(", ")}`);
|
|
1082
|
+
reportToolCollisions(a.toolCollisions);
|
|
1083
|
+
reportModuleLoadFailures(a.toolFailures);
|
|
1084
|
+
reportDefinitionWarnings(a.definition.collisions, a.definition.diagnostics);
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* `fastagent dev`: a SUPERVISOR that spawns a worker (this command with FASTAGENT_DEV_WORKER set) to
|
|
1088
|
+
* assemble + serve, restarting it on workspace edits. A fresh process per reload means what is served
|
|
1089
|
+
* is always the latest code, including modules a tool/config imports.
|
|
1090
|
+
*/
|
|
1091
|
+
async function runDev() {
|
|
1092
|
+
setLogLevel("debug"); // dev posture: verbose, includes the debug turn trace (content) — supervisor and worker both
|
|
1093
|
+
const isWorker = process.env.FASTAGENT_DEV_WORKER === "1";
|
|
1094
|
+
// Pick a model interactively once, in the parent (both watch and --no-watch have a TTY); a spawned
|
|
1095
|
+
// watch worker inherits the choice via FASTAGENT_MODEL, so it must not prompt again. Load .env and
|
|
1096
|
+
// the proxy FIRST (as invoke/start do): the picker reads FASTAGENT_MODEL and provider keys from
|
|
1097
|
+
// .env, and getAuth's OAuth refresh must go through HTTPS_PROXY. The worker re-loads both in serveOnce.
|
|
1098
|
+
if (!isWorker) {
|
|
1099
|
+
loadDotEnv(dir);
|
|
1100
|
+
installProxyFetch();
|
|
1101
|
+
await resolveFirstRunModel(dir);
|
|
1102
|
+
}
|
|
1103
|
+
if (isWorker || values["no-watch"]) {
|
|
1104
|
+
await serveOnce();
|
|
1105
|
+
return;
|
|
1106
|
+
}
|
|
1107
|
+
parsePort(values.port, "--port"); // flag-shape check before spawning
|
|
1108
|
+
await runDevSupervisor(dir, { tunnel: values.tunnel ?? false });
|
|
1109
|
+
}
|
|
1110
|
+
/**
|
|
1111
|
+
* Start a Cloudflare tunnel + announce/register webhooks once the server is bound — unless this is a
|
|
1112
|
+
* watch-supervisor worker, where the supervisor owns the long-lived tunnel so the public URL survives
|
|
1113
|
+
* reloads.
|
|
1114
|
+
*/
|
|
1115
|
+
function maybeTunnel(workspaceDir, boundPort) {
|
|
1116
|
+
if (!values.tunnel || process.env.FASTAGENT_DEV_WORKER === "1")
|
|
1117
|
+
return;
|
|
1118
|
+
void startCloudflareTunnel(boundPort).then((t) => {
|
|
1119
|
+
if (!t)
|
|
1120
|
+
return;
|
|
1121
|
+
void announceWebhooks(workspaceDir, t.url);
|
|
1122
|
+
// Single-process (start / --no-watch): close the tunnel on exit (watch mode's supervisor owns its own).
|
|
1123
|
+
const cleanup = () => {
|
|
1124
|
+
t.close();
|
|
1125
|
+
process.exit(0);
|
|
1126
|
+
};
|
|
1127
|
+
process.once("SIGINT", cleanup);
|
|
1128
|
+
process.once("SIGTERM", cleanup);
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
async function runChat() {
|
|
1132
|
+
loadDotEnv(dir);
|
|
1133
|
+
installProxyFetch(); // model calls (and the login dialog) must go through the proxy too
|
|
1134
|
+
// Run the chat process IN the workspace: pi resolves a session's cwd as `header.cwd ?? process.cwd()`,
|
|
1135
|
+
// so aligning process.cwd() with the workspace keeps a cwd-less session on the workspace. `dir` is absolute.
|
|
1136
|
+
process.chdir(dir);
|
|
1137
|
+
// Lazy-import: chat pulls pi's interactive TUI module graph; headless start/dev never need it.
|
|
1138
|
+
const { runPiChat } = await import("./engines/pi/chat.js");
|
|
1139
|
+
await runPiChat(dir, { model: values.model }).catch(failStartup);
|
|
1140
|
+
}
|
|
1141
|
+
/** Assemble the workspace agent and serve it once (the dev worker; also the --no-watch path). */
|
|
1142
|
+
async function serveOnce() {
|
|
1143
|
+
const portFlag = parsePort(values.port, "--port");
|
|
1144
|
+
loadDotEnv(dir);
|
|
1145
|
+
installProxyFetch();
|
|
1146
|
+
const a = await createPiAgentFromWorkspace(dir, {
|
|
1147
|
+
model: values.model,
|
|
1148
|
+
authPath: resolveAuthPathOverride(values["auth-path"]),
|
|
1149
|
+
serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
|
|
1150
|
+
}).catch(failStartup);
|
|
1151
|
+
log.info(`[fastagent] dir: ${dir}`);
|
|
1152
|
+
if (a.agentDir !== dir)
|
|
1153
|
+
log.info(`[fastagent] agent: ${a.agentDir}`);
|
|
1154
|
+
log.info(`[fastagent] config: ${a.configPath ?? "(zero-config)"}`);
|
|
1155
|
+
log.info(`[fastagent] model: ${a.modelSpec}`);
|
|
1156
|
+
await reportAuth(a.modelSpec, a.authPath);
|
|
1157
|
+
reportAgentsSkillsTools(a);
|
|
1158
|
+
// Trace each turn's agent loop (tool calls + reply) to the log at debug level — shown in dev, gated
|
|
1159
|
+
// out in start (level info), keeping end-user content out of production logs. Wired in both postures.
|
|
1160
|
+
const traced = logAgentLoop(a.agent);
|
|
1161
|
+
const routes = await routesFor(a.agentDir, traced, a.stateRoot).catch(failStartup);
|
|
1162
|
+
await startSchedules(a.agentDir, traced, a.stateRoot, a.config.selfSchedule ?? false);
|
|
1163
|
+
serve(routes, portFlag ?? a.config.http?.port ?? 8787, (p) => maybeTunnel(a.agentDir, p));
|
|
1164
|
+
}
|
|
1165
|
+
async function runStart() {
|
|
1166
|
+
setLogLevel("info"); // production posture: info+, the debug turn trace (and its end-user content) gated out
|
|
1167
|
+
const portFlag = parsePort(values.port, "--port");
|
|
1168
|
+
loadDotEnv(dir);
|
|
1169
|
+
installProxyFetch();
|
|
1170
|
+
await resolveFirstRunModel(dir);
|
|
1171
|
+
// A `deploy fly --run` deploy may carry the operator's local credential as FASTAGENT_AUTH_SEED —
|
|
1172
|
+
// materialize it onto the writable state root BEFORE the opener resolves auth (once, absent-only).
|
|
1173
|
+
const authPathOverride = resolveAuthPathOverride(values["auth-path"]);
|
|
1174
|
+
await maybeSeedAuth(authPathOverride ?? defaultAuthPath(resolveStateRoot(dir)));
|
|
1175
|
+
// The same opener dev uses (single assembly source), just no watch.
|
|
1176
|
+
const sessionsDirOverride = resolveSessionsDirOverride(values["sessions-dir"]);
|
|
1177
|
+
const { agent, definition, agentDir, config, modelSpec, stateRoot, sessionsDir, authPath, toolNames, toolCollisions, toolFailures, } = await createPiAgentFromWorkspace(dir, {
|
|
1178
|
+
model: values.model,
|
|
1179
|
+
sessionsDir: sessionsDirOverride,
|
|
1180
|
+
authPath: authPathOverride,
|
|
1181
|
+
serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
|
|
1182
|
+
}).catch(failStartup);
|
|
1183
|
+
log.info(`[fastagent] start: ${dir}`);
|
|
1184
|
+
if (agentDir !== dir)
|
|
1185
|
+
log.info(`[fastagent] agent: ${agentDir}`);
|
|
1186
|
+
log.info(`[fastagent] model: ${modelSpec}`);
|
|
1187
|
+
await reportAuth(modelSpec, authPath);
|
|
1188
|
+
log.info(`[fastagent] context: ${definition.contextFiles.map((f) => f.path).join(", ") || "(none)"}`);
|
|
1189
|
+
if (definition.persona)
|
|
1190
|
+
log.info(`[fastagent] persona: persona.md`);
|
|
1191
|
+
log.info(`[fastagent] skills: ${definition.skills.map((s) => s.name).join(", ") || "(none)"}`);
|
|
1192
|
+
if (toolNames.length > 0)
|
|
1193
|
+
log.info(`[fastagent] tools: ${toolNames.join(", ")}`);
|
|
1194
|
+
reportToolCollisions(toolCollisions);
|
|
1195
|
+
reportModuleLoadFailures(toolFailures);
|
|
1196
|
+
log.info(`[fastagent] state: ${stateRoot}`);
|
|
1197
|
+
log.info(`[fastagent] sessions: ${sessionsDir}`);
|
|
1198
|
+
// State defaults under the definition dir, which a redeploy may replace wholesale. Gate on where the
|
|
1199
|
+
// root ACTUALLY resolved (in-tree?), not on the raw env var: an empty `FASTAGENT_STATE_DIR=""` reads
|
|
1200
|
+
// as unset (resolveStateRoot) and still lands in-tree, so a raw `=== undefined` check would wrongly
|
|
1201
|
+
// silence the warning. A sessions/auth override to a volume does not help — channel state (the
|
|
1202
|
+
// telegram turn/context files replay depends on) is still in-tree.
|
|
1203
|
+
if (isUnderDir(stateRoot, dir)) {
|
|
1204
|
+
log.info(`[fastagent] note: state (auth, sessions, channel state) lives under the definition dir; point ` +
|
|
1205
|
+
`FASTAGENT_STATE_DIR at a persistent volume so a redeploy that replaces the dir does not wipe it.`);
|
|
1206
|
+
}
|
|
1207
|
+
reportDefinitionWarnings(definition.collisions, definition.diagnostics);
|
|
1208
|
+
// Same debug turn trace as dev; gated out here by the info level (see serveOnce).
|
|
1209
|
+
const traced = logAgentLoop(agent);
|
|
1210
|
+
const routes = await routesFor(agentDir, traced, stateRoot).catch(failStartup);
|
|
1211
|
+
await startSchedules(agentDir, traced, stateRoot, config.selfSchedule ?? false);
|
|
1212
|
+
serve(routes, portFlag ?? parsePort(process.env.PORT, "PORT env") ?? config.http?.port ?? 8787, (p) => maybeTunnel(agentDir, p));
|
|
1213
|
+
// No graceful drain: webhook turns run fire-and-forget; SIGTERM just exits mid-turn. Whether an
|
|
1214
|
+
// in-flight turn is LOST depends on the channel: the Telegram channel persists turn intent pre-ACK
|
|
1215
|
+
// and replays it next start (turn-store.ts, L1 durable execution, at-least-once); HTTP and other
|
|
1216
|
+
// channels have no such layer, so their in-flight turns are still lost (the asker re-invokes).
|
|
1217
|
+
}
|
|
1218
|
+
/**
|
|
1219
|
+
* The routes this deployment serves: a default `GET /health` plus the workspace's discovered
|
|
1220
|
+
* `channels/` — or the default invoke channel at POST /invoke when none are declared.
|
|
1221
|
+
*/
|
|
1222
|
+
async function routesFor(workspaceDir, agent, stateRoot) {
|
|
1223
|
+
const { routes, collisions, failures } = await loadChannels(workspaceDir, { agent, stateRoot });
|
|
1224
|
+
for (const c of collisions) {
|
|
1225
|
+
console.error(`[fastagent] warn: channel route "${c.route}" (${c.source}) collides with an earlier channel — not mounted`);
|
|
1226
|
+
}
|
|
1227
|
+
reportModuleLoadFailures(failures);
|
|
1228
|
+
if (failures.length > 0 || collisions.length > 0) {
|
|
1229
|
+
throw new Error(`channel setup is invalid (${failures.length} load failure(s), ${collisions.length} route collision(s)) — ` +
|
|
1230
|
+
`fix it, or rename an intentionally disabled file to *.disabled`);
|
|
1231
|
+
}
|
|
1232
|
+
const channels = Object.keys(routes).length > 0 ? routes : { "POST /invoke": createInvokeHandler(agent) };
|
|
1233
|
+
// Add a default GET /health unless a channel already covers it (overlap, not exact-key: an
|
|
1234
|
+
// any-method `/health` also handles GET, so the built-in steps aside).
|
|
1235
|
+
const healthCovered = Object.keys(channels).some((k) => {
|
|
1236
|
+
const e = parseRouteKey(k);
|
|
1237
|
+
return e.path === "/health" && (e.method === undefined || e.method === "GET");
|
|
1238
|
+
});
|
|
1239
|
+
return healthCovered ? channels : { "GET /health": () => text("ok\n", 200), ...channels };
|
|
1240
|
+
}
|
|
1241
|
+
/** Serve `routes` via the Node host. serveNode owns binding; the CLI owns policy (errors, ready signal, log). */
|
|
1242
|
+
function serve(routes, port, onListening) {
|
|
1243
|
+
serveNode(router(routes), { port }).listening.then((boundPort) => {
|
|
1244
|
+
process.send?.({ type: "ready", port: boundPort }); // tell the dev supervisor we bound + on which port
|
|
1245
|
+
log.info(`[fastagent] http channel on :${boundPort}`);
|
|
1246
|
+
log.info(`[fastagent] routes: ${Object.keys(routes).join(", ") || "(none)"}`);
|
|
1247
|
+
onListening?.(boundPort);
|
|
1248
|
+
}, (error) => {
|
|
1249
|
+
if (error.code === "EADDRINUSE")
|
|
1250
|
+
console.error(`port ${port} is already in use; choose another with --port`);
|
|
1251
|
+
else
|
|
1252
|
+
console.error(`cannot bind http channel on :${port}: ${error.message}`);
|
|
1253
|
+
process.exit(1);
|
|
1254
|
+
});
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Load and start the workspace's `schedules/` — a time-trigger firing the agent on each cron. Starts iff
|
|
1258
|
+
* there are static schedules OR `selfSchedule` is on (the scheduler also polls the agent's self-scheduled
|
|
1259
|
+
* wake-ups, which the built-in `wake` tool creates only when opted in). Shares the SAME (trace-wrapped)
|
|
1260
|
+
* agent the routes serve, so a scheduled turn is observed like any other. Best-effort stop on exit; dev's
|
|
1261
|
+
* watch restart re-reads schedules with the worker (schedules are a code input). Single-process.
|
|
1262
|
+
*/
|
|
1263
|
+
async function startSchedules(workspaceDir, agent, stateRoot, selfSchedule) {
|
|
1264
|
+
const { schedules, failures } = await loadSchedules(workspaceDir).catch(failStartup);
|
|
1265
|
+
reportModuleLoadFailures(failures);
|
|
1266
|
+
// Nothing to run when there are neither static `schedules/` nor self-scheduling (the `wake` tool, and
|
|
1267
|
+
// thus any wake-up to poll, is mounted only when config.selfSchedule is on) — skip the poller entirely.
|
|
1268
|
+
if (schedules.length === 0 && !selfSchedule)
|
|
1269
|
+
return;
|
|
1270
|
+
const scheduler = createScheduler({ agent, stateRoot, schedules });
|
|
1271
|
+
scheduler.start();
|
|
1272
|
+
if (schedules.length > 0)
|
|
1273
|
+
log.info(`[fastagent] schedules: ${schedules.map((s) => s.name).join(", ")}`);
|
|
1274
|
+
const stop = () => scheduler.stop();
|
|
1275
|
+
process.once("SIGINT", stop);
|
|
1276
|
+
process.once("SIGTERM", stop);
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* User-fixable startup problems (missing model / bad config / broken definition) are thrown as plain
|
|
1280
|
+
* `Error` — print just the message. Anything else (TypeError, non-Error) is a bug: keep the stack.
|
|
1281
|
+
*/
|
|
1282
|
+
function failStartup(error) {
|
|
1283
|
+
if (error instanceof Error && error.constructor === Error)
|
|
1284
|
+
console.error(error.message);
|
|
1285
|
+
else
|
|
1286
|
+
console.error(error);
|
|
1287
|
+
process.exit(1);
|
|
1288
|
+
}
|