@fastagent-sh/fastagent 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +2 -2
  2. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  3. package/dist/channels/feishu/context-buffer.js +133 -0
  4. package/dist/channels/feishu/crypto.d.ts +4 -2
  5. package/dist/channels/feishu/crypto.js +4 -2
  6. package/dist/channels/feishu/feishu-api.d.ts +4 -4
  7. package/dist/channels/feishu/feishu-api.js +2 -2
  8. package/dist/channels/feishu/feishu.d.ts +11 -1
  9. package/dist/channels/feishu/feishu.js +143 -30
  10. package/dist/channels/feishu/invoke-turn.d.ts +19 -13
  11. package/dist/channels/feishu/invoke-turn.js +70 -19
  12. package/dist/channels/feishu/model.d.ts +97 -0
  13. package/dist/channels/feishu/model.js +9 -0
  14. package/dist/channels/feishu/normalize.d.ts +22 -0
  15. package/dist/channels/feishu/normalize.js +132 -0
  16. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  17. package/dist/channels/feishu/owned-threads.js +47 -0
  18. package/dist/channels/feishu/parse.d.ts +20 -102
  19. package/dist/channels/feishu/parse.js +35 -145
  20. package/dist/channels/feishu/preview.js +2 -2
  21. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  22. package/dist/channels/feishu/seen.js +12 -12
  23. package/dist/channels/lark/scaffold/channel.ts +10 -3
  24. package/dist/cli/commands/add.d.ts +9 -0
  25. package/dist/cli/commands/add.js +142 -0
  26. package/dist/cli/commands/chat.d.ts +3 -0
  27. package/dist/cli/commands/chat.js +16 -0
  28. package/dist/cli/commands/deploy.d.ts +13 -0
  29. package/dist/cli/commands/deploy.js +338 -0
  30. package/dist/cli/commands/dev.d.ts +11 -0
  31. package/dist/cli/commands/dev.js +76 -0
  32. package/dist/cli/commands/fire.d.ts +7 -0
  33. package/dist/cli/commands/fire.js +45 -0
  34. package/dist/cli/commands/info.d.ts +7 -0
  35. package/dist/cli/commands/info.js +108 -0
  36. package/dist/cli/commands/init.d.ts +8 -0
  37. package/dist/cli/commands/init.js +81 -0
  38. package/dist/cli/commands/invoke.d.ts +7 -0
  39. package/dist/cli/commands/invoke.js +29 -0
  40. package/dist/cli/commands/login.d.ts +6 -0
  41. package/dist/cli/commands/login.js +63 -0
  42. package/dist/cli/commands/models.d.ts +1 -0
  43. package/dist/cli/commands/models.js +15 -0
  44. package/dist/cli/commands/schedule.d.ts +12 -0
  45. package/dist/cli/commands/schedule.js +89 -0
  46. package/dist/cli/commands/start.d.ts +10 -0
  47. package/dist/cli/commands/start.js +90 -0
  48. package/dist/cli/commands/tool.d.ts +1 -0
  49. package/dist/cli/commands/tool.js +37 -0
  50. package/dist/cli/fail.d.ts +19 -0
  51. package/dist/cli/fail.js +32 -0
  52. package/dist/cli/kernel.d.ts +89 -0
  53. package/dist/cli/kernel.js +190 -0
  54. package/dist/cli/program.d.ts +11 -0
  55. package/dist/cli/program.js +421 -0
  56. package/dist/cli/serve.d.ts +28 -0
  57. package/dist/cli/serve.js +90 -0
  58. package/dist/cli/shared.d.ts +24 -0
  59. package/dist/cli/shared.js +116 -0
  60. package/dist/cli.js +8 -1329
  61. package/dist/deploy/docker/plan.d.ts +45 -0
  62. package/dist/deploy/docker/plan.js +139 -0
  63. package/dist/deploy/docker/run.d.ts +40 -0
  64. package/dist/deploy/docker/run.js +126 -0
  65. package/dist/deploy/preflight.js +4 -3
  66. package/dist/deploy/runner.d.ts +4 -1
  67. package/dist/deploy/runner.js +1 -0
  68. package/dist/engines/pi/auth.js +160 -46
  69. package/dist/engines/pi/chat.js +77 -4
  70. package/dist/engines/pi/config.d.ts +12 -3
  71. package/dist/engines/pi/config.js +16 -1
  72. package/dist/engines/pi/create.d.ts +14 -5
  73. package/dist/engines/pi/create.js +44 -9
  74. package/dist/engines/pi/harness.d.ts +16 -1
  75. package/dist/engines/pi/harness.js +77 -1
  76. package/dist/engines/pi/invoke.d.ts +1 -1
  77. package/dist/engines/pi/invoke.js +37 -2
  78. package/dist/engines/pi/login.js +1 -1
  79. package/dist/engines/pi/search-tools.d.ts +10 -0
  80. package/dist/engines/pi/search-tools.js +138 -0
  81. package/dist/engines/pi/tool-context.d.ts +28 -0
  82. package/dist/engines/pi/tool-context.js +8 -0
  83. package/dist/engines/pi/tool.d.ts +32 -1
  84. package/dist/engines/pi/tool.js +42 -1
  85. package/dist/engines/pi/workspace.d.ts +4 -1
  86. package/dist/engines/pi/workspace.js +3 -1
  87. package/dist/pi.d.ts +2 -1
  88. package/dist/scaffold/add-channel.js +3 -1
  89. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  90. package/package.json +7 -4
