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