@fastagent-sh/fastagent 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -0
  3. package/dist/agent.d.ts +72 -0
  4. package/dist/agent.js +14 -0
  5. package/dist/channels/body.d.ts +9 -0
  6. package/dist/channels/body.js +29 -0
  7. package/dist/channels/github/github.d.ts +31 -0
  8. package/dist/channels/github/github.js +80 -0
  9. package/dist/channels/github/scaffold/channel.ts +25 -0
  10. package/dist/channels/http.d.ts +13 -0
  11. package/dist/channels/http.js +161 -0
  12. package/dist/channels/respond.d.ts +5 -0
  13. package/dist/channels/respond.js +3 -0
  14. package/dist/channels/telegram/context-buffer.d.ts +59 -0
  15. package/dist/channels/telegram/context-buffer.js +139 -0
  16. package/dist/channels/telegram/invoke-turn.d.ts +53 -0
  17. package/dist/channels/telegram/invoke-turn.js +127 -0
  18. package/dist/channels/telegram/parse.d.ts +160 -0
  19. package/dist/channels/telegram/parse.js +153 -0
  20. package/dist/channels/telegram/preview.d.ts +22 -0
  21. package/dist/channels/telegram/preview.js +266 -0
  22. package/dist/channels/telegram/register-webhook.d.ts +13 -0
  23. package/dist/channels/telegram/register-webhook.js +73 -0
  24. package/dist/channels/telegram/scaffold/channel.ts +25 -0
  25. package/dist/channels/telegram/scaffold/telegram-send.ts +107 -0
  26. package/dist/channels/telegram/state.d.ts +9 -0
  27. package/dist/channels/telegram/state.js +58 -0
  28. package/dist/channels/telegram/telegram-api.d.ts +77 -0
  29. package/dist/channels/telegram/telegram-api.js +345 -0
  30. package/dist/channels/telegram/telegram.d.ts +35 -0
  31. package/dist/channels/telegram/telegram.js +352 -0
  32. package/dist/channels/telegram/turn-queue.d.ts +21 -0
  33. package/dist/channels/telegram/turn-queue.js +47 -0
  34. package/dist/channels/telegram/turn-store.d.ts +38 -0
  35. package/dist/channels/telegram/turn-store.js +150 -0
  36. package/dist/cli-auth.d.ts +24 -0
  37. package/dist/cli-auth.js +30 -0
  38. package/dist/cli-models.d.ts +11 -0
  39. package/dist/cli-models.js +20 -0
  40. package/dist/cli.d.ts +2 -0
  41. package/dist/cli.js +1288 -0
  42. package/dist/collect.d.ts +20 -0
  43. package/dist/collect.js +27 -0
  44. package/dist/core.d.ts +10 -0
  45. package/dist/core.js +8 -0
  46. package/dist/deploy/container.d.ts +56 -0
  47. package/dist/deploy/container.js +169 -0
  48. package/dist/deploy/fly/plan.d.ts +65 -0
  49. package/dist/deploy/fly/plan.js +142 -0
  50. package/dist/deploy/fly/run.d.ts +51 -0
  51. package/dist/deploy/fly/run.js +89 -0
  52. package/dist/deploy/preflight.d.ts +59 -0
  53. package/dist/deploy/preflight.js +191 -0
  54. package/dist/deploy/railway/plan.d.ts +48 -0
  55. package/dist/deploy/railway/plan.js +91 -0
  56. package/dist/deploy/railway/run.d.ts +81 -0
  57. package/dist/deploy/railway/run.js +176 -0
  58. package/dist/deploy/runner.d.ts +18 -0
  59. package/dist/deploy/runner.js +25 -0
  60. package/dist/deploy/secrets.d.ts +49 -0
  61. package/dist/deploy/secrets.js +88 -0
  62. package/dist/dev-supervisor.d.ts +14 -0
  63. package/dist/dev-supervisor.js +158 -0
  64. package/dist/engines/pi/auth.d.ts +17 -0
  65. package/dist/engines/pi/auth.js +118 -0
  66. package/dist/engines/pi/channel.d.ts +31 -0
  67. package/dist/engines/pi/channel.js +113 -0
  68. package/dist/engines/pi/chat.d.ts +19 -0
  69. package/dist/engines/pi/chat.js +200 -0
  70. package/dist/engines/pi/config.d.ts +110 -0
  71. package/dist/engines/pi/config.js +285 -0
  72. package/dist/engines/pi/create.d.ts +127 -0
  73. package/dist/engines/pi/create.js +222 -0
  74. package/dist/engines/pi/definition.d.ts +72 -0
  75. package/dist/engines/pi/definition.js +129 -0
  76. package/dist/engines/pi/harness.d.ts +50 -0
  77. package/dist/engines/pi/harness.js +41 -0
  78. package/dist/engines/pi/invoke.d.ts +28 -0
  79. package/dist/engines/pi/invoke.js +298 -0
  80. package/dist/engines/pi/login.d.ts +51 -0
  81. package/dist/engines/pi/login.js +125 -0
  82. package/dist/engines/pi/models.d.ts +41 -0
  83. package/dist/engines/pi/models.js +76 -0
  84. package/dist/engines/pi/report.d.ts +13 -0
  85. package/dist/engines/pi/report.js +21 -0
  86. package/dist/engines/pi/sessions.d.ts +15 -0
  87. package/dist/engines/pi/sessions.js +130 -0
  88. package/dist/engines/pi/tool-context.d.ts +17 -0
  89. package/dist/engines/pi/tool-context.js +13 -0
  90. package/dist/engines/pi/tool.d.ts +45 -0
  91. package/dist/engines/pi/tool.js +85 -0
  92. package/dist/engines/pi/wake-tool.d.ts +20 -0
  93. package/dist/engines/pi/wake-tool.js +110 -0
  94. package/dist/engines/pi/workspace.d.ts +53 -0
  95. package/dist/engines/pi/workspace.js +70 -0
  96. package/dist/env.d.ts +24 -0
  97. package/dist/env.js +57 -0
  98. package/dist/github.d.ts +3 -0
  99. package/dist/github.js +2 -0
  100. package/dist/host/node.d.ts +41 -0
  101. package/dist/host/node.js +50 -0
  102. package/dist/index.d.ts +2 -0
  103. package/dist/index.js +5 -0
  104. package/dist/invoke-stream.d.ts +10 -0
  105. package/dist/invoke-stream.js +26 -0
  106. package/dist/loader.d.ts +38 -0
  107. package/dist/loader.js +69 -0
  108. package/dist/log.d.ts +26 -0
  109. package/dist/log.js +51 -0
  110. package/dist/observe.d.ts +12 -0
  111. package/dist/observe.js +44 -0
  112. package/dist/pi.d.ts +16 -0
  113. package/dist/pi.js +12 -0
  114. package/dist/proxy.d.ts +9 -0
  115. package/dist/proxy.js +23 -0
  116. package/dist/runtime.d.ts +26 -0
  117. package/dist/runtime.js +29 -0
  118. package/dist/scaffold/add-channel.d.ts +46 -0
  119. package/dist/scaffold/add-channel.js +227 -0
  120. package/dist/scaffold/init.d.ts +54 -0
  121. package/dist/scaffold/init.js +249 -0
  122. package/dist/scaffold/templates/env.example +24 -0
  123. package/dist/scaffold/templates/fastagent.config.mjs +19 -0
  124. package/dist/scaffold/templates/gitignore +8 -0
  125. package/dist/scaffold/templates/gitignore.agentdir-root +5 -0
  126. package/dist/scaffold/templates/gitignore.kit +2 -0
  127. package/dist/scaffold/templates/persona.md +10 -0
  128. package/dist/scaffold/templates/skills/writing-great-skills/GLOSSARY.md +195 -0
  129. package/dist/scaffold/templates/skills/writing-great-skills/LICENSE +21 -0
  130. package/dist/scaffold/templates/skills/writing-great-skills/SKILL.md +82 -0
  131. package/dist/scaffold/templates/tools/fetch-url.ts +27 -0
  132. package/dist/scaffold/templates.d.ts +17 -0
  133. package/dist/scaffold/templates.js +56 -0
  134. package/dist/scaffold/vendor-skill.d.ts +24 -0
  135. package/dist/scaffold/vendor-skill.js +157 -0
  136. package/dist/schedule/audit.d.ts +18 -0
  137. package/dist/schedule/audit.js +57 -0
  138. package/dist/schedule/cron.d.ts +7 -0
  139. package/dist/schedule/cron.js +33 -0
  140. package/dist/schedule/discover.d.ts +17 -0
  141. package/dist/schedule/discover.js +64 -0
  142. package/dist/schedule/schedule.d.ts +35 -0
  143. package/dist/schedule/schedule.js +23 -0
  144. package/dist/schedule/scheduler.d.ts +36 -0
  145. package/dist/schedule/scheduler.js +253 -0
  146. package/dist/schedule/state.d.ts +14 -0
  147. package/dist/schedule/state.js +56 -0
  148. package/dist/schedule/wakeups.d.ts +82 -0
  149. package/dist/schedule/wakeups.js +196 -0
  150. package/dist/telegram.d.ts +2 -0
  151. package/dist/telegram.js +2 -0
  152. package/dist/tunnel.d.ts +31 -0
  153. package/dist/tunnel.js +139 -0
  154. package/dist/version.d.ts +5 -0
  155. package/dist/version.js +13 -0
  156. package/dist/workspace.d.ts +9 -0
  157. package/dist/workspace.js +45 -0
  158. package/package.json +97 -0