@@ -0,0 +1,81 @@
1
+ /**
2
+ * `fastagent init [dir]`: scaffold a runnable agent and install its dependencies. Layout: flags force;
3
+ * otherwise the jurisdiction rule decides (see detectHostSignals) and the reason is printed.
4
+ * Deliberately no prompt — non-interactive executors (coding agents) get a deterministic default they
5
+ * can read and override.
6
+ */
7
+ import { spawn } from "node:child_process";
8
+ import { isAbsolute, join, relative, resolve, sep } from "node:path";
9
+ import { detectHostSignals, nextStepCd, scaffoldWorkspace } from "../../scaffold/init.js";
10
+ import { failStartup, failUsage } from "../fail.js";
11
+ export async function runInit(dirArg, opts) {
12
+ const dir = resolve(dirArg);
13
+ let agentDir;
14
+ let signals = [];
15
+ if (opts.agentDir) {
16
+ // Same containment contract loadConfig enforces on config.agentDir: an escaping value would write
17
+ // the kit outside the workspace AND produce a config that can never load — refuse up front.
18
+ // POSIX-normalized: this lands verbatim in the generated config (agentDir: "./a/b") and the persona
19
+ // locator note — a Windows `relative()` would write backslashes into both.
20
+ const rel = relative(dir, resolve(dir, opts.agentDir)).split(sep).join("/");
21
+ if (rel === "" || rel === ".." || rel.startsWith("../") || isAbsolute(rel)) {
22
+ // An invalid flag VALUE is a usage error (exit 2), same class as a value the parser rejects.
23
+ failUsage(`--agent-dir ("${opts.agentDir}") must be a subdirectory of ${dir}`);
24
+ }
25
+ agentDir = `./${rel}`;
26
+ }
27
+ else if (!opts.flat) {
28
+ signals = await detectHostSignals(dir).catch(failStartup);
29
+ if (signals.length > 0)
30
+ agentDir = "./agent";
31
+ }
32
+ const { complete, created, skipped, patched, intoNonEmpty, warnings } = await scaffoldWorkspace(dir, {
33
+ minimal: opts.minimal,
34
+ agentDir,
35
+ }).catch(failStartup);
36
+ // The layout reason prints only once the scaffold actually happened — an "already a workspace" refusal
37
+ // must not be preceded by an announced decision that then never takes place.
38
+ if (signals.length > 0) {
39
+ 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`);
40
+ }
41
+ console.error(`[fastagent] initialized ${dir}${complete ? "" : " (minimal)"}${agentDir ? ` — agent kit in ${agentDir}` : ""}`);
42
+ if (created.length > 0)
43
+ console.error(` created: ${created.join(", ")}`);
44
+ if (skipped.length > 0)
45
+ console.error(` kept existing: ${skipped.join(", ")}`);
46
+ if (patched.length > 0)
47
+ console.error(` updated: ${patched.join(", ")} (missing fastagent excludes appended)`);
48
+ if (intoNonEmpty && !agentDir) {
49
+ 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`);
50
+ }
51
+ for (const w of warnings)
52
+ console.error(`[fastagent] warn: ${w}`);
53
+ // Install deps only for a complete agent whose package.json we just wrote (a kept one is not ours).
54
+ // The manifest lives with the kit (agentDir when set), so the install runs there — never against a
55
+ // host repo's own package.json.
56
+ const kitDir = resolve(dir, agentDir ?? ".");
57
+ const willInstall = complete && opts.install && created.includes(join(agentDir ?? ".", "package.json"));
58
+ let installFailed = false;
59
+ if (willInstall) {
60
+ console.error(`[fastagent] installing dependencies (npm install${agentDir ? ` in ${agentDir}` : ""})…`);
61
+ installFailed = (await npmInstall(kitDir)) !== 0;
62
+ if (installFailed)
63
+ console.error(`[fastagent] warn: npm install failed — run it manually in ${kitDir} before \`fastagent dev\``);
64
+ }
65
+ console.error(` next steps:`);
66
+ const cdTarget = nextStepCd(process.cwd(), dir);
67
+ if (cdTarget)
68
+ console.error(` cd ${cdTarget}`);
69
+ if (complete && (!opts.install || installFailed))
70
+ console.error(` ${agentDir ? `(cd ${agentDir} && npm install)` : "npm install"}`);
71
+ console.error(` fastagent dev # serve locally and iterate`);
72
+ console.error(` fastagent add skill <owner/repo/path> # vendor more skills from GitHub`);
73
+ }
74
+ /** Run `npm install` in `cwd` (inherit stdio). Returns the exit code. */
75
+ function npmInstall(cwd) {
76
+ return new Promise((resolveCode) => {
77
+ const child = spawn("npm", ["install"], { cwd, stdio: "inherit" });
78
+ child.on("close", (code) => resolveCode(code ?? 1));
79
+ child.on("error", () => resolveCode(1));
80
+ });
81
+ }
@@ -0,0 +1,7 @@
1
+ export interface InvokeOptions {
2
+ model?: string;
3
+ authPath?: string;
4
+ /** false ⇔ `--no-input`. */
5
+ input?: boolean;
6
+ }
7
+ export declare function runInvoke(message: string, dirArg: string, opts: InvokeOptions): Promise<void>;
@@ -0,0 +1,29 @@
1
+ /** `fastagent invoke <message> [dir]`: run ONE turn against the assembled agent, then exit. */
2
+ import { randomUUID } from "node:crypto";
3
+ import { resolve } from "node:path";
4
+ import { loadDotEnv } from "../../env.js";
5
+ import { resolveAuthPathOverride } from "../../engines/pi/config.js";
6
+ import { createPiAgentFromWorkspace } from "../../engines/pi/workspace.js";
7
+ import { runInvokeStream } from "../../invoke-stream.js";
8
+ import { installProxyFetch } from "../../proxy.js";
9
+ import { failStartup } from "../fail.js";
10
+ import { reportAuth, resolveFirstRunModel } from "../shared.js";
11
+ export async function runInvoke(message, dirArg, opts) {
12
+ const invokeDir = resolve(dirArg);
13
+ loadDotEnv(invokeDir);
14
+ installProxyFetch();
15
+ await resolveFirstRunModel(invokeDir, opts);
16
+ const { agent, modelSpec, authPath } = await createPiAgentFromWorkspace(invokeDir, {
17
+ model: opts.model,
18
+ authPath: resolveAuthPathOverride(opts.authPath),
19
+ }).catch(failStartup);
20
+ console.error(`[fastagent] invoke: ${invokeDir} (${modelSpec})`);
21
+ await reportAuth(modelSpec, authPath);
22
+ // Fresh session per invoke (one-shot, no resume). runInvokeStream maps events→IO: reply→stdout,
23
+ // tool/failure→stderr, exit 1 iff the turn failed (so CI can gate on it).
24
+ const exitCode = await runInvokeStream(agent.invoke({ session: randomUUID() }, { text: message }), (text) => process.stdout.write(text), (line) => console.error(line));
25
+ process.stdout.write("\n");
26
+ // Always exit explicitly: the undici proxy agent's keep-alive sockets would otherwise hold the
27
+ // event loop open after a successful one-shot turn.
28
+ process.exit(exitCode);
29
+ }
@@ -0,0 +1,6 @@
1
+ export interface LoginOptions {
2
+ authPath?: string;
3
+ /** false ⇔ `--no-input`. */
4
+ input?: boolean;
5
+ }
6
+ export declare function runLogin(provider: string | undefined, opts: LoginOptions): Promise<void>;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `fastagent login [provider]`: authenticate a model provider into the project-level auth file
3
+ * (`<cwd>/.fastagent/auth.json`) by default, or `--auth-path`/`FASTAGENT_AUTH_PATH`. The positional is
4
+ * the PROVIDER (not a dir), so the project is cwd — `cd` into your agent before logging in (running it
5
+ * from $HOME writes the global `~/.fastagent/auth.json`).
6
+ *
7
+ * Creates and self-ignores `<cwd>/.fastagent/` (the credential's gitignored home) BEFORE the auth flow,
8
+ * so the secret can never land untracked — a flow that then fails (bad provider, abort) leaves that
9
+ * empty state dir behind, by design (no secret without its `.gitignore`). Skipped for the HOME-global dir.
10
+ */
11
+ import { autocomplete, isCancel, log as clackLog, password, select, text as clackText } from "@clack/prompts";
12
+ import { loadDotEnv } from "../../env.js";
13
+ import { defaultAuthPath, resolveAuthPathOverride, resolveStateRoot } from "../../engines/pi/config.js";
14
+ import { ensureStateRootSelfIgnored, isUnderDir } from "../../engines/pi/definition.js";
15
+ import { loginFlow } from "../../engines/pi/login.js";
16
+ import { openExternalUrl } from "../../open-url.js";
17
+ import { installProxyFetch } from "../../proxy.js";
18
+ import { failStartup } from "../fail.js";
19
+ import { isInteractive } from "../shared.js";
20
+ export async function runLogin(provider, opts) {
21
+ const loginDir = process.cwd();
22
+ loadDotEnv(loginDir); // FASTAGENT_AUTH_PATH / a proxy (HTTPS_PROXY) may be configured in the project .env
23
+ installProxyFetch(); // the OAuth token exchange must go through HTTPS_PROXY (region-locked providers)
24
+ const stateRoot = resolveStateRoot(loginDir);
25
+ const authPath = resolveAuthPathOverride(opts.authPath) ?? defaultAuthPath(stateRoot);
26
+ // login is the command that CREATES the credential file, so the leak guard binds HERE too (not only
27
+ // in the opener): on an adapted project dir, a `login` before the first dev/start would otherwise
28
+ // leave the secret untracked-but-committable. Unlike the opener (which populates the WHOLE root, so
29
+ // it always self-ignores an in-tree root), login writes ONLY auth.json — so guard iff the credential
30
+ // actually lands under the in-tree root. An external `--auth-path`/`FASTAGENT_AUTH_PATH` writes
31
+ // nothing in-tree (don't create an empty `.fastagent`); the guard also skips the HOME-global root.
32
+ if (isUnderDir(authPath, stateRoot))
33
+ await ensureStateRootSelfIgnored(loginDir, stateRoot);
34
+ // login is inherently interactive — loginFlow renders provider/method menus and opens a browser (or
35
+ // prompts for a key). In a non-TTY (a pipe, CI, a coding-agent shell) the menu can't receive keystrokes
36
+ // and would hang; --no-input asks for the same posture explicitly. Fail fast with the reason instead
37
+ // of stalling on an unanswerable prompt. (After the secret-hygiene self-ignore above, which is cheap
38
+ // prep, so a later terminal login is safe.)
39
+ if (opts.input === false || !isInteractive()) {
40
+ failStartup(new Error(`login is interactive (it shows a menu and opens a browser) — run it in a terminal, not a pipe/CI`));
41
+ }
42
+ const io = terminalLoginIO();
43
+ const result = await loginFlow(io, { provider, authPath }).catch(failStartup);
44
+ console.error(`[fastagent] logged in to ${result.provider} (${result.method}) — saved to ${authPath}`);
45
+ process.exit(0); // the undici proxy agent's keep-alive sockets would otherwise hold the event loop open
46
+ }
47
+ /** Login terminal IO via @clack/prompts: a searchable list once long, a hidden prompt for keys. */
48
+ function terminalLoginIO() {
49
+ return {
50
+ async select(message, options) {
51
+ const r = await (options.length > 7 ? autocomplete : select)({ message, options });
52
+ return isCancel(r) ? undefined : r;
53
+ },
54
+ async prompt(message, opts) {
55
+ const r = opts?.hidden
56
+ ? await password({ message, signal: opts.signal })
57
+ : await clackText({ message, signal: opts?.signal });
58
+ return isCancel(r) ? undefined : r;
59
+ },
60
+ note: (message) => clackLog.info(message),
61
+ openUrl: openExternalUrl,
62
+ };
63
+ }
@@ -0,0 +1 @@
1
+ export declare function runModels(search: string | undefined): void;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * `fastagent models [search]`: print every registered "provider/modelId"; `[search]` filters by
3
+ * substring. This module pulls the pi model catalog (heavy) — it is lazy-imported by the spec, so
4
+ * only an actual `models` invocation pays for it.
5
+ */
6
+ import { formatModelsCommand } from "../../cli-models.js";
7
+ import { listModels } from "../../engines/pi/config.js";
8
+ import { createPiModels } from "../../engines/pi/models.js";
9
+ export function runModels(search) {
10
+ const { lines, error } = formatModelsCommand(listModels(createPiModels()), search);
11
+ for (const spec of lines)
12
+ console.log(spec);
13
+ if (error)
14
+ console.error(error);
15
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `fastagent schedule history <name> [dir]`: print the run audit for one schedule (or "wake") — fired
3
+ * time, outcome, duration, reply/error. Read-only (reads `<stateRoot>/schedule/runs.jsonl`); the answer
4
+ * to "did last night's run silently fail?". Text mode previews the reply/error; --json is the full record.
5
+ */
6
+ export declare function runScheduleHistory(name: string, dirArg: string, json: boolean): void;
7
+ /** `fastagent schedule list [dir]`: everything that will fire — BOTH producers: the static `schedules/`
8
+ * files (with their next instant) and the agent's pending self-scheduled wake-ups. Read-only. */
9
+ export declare function runScheduleList(dirArg: string, json: boolean): Promise<void>;
10
+ /** `fastagent schedule cancel <id> [dir]`: remove a pending wake-up — the operator's kill switch (the
11
+ * agent's own is the `unwake` tool). Unlike unwake it is NOT session-scoped: the operator owns the box. */
12
+ export declare function runScheduleCancel(id: string, dirArg: string): void;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * `fastagent schedule history|list|cancel` — the operator's surface over schedule state. history and
3
+ * list are read-only; cancel is the kill switch for a pending wake-up (the agent's own is the `unwake`
4
+ * tool). All three read the SAME state root the scheduler writes (FASTAGENT_STATE_DIR may live in .env).
5
+ */
6
+ import { resolve } from "node:path";
7
+ import { loadDotEnv } from "../../env.js";
8
+ import { loadConfig, resolveAgentDir, resolveStateRoot } from "../../engines/pi/config.js";
9
+ import { reportModuleLoadFailures } from "../../engines/pi/report.js";
10
+ import { readRuns } from "../../schedule/audit.js";
11
+ import { nextRun } from "../../schedule/cron.js";
12
+ import { loadSchedules } from "../../schedule/discover.js";
13
+ import { listWakeups, removeWakeup } from "../../schedule/wakeups.js";
14
+ import { failStartup } from "../fail.js";
15
+ /**
16
+ * `fastagent schedule history <name> [dir]`: print the run audit for one schedule (or "wake") — fired
17
+ * time, outcome, duration, reply/error. Read-only (reads `<stateRoot>/schedule/runs.jsonl`); the answer
18
+ * to "did last night's run silently fail?". Text mode previews the reply/error; --json is the full record.
19
+ */
20
+ export function runScheduleHistory(name, dirArg, json) {
21
+ const target = resolve(dirArg);
22
+ loadDotEnv(target); // FASTAGENT_STATE_DIR may live in .env — read the SAME state root the scheduler wrote
23
+ const runs = readRuns(resolveStateRoot(target), name);
24
+ if (json) {
25
+ console.log(JSON.stringify(runs, null, 2));
26
+ return;
27
+ }
28
+ if (runs.length === 0) {
29
+ console.error(`no recorded runs for "${name}" (state: ${resolveStateRoot(target)})`);
30
+ return;
31
+ }
32
+ // The question is "did LAST NIGHT's run fail?" — so text mode tails the most recent runs (chronological
33
+ // within the tail); --json above returns the full history.
34
+ const TAIL = 20;
35
+ const shown = runs.slice(-TAIL);
36
+ if (runs.length > shown.length) {
37
+ console.error(`(showing the last ${shown.length} of ${runs.length} runs — --json for all)`);
38
+ }
39
+ for (const r of shown) {
40
+ const detail = r.error ?? r.reply ?? "";
41
+ const preview = detail.replace(/\s+/g, " ").slice(0, 100);
42
+ console.log(`${r.firedAt} ${r.outcome.padEnd(9)} ${String(r.ms).padStart(6)}ms ${preview}`);
43
+ }
44
+ }
45
+ /** `fastagent schedule list [dir]`: everything that will fire — BOTH producers: the static `schedules/`
46
+ * files (with their next instant) and the agent's pending self-scheduled wake-ups. Read-only. */
47
+ export async function runScheduleList(dirArg, json) {
48
+ const target = resolve(dirArg);
49
+ loadDotEnv(target);
50
+ const { config } = await loadConfig(target).catch(failStartup);
51
+ const agentDir = resolveAgentDir(target, config);
52
+ const { schedules, failures } = await loadSchedules(agentDir).catch(failStartup);
53
+ reportModuleLoadFailures(failures);
54
+ const wakeups = listWakeups(resolveStateRoot(target));
55
+ if (json) {
56
+ console.log(JSON.stringify({
57
+ schedules: schedules.map((s) => ({ ...s, next: nextRun(s.cron, s.tz, new Date())?.toISOString() })),
58
+ wakeups,
59
+ }, null, 2));
60
+ return;
61
+ }
62
+ if (schedules.length === 0 && wakeups.length === 0) {
63
+ console.error(`nothing scheduled — no schedules/ files, no pending wake-ups (state: ${resolveStateRoot(target)})`);
64
+ return;
65
+ }
66
+ for (const s of schedules) {
67
+ const next = nextRun(s.cron, s.tz, new Date())?.toISOString() ?? "(never)";
68
+ console.log(`schedule ${s.name.padEnd(20)} ${next} cron ${s.cron}${s.tz ? ` ${s.tz}` : ""}`);
69
+ }
70
+ for (const w of wakeups) {
71
+ const kind = w.cron ? `cron ${w.cron}${w.tz ? ` ${w.tz}` : ""}` : "one-shot";
72
+ console.log(`wake ${w.id} ${w.fireAt} ${kind} session=${w.session} ${w.prompt.slice(0, 60)}`);
73
+ }
74
+ }
75
+ /** `fastagent schedule cancel <id> [dir]`: remove a pending wake-up — the operator's kill switch (the
76
+ * agent's own is the `unwake` tool). Unlike unwake it is NOT session-scoped: the operator owns the box. */
77
+ export function runScheduleCancel(id, dirArg) {
78
+ const target = resolve(dirArg);
79
+ loadDotEnv(target);
80
+ if (removeWakeup(resolveStateRoot(target), id)) {
81
+ // ponytail: the store's load→save is lock-free — a serving scheduler's claim-advance can race this
82
+ // write (window = ms around each fire). Tell the operator to verify; a lockfile/CAS is the upgrade
83
+ // path if it ever bites.
84
+ console.error(`[fastagent] cancelled wake-up ${id} — if a server is running, verify with \`fastagent schedule list\``);
85
+ }
86
+ else {
87
+ failStartup(new Error(`no pending wake-up ${id} (state: ${resolveStateRoot(target)}) — \`fastagent schedule list\` shows ids`));
88
+ }
89
+ }
@@ -0,0 +1,10 @@
1
+ export interface StartOptions {
2
+ port?: string;
3
+ model?: string;
4
+ sessionsDir?: string;
5
+ authPath?: string;
6
+ tunnel?: boolean;
7
+ /** false ⇔ `--no-input`. */
8
+ input?: boolean;
9
+ }
10
+ export declare function runStart(dirArg: string, opts: StartOptions): Promise<void>;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * `fastagent start [dir]`: run the agent in production posture — the SAME assembly as dev (your
3
+ * directory is the agent), just no file-watching. No build step: start reads the definition directly.
4
+ */
5
+ import { mkdir, writeFile } from "node:fs/promises";
6
+ import { dirname, resolve } from "node:path";
7
+ import { authSeedBytes } from "../../deploy/fly/run.js";
8
+ import { loadDotEnv } from "../../env.js";
9
+ import { defaultAuthPath, resolveAuthPathOverride, resolveSessionsDirOverride, resolveStateRoot, } from "../../engines/pi/config.js";
10
+ import { isUnderDir } from "../../engines/pi/definition.js";
11
+ import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "../../engines/pi/report.js";
12
+ import { createPiAgentFromWorkspace } from "../../engines/pi/workspace.js";
13
+ import { log, setLogLevel } from "../../log.js";
14
+ import { logAgentLoop } from "../../observe.js";
15
+ import { installProxyFetch } from "../../proxy.js";
16
+ import { exists } from "../../scaffold/init.js";
17
+ import { failStartup } from "../fail.js";
18
+ import { maybeTunnel, routesFor, serve, startSchedules } from "../serve.js";
19
+ import { parsePort, reportAuth, resolveFirstRunModel } from "../shared.js";
20
+ export async function runStart(dirArg, opts) {
21
+ const dir = resolve(dirArg);
22
+ setLogLevel("info"); // production posture: info+, the debug turn trace (and its end-user content) gated out
23
+ const portFlag = parsePort(opts.port, "--port", "flag");
24
+ loadDotEnv(dir);
25
+ installProxyFetch();
26
+ await resolveFirstRunModel(dir, opts);
27
+ // A `deploy --run` may carry the operator's local credential as FASTAGENT_AUTH_SEED —
28
+ // materialize it onto the writable state root BEFORE the opener resolves auth (once, absent-only).
29
+ const authPathOverride = resolveAuthPathOverride(opts.authPath);
30
+ await maybeSeedAuth(authPathOverride ?? defaultAuthPath(resolveStateRoot(dir)));
31
+ // The same opener dev uses (single assembly source), just no watch.
32
+ const sessionsDirOverride = resolveSessionsDirOverride(opts.sessionsDir);
33
+ const { agent, definition, agentDir, config, modelSpec, stateRoot, sessionsDir, authPath, toolNames, deferredToolNames, toolCollisions, toolFailures, } = await createPiAgentFromWorkspace(dir, {
34
+ model: opts.model,
35
+ sessionsDir: sessionsDirOverride,
36
+ authPath: authPathOverride,
37
+ serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
38
+ }).catch(failStartup);
39
+ log.info(`[fastagent] start: ${dir}`);
40
+ if (agentDir !== dir)
41
+ log.info(`[fastagent] agent: ${agentDir}`);
42
+ log.info(`[fastagent] model: ${modelSpec}${config.thinkingLevel ? ` (thinking: ${config.thinkingLevel})` : ""}`);
43
+ await reportAuth(modelSpec, authPath);
44
+ log.info(`[fastagent] context: ${definition.contextFiles.map((f) => f.path).join(", ") || "(none)"}`);
45
+ if (definition.persona)
46
+ log.info(`[fastagent] persona: persona.md`);
47
+ log.info(`[fastagent] skills: ${definition.skills.map((s) => s.name).join(", ") || "(none)"}`);
48
+ if (toolNames.length > 0)
49
+ log.info(`[fastagent] tools: ${toolNames.join(", ")}`);
50
+ if (deferredToolNames.length > 0) {
51
+ log.info(`[fastagent] deferred: ${deferredToolNames.join(", ")} (activated via search_tools)`);
52
+ }
53
+ reportToolCollisions(toolCollisions);
54
+ reportModuleLoadFailures(toolFailures);
55
+ log.info(`[fastagent] state: ${stateRoot}`);
56
+ log.info(`[fastagent] sessions: ${sessionsDir}`);
57
+ // State defaults under the definition dir, which a redeploy may replace wholesale. Gate on where the
58
+ // root ACTUALLY resolved (in-tree?), not on the raw env var: an empty `FASTAGENT_STATE_DIR=""` reads
59
+ // as unset (resolveStateRoot) and still lands in-tree, so a raw `=== undefined` check would wrongly
60
+ // silence the warning. A sessions/auth override to a volume does not help — channel state (the
61
+ // telegram turn/context files replay depends on) is still in-tree.
62
+ if (isUnderDir(stateRoot, dir)) {
63
+ log.info(`[fastagent] note: state (auth, sessions, channel state) lives under the definition dir; point ` +
64
+ `FASTAGENT_STATE_DIR at a persistent volume so a redeploy that replaces the dir does not wipe it.`);
65
+ }
66
+ reportDefinitionWarnings(definition.collisions, definition.diagnostics);
67
+ // Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
68
+ const traced = logAgentLoop(agent);
69
+ const routes = await routesFor(agentDir, traced, stateRoot).catch(failStartup);
70
+ await startSchedules(agentDir, traced, stateRoot, config.selfSchedule ?? false);
71
+ serve(routes, portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, (p) => maybeTunnel(agentDir, p, opts.tunnel ?? false));
72
+ // No graceful drain: webhook turns run fire-and-forget; SIGTERM just exits mid-turn. Whether an
73
+ // in-flight turn is LOST depends on the channel: the Telegram channel persists turn intent pre-ACK
74
+ // and replays it next start (turn-store.ts, L1 durable execution, at-least-once); HTTP and other
75
+ // channels have no such layer, so their in-flight turns are still lost (the asker re-invokes).
76
+ }
77
+ /**
78
+ * Materialize `FASTAGENT_AUTH_SEED` (base64 of an auth.json, set by `deploy --run`) onto the
79
+ * writable state root ONCE — only when the seed is set AND the auth file is absent, so a refreshed
80
+ * volume copy is never clobbered by the stale seed. Lets a deploy carry the operator's local
81
+ * OAuth/API credential so the box runs on the SAME subscription. No-op locally (the seed is unset).
82
+ */
83
+ async function maybeSeedAuth(authPath) {
84
+ const bytes = authSeedBytes(process.env.FASTAGENT_AUTH_SEED, await exists(authPath));
85
+ if (!bytes)
86
+ return;
87
+ await mkdir(dirname(authPath), { recursive: true });
88
+ await writeFile(authPath, bytes);
89
+ log.info(`[fastagent] seeded ${authPath} from FASTAGENT_AUTH_SEED (first boot)`);
90
+ }
@@ -0,0 +1 @@
1
+ export declare function runTool(name: string, argsJson: string, dirArg: string): Promise<void>;
@@ -0,0 +1,37 @@
1
+ /** `fastagent tool <name> '<json>' [dir]`: run one tool's body directly with JSON args — no model. */
2
+ import { resolve } from "node:path";
3
+ import { loadDotEnv } from "../../env.js";
4
+ import { loadConfig, resolveAgentDir } from "../../engines/pi/config.js";
5
+ import { resolveWorkspaceTools } from "../../engines/pi/create.js";
6
+ import { reportModuleLoadFailures } from "../../engines/pi/report.js";
7
+ import { failStartup, failUsage } from "../fail.js";
8
+ export async function runTool(name, argsJson, dirArg) {
9
+ const toolDir = resolve(dirArg);
10
+ loadDotEnv(toolDir); // a tool may read a key from .env
11
+ const { config } = await loadConfig(toolDir).catch(failStartup);
12
+ // The same tool set dev/start mount (defaults + config.tools + discovered, deduped), so the runner
13
+ // exercises exactly what gets served — a shadowed tool is surfaced, not silently run. Resolve agentDir
14
+ // like the openers so `fastagent tool` finds the SAME tools/ as dev/start when config.agentDir is set.
15
+ const agentDir = resolveAgentDir(toolDir, config);
16
+ const { tools, toolCollisions, toolFailures } = await resolveWorkspaceTools(config, agentDir, toolDir).catch(failStartup);
17
+ for (const c of toolCollisions) {
18
+ console.error(`[fastagent] warn: tool "${c.name}" (${c.source}) is shadowed by a default/config tool — not mounted`);
19
+ }
20
+ reportModuleLoadFailures(toolFailures);
21
+ const tool = tools.find((t) => t.name === name);
22
+ if (!tool) {
23
+ failStartup(new Error(`unknown tool "${name}". available: ${tools.map((t) => t.name).join(", ") || "(none)"}`));
24
+ }
25
+ let args;
26
+ try {
27
+ args = JSON.parse(argsJson);
28
+ }
29
+ catch {
30
+ failUsage(`invalid JSON args: ${argsJson}`); // malformed input syntax = usage error, exit 2
31
+ }
32
+ const result = await tool.execute(`cli-${name}`, args).catch(failStartup);
33
+ const out = result?.details !== undefined
34
+ ? result.details
35
+ : (result?.content ?? []).map((c) => ("text" in c ? c.text : "")).join("");
36
+ console.log(typeof out === "string" ? out : JSON.stringify(out, null, 2));
37
+ }
@@ -0,0 +1,19 @@
1
+ /** stderr renders color: a color TTY, with Node's `hasColors()` carrying the NO_COLOR/TERM=dumb veto. */
2
+ export declare function stderrHasColors(): boolean;
3
+ /**
4
+ * The ONE error prefix every error message carries — bold red when stderr renders color, plain
5
+ * otherwise. Errors are the only place the CLI uses color at all.
6
+ */
7
+ export declare function errorPrefix(colors?: boolean): string;
8
+ /**
9
+ * User-fixable startup problems (missing model / bad config / broken definition) are thrown as plain
10
+ * `Error` — print just the message. Anything else (TypeError, non-Error) is a bug: keep the stack.
11
+ * Shared by the kernel and the command modules; exit 1 (runtime failure).
12
+ */
13
+ export declare function failStartup(error: unknown): never;
14
+ /**
15
+ * A usage error the parser could not catch (a bad value shape, an invalid flag/argument combination
16
+ * discovered in a command body): print the message and exit 2 — the same class as a parse error.
17
+ * Exit codes follow responsibility, not the layer that happens to discover the problem.
18
+ */
19
+ export declare function failUsage(message: string): never;
@@ -0,0 +1,32 @@
1
+ /** stderr renders color: a color TTY, with Node's `hasColors()` carrying the NO_COLOR/TERM=dumb veto. */
2
+ export function stderrHasColors() {
3
+ return process.stderr.isTTY === true && (process.stderr.hasColors?.() ?? false);
4
+ }
5
+ /**
6
+ * The ONE error prefix every error message carries — bold red when stderr renders color, plain
7
+ * otherwise. Errors are the only place the CLI uses color at all.
8
+ */
9
+ export function errorPrefix(colors = stderrHasColors()) {
10
+ return colors ? "\x1b[1;31mError:\x1b[0m" : "Error:";
11
+ }
12
+ /**
13
+ * User-fixable startup problems (missing model / bad config / broken definition) are thrown as plain
14
+ * `Error` — print just the message. Anything else (TypeError, non-Error) is a bug: keep the stack.
15
+ * Shared by the kernel and the command modules; exit 1 (runtime failure).
16
+ */
17
+ export function failStartup(error) {
18
+ if (error instanceof Error && error.constructor === Error)
19
+ console.error(`${errorPrefix()} ${error.message}`);
20
+ else
21
+ console.error(errorPrefix(), error);
22
+ process.exit(1);
23
+ }
24
+ /**
25
+ * A usage error the parser could not catch (a bad value shape, an invalid flag/argument combination
26
+ * discovered in a command body): print the message and exit 2 — the same class as a parse error.
27
+ * Exit codes follow responsibility, not the layer that happens to discover the problem.
28
+ */
29
+ export function failUsage(message) {
30
+ console.error(`${errorPrefix()} ${message}`);
31
+ process.exit(2);
32
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * CLI kernel — commands as data. A {@link CommandSpec} carries everything the CLI surface needs
3
+ * (summary, args/flags, examples, narrative notes, a lazy `run`), and {@link buildProgram} renders
4
+ * the set through commander. Commander is called ONLY from this module; the notation specs are
5
+ * written in — docopt-style argument brackets (`<required>`/`[optional]`), the flag DSL
6
+ * (`--auth-path <file>`, `--no-x` negation), and the derived option keys ({@link optionKey}) — is a
7
+ * contract this module owns and validates at build time. Replacing the parser means re-implementing
8
+ * that notation here (one module), not editing the specs.
9
+ *
10
+ * Follows clig.dev: per-command help in four spellings (`-h`/`--help`/`help <cmd>`/bare-with-missing-args),
11
+ * examples in help, "did you mean" suggestions (never auto-run), and one exit-code policy — 0 success,
12
+ * 1 runtime failure (owned by the command bodies), 2 usage error (anything the parser itself rejects).
13
+ */
14
+ import { Command } from "commander";
15
+ /** One positional argument, in commander syntax: `<name>` required, `[dir]` optional. */
16
+ export interface ArgSpec {
17
+ name: string;
18
+ description: string;
19
+ default?: string;
20
+ /** Closed value set — the parser rejects anything else as a usage error (exit 2). */
21
+ choices?: string[];
22
+ }
23
+ /** One flag, in the flag DSL: `--json`, or `--auth-path <file>` for a value-taking flag. */
24
+ export interface FlagSpec {
25
+ flags: string;
26
+ description: string;
27
+ /** Parses but does not appear in help — for retired flags that should still explain themselves. */
28
+ hidden?: boolean;
29
+ /** Mutually exclusive with these {@link optionKey} values — validated at build time. */
30
+ conflicts?: string[];
31
+ }
32
+ export interface ExampleSpec {
33
+ cmd: string;
34
+ note?: string;
35
+ }
36
+ export interface CommandSpec {
37
+ name: string;
38
+ /** One line for the command list in the parent help. */
39
+ summary: string;
40
+ /** Longer description for the command's own help; defaults to `summary`. */
41
+ description?: string;
42
+ args?: ArgSpec[];
43
+ flags?: FlagSpec[];
44
+ /** Shown in an "Examples:" section of the command's help — clig: users reach for examples first. */
45
+ examples?: ExampleSpec[];
46
+ /** Narrative help (behavior, precedence rules, caveats) appended after the generated sections. */
47
+ notes?: string;
48
+ /** A group command (e.g. `schedule`) declares subcommands instead of `run`. */
49
+ subcommands?: CommandSpec[];
50
+ /**
51
+ * The implementation: positional args in declaration order (an optional arg without a default is
52
+ * `undefined`), then the parsed flags. Lazy-import the actual work so `fastagent <cmd>` pays only
53
+ * for the modules that command uses. Runtime failures exit 1 from inside (fail visibly).
54
+ */
55
+ run?: (args: (string | undefined)[], flags: Record<string, unknown>) => Promise<void> | void;
56
+ }
57
+ /** Program-level configuration. Output/exit/width seams let tests drive the program in-process. */
58
+ export interface ProgramOptions {
59
+ /** Printed by `-v`/`--version`. */
60
+ version?: string;
61
+ /** Top-level Examples — rendered by the same Help pipeline as every command's. */
62
+ examples?: ExampleSpec[];
63
+ /** Top-level closing prose (the docs link) — reflowed like any notes. */
64
+ notes?: string;
65
+ /**
66
+ * Fixed help width — a TEST seam. Production omits it: commander then adapts to the terminal
67
+ * (and falls back to 80 when piped), the modern behavior. Our verbatim Examples/notes text is
68
+ * hand-wrapped at ≤78 columns so it reads well at any width ≥ 80 (prose caps, like man pages).
69
+ */
70
+ helpWidth?: number;
71
+ /**
72
+ * Force help colors on/off — a TEST seam. Production omits it: commander detects per stream
73
+ * (color TTY → on; pipe, NO_COLOR, TERM=dumb → off) and strips every SGR code when off.
74
+ */
75
+ colors?: boolean;
76
+ out?: (chunk: string) => void;
77
+ err?: (chunk: string) => void;
78
+ exit?: (code: number) => never;
79
+ }
80
+ /**
81
+ * The option key a flag string yields on the parsed-flags record — THE naming rule specs rely on:
82
+ * camelCase of the long name (`--auth-path` → `authPath`); a `--no-x` flag negates and stores under
83
+ * `x` (absent ⇒ `x !== false`). Owned and enforced here so `conflicts` references and run-body reads
84
+ * answer to one authority, not to an implicit parser behavior. Throws on a flag without a long form
85
+ * (clig: every flag has a full-length spelling).
86
+ */
87
+ export declare function optionKey(flags: string): string;
88
+ /** Build the commander program for `specs`. The CLI entry parses with it; tests inject the IO seams. */
89
+ export declare function buildProgram(specs: readonly CommandSpec[], options?: ProgramOptions): Command;