@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,72 @@
1
+ import type { ExecutionEnv, Skill, SkillDiagnostic } from "@earendil-works/pi-agent-core";
2
+ /** A same-name skill collision (the discarded side). Surfaced, never swallowed. */
3
+ export interface SkillCollision {
4
+ name: string;
5
+ winnerPath: string;
6
+ loserPath: string;
7
+ }
8
+ /** Result of loading a definition directory. Produced by {@link loadAgentDefinition}. */
9
+ export interface LoadedDefinition {
10
+ /**
11
+ * Project-context files feeding segment ② `<project_context>`, sourced via pi's `loadProjectContextFiles`:
12
+ * the agentDir's own AGENTS.md/CLAUDE.md FIRST, then every AGENTS.md from root down to `cwd` (so the
13
+ * file nearest `cwd` comes LAST — pi's array order). Empty when none exist. This WALKS cwd's ancestors (pi's coding-agent behaviour) — see core.md §6.
14
+ */
15
+ contextFiles: Array<{
16
+ path: string;
17
+ content: string;
18
+ }>;
19
+ /**
20
+ * Verbatim `persona.md` content — the authored persona that OVERRIDES segment ①'s identity line
21
+ * (piBasePrompt keeps the tool list + guidelines; NOT a full system-prompt replacement — that is L1
22
+ * createPiAgent's `instructions`). undefined when absent → segment ① is the default engine identity.
23
+ */
24
+ persona?: string;
25
+ skills: Skill[];
26
+ /** Non-fatal per-file skill problems reported by pi's loader. */
27
+ diagnostics: SkillDiagnostic[];
28
+ /** Same-name conflicts across mounts (first-wins). */
29
+ collisions: SkillCollision[];
30
+ /** Absolute agent-definition directory path (persona.md/skills/ live here). */
31
+ dir: string;
32
+ }
33
+ export interface LoadAgentDefinitionOptions {
34
+ /**
35
+ * Working directory whose ancestors are walked for context files (segment ②). Default = `agentDir`
36
+ * (flat: the agent dir is also the run root). The opener passes the run root so a coding agent that
37
+ * lives in `agentDir` picks up the host repo's AGENTS.md up the tree (core.md scenario grid).
38
+ */
39
+ cwd?: string;
40
+ env?: ExecutionEnv;
41
+ }
42
+ /** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
43
+ export declare function loadAgentDefinition(agentDir: string, options?: LoadAgentDefinitionOptions): Promise<LoadedDefinition>;
44
+ /**
45
+ * Whether `targetPath` lives inside `baseDir` (same path counts). The self-ignore guard uses it to ask
46
+ * "does the resolved state root land inside the workspace tree?" — an in-tree root (the default
47
+ * `.fastagent`, or a custom `FASTAGENT_STATE_DIR` pointed inside the agent dir) is ours to self-ignore;
48
+ * a root on an external volume resolves outside and must not be (we never write a `.gitignore` outside
49
+ * the tree). Whether a relative override lands in-tree is a cwd question — see `resolveStateRoot`.
50
+ */
51
+ export declare function isUnderDir(targetPath: string, baseDir: string): boolean;
52
+ /**
53
+ * The single owner of the self-ignore MECHANISM: iff the resolved state ROOT lands inside the workspace
54
+ * tree, write `<stateRoot>/.gitignore="*"` — which then covers EVERYTHING under it (sessions, auth.json,
55
+ * every channel's `channels/<kind>` home). `ensureStateDirSelfIgnored` is private, so a caller cannot
56
+ * write a `.gitignore` bypassing this.
57
+ *
58
+ * ROOT-based, not path-based: everything derives from the state root (config.ts), so protecting the
59
+ * root protects all of it — INCLUDING a custom in-tree root (a `FASTAGENT_STATE_DIR` inside the agent
60
+ * dir), the case a path-based (`.fastagent`-only) guard would leak. A per-path override
61
+ * (`--sessions-dir`/`--auth-path`) is operator-owned: pointed at an external volume it is out-of-tree
62
+ * (correctly not ours to ignore); pointed at a custom in-tree dir WE DON'T OWN, we do not write a
63
+ * `.gitignore` into it (it may be a directory the operator deliberately tracks).
64
+ *
65
+ * Excludes the user's HOME-global `~/.fastagent` (e.g. `login`/`dev` run from `$HOME`): self-ignore is
66
+ * for protecting state inside an agent PROJECT tree, not for writing a `.gitignore` into the user's
67
+ * home, which a dotfiles repo may track. The global credential file there was never self-ignored.
68
+ */
69
+ export declare function ensureStateRootSelfIgnored(dir: string, stateRoot: string): Promise<void>;
70
+ /** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath.
71
+ * A non-existent path can't be realpath'd, so it stays as the plain absolute resolve. */
72
+ export declare function canonicalPath(p: string): string;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Definition domain: read an agent definition directory (AGENTS.md + skills/) into memory. Produces
3
+ * data; create.ts consumes it.
4
+ *
5
+ * IO policy: persona.md + skills load through ExecutionEnv (portable across local/sandbox/remote); the
6
+ * invoke path never touches disk. EXCEPTION — ② project context comes from pi's loadProjectContextFiles,
7
+ * which reads via node fs DIRECTLY (not the injected `env`) and, on failure, at best warns to stderr and
8
+ * at worst is fully silent (its `existsSync` probe swallows a permission error) — no structured signal. So under a
9
+ * non-local ExecutionEnv the ② files still resolve against THIS process's filesystem, not the target env
10
+ * — a known break in the portability contract, deferred with the sandbox work (core.md §6). config/auth/
11
+ * sessions and this module's Node helpers are composition-root code and may use node fs.
12
+ *
13
+ * Errors: a broken persona.md / unresolvable dir throws (fail loudly at startup); non-fatal findings
14
+ * (bad skill files, name collisions) are returned as data. An unreadable ② context file only warns (pi).
15
+ */
16
+ import { realpathSync } from "node:fs";
17
+ import { mkdir, writeFile } from "node:fs/promises";
18
+ import { homedir } from "node:os";
19
+ import { isAbsolute, join, relative, resolve } from "node:path";
20
+ import { loadSkills } from "@earendil-works/pi-agent-core";
21
+ import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
22
+ import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
23
+ /** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
24
+ export async function loadAgentDefinition(agentDir, options = {}) {
25
+ // One resolved default for the working directory (env cwd AND the context-walk start), so they can
26
+ // never diverge if a caller passes a relative agentDir.
27
+ const cwd = options.cwd ?? agentDir;
28
+ const e = options.env ?? new NodeExecutionEnv({ cwd });
29
+ const rootResult = await e.absolutePath(agentDir);
30
+ if (!rootResult.ok) {
31
+ throw new Error(`cannot resolve agent dir "${agentDir}": ${rootResult.error.message}`);
32
+ }
33
+ const root = rootResult.value;
34
+ // ② project context, following pi: the agentDir's own AGENTS.md + every AGENTS.md walking cwd up to
35
+ // root (loadProjectContextFiles). It reads via node fs directly (mirrors pi), NOT the ExecutionEnv —
36
+ // a deliberate, deferred deviation from this module's portable-IO policy (revisit with the sandbox; core.md §6).
37
+ const contextFiles = loadProjectContextFiles({ cwd, agentDir: root });
38
+ // persona.md → segment ① persona (overrides the identity line). Same error policy as AGENTS.md:
39
+ // only not_found means "absent"; any other read error surfaces rather than silently dropping the persona.
40
+ const personaPath = join(root, "persona.md");
41
+ const personaRead = await e.readTextFile(personaPath);
42
+ if (!personaRead.ok && personaRead.error.code !== "not_found") {
43
+ throw new Error(`cannot read ${personaPath}: ${personaRead.error.message}`);
44
+ }
45
+ const persona = personaRead.ok ? personaRead.value : undefined;
46
+ // Skills come ONLY from the definition's own skills/ (no external/global mount), so the same
47
+ // definition loads the same skills on every machine.
48
+ const { skills: raw, diagnostics } = await loadSkills(e, [join(root, "skills")]);
49
+ const byName = new Map();
50
+ const collisions = [];
51
+ for (const skill of raw) {
52
+ const existing = byName.get(skill.name);
53
+ if (existing) {
54
+ collisions.push({ name: skill.name, winnerPath: existing.filePath, loserPath: skill.filePath });
55
+ }
56
+ else {
57
+ byName.set(skill.name, skill);
58
+ }
59
+ }
60
+ return { contextFiles, persona, skills: [...byName.values()], diagnostics, collisions, dir: root };
61
+ }
62
+ /**
63
+ * Whether `targetPath` lives inside `baseDir` (same path counts). The self-ignore guard uses it to ask
64
+ * "does the resolved state root land inside the workspace tree?" — an in-tree root (the default
65
+ * `.fastagent`, or a custom `FASTAGENT_STATE_DIR` pointed inside the agent dir) is ours to self-ignore;
66
+ * a root on an external volume resolves outside and must not be (we never write a `.gitignore` outside
67
+ * the tree). Whether a relative override lands in-tree is a cwd question — see `resolveStateRoot`.
68
+ */
69
+ export function isUnderDir(targetPath, baseDir) {
70
+ const rel = relative(baseDir, targetPath);
71
+ return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
72
+ }
73
+ /**
74
+ * Self-ignore a state dir: create it if missing, then write `<stateDir>/.gitignore` = "*" (idempotent
75
+ * — an existing one is kept), so a workspace that runs dev/start never shows machine state as
76
+ * untracked. Creates the dir because a caller may self-ignore it before anything else populates it
77
+ * (e.g. `login` writing auth.json into a not-yet-created root).
78
+ *
79
+ * Module-PRIVATE on purpose: the only entry to the leak guard is {@link ensureStateRootSelfIgnored}
80
+ * (home exclusion + containment). Keeping this unexported makes that single-owner claim hold at the
81
+ * type level — a sibling command can't bypass those checks by writing a `.gitignore` directly.
82
+ */
83
+ async function ensureStateDirSelfIgnored(stateDir) {
84
+ await mkdir(stateDir, { recursive: true });
85
+ await writeFile(join(stateDir, ".gitignore"), "*\n", { flag: "wx" }).catch((e) => {
86
+ if (e.code !== "EEXIST")
87
+ throw e;
88
+ });
89
+ }
90
+ /**
91
+ * The single owner of the self-ignore MECHANISM: iff the resolved state ROOT lands inside the workspace
92
+ * tree, write `<stateRoot>/.gitignore="*"` — which then covers EVERYTHING under it (sessions, auth.json,
93
+ * every channel's `channels/<kind>` home). `ensureStateDirSelfIgnored` is private, so a caller cannot
94
+ * write a `.gitignore` bypassing this.
95
+ *
96
+ * ROOT-based, not path-based: everything derives from the state root (config.ts), so protecting the
97
+ * root protects all of it — INCLUDING a custom in-tree root (a `FASTAGENT_STATE_DIR` inside the agent
98
+ * dir), the case a path-based (`.fastagent`-only) guard would leak. A per-path override
99
+ * (`--sessions-dir`/`--auth-path`) is operator-owned: pointed at an external volume it is out-of-tree
100
+ * (correctly not ours to ignore); pointed at a custom in-tree dir WE DON'T OWN, we do not write a
101
+ * `.gitignore` into it (it may be a directory the operator deliberately tracks).
102
+ *
103
+ * Excludes the user's HOME-global `~/.fastagent` (e.g. `login`/`dev` run from `$HOME`): self-ignore is
104
+ * for protecting state inside an agent PROJECT tree, not for writing a `.gitignore` into the user's
105
+ * home, which a dotfiles repo may track. The global credential file there was never self-ignored.
106
+ */
107
+ export async function ensureStateRootSelfIgnored(dir, stateRoot) {
108
+ // Compare CANONICAL paths for the home check: `dir` arrives realpath-resolved (it is `process.cwd()`
109
+ // or `resolve(".")`) but `homedir()` returns the raw `$HOME`, so a symlinked home would slip past raw
110
+ // equality and we'd write a `.gitignore` into the real `~/.fastagent` — the very thing the doc forbids
111
+ // (chat.ts canonicalizes for the same reason).
112
+ if (canonicalPath(dir) === canonicalPath(homedir()))
113
+ return;
114
+ // Containment on RAW paths: stateRoot is resolve()'d (config.ts) and `dir` is absolute, so it is exact
115
+ // by construction. An external-volume root resolves outside the tree → skip (not ours to ignore).
116
+ if (isUnderDir(stateRoot, dir))
117
+ await ensureStateDirSelfIgnored(stateRoot);
118
+ }
119
+ /** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath.
120
+ * A non-existent path can't be realpath'd, so it stays as the plain absolute resolve. */
121
+ export function canonicalPath(p) {
122
+ const resolved = resolve(p);
123
+ try {
124
+ return realpathSync(resolved);
125
+ }
126
+ catch {
127
+ return resolved;
128
+ }
129
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * pi harness wiring: construct one pi `AgentHarness` per session. The agent definition (AGENTS.md +
3
+ * skills) is content fed INTO the harness (see definition.ts), not part of it.
4
+ *
5
+ * Under the stateless design the harness is discarded after each use; continuity comes from
6
+ * persisting the session (PiSessionStore) and re-opening it per invoke — pi's prompt() folds the
7
+ * historical entries back into context via buildContext().
8
+ */
9
+ import { AgentHarness } from "@earendil-works/pi-agent-core";
10
+ import type { AgentTool, ExecutionEnv, Skill } from "@earendil-works/pi-agent-core";
11
+ import type { Model, Models } from "@earendil-works/pi-ai";
12
+ import type { PiSessionStore } from "./sessions.ts";
13
+ /**
14
+ * pi's Model with the API-shape generic erased — fastagent only passes models through to the
15
+ * harness, so the generic carries no information. One alias keeps the `any` auditable.
16
+ */
17
+ export type AnyModel = Model<any>;
18
+ /** Builds a pi harness bound to the given session — called once per invoke. */
19
+ export type PiHarnessFactory = (session: string) => AgentHarness | Promise<AgentHarness>;
20
+ export interface PiHarnessFactoryOptions {
21
+ /** Session persistence. Continuity = same backing store + same session id. */
22
+ sessions: PiSessionStore;
23
+ env: ExecutionEnv;
24
+ /** Provider collection for all model requests; {@link model} must belong to it (same provider id). */
25
+ models: Models;
26
+ model: AnyModel;
27
+ tools?: AgentTool[];
28
+ /**
29
+ * Final assembled prompt, or a SYNC factory re-evaluated per invoke (how L1 serves dynamic
30
+ * `instructions` + the skills listing). Distinct from {@link live}, which is the directory rung's
31
+ * ASYNC re-read of prompt AND skills as one pair — both are exercised, by different rungs.
32
+ */
33
+ systemPrompt?: string | (() => string);
34
+ /** Skills visible to the model / explicitly invokable (injected as harness resources). */
35
+ skills?: Skill[];
36
+ /**
37
+ * Per-invoke source for the prompt+skills PAIR, re-evaluated on every harness build. When set it
38
+ * supersedes {@link systemPrompt}/{@link skills} — one call yields both, so the skills listing
39
+ * inside the prompt and the mounted skill resources can never come from two different reads. The
40
+ * directory rung (L2) uses it to re-read the definition, so AGENTS.md/skills edits — the author's or
41
+ * the agent's own — take effect on the next turn without a process restart. A rejection surfaces
42
+ * as that invoke's `failed` event (the factory throw path), never a crash.
43
+ */
44
+ live?: () => Promise<{
45
+ systemPrompt?: string;
46
+ skills?: Skill[];
47
+ }>;
48
+ }
49
+ /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
50
+ export declare function piHarnessFactory(options: PiHarnessFactoryOptions): PiHarnessFactory;
@@ -0,0 +1,41 @@
1
+ /**
2
+ * pi harness wiring: construct one pi `AgentHarness` per session. The agent definition (AGENTS.md +
3
+ * skills) is content fed INTO the harness (see definition.ts), not part of it.
4
+ *
5
+ * Under the stateless design the harness is discarded after each use; continuity comes from
6
+ * persisting the session (PiSessionStore) and re-opening it per invoke — pi's prompt() folds the
7
+ * historical entries back into context via buildContext().
8
+ */
9
+ import { AgentHarness } from "@earendil-works/pi-agent-core";
10
+ /**
11
+ * Provider request retries. The OpenAI-family / Anthropic / Azure / Codex pi-ai adapters
12
+ * implement client-side retries (429/5xx/request-phase network failures with backoff, honoring
13
+ * Retry-After; a Codex websocket that fails before the stream starts falls back to SSE) but all
14
+ * default maxRetries to 0 (even SDK-backed ones override the SDK default), so a single transient
15
+ * `fetch failed` would otherwise kill the whole turn; 2 matches the OpenAI/Anthropic SDK default.
16
+ * The google / vertex / bedrock / mistral adapters ignore this option (a pi-ai upstream gap) —
17
+ * transients there still fail the turn. Mid-stream drops are deliberately NOT retried anywhere on
18
+ * this path — partial output was already streamed and the SPEC event stream cannot retract it,
19
+ * so a mid-stream failure surfaces as a `failed` event.
20
+ */
21
+ const PROVIDER_MAX_RETRIES = 2;
22
+ /** Open-or-create the session per invoke: existing → open (history via buildContext); missing → create. */
23
+ export function piHarnessFactory(options) {
24
+ return async (sessionId) => {
25
+ const session = await options.sessions.openOrCreate(sessionId);
26
+ const fresh = options.live ? await options.live() : undefined;
27
+ const { systemPrompt } = options;
28
+ const prompt = fresh ? fresh.systemPrompt : typeof systemPrompt === "function" ? systemPrompt() : systemPrompt;
29
+ const skills = fresh ? fresh.skills : options.skills;
30
+ return new AgentHarness({
31
+ env: options.env,
32
+ session,
33
+ models: options.models,
34
+ model: options.model,
35
+ tools: options.tools,
36
+ systemPrompt: prompt,
37
+ resources: skills ? { skills } : undefined,
38
+ streamOptions: { maxRetries: PROVIDER_MAX_RETRIES },
39
+ });
40
+ };
41
+ }
@@ -0,0 +1,28 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import { type Agent, type AgentEvent } from "../../agent.ts";
3
+ import type { PiHarnessFactory } from "./harness.ts";
4
+ export type Release = () => void;
5
+ export interface Lease {
6
+ /** Try to acquire exclusive write access for the session (fail-fast). Returns null if held. */
7
+ tryAcquire(session: string): Release | null;
8
+ }
9
+ export declare function inProcessLease(): Lease;
10
+ /** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
11
+ export declare function classifyRetryable(details: string, signal: {
12
+ status?: number;
13
+ code?: unknown;
14
+ }): boolean;
15
+ /**
16
+ * Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
17
+ * with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
18
+ * entire failure class (violating SPEC MUST 1).
19
+ */
20
+ export declare function toTerminal(message: AssistantMessage): AgentEvent;
21
+ export declare function errorToTerminal(error: unknown): AgentEvent;
22
+ export interface CreatePiAgentFromHarnessOptions {
23
+ harnessFactory: PiHarnessFactory;
24
+ /** Single-writer lease. Defaults to the in-process per-session fail-fast lease. */
25
+ lease?: Lease;
26
+ }
27
+ /** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
28
+ export declare function createPiAgentFromHarness(options: CreatePiAgentFromHarnessOptions): Agent;
@@ -0,0 +1,298 @@
1
+ import { DEFAULT_COMPACTION_SETTINGS, calculateContextTokens, shouldCompact } from "@earendil-works/pi-agent-core";
2
+ import { SESSION_BUSY_CODE } from "../../agent.js";
3
+ import { log } from "../../log.js";
4
+ import { turnContext } from "./tool-context.js";
5
+ export function inProcessLease() {
6
+ const busy = new Set();
7
+ return {
8
+ tryAcquire(session) {
9
+ if (busy.has(session))
10
+ return null;
11
+ busy.add(session);
12
+ let released = false;
13
+ return () => {
14
+ if (released)
15
+ return;
16
+ released = true;
17
+ busy.delete(session);
18
+ };
19
+ },
20
+ };
21
+ }
22
+ // ── §2 translate: the single pi↔SPEC translation point ───────────────────────
23
+ //
24
+ // `retryable` = worth re-sending with the same session (SPEC §6: advisory, not a session-atomicity
25
+ // guarantee). Classify from the STRUCTURED signal first, prose only as the last-resort ceiling. What
26
+ // is actually available differs by path, and the two are NOT symmetric:
27
+ // - thrown error (errorToTerminal): an HTTP `.status`/`.statusCode` AND a network `.code` (incl.
28
+ // `.cause.code`) — this is where a numeric status genuinely drives the decision.
29
+ // - failed message (toTerminal): ONLY `diagnostics[].error.code`. pi's `DiagnosticErrorInfo` carries
30
+ // a `code` (a network code, or a status delivered as a code), with no separate HTTP-status field —
31
+ // so a message whose provider `code` is a string label (e.g. "rate_limit_exceeded") is not
32
+ // decisive here and falls to prose.
33
+ // The prose fallback is bounded, not a cop-out: pi-ai already ran its own status-code-based client
34
+ // retries (harness.ts PROVIDER_MAX_RETRIES) before surfacing, so an error that reaches this point has
35
+ // already exhausted the cleanly-retryable cases. The regex is the narrow ceiling, not the classifier.
36
+ // Upstream ask: a first-class `retryable`/`kind` on pi's terminal error would retire the prose path
37
+ // entirely (mirrors the §11 "the deeper fix is upstream in pi" pattern).
38
+ /** Clearly-transient network error codes (Node/undici), decisive on their own. */
39
+ const RETRYABLE_CODES = new Set([
40
+ "ECONNRESET",
41
+ "ETIMEDOUT",
42
+ "ENETUNREACH",
43
+ "ENETDOWN",
44
+ "EAI_AGAIN",
45
+ "EPIPE",
46
+ "UND_ERR_CONNECT_TIMEOUT",
47
+ "UND_ERR_SOCKET",
48
+ ]);
49
+ /** 429 (rate limit) and 5xx (server) are worth retrying; other statuses are decisive NON-retryable. */
50
+ const statusIsRetryable = (status) => status === 429 || (status >= 500 && status < 600);
51
+ /** Last-resort prose match, used only when no structured status/code is available. */
52
+ const RETRYABLE_MESSAGE = /\b(429|5\d\d|timeout|timed out|rate.?limit|overloaded|ECONNRESET|ETIMEDOUT|ENETUNREACH|EAI_AGAIN|socket hang up)\b/i;
53
+ /** A structured status/code decision, or `null` when the signal is absent/undecisive → fall to prose. */
54
+ function retryableFromSignal(signal) {
55
+ if (typeof signal.status === "number")
56
+ return statusIsRetryable(signal.status);
57
+ const { code } = signal;
58
+ if (typeof code === "number")
59
+ return statusIsRetryable(code);
60
+ if (typeof code === "string") {
61
+ if (RETRYABLE_CODES.has(code))
62
+ return true;
63
+ if (/^\d{3}$/.test(code))
64
+ return statusIsRetryable(Number(code)); // a status carried as a string
65
+ }
66
+ return null; // no code, or an unknown one — not decisive on its own
67
+ }
68
+ /** Classify `retryable`: structured status/code first, message prose only as the last-resort ceiling. */
69
+ export function classifyRetryable(details, signal) {
70
+ return retryableFromSignal(signal) ?? RETRYABLE_MESSAGE.test(details);
71
+ }
72
+ /** Pull a structured status/code off a thrown error (HTTP status or a network code, incl. its cause). */
73
+ function errorSignal(error) {
74
+ if (!error || typeof error !== "object")
75
+ return {};
76
+ const e = error;
77
+ const status = typeof e.status === "number" ? e.status : typeof e.statusCode === "number" ? e.statusCode : undefined;
78
+ const causeCode = e.cause && typeof e.cause === "object" ? e.cause.code : undefined;
79
+ return { status, code: e.code ?? causeCode };
80
+ }
81
+ /**
82
+ * Pull the structured error `code` pi records on a failed message's diagnostics. `diagnostics`
83
+ * accumulates across attempts (`appendAssistantMessageDiagnostic`), so the terminal cause is the LAST
84
+ * code-bearing entry — `findLast`, not `find`: an earlier attempt's transient 503 must not classify a
85
+ * terminal 400/auth failure as retryable. (Reverse scan rather than `findLast` — the tsconfig lib is
86
+ * ES2022.)
87
+ */
88
+ function messageSignal(message) {
89
+ const diagnostics = message.diagnostics ?? [];
90
+ for (let i = diagnostics.length - 1; i >= 0; i--) {
91
+ const code = diagnostics[i]?.error?.code;
92
+ if (code !== undefined)
93
+ return { code };
94
+ }
95
+ return {};
96
+ }
97
+ /** In-stream event mapping. Non text/tool_* pi events (turn_start, message_start, …) are dropped. */
98
+ function toAgentEvent(pe) {
99
+ switch (pe.type) {
100
+ case "message_update": {
101
+ const ev = pe.assistantMessageEvent;
102
+ if (ev.type === "text_delta")
103
+ return { type: "text", delta: ev.delta };
104
+ if (ev.type === "thinking_delta")
105
+ return { type: "thinking", delta: ev.delta };
106
+ return null;
107
+ }
108
+ case "tool_execution_start":
109
+ return { type: "tool_started", id: pe.toolCallId, name: pe.toolName, args: pe.args };
110
+ case "tool_execution_end":
111
+ return { type: "tool_ended", id: pe.toolCallId, isError: pe.isError, content: pe.result };
112
+ default:
113
+ return null;
114
+ }
115
+ }
116
+ /**
117
+ * Terminal mapping, decided by the resolved message's stopReason: pi's prompt() resolves a message
118
+ * with stopReason "error"/"aborted" rather than throwing, so relying on catch alone would miss this
119
+ * entire failure class (violating SPEC MUST 1).
120
+ */
121
+ export function toTerminal(message) {
122
+ if (message.stopReason === "error" || message.stopReason === "aborted") {
123
+ const details = message.errorMessage ?? `model stopped: ${message.stopReason}`;
124
+ return { type: "failed", details, retryable: classifyRetryable(details, messageSignal(message)) };
125
+ }
126
+ return { type: "completed" };
127
+ }
128
+ export function errorToTerminal(error) {
129
+ const details = error instanceof Error ? error.message : String(error);
130
+ return { type: "failed", details, retryable: classifyRetryable(details, errorSignal(error)) };
131
+ }
132
+ /**
133
+ * After a successful turn, compact the session if its context has grown past pi's threshold — a long
134
+ * shared (group) or 1:1 conversation otherwise overflows the model's window. pi owns the mechanism
135
+ * (`harness.compact()` writes a summary entry into the session, so the next reopen is compacted); the
136
+ * bare harness does NOT auto-trigger it, so fastagent checks `shouldCompact` here and fires it. The
137
+ * context size is the provider's own count from the turn's assistant message (`usage`).
138
+ */
139
+ async function maybeCompact(harness, message) {
140
+ const contextWindow = harness.getModel().contextWindow;
141
+ if (!contextWindow)
142
+ return;
143
+ if (shouldCompact(calculateContextTokens(message.usage), contextWindow, DEFAULT_COMPACTION_SETTINGS)) {
144
+ await harness.compact();
145
+ }
146
+ }
147
+ /**
148
+ * Map prompt images to pi ImageContent, resizing each to model-friendly dimensions/size with pi's
149
+ * Photon resizer (reused from pi-coding-agent, lazy-imported so the common no-image headless path never
150
+ * loads the TUI module graph). A null resize (unresizable / Photon unavailable) keeps the original
151
+ * bytes — the provider then applies its own limit.
152
+ */
153
+ async function toPiPromptOptions(prompt) {
154
+ if (!prompt.images || prompt.images.length === 0)
155
+ return undefined;
156
+ const { resizeImage } = await import("@earendil-works/pi-coding-agent");
157
+ const images = await Promise.all(prompt.images.map(async (img) => {
158
+ const resized = await resizeImage(Buffer.from(img.data, "base64"), img.mimeType, {
159
+ maxWidth: 1568,
160
+ maxHeight: 1568,
161
+ maxBytes: 5 * 1024 * 1024,
162
+ }).catch(() => null);
163
+ return resized
164
+ ? { type: "image", data: resized.data, mimeType: resized.mimeType }
165
+ : { type: "image", data: img.data, mimeType: img.mimeType };
166
+ }));
167
+ return { images };
168
+ }
169
+ // ── §3 EventQueue: push→pull plumbing for pi's two-port shape ────────────────
170
+ //
171
+ // Single-consumer async queue; single-threaded JS means no await interleaves between push and
172
+ // drain, so no locking. Engines that are natively async-iterable would not need it.
173
+ class EventQueue {
174
+ buffer = [];
175
+ wake;
176
+ push(item) {
177
+ this.buffer.push(item);
178
+ const wake = this.wake;
179
+ this.wake = undefined;
180
+ wake?.();
181
+ }
182
+ /**
183
+ * Yield pushed events in order until `done` settles AND the buffer is drained. The terminal is
184
+ * produced separately (toTerminal); rejections of `done` are swallowed here (the caller awaits
185
+ * `run` itself) to avoid unhandled rejections.
186
+ */
187
+ async *drainUntil(done) {
188
+ let settled = false;
189
+ const onSettle = () => {
190
+ settled = true;
191
+ const wake = this.wake;
192
+ this.wake = undefined;
193
+ wake?.();
194
+ };
195
+ const finished = done.then(onSettle, onSettle);
196
+ while (true) {
197
+ while (this.buffer.length > 0) {
198
+ yield this.buffer.shift();
199
+ }
200
+ if (settled)
201
+ break;
202
+ await new Promise((resolve) => {
203
+ this.wake = resolve;
204
+ });
205
+ }
206
+ await finished;
207
+ }
208
+ }
209
+ /** "From a harness factory": engine wired by the caller; adds only the concurrency/stream shell. */
210
+ export function createPiAgentFromHarness(options) {
211
+ const { harnessFactory, lease = inProcessLease() } = options;
212
+ async function* invoke(scope, prompt) {
213
+ const release = lease.tryAcquire(scope.session);
214
+ if (!release) {
215
+ yield {
216
+ type: "failed",
217
+ details: "session busy: a turn is already in flight for this session",
218
+ retryable: true,
219
+ code: SESSION_BUSY_CODE,
220
+ };
221
+ return;
222
+ }
223
+ try {
224
+ let harness;
225
+ try {
226
+ harness = await harnessFactory(scope.session);
227
+ }
228
+ catch (error) {
229
+ // Setup failures (session open / auth / …) MUST surface as a failed event, never a throw.
230
+ yield errorToTerminal(error);
231
+ return;
232
+ }
233
+ const queue = new EventQueue();
234
+ const unsub = harness.subscribe((pe) => {
235
+ const event = toAgentEvent(pe);
236
+ if (event)
237
+ queue.push(event);
238
+ });
239
+ let completed; // the assistant message of a cleanly completed turn
240
+ try {
241
+ // Run the turn inside the session context so a tool's `execute` can read which session it is in
242
+ // (turnContext / ToolContext.session). prompt() starts the async work synchronously here, so the
243
+ // store propagates to the tool calls awaited within it.
244
+ const opts = await toPiPromptOptions(prompt);
245
+ const run = turnContext.run({ session: scope.session }, () => harness.prompt(prompt.text, opts));
246
+ yield* queue.drainUntil(run);
247
+ let terminal;
248
+ try {
249
+ const message = await run;
250
+ terminal = toTerminal(message);
251
+ if (terminal.type === "completed")
252
+ completed = message;
253
+ }
254
+ catch (error) {
255
+ terminal = errorToTerminal(error);
256
+ }
257
+ yield terminal;
258
+ }
259
+ finally {
260
+ // After a successful turn, keep the session under the model's context window (a long shared group
261
+ // or 1:1 conversation would otherwise overflow). Runs HERE — before teardown (it uses the harness)
262
+ // and BEFORE the lease release below, and is awaited via the generator's return(), so the next
263
+ // turn for this session waits and never reopens mid-compaction. That await rides the consumer's
264
+ // iteration: a STREAMING consumer (e.g. telegram) already sent the reply on the terminal before
265
+ // returning, so compaction — rare, only over threshold — does not delay it; a `collect`-style
266
+ // consumer returns the reply FROM the loop, so it waits for the (occasional) compaction. Non-fatal:
267
+ // a failed compaction leaves the (still-valid) session for the next turn to retry.
268
+ if (completed) {
269
+ try {
270
+ await maybeCompact(harness, completed);
271
+ }
272
+ catch (error) {
273
+ log.warn(`[fastagent] auto-compaction failed during cleanup: ${String(error)}`);
274
+ }
275
+ }
276
+ // Cleanup MUST NOT throw after the terminal was yielded — that would make an already-closed
277
+ // event stream throw on iteration (violating SPEC MUST 2 / MUST 3). Contain it, but surface it
278
+ // (a cleanup failure is abnormal).
279
+ try {
280
+ unsub();
281
+ }
282
+ catch (error) {
283
+ log.warn(`[fastagent] harness unsubscribe failed during cleanup: ${String(error)}`);
284
+ }
285
+ try {
286
+ await harness.abort();
287
+ }
288
+ catch (error) {
289
+ log.warn(`[fastagent] harness abort failed during cleanup: ${String(error)}`);
290
+ }
291
+ }
292
+ }
293
+ finally {
294
+ release(); // after cleanup, so the next invoke for this session can enter
295
+ }
296
+ }
297
+ return { invoke };
298
+ }