@@ -0,0 +1,20 @@
1
+ import type { AgentTool } from "@earendil-works/pi-agent-core";
2
+ /**
3
+ * Parse a delay to milliseconds: a number is SECONDS; a string MUST carry a unit — `"<n><s|m|h|d>"`
4
+ * ("30m", "2h", "1d"). Undefined for anything else, INCLUDING a bare numeric string like "120": one
5
+ * encoding, one scale. Deliberate — an LLM freely emits a number OR a numeral-string, so letting a
6
+ * unitless string alias to a different unit than a number (120s vs 120min, 60x apart) is a silent
7
+ * footgun the guardrail (min 60s) would mask. A rejected value comes back to the model to fix.
8
+ */
9
+ export declare function parseDelayMs(input: string | number): number | undefined;
10
+ /**
11
+ * Append the built-in `wake` tool to `tools` — but only when `enabled` (the serving path, where the
12
+ * scheduler poller honors a wake-up) and only when the workspace hasn't defined its own `wake` (that
13
+ * wins, like any tool collision). The single place the mount decision + collision rule run.
14
+ */
15
+ export declare function withWakeTool(tools: AgentTool[], stateRoot: string, enabled: boolean): AgentTool[];
16
+ /** Build the `wake` tool bound to `stateRoot` (where wake-ups persist). */
17
+ export declare function makeWakeTool(stateRoot: string, now?: () => Date): AgentTool;
18
+ /** Build the `unwake` tool: cancel one of THIS conversation's pending wake-ups by id (a wake/recurring
19
+ * that is no longer needed). Session-scoped — a conversation can never cancel another's. */
20
+ export declare function makeUnwakeTool(stateRoot: string): AgentTool;
@@ -0,0 +1,110 @@
1
+ /**
2
+ * The built-in `wake` tool: the agent's self-scheduling surface. Calling it records a one-shot wake-up
3
+ * (wakeups.ts); the scheduler fires it back into the SAME session, so the agent resumes THIS
4
+ * conversation after a delay ("check the deploy in 10 minutes"). The session comes from the turn
5
+ * context (ToolContext.session, set around the harness turn); the state root is closed over at build
6
+ * time (where it is known — the workspace opener), never read from the turn.
7
+ *
8
+ * Mounted by the opener ONLY when `config.selfSchedule` is on AND on the serving path (`dev`/`start`, where
9
+ * the scheduler poller honors a wake-up) — never on the one-shot `invoke`/`fire` (they exit and never poll).
10
+ * To opt OUT: leave `selfSchedule` off (the default), or override the built-in by defining your own
11
+ * `tools/wake.ts` (a same-name collision wins — see {@link withWakeTool}). There is no name-based tool
12
+ * exclusion config today.
13
+ */
14
+ import { z } from "zod";
15
+ import { addWakeup, removeWakeup } from "../../schedule/wakeups.js";
16
+ import { defineTool } from "./tool.js";
17
+ /**
18
+ * Parse a delay to milliseconds: a number is SECONDS; a string MUST carry a unit — `"<n><s|m|h|d>"`
19
+ * ("30m", "2h", "1d"). Undefined for anything else, INCLUDING a bare numeric string like "120": one
20
+ * encoding, one scale. Deliberate — an LLM freely emits a number OR a numeral-string, so letting a
21
+ * unitless string alias to a different unit than a number (120s vs 120min, 60x apart) is a silent
22
+ * footgun the guardrail (min 60s) would mask. A rejected value comes back to the model to fix.
23
+ */
24
+ export function parseDelayMs(input) {
25
+ if (typeof input === "number")
26
+ return Number.isFinite(input) && input > 0 ? input * 1000 : undefined;
27
+ const m = input.trim().match(/^(\d+(?:\.\d+)?)\s*(s|m|h|d)$/i); // unit REQUIRED on a string
28
+ if (!m)
29
+ return undefined;
30
+ const mult = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2].toLowerCase()];
31
+ return Number(m[1]) * mult;
32
+ }
33
+ /**
34
+ * Append the built-in `wake` tool to `tools` — but only when `enabled` (the serving path, where the
35
+ * scheduler poller honors a wake-up) and only when the workspace hasn't defined its own `wake` (that
36
+ * wins, like any tool collision). The single place the mount decision + collision rule run.
37
+ */
38
+ export function withWakeTool(tools, stateRoot, enabled) {
39
+ if (!enabled)
40
+ return tools;
41
+ // wake/unwake are a PAIR over one store: if the workspace defines EITHER name, mount NEITHER built-in.
42
+ // Mixing halves would mislead — an author's wake doesn't write our wakeups store, so our unwake could
43
+ // never cancel what it returns ("not yours" forever); the author owns the whole concept or none of it.
44
+ if (tools.some((t) => t.name === "wake" || t.name === "unwake"))
45
+ return tools;
46
+ return [...tools, makeWakeTool(stateRoot), makeUnwakeTool(stateRoot)];
47
+ }
48
+ /** Build the `wake` tool bound to `stateRoot` (where wake-ups persist). */
49
+ export function makeWakeTool(stateRoot, now = () => new Date()) {
50
+ return defineTool({
51
+ name: "wake",
52
+ description: "Schedule yourself to wake up later and continue in THIS conversation. ONE-SHOT: pass `in` — a " +
53
+ 'duration string with a unit ("30m", "2h", "1d") or a number of seconds — to resume a task after a ' +
54
+ "delay. RECURRING: pass `cron` (5-field, optional `tz`) to wake repeatedly — use sparingly, and " +
55
+ "`unwake` with the returned id when the job is done. Exactly one of `in`/`cron`. When the time " +
56
+ "comes, a new turn runs in this same session with `prompt` as its instruction (tagged with the wake-up's " +
57
+ "id so you can tell it from a user message) — you keep the full context of this conversation. IMPORTANT: the woken turn's plain reply is NOT delivered to anyone — " +
58
+ "to reach the user it must call a delivery tool (e.g. a channel's send tool), exactly as a scheduled " +
59
+ "job would.",
60
+ input: z.object({
61
+ in: z
62
+ .union([z.string(), z.number()])
63
+ .optional()
64
+ .describe('one-shot delay: a duration string with a unit ("30m" / "2h" / "1d"), or a number of seconds'),
65
+ cron: z.string().optional().describe('recurring: a 5-field cron expression (e.g. "0 9 * * *")'),
66
+ tz: z.string().optional().describe('IANA timezone for `cron` (default "UTC")'),
67
+ prompt: z.string().min(1).describe("the instruction for the woken turn (runs in this same conversation)"),
68
+ }),
69
+ execute(input, ctx) {
70
+ if (!ctx.session)
71
+ return "wake is only available inside a conversation (there is no session to resume).";
72
+ if ((input.in === undefined) === (input.cron === undefined)) {
73
+ return "pass exactly one of `in` (one-shot) or `cron` (recurring).";
74
+ }
75
+ if (input.cron !== undefined) {
76
+ // addWakeup validates the cron and DERIVES the first instant itself — one computation, one truth.
77
+ const r = addWakeup(stateRoot, { session: ctx.session, prompt: input.prompt, cron: input.cron, tz: input.tz }, now());
78
+ if (!r.ok)
79
+ return r.error; // guardrail message the model can act on
80
+ return `OK — recurring wake ${r.id} (cron "${input.cron}"${input.tz ? ` ${input.tz}` : ""}), first at ${r.fireAt}: ${input.prompt}. Use unwake({ id: "${r.id}" }) to stop it.`;
81
+ }
82
+ const ms = parseDelayMs(input.in);
83
+ if (ms === undefined) {
84
+ return `couldn't parse "in" (${JSON.stringify(input.in)}) — use a unit like "30m" / "2h" / "1d", or a number of seconds (a bare number as text like "120" is rejected).`;
85
+ }
86
+ const at = new Date(now().getTime() + ms);
87
+ const r = addWakeup(stateRoot, { session: ctx.session, prompt: input.prompt, fireAt: at }, now());
88
+ if (!r.ok)
89
+ return r.error; // guardrail message the model can act on
90
+ return `OK — I'll wake up at ${r.fireAt} (id ${r.id}) to: ${input.prompt}. Use unwake({ id: "${r.id}" }) if it becomes unnecessary.`;
91
+ },
92
+ });
93
+ }
94
+ /** Build the `unwake` tool: cancel one of THIS conversation's pending wake-ups by id (a wake/recurring
95
+ * that is no longer needed). Session-scoped — a conversation can never cancel another's. */
96
+ export function makeUnwakeTool(stateRoot) {
97
+ return defineTool({
98
+ name: "unwake",
99
+ description: "Cancel one of YOUR pending wake-ups (one-shot or recurring) by the id `wake` returned. Use it when " +
100
+ "a scheduled follow-up is no longer needed — especially to stop a recurring wake once its job is done.",
101
+ input: z.object({ id: z.string().min(1).describe("the wake-up id (returned by `wake`)") }),
102
+ execute(input, ctx) {
103
+ if (!ctx.session)
104
+ return "unwake is only available inside a conversation.";
105
+ return removeWakeup(stateRoot, input.id, ctx.session)
106
+ ? `OK — wake-up ${input.id} cancelled.`
107
+ : `no pending wake-up ${input.id} in this conversation (already fired, or not yours).`;
108
+ },
109
+ });
110
+ }
@@ -0,0 +1,53 @@
1
+ import type { Agent } from "../../agent.ts";
2
+ import { type FastagentConfig } from "./config.ts";
3
+ import type { ModuleLoadFailure } from "../../loader.ts";
4
+ import { type LoadedDefinition } from "./definition.ts";
5
+ import type { ToolCollision } from "./tool.ts";
6
+ export interface CreatePiAgentFromWorkspaceOptions {
7
+ /** Model spec override (e.g. the CLI --model flag). Precedence: this > FASTAGENT_MODEL > config.model. */
8
+ model?: string;
9
+ /**
10
+ * Session store directory. Default `<dir>/.fastagent/sessions` (gitignored machine state). `start`
11
+ * overrides it (--sessions-dir / FASTAGENT_SESSIONS_DIR / a mounted volume) so production continuity
12
+ * survives redeploys.
13
+ */
14
+ sessionsDir?: string;
15
+ /**
16
+ * Credentials file override. Default `<dir>/.fastagent/auth.json` (project-level, gitignored under
17
+ * the same `*`-ignored `.fastagent`). Override via --auth-path / FASTAGENT_AUTH_PATH; point it at
18
+ * `~/.fastagent/auth.json` to share one credential across projects.
19
+ */
20
+ authPath?: string;
21
+ /**
22
+ * This is a long-running SERVE (`dev`/`start`), where the scheduler poller runs — so a self-scheduled
23
+ * wake-up is actually honored. One-shot entries (`invoke`/`fire`) leave it off (they exit after the turn
24
+ * and never poll). The built-in `wake` tool mounts only when this is set AND `config.selfSchedule` is on.
25
+ */
26
+ serving?: boolean;
27
+ }
28
+ /**
29
+ * "Point at a workspace → agent": load the config, resolve model and tools, then L2. Throws a clear
30
+ * error when no model source is set (fail visibly at startup). Returns everything an entry point needs
31
+ * to report what it assembled.
32
+ */
33
+ export declare function createPiAgentFromWorkspace(dir: string, options?: CreatePiAgentFromWorkspaceOptions): Promise<{
34
+ agent: Agent;
35
+ definition: LoadedDefinition;
36
+ config: FastagentConfig;
37
+ configPath?: string;
38
+ /** The resolved "provider/modelId" spec actually in use. */
39
+ modelSpec: string;
40
+ /** Absolute agent-definition dir in use (config.agentDir resolved against dir; = dir when unset). Channels/tools/persona come from here. */
41
+ agentDir: string;
42
+ /** Absolute state root in use (FASTAGENT_STATE_DIR > <dir>/.fastagent) — the ChannelContext's stateRoot. */
43
+ stateRoot: string;
44
+ /** Absolute session store directory in use (for the startup report). */
45
+ sessionsDir: string;
46
+ /** Absolute credentials file in use (for the startup report). */
47
+ authPath: string;
48
+ /** Non-default tool names in effect: config.tools + discovered tools/. */
49
+ toolNames: string[];
50
+ toolCollisions: ToolCollision[];
51
+ /** `tools/` files that failed to import — skipped, reported by the caller, never fatal. */
52
+ toolFailures: ModuleLoadFailure[];
53
+ }>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * Open a definition directory into an agent — the single workspace opener BOTH `fastagent dev` and
3
+ * `fastagent start` drive.
4
+ *
5
+ * A thin command-posture composition over L2 `createPiAgentFromDefinition`: open the directory →
6
+ * resolve model (flag > env > config) and tools (append-after-defaults) → pick session storage →
7
+ * call L2. dev and start share the SAME assembly here (what you iterate is what you serve); they
8
+ * differ only at the CLI — dev watches and uses the in-tree sessions default, start runs without
9
+ * watch and can point sessions at a mounted volume.
10
+ */
11
+ import { mkdir } from "node:fs/promises";
12
+ import { defaultAuthPath, defaultSessionsDir, loadConfig, resolveAgentDir, resolveModelSpec, resolveStateRoot, } from "./config.js";
13
+ import { createPiAgentFromDefinition, resolveWorkspaceTools } from "./create.js";
14
+ import { withWakeTool } from "./wake-tool.js";
15
+ import { ensureStateRootSelfIgnored } from "./definition.js";
16
+ import { jsonlSessionStore } from "./sessions.js";
17
+ /**
18
+ * "Point at a workspace → agent": load the config, resolve model and tools, then L2. Throws a clear
19
+ * error when no model source is set (fail visibly at startup). Returns everything an entry point needs
20
+ * to report what it assembled.
21
+ */
22
+ export async function createPiAgentFromWorkspace(dir, options = {}) {
23
+ const { config, path: configPath } = await loadConfig(dir);
24
+ const modelSpec = resolveModelSpec(options.model, config);
25
+ if (!modelSpec) {
26
+ throw new Error(`missing model: set --model, "model" in fastagent.config.ts, or FASTAGENT_MODEL (e.g. "openai-codex/gpt-5.5")`);
27
+ }
28
+ // The run root is `dir` (cwd — where config lives, whose AGENTS.md is ② context); the agent's own
29
+ // surface (persona/skills/tools/channels) lives in `agentDir` (config.agentDir, or `dir` when flat).
30
+ const agentDir = resolveAgentDir(dir, config);
31
+ const { tools, toolNames, toolCollisions, toolFailures } = await resolveWorkspaceTools(config, agentDir, dir);
32
+ // The state root: auth/sessions/channel state all derive from it, so FASTAGENT_STATE_DIR moves the
33
+ // whole machine-state home in one knob (a container mounts one volume); the finer overrides below
34
+ // still win for their specific path.
35
+ const stateRoot = resolveStateRoot(dir);
36
+ // Mount the built-in `wake` tool only when BOTH: this is a long-running serve (the poller honors it) AND
37
+ // the author opted into self-scheduling (config.selfSchedule). The workspace's own `wake` wins if defined.
38
+ const mountedTools = withWakeTool(tools, stateRoot, !!options.serving && !!config.selfSchedule);
39
+ const sessionsDir = options.sessionsDir ?? defaultSessionsDir(stateRoot);
40
+ await mkdir(sessionsDir, { recursive: true });
41
+ // The credentials file: project-level by default (under the self-ignored state root); only READ here,
42
+ // so no mkdir (a missing file reads as not-configured — `fastagent login` creates it).
43
+ const authPath = options.authPath ?? defaultAuthPath(stateRoot);
44
+ // Self-ignore the state root iff it lands in-tree — which covers everything under it (sessions, auth,
45
+ // every channel's `channels/<kind>` home), including a custom in-tree `FASTAGENT_STATE_DIR`. A
46
+ // per-path override to an external volume is out-of-tree and correctly left alone.
47
+ await ensureStateRootSelfIgnored(dir, stateRoot);
48
+ const { agent, definition } = await createPiAgentFromDefinition(agentDir, {
49
+ model: modelSpec,
50
+ cwd: dir,
51
+ tools: mountedTools,
52
+ authPath,
53
+ // Skills are definition-only (the agent is its directory), so dev mirrors deployment exactly.
54
+ sessions: jsonlSessionStore({ dir: sessionsDir, cwd: dir }),
55
+ });
56
+ return {
57
+ agent,
58
+ definition,
59
+ agentDir,
60
+ config,
61
+ configPath,
62
+ modelSpec,
63
+ stateRoot,
64
+ sessionsDir,
65
+ authPath,
66
+ toolNames,
67
+ toolCollisions,
68
+ toolFailures,
69
+ };
70
+ }
package/dist/env.d.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Load a `.env` file into `process.env`, matching Node's `--env-file` / `process.loadEnvFile` precedence
3
+ * on BOTH axes (verified against Node): a real env var wins over the file (an already-set key is kept),
4
+ * and within the file a repeated key takes the LAST occurrence. Portable across Node and Bun: Bun has no
5
+ * `process.loadEnvFile`, so we parse the file ourselves rather than depend on a Node-only entry point.
6
+ *
7
+ * Two phases keep the two rules distinct: parse into a map (later line overrides earlier = last-wins),
8
+ * then apply only the keys `process.env` doesn't already have (env-wins). Minimal parser — `KEY=VALUE`
9
+ * per line, `#` comments and blank lines skipped, surrounding matched single/double quotes stripped:
10
+ * enough for the flat secret files fastagent reads (tokens, keys), not a full dotenv dialect (no
11
+ * multiline, no `export`, no interpolation). A missing file throws ENOENT for the caller to treat as
12
+ * "no .env"; any other read error propagates.
13
+ */
14
+ export declare function loadEnvFile(file: string): void;
15
+ /** Parse .env content into key → value (the dialect above; last occurrence of a key wins). THE parser —
16
+ * anything else reading/deciding on .env content (e.g. `add`'s secret pre-fill) must use this, never a
17
+ * private re-implementation: two parsers of one dialect diverge silently. */
18
+ export declare function parseEnvContent(content: string): Map<string, string>;
19
+ /**
20
+ * Load `<dir>/.env` into `process.env` ({@link loadEnvFile}), treating a MISSING file as normal (no .env)
21
+ * — the workspace-facing entry every command + the tunnel use. Only ENOENT is swallowed; any other read
22
+ * error (a corrupt/unreadable file) propagates, so a real problem surfaces instead of silently skipping.
23
+ */
24
+ export declare function loadDotEnv(dir: string): void;
package/dist/env.js ADDED
@@ -0,0 +1,57 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ /**
4
+ * Load a `.env` file into `process.env`, matching Node's `--env-file` / `process.loadEnvFile` precedence
5
+ * on BOTH axes (verified against Node): a real env var wins over the file (an already-set key is kept),
6
+ * and within the file a repeated key takes the LAST occurrence. Portable across Node and Bun: Bun has no
7
+ * `process.loadEnvFile`, so we parse the file ourselves rather than depend on a Node-only entry point.
8
+ *
9
+ * Two phases keep the two rules distinct: parse into a map (later line overrides earlier = last-wins),
10
+ * then apply only the keys `process.env` doesn't already have (env-wins). Minimal parser — `KEY=VALUE`
11
+ * per line, `#` comments and blank lines skipped, surrounding matched single/double quotes stripped:
12
+ * enough for the flat secret files fastagent reads (tokens, keys), not a full dotenv dialect (no
13
+ * multiline, no `export`, no interpolation). A missing file throws ENOENT for the caller to treat as
14
+ * "no .env"; any other read error propagates.
15
+ */
16
+ export function loadEnvFile(file) {
17
+ const parsed = parseEnvContent(readFileSync(file, "utf8"));
18
+ for (const [key, value] of parsed) {
19
+ if (!(key in process.env))
20
+ process.env[key] = value; // env-vs-file: a real env var wins
21
+ }
22
+ }
23
+ /** Parse .env content into key → value (the dialect above; last occurrence of a key wins). THE parser —
24
+ * anything else reading/deciding on .env content (e.g. `add`'s secret pre-fill) must use this, never a
25
+ * private re-implementation: two parsers of one dialect diverge silently. */
26
+ export function parseEnvContent(content) {
27
+ const parsed = new Map();
28
+ for (const raw of content.split("\n")) {
29
+ const line = raw.trim();
30
+ if (!line || line.startsWith("#"))
31
+ continue;
32
+ const eq = line.indexOf("=");
33
+ if (eq < 1)
34
+ continue; // no `=`, or an empty key
35
+ const key = line.slice(0, eq).trim();
36
+ let value = line.slice(eq + 1).trim();
37
+ const quote = value[0];
38
+ if ((quote === '"' || quote === "'") && value.at(-1) === quote)
39
+ value = value.slice(1, -1);
40
+ parsed.set(key, value); // in-file: last occurrence wins (Map overwrite)
41
+ }
42
+ return parsed;
43
+ }
44
+ /**
45
+ * Load `<dir>/.env` into `process.env` ({@link loadEnvFile}), treating a MISSING file as normal (no .env)
46
+ * — the workspace-facing entry every command + the tunnel use. Only ENOENT is swallowed; any other read
47
+ * error (a corrupt/unreadable file) propagates, so a real problem surfaces instead of silently skipping.
48
+ */
49
+ export function loadDotEnv(dir) {
50
+ try {
51
+ loadEnvFile(join(dir, ".env"));
52
+ }
53
+ catch (error) {
54
+ if (error.code !== "ENOENT")
55
+ throw error;
56
+ }
57
+ }
@@ -0,0 +1,3 @@
1
+ /** `@fastagent-sh/fastagent/github` — the GitHub channel subpath export, kept off the root surface. */
2
+ export { githubChannel, type GithubChannelOptions, type GithubEvent, type Intent, } from "./channels/github/github.ts";
3
+ export type { Schema } from "@octokit/webhooks-types";
package/dist/github.js ADDED
@@ -0,0 +1,2 @@
1
+ /** `@fastagent-sh/fastagent/github` — the GitHub channel subpath export, kept off the root surface. */
2
+ export { githubChannel, } from "./channels/github/github.js";
@@ -0,0 +1,41 @@
1
+ import type { Agent } from "../agent.ts";
2
+ /** A mounted request handler (a channel's fetch, or a plain route like health). */
3
+ export type ChannelHandler = (req: Request) => Response | Promise<Response>;
4
+ /** This deployment's HTTP surface: route key → handler. Key is `"/path"` or `"METHOD /path"`. */
5
+ export type Routes = Record<string, ChannelHandler>;
6
+ /**
7
+ * What the framework hands a channel at mount time: the assembled agent plus the resolved state ROOT
8
+ * (absolute; `FASTAGENT_STATE_DIR` > `<dir>/.fastagent`). Channels derive their OWN durable home from
9
+ * it (`<stateRoot>/channels/<kind>/`) — they never anchor on `process.cwd()`. env is the OPERATOR
10
+ * input plane; this context is how the resolved result reaches code (embedders without the workspace
11
+ * opener construct it explicitly).
12
+ */
13
+ export interface ChannelContext {
14
+ agent: Agent;
15
+ stateRoot: string;
16
+ }
17
+ /** A `channels/<name>.ts` default export: receives the mount context, returns the routes it mounts.
18
+ * Adapters (`telegramChannel(opts)`, `githubChannel(opts)`) RETURN one of these, so user glue holds
19
+ * only policy — the framework pipes `agent`/`stateRoot` to the adapter without transiting user code. */
20
+ export type ChannelModule = (ctx: ChannelContext) => Routes;
21
+ /** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method). */
22
+ export declare function parseRouteKey(key: string): {
23
+ method?: string;
24
+ path: string;
25
+ };
26
+ /**
27
+ * Compose a {@link Routes} table into one handler: exact pathname match (optionally method-qualified),
28
+ * 405 when the path exists under another method, 404 otherwise. No params/wildcards.
29
+ */
30
+ export declare function router(routes: Routes): ChannelHandler;
31
+ /**
32
+ * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller close
33
+ * it — no logging/signals/exit (the CLI owns those). `listening` resolves with the bound port (useful
34
+ * for port 0) or rejects on a bind error.
35
+ */
36
+ export declare function serveNode(handler: ChannelHandler, options: {
37
+ port: number;
38
+ }): {
39
+ listening: Promise<number>;
40
+ close: () => Promise<void>;
41
+ };
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Node host (K-axis): mount a route table of Fetch handlers on a node:http server. Post-ACK work
3
+ * (e.g. a webhook channel's fire-and-forget turns) runs on this process's event loop and is lost on
4
+ * shutdown (the accepted tradeoff until durable execution exists).
5
+ */
6
+ import { createServer } from "node:http";
7
+ import { nodeListener } from "../channels/http.js";
8
+ import { text } from "../channels/respond.js";
9
+ /** Parse a route key: `"METHOD /path"` → `{ method, path }`, or `"/path"` → `{ path }` (any method). */
10
+ export function parseRouteKey(key) {
11
+ const sp = key.indexOf(" ");
12
+ return sp === -1 ? { path: key } : { method: key.slice(0, sp).toUpperCase(), path: key.slice(sp + 1) };
13
+ }
14
+ /**
15
+ * Compose a {@link Routes} table into one handler: exact pathname match (optionally method-qualified),
16
+ * 405 when the path exists under another method, 404 otherwise. No params/wildcards.
17
+ */
18
+ export function router(routes) {
19
+ const entries = Object.entries(routes).map(([key, handler]) => ({
20
+ ...parseRouteKey(key),
21
+ handler,
22
+ }));
23
+ return (req) => {
24
+ const { pathname } = new URL(req.url);
25
+ const onPath = entries.filter((e) => e.path === pathname);
26
+ if (onPath.length === 0)
27
+ return text("not found\n", 404);
28
+ const match = onPath.find((e) => e.method === undefined || e.method === req.method);
29
+ if (!match)
30
+ return text("method not allowed\n", 405);
31
+ return match.handler(req);
32
+ };
33
+ }
34
+ /**
35
+ * Serve `handler` on a Node HTTP server. Thin mechanism: bind, report the port, let the caller close
36
+ * it — no logging/signals/exit (the CLI owns those). `listening` resolves with the bound port (useful
37
+ * for port 0) or rejects on a bind error.
38
+ */
39
+ export function serveNode(handler, options) {
40
+ const server = createServer(nodeListener(async (req) => handler(req)));
41
+ const listening = new Promise((resolve, reject) => {
42
+ server.once("error", reject); // a bind failure surfaces here, before "listening"
43
+ server.listen(options.port, () => {
44
+ server.off("error", reject);
45
+ resolve(server.address().port);
46
+ });
47
+ });
48
+ const close = () => new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())));
49
+ return { listening, close };
50
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./core.ts";
2
+ export * from "./pi.ts";
package/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ // Convenience entry: the engine-neutral core plus the pi reference implementation.
2
+ // Contract/channel-only consumers should prefer `@fastagent-sh/fastagent/core`; pi-specific consumers
3
+ // may use `@fastagent-sh/fastagent/pi`. The root remains the supported all-in-one surface.
4
+ export * from "./core.js";
5
+ export * from "./pi.js";
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Render an Agent event stream to two sinks plus an exit code — the `fastagent invoke` contract, a
3
+ * PURE function (IO injected) so it is unit-testable: text deltas → `out`; tool start + an ERRORED
4
+ * tool_ended → `err`; `failed` → `err` + a non-zero exit code (the CI-gating guarantee).
5
+ *
6
+ * An errored tool inside an otherwise-completed turn still surfaces (the diagnostic the operator
7
+ * needs); tool_ended carries no name, so it is remembered from the matching tool_started.
8
+ */
9
+ import type { AgentEvent } from "./agent.ts";
10
+ export declare function runInvokeStream(events: AsyncIterable<AgentEvent>, out: (text: string) => void, err: (line: string) => void): Promise<number>;
@@ -0,0 +1,26 @@
1
+ export async function runInvokeStream(events, out, err) {
2
+ const toolName = new Map(); // tool_ended carries no name — remember it from tool_started
3
+ let exitCode = 0;
4
+ for await (const event of events) {
5
+ switch (event.type) {
6
+ case "text":
7
+ out(event.delta);
8
+ break;
9
+ case "tool_started":
10
+ toolName.set(event.id, event.name);
11
+ err(`[tool] ${event.name}`);
12
+ break;
13
+ case "tool_ended":
14
+ if (event.isError)
15
+ err(`[tool] ${toolName.get(event.id) ?? event.id} failed`);
16
+ break;
17
+ case "failed":
18
+ err(`[fastagent] failed: ${event.details}${event.retryable ? " (retryable)" : ""}`);
19
+ exitCode = 1;
20
+ break;
21
+ case "completed":
22
+ break; // terminal success — nothing to render (structured data, if any, is not a CLI concern)
23
+ }
24
+ }
25
+ return exitCode;
26
+ }
@@ -0,0 +1,38 @@
1
+ /** Whether `name` is an importable workspace module (a discovery candidate, not a type declaration). */
2
+ export declare function isModuleFile(name: string): boolean;
3
+ export interface DiscoveredModule {
4
+ /** Basename without extension — the authoritative name for tools/channels. */
5
+ name: string;
6
+ /** "tools/foo.ts"-style label for errors and collisions. */
7
+ label: string;
8
+ file: string;
9
+ mod: {
10
+ default?: unknown;
11
+ };
12
+ }
13
+ /** A workspace module that failed to load, surfaced as data so its caller can report the exact file.
14
+ * `loadModuleDir` fills it for import failures; domain loaders add validation failures. The caller owns
15
+ * policy: tools/schedules may skip one bad file, while serving treats a broken declared channel as fatal. */
16
+ export interface ModuleLoadFailure {
17
+ /** "tools/foo.ts"-style label. */
18
+ label: string;
19
+ file: string;
20
+ /** The failure message (an import error carries {@link moduleLoadHint}). */
21
+ message: string;
22
+ }
23
+ /**
24
+ * Import every module file in `subDir`, sorted by name. Missing dir returns none. A file that fails to
25
+ * IMPORT is collected into `failures` (with {@link moduleLoadHint}) rather than thrown, so the caller can
26
+ * report every bad file and apply domain policy; `loadTools`/`loadChannels` add validation failures the
27
+ * same way. (A missing DIRECTORY still returns empty; an unreadable directory still throws
28
+ * — that's not a per-file problem.)
29
+ */
30
+ export declare function loadModuleDir(subDir: string): Promise<{
31
+ modules: DiscoveredModule[];
32
+ failures: ModuleLoadFailure[];
33
+ }>;
34
+ /**
35
+ * A hint for the two common dynamic-import failures — an uninstalled dependency or a non-ESM
36
+ * package — and empty otherwise, so an unrelated error is reported on its own.
37
+ */
38
+ export declare function moduleLoadHint(error: NodeJS.ErrnoException): string;
package/dist/loader.js ADDED
@@ -0,0 +1,69 @@
1
+ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
2
+ if (typeof path === "string" && /^\.\.?\//.test(path)) {
3
+ return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
4
+ return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
5
+ });
6
+ }
7
+ return path;
8
+ };
9
+ import { readdir } from "node:fs/promises";
10
+ import { basename, extname, join } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ const MODULE_EXTS = new Set([".ts", ".js", ".mjs"]);
13
+ /** Whether `name` is an importable workspace module (a discovery candidate, not a type declaration). */
14
+ export function isModuleFile(name) {
15
+ return MODULE_EXTS.has(extname(name)) && !name.endsWith(".d.ts");
16
+ }
17
+ /**
18
+ * Import every module file in `subDir`, sorted by name. Missing dir returns none. A file that fails to
19
+ * IMPORT is collected into `failures` (with {@link moduleLoadHint}) rather than thrown, so the caller can
20
+ * report every bad file and apply domain policy; `loadTools`/`loadChannels` add validation failures the
21
+ * same way. (A missing DIRECTORY still returns empty; an unreadable directory still throws
22
+ * — that's not a per-file problem.)
23
+ */
24
+ export async function loadModuleDir(subDir) {
25
+ let entries;
26
+ try {
27
+ entries = await readdir(subDir, { withFileTypes: true });
28
+ }
29
+ catch (error) {
30
+ const e = error;
31
+ if (e.code === "ENOENT" || e.code === "not_found")
32
+ return { modules: [], failures: [] };
33
+ throw new Error(`cannot read ${subDir}: ${error.message}`);
34
+ }
35
+ const sub = basename(subDir);
36
+ const modules = [];
37
+ const failures = [];
38
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
39
+ if (!entry.isFile() || !isModuleFile(entry.name))
40
+ continue;
41
+ const file = join(subDir, entry.name);
42
+ const label = `${sub}/${entry.name}`;
43
+ try {
44
+ const mod = (await import(__rewriteRelativeImportExtension(pathToFileURL(file).href)));
45
+ modules.push({ name: basename(entry.name, extname(entry.name)), label, file, mod });
46
+ }
47
+ catch (error) {
48
+ failures.push({
49
+ label,
50
+ file,
51
+ message: `${error.message}${moduleLoadHint(error)}`,
52
+ });
53
+ }
54
+ }
55
+ return { modules, failures };
56
+ }
57
+ /**
58
+ * A hint for the two common dynamic-import failures — an uninstalled dependency or a non-ESM
59
+ * package — and empty otherwise, so an unrelated error is reported on its own.
60
+ */
61
+ export function moduleLoadHint(error) {
62
+ if (error.code === "ERR_MODULE_NOT_FOUND" || /Cannot find (package|module)/.test(error.message)) {
63
+ return "\n (a dependency is not installed — run `npm install` in the workspace)";
64
+ }
65
+ if (/import statement outside a module|Unexpected token 'export'|ERR_REQUIRE_ESM/.test(error.message)) {
66
+ return '\n (this workspace must be ESM — set "type": "module" in package.json)';
67
+ }
68
+ return "";
69
+ }
package/dist/log.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Leveled logging. Runtime logs (lifecycle, warnings, errors, the debug turn trace) flow through ONE
3
+ * process-level logger, gated by a single level. It is a module singleton — not an injected dependency —
4
+ * because most runtime logs originate inside author-constructed channels (`channels/*.ts` call the
5
+ * channel factory themselves) and deep engine code, which the CLI cannot thread a logger into. The CLI
6
+ * sets the level by posture (dev → debug, start → info); `FASTAGENT_LOG_LEVEL` overrides it.
7
+ *
8
+ * This is NOT the CLI's user-facing output (help text, command results): that is the program talking to
9
+ * its operator and stays on plain `console`. Everything here is operational logging to stderr.
10
+ */
11
+ export type LogLevel = "debug" | "info" | "warn" | "error";
12
+ export interface Logger {
13
+ debug(msg: string): void;
14
+ info(msg: string): void;
15
+ warn(msg: string): void;
16
+ error(msg: string): void;
17
+ }
18
+ /** A standalone logger over an explicit sink — used in tests to assert level gating without the singleton. */
19
+ export declare function createLogger(opts: {
20
+ level: LogLevel;
21
+ sink?: (line: string) => void;
22
+ }): Logger;
23
+ /** Set the posture default. A valid `FASTAGENT_LOG_LEVEL` override, if present, wins and is not changed. */
24
+ export declare function setLogLevel(level: LogLevel): void;
25
+ /** The process logger. Runtime code imports this and calls `log.info(...)` etc. */
26
+ export declare const log: Logger;