@fastagent-sh/fastagent 0.18.0 → 0.19.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 (159) hide show
  1. package/README.md +25 -7
  2. package/dist/agent.d.ts +1 -1
  3. package/dist/agent.js +1 -1
  4. package/dist/atomic-write.d.ts +19 -0
  5. package/dist/atomic-write.js +48 -0
  6. package/dist/{host/node.d.ts → channel.d.ts} +24 -28
  7. package/dist/channel.js +1 -0
  8. package/dist/channels/agentcore-service.d.ts +54 -0
  9. package/dist/channels/agentcore-service.js +111 -0
  10. package/dist/channels/agentcore.d.ts +12 -2
  11. package/dist/channels/agentcore.js +6 -4
  12. package/dist/channels/busy.d.ts +1 -1
  13. package/dist/channels/busy.js +1 -1
  14. package/dist/channels/control.d.ts +16 -6
  15. package/dist/channels/control.js +106 -4
  16. package/dist/{engines/pi/channel.d.ts → channels/discover.d.ts} +2 -2
  17. package/dist/{engines/pi/channel.js → channels/discover.js} +8 -12
  18. package/dist/channels/feishu/card.js +1 -1
  19. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  20. package/dist/channels/feishu/context-buffer.js +4 -4
  21. package/dist/channels/feishu/feishu-api.js +1 -1
  22. package/dist/channels/feishu/feishu.d.ts +1 -1
  23. package/dist/channels/feishu/feishu.js +7 -7
  24. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  25. package/dist/channels/feishu/invoke-turn.js +3 -4
  26. package/dist/channels/feishu/preview.d.ts +1 -1
  27. package/dist/channels/feishu/preview.js +7 -3
  28. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  29. package/dist/channels/github/github.d.ts +1 -1
  30. package/dist/channels/http.d.ts +11 -7
  31. package/dist/channels/http.js +1 -109
  32. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +2 -2
  33. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  34. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  35. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  36. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  37. package/dist/channels/{state.js → kit/state.js} +4 -7
  38. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  39. package/dist/channels/{stop-command.js → kit/stop-command.js} +2 -2
  40. package/dist/channels/{tasks.js → kit/tasks.js} +1 -1
  41. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  42. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  43. package/dist/channels/{turn-store.js → kit/turn-store.js} +1 -1
  44. package/dist/channels/lark/lark.d.ts +1 -1
  45. package/dist/channels/serve.d.ts +73 -0
  46. package/dist/channels/serve.js +243 -0
  47. package/dist/channels/slack/bot-auth.js +3 -14
  48. package/dist/channels/slack/context-buffer.d.ts +2 -2
  49. package/dist/channels/slack/context-buffer.js +2 -2
  50. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  51. package/dist/channels/slack/invoke-turn.js +8 -5
  52. package/dist/channels/slack/parse.js +2 -2
  53. package/dist/channels/slack/preview.d.ts +1 -1
  54. package/dist/channels/slack/preview.js +11 -3
  55. package/dist/channels/slack/slack-api.js +1 -1
  56. package/dist/channels/slack/slack.d.ts +1 -1
  57. package/dist/channels/slack/slack.js +8 -8
  58. package/dist/channels/slack/welcomed.js +1 -1
  59. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  60. package/dist/channels/telegram/context-buffer.js +2 -2
  61. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  62. package/dist/channels/telegram/invoke-turn.js +1 -1
  63. package/dist/channels/telegram/parse.js +2 -2
  64. package/dist/channels/telegram/preview.d.ts +1 -1
  65. package/dist/channels/telegram/preview.js +6 -2
  66. package/dist/channels/telegram/telegram.d.ts +1 -1
  67. package/dist/channels/telegram/telegram.js +3 -3
  68. package/dist/channels/telegram/turn-store.d.ts +2 -2
  69. package/dist/channels/telegram/turn-store.js +2 -2
  70. package/dist/cli/commands/add.js +1 -1
  71. package/dist/cli/commands/dev.js +25 -13
  72. package/dist/cli/commands/fire.js +1 -1
  73. package/dist/cli/commands/info.js +7 -4
  74. package/dist/cli/commands/schedule.js +1 -1
  75. package/dist/cli/commands/start.js +63 -94
  76. package/dist/cli/commands/tool.js +5 -7
  77. package/dist/cli/program.js +1 -1
  78. package/dist/cli/serve.d.ts +18 -57
  79. package/dist/cli/serve.js +57 -243
  80. package/dist/cli/shared.d.ts +1 -1
  81. package/dist/cli/shared.js +2 -2
  82. package/dist/core.d.ts +3 -5
  83. package/dist/core.js +7 -5
  84. package/dist/deploy/preflight.js +1 -1
  85. package/dist/dev-supervisor.js +9 -3
  86. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  87. package/dist/engines/pi/agent-session-factory.js +314 -0
  88. package/dist/engines/pi/config.d.ts +4 -4
  89. package/dist/engines/pi/config.js +1 -1
  90. package/dist/engines/pi/create.d.ts +56 -43
  91. package/dist/engines/pi/create.js +141 -71
  92. package/dist/engines/pi/definition.d.ts +41 -7
  93. package/dist/engines/pi/definition.js +115 -11
  94. package/dist/engines/pi/invoke-session.d.ts +22 -19
  95. package/dist/engines/pi/invoke-session.js +286 -100
  96. package/dist/engines/pi/login.js +7 -4
  97. package/dist/engines/pi/models.d.ts +14 -0
  98. package/dist/engines/pi/models.js +9 -1
  99. package/dist/engines/pi/open.d.ts +9 -6
  100. package/dist/engines/pi/open.js +20 -10
  101. package/dist/engines/pi/report.d.ts +0 -4
  102. package/dist/engines/pi/report.js +0 -7
  103. package/dist/engines/pi/service.d.ts +23 -0
  104. package/dist/engines/pi/service.js +19 -0
  105. package/dist/engines/pi/session-builder.js +84 -88
  106. package/dist/engines/pi/session-control.d.ts +10 -9
  107. package/dist/engines/pi/session-control.js +114 -77
  108. package/dist/engines/pi/session-inheritance.d.ts +44 -0
  109. package/dist/engines/pi/session-inheritance.js +242 -0
  110. package/dist/engines/pi/session-settings.d.ts +14 -2
  111. package/dist/engines/pi/session-settings.js +18 -0
  112. package/dist/engines/pi/session-store.d.ts +70 -0
  113. package/dist/engines/pi/session-store.js +313 -0
  114. package/dist/engines/pi/tool-context.d.ts +18 -8
  115. package/dist/engines/pi/tool-context.js +23 -1
  116. package/dist/engines/pi/tool.d.ts +5 -5
  117. package/dist/engines/pi/tool.js +1 -1
  118. package/dist/engines/pi/turn-kit.d.ts +32 -6
  119. package/dist/engines/pi/turn-kit.js +28 -1
  120. package/dist/engines/pi/wake-tool.js +1 -1
  121. package/dist/index.d.ts +1 -0
  122. package/dist/index.js +1 -0
  123. package/dist/log.d.ts +3 -0
  124. package/dist/log.js +5 -1
  125. package/dist/node.d.ts +16 -0
  126. package/dist/node.js +20 -0
  127. package/dist/paths.d.ts +7 -0
  128. package/dist/paths.js +10 -0
  129. package/dist/pi.d.ts +14 -8
  130. package/dist/pi.js +6 -5
  131. package/dist/scaffold/templates/persona.md +2 -2
  132. package/dist/schedule/state.js +4 -9
  133. package/dist/schedule/wakeups.d.ts +1 -1
  134. package/dist/schedule/wakeups.js +1 -1
  135. package/dist/service.d.ts +158 -0
  136. package/dist/service.js +354 -0
  137. package/dist/session-remote.d.ts +1 -1
  138. package/dist/session.d.ts +4 -2
  139. package/dist/session.js +1 -1
  140. package/package.json +14 -5
  141. package/dist/engines/pi/harness.d.ts +0 -115
  142. package/dist/engines/pi/harness.js +0 -154
  143. package/dist/engines/pi/invoke.d.ts +0 -60
  144. package/dist/engines/pi/invoke.js +0 -459
  145. package/dist/engines/pi/read-image.d.ts +0 -4
  146. package/dist/engines/pi/read-image.js +0 -62
  147. package/dist/engines/pi/sessions.d.ts +0 -60
  148. package/dist/engines/pi/sessions.js +0 -464
  149. package/dist/host/node.js +0 -52
  150. /package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +0 -0
  151. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  152. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  153. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  154. /package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +0 -0
  155. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  156. /package/dist/channels/{text.js → kit/text.js} +0 -0
  157. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  158. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
  159. /package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +0 -0
@@ -4,73 +4,97 @@
4
4
  *
5
5
  * L2 createPiAgentFromDefinition(dir, options) — load a definition directory, assemble, then L1.
6
6
  * L1 createPiAgent(options) — assemble from typed parts (the canonical ctor).
7
- * L0 createPiAgentFromHarness({ harnessFactory }) — in invoke.ts (its body is the turn mechanism).
7
+ * L0 createPiAgentFromSession({ sessionFactory }) — in invoke-session.ts (the turn mechanism).
8
8
  *
9
9
  * Above L2 sits the agent opener createPiAgentFromDir (open.ts), which both `dev` and
10
10
  * `start` drive. Each rung calls the one below; options narrow as you go up (L2 owns systemPrompt/skills —
11
11
  * they come from the definition; the openers own model/tools — from config resolution).
12
12
  */
13
13
  import { formatSkillsForSystemPrompt } from "@earendil-works/pi-agent-core";
14
- import { createBashTool, createEditTool, createReadTool, createWriteTool, } from "@earendil-works/pi-agent-core";
15
14
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
16
- import { readImageProcessor } from "./read-image.js";
15
+ import { createCodingTools, createPowerShellTool, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
17
16
  import { defaultAuthPath, resolveModel } from "./config.js";
18
17
  import { resolveSecretsDir } from "../../paths.js";
19
- import { loadAgentDefinition } from "./definition.js";
20
- import { DEFAULT_THINKING_LEVEL, piHarnessFactory } from "./harness.js";
21
- import { createPiModelRuntime, createPiModels } from "./models.js";
18
+ import { loadAgentDefinition, loadExtensionPaths } from "./definition.js";
22
19
  import { reportFindingsIfChanged } from "./report.js";
23
- import { inMemorySessionStore } from "./sessions.js";
24
20
  import { isDeferredTool, loadTools, mergeDiscoveredTools, } from "./tool.js";
25
21
  import { withSearchTool } from "./search-tools.js";
26
- import { createPiAgentFromHarness } from "./invoke.js";
22
+ import { createPiAgentFromSession } from "./invoke-session.js";
23
+ import { piAgentSessionFactory } from "./agent-session-factory.js";
24
+ import { DEFAULT_THINKING_LEVEL, createPiModelRuntime } from "./models.js";
25
+ import { piInMemorySessionRecordStore } from "./session-store.js";
27
26
  import { inProcessLease } from "./turn-kit.js";
28
27
  // ── §1 tools ─────────────────────────────────────────────────────────────────
29
28
  //
30
- // The full pi toolset is the default for fidelity: authors vibe in local pi with it, so serving with
31
- // fewer tools is behavior drift. Locking down for public exposure = passing a restricted `tools` list
32
- // (a deployment posture).
29
+ // A directory agent gets every pi coding tool. Restricting what those tools can reach belongs at the
30
+ // sandbox boundary, not in an allowlist that leaves authored tools unrestricted. L1/L2 library callers
31
+ // can still replace the coding defaults through `tools`; deferred tools may add `search_tools`.
33
32
  //
34
- // These are pi-agent-core's tools, which reach the filesystem and the shell through the
35
- // {@link ExecutionEnv} the harness hands them per turn — NOT pi-coding-agent's, which are the same four
36
- // tools wired to `node:fs` directly. Going through the env is the point, and the whole of it: it makes
37
- // {@link CreatePiAgentOptions.env} the one seam a sandbox adapter has to implement, instead of a knob
38
- // that governed everything except the tools that actually touch the machine. (It buys no decoupling
39
- // from pi-coding-agent — definition.ts, models.ts and read-image.ts all import it regardless.)
33
+ // This is not a security boundary and no default here could be one: the built-in POST /invoke has no
34
+ // authentication, author-written `tools/` import whatever they like, and a WebSocket or Socket-Mode
35
+ // channel dials OUT, so no bind address constrains who can message the agent. Whoever can reach an
36
+ // agent can use everything it mounts put it behind something that decides who may.
40
37
  //
41
- // The swap holds only while the two behave alike, which they do NOT for free: core's `read` does
42
- // nothing with images unless a processor is injected (read-image.ts), and both families are compared
43
- // on every path in test/tools-parity.test.ts.
38
+ // All seven come from pi-coding-agent and reach the machine through `node:fs`, bypassing the per-turn
39
+ // {@link ExecutionEnv}. pi-agent-core ships env-routed look-alikes, but that seam narrows a blast
40
+ // radius rather than closing one (authored `tools/` ignore it), so a real sandbox must wrap the
41
+ // process instead.
44
42
  //
45
- // `chat` is unaffected: it takes these NAMES only and lets pi's own runtime rebuild the tools it
46
- // renders (see session-builder.ts).
47
- /** pi's core default toolset (read/bash/edit/write). Rooted at the ExecutionEnv's cwd, supplied per
48
- * turn as the harness tool context — hence no argument here. */
49
- export function piDefaultTools() {
50
- // `read` needs its image pipeline INJECTED (core ships none); see read-image.ts for what is at stake.
43
+ // Chat receives the same tool objects through pi's `customTools` path; pi's builtin copies stay off.
44
+ /**
45
+ * Every pi coding tool, in canonical order, rooted at the workspace it operates in.
46
+ *
47
+ * pi ships two overlapping groupings and neither is the whole set: `createCodingTools` is the four it
48
+ * ACTIVATES for a terminal (read/bash/edit/write, no searching), `createReadOnlyTools` is
49
+ * read/grep/find/ls. `read` is in both; a directory agent mounts their union.
50
+ */
51
+ export const CODING_TOOL_NAMES = ["read", "grep", "find", "ls", "bash", "edit", "write"];
52
+ export function piAllCodingTools(cwd) {
53
+ const mutating = createCodingTools(cwd).filter((tool) => tool.name !== "read");
54
+ return [...createReadOnlyTools(cwd), ...mutating];
55
+ }
56
+ /**
57
+ * Every tool an `AgentSession` registers on its own, whether or not a directory agent mounts it.
58
+ *
59
+ * Wider than {@link CODING_TOOL_NAMES} on purpose: pi 0.84.3 added `powershell`, which the session
60
+ * registers regardless of the tools we pass. A name absent from THIS list is a name never excluded,
61
+ * and an excluded name is the only thing that cannot be activated — so leaving it out would let
62
+ * `tools: [read]` keep a reachable shell.
63
+ */
64
+ function piRegisteredToolNames(cwd) {
51
65
  return [
52
- createReadTool({ imageProcessor: readImageProcessor }),
53
- createBashTool(),
54
- createEditTool(),
55
- createWriteTool(),
66
+ ...createReadOnlyTools(cwd).map((tool) => tool.name),
67
+ ...createCodingTools(cwd).map((tool) => tool.name),
68
+ createPowerShellTool(cwd).name,
56
69
  ];
57
70
  }
58
- /** `config.tools` semantics: extra tools APPENDED after pi's defaults, never replacing them. */
59
- export function resolveTools(config) {
60
- const defaults = piDefaultTools();
61
- return config.tools ? [...defaults, ...config.tools] : defaults;
71
+ /** Built-ins omitted by an explicit lower-level tool list. A reused name stays mounted. */
72
+ function omittedBuiltinNames(mounted, cwd) {
73
+ const mountedNames = new Set(mounted.map((tool) => tool.name));
74
+ return [...new Set(piRegisteredToolNames(cwd))].filter((name) => !mountedNames.has(name));
62
75
  }
63
76
  /**
64
- * The full tool set an agent mounts: pi defaults + `config.tools` + discovered `tools/` (deduped,
65
- * existing win), plus the non-default tool names and collisions to report. One source for the
77
+ * The full directory-agent tool set: all pi coding tools + `config.tools` + discovered `tools/`
78
+ * (deduped, existing win), plus the authored names and collisions to report. One source for the
66
79
  * dev/start openers AND `fastagent tool`, so they all mount exactly the same set.
67
80
  */
68
- export async function resolveAgentTools(config, agentDir) {
69
- // Discovered `tools/` come from `agentDir` (the agent's own surface); the default coding tools carry
70
- // no root of their ownthey operate through the ExecutionEnv handed to them per turn, whose cwd is
71
- // the workspace.
81
+ export async function resolveAgentTools(config, agentDir, cwd) {
82
+ // Discovered `tools/` come from `agentDir` (the agent's own surface); the coding tools are
83
+ // rooted at `cwd`, the WORKSPACEthe project the agent works on, which is where an author expects
84
+ // `read`/`bash` to land, and which is not always the definition directory.
72
85
  const discovered = await loadTools(agentDir);
73
- const merged = mergeDiscoveredTools(resolveTools(config), discovered.tools);
86
+ const configured = piAllCodingTools(cwd);
87
+ const configuredNames = new Set(configured.map((tool) => tool.name));
88
+ const configuredCollisions = [];
89
+ for (const tool of config.tools ?? []) {
90
+ if (configuredNames.has(tool.name)) {
91
+ configuredCollisions.push({ name: tool.name, source: "config.tools" });
92
+ continue;
93
+ }
94
+ configuredNames.add(tool.name);
95
+ configured.push(tool);
96
+ }
97
+ const merged = mergeDiscoveredTools(configured, discovered.tools);
74
98
  // The built-in `search_tools` loader mounts here — the one place the agent's full tool set is
75
99
  // computed — so `dev`/`start`/`info`/`fastagent tool` all see the same surface (idempotent; an
76
100
  // agent-defined search_tools wins).
@@ -78,12 +102,12 @@ export async function resolveAgentTools(config, agentDir) {
78
102
  // Builtin = a search_tools that was ABSENT before withSearchTool (a reference compare would misfire
79
103
  // on the deferred-authored-loader case, where withSearchTool returns a new array without adding one).
80
104
  const builtinLoaderMounted = !merged.tools.some((t) => t.name === "search_tools") && tools.some((t) => t.name === "search_tools");
81
- const toolCollisions = [...discovered.collisions, ...merged.collisions];
82
- // `toolNames` is the AUTHOR's active-by-default surface (config.tools + tools/): exclude pi
83
- // defaults, the builtin loader (like wake, a builtin gets its own report line, not an anonymous
84
- // slot in the author's list — an author-DEFINED search_tools still shows), and deferred tools
85
- // each name lives in exactly ONE report slot, and deferred names live in `deferredToolNames`.
86
- const defaultNames = new Set(piDefaultTools().map((t) => t.name));
105
+ const toolCollisions = [...discovered.collisions, ...configuredCollisions, ...merged.collisions];
106
+ // `toolNames` is the AUTHOR's active-by-default surface (config.tools + tools/): exclude pi coding
107
+ // tools, the builtin loader (like wake, a builtin gets its own report line, not an anonymous slot in
108
+ // the author's list — an author-DEFINED search_tools still shows), and deferred tools. Each name
109
+ // lives in exactly ONE report slot, and deferred names live in `deferredToolNames`.
110
+ const defaultNames = new Set(CODING_TOOL_NAMES);
87
111
  const toolNames = tools
88
112
  .filter((t) => !defaultNames.has(t.name) && !isDeferredTool(t) && !(builtinLoaderMounted && t.name === "search_tools"))
89
113
  .map((t) => t.name);
@@ -123,9 +147,16 @@ export function piBasePrompt(options = {}) {
123
147
  const deferredCount = mounted.length - tools.length;
124
148
  const toolsList = tools.length > 0 ? tools.map((t) => `- ${t.name}: ${(t.description ?? "").split("\n")[0]}`).join("\n") : "(none)";
125
149
  // Segment ① identity: an authored persona (persona.md) replaces the default engine identity line
126
- // (core.md §11), keeping the tools list + guidelines below.
150
+ // (core.md §11), keeping the tools list + guidelines below. Preserve pi's coding identity only for
151
+ // the full coding surface; a partial/empty surface must not claim machine capabilities it lacks.
152
+ // The four this sentence NAMES — reading, executing, editing, writing. Searching is not part of the
153
+ // claim, so requiring it would demote an agent that can do everything the identity says it can.
154
+ const mountedNames = new Set(mounted.map((tool) => tool.name));
155
+ const fullCodingSurface = ["read", "bash", "edit", "write"].every((name) => mountedNames.has(name));
127
156
  const identity = options.persona?.trim() ||
128
- "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files.";
157
+ (fullCodingSurface
158
+ ? "You are an expert coding assistant operating inside pi, a coding agent harness. You help users by reading files, executing commands, editing code, and writing new files."
159
+ : "You are an AI assistant operating inside pi, an agent harness. Help users using only the tools and context available to you.");
129
160
  const deferredNote = deferredCount > 0
130
161
  ? `\n\n${deferredCount} additional tool(s) are registered but inactive — use search_tools to discover and activate them before concluding a capability is missing.`
131
162
  : "";
@@ -165,30 +196,56 @@ export function assembleSystemPrompt(options) {
165
196
  * directory) and route through here.
166
197
  */
167
198
  function buildPiAgent(opts) {
168
- const models = opts.models ?? createPiModels({ providers: opts.providers, authPath: opts.authPath });
169
199
  const env = opts.env ?? new NodeExecutionEnv({ cwd: process.cwd() });
170
- // Materialized here (not defaulted inside createPiAgentFromHarness) so the exposed parts carry
171
- // the SAME lease instance the agent runs under boundary mutations must contend on it.
200
+ const cwd = opts.cwd ?? env.cwd;
201
+ // Materialized here (not defaulted inside the L0) so the exposed parts carry the SAME lease
202
+ // instance the agent runs under — boundary mutations must contend on it.
172
203
  const lease = opts.lease ?? inProcessLease();
173
- // The assembly's configured PAIR handed to the factory and to the control plane as ONE value, so
174
- // there is no wiring in which they could disagree (which levels exist depends on the model).
175
- const defaults = {
176
- model: resolveModel(models, opts.model),
177
- thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
204
+ const sessions = opts.sessions ?? piInMemorySessionRecordStore({ cwd });
205
+ // The model and its runtime resolve on FIRST USE: building a ModelRuntime is async while
206
+ // assembling an agent is not, so the credential read belongs on the first turn rather than in the
207
+ // caller's constructor. Memoized by the factory, and shared with the control plane below so a
208
+ // boundary mutation validates against the registry the runs actually use.
209
+ let engine;
210
+ const resolveEngine = () => {
211
+ engine ??= (async () => {
212
+ // The caller's registry when there is one — the directory rung builds it from the agent's own
213
+ // models.json, so a custom endpoint declared there is the one a turn resolves against.
214
+ const modelRuntime = opts.models ?? (await createPiModelRuntime({ authPath: opts.authPath }));
215
+ // ModelRuntime registers providers by config record, so an injected Provider INSTANCE (a
216
+ // gateway, a self-hosted endpoint, a test fake) goes in through its native seam.
217
+ for (const provider of opts.providers ?? [])
218
+ modelRuntime.registerNativeProvider(provider);
219
+ return { modelRuntime, model: resolveModel(modelRuntime, opts.model) };
220
+ })();
221
+ return engine;
178
222
  };
179
- const harnessFactory = piHarnessFactory({
180
- sessions: opts.sessions ?? inMemorySessionStore(),
181
- env,
182
- models,
183
- model: defaults.model,
223
+ const sessionFactory = piAgentSessionFactory({
224
+ sessions,
225
+ engine: resolveEngine,
184
226
  thinkingLevel: opts.thinkingLevel,
185
- systemPrompt: opts.systemPrompt,
186
227
  tools: opts.tools,
228
+ systemPrompt: opts.systemPrompt,
187
229
  skills: opts.skills,
188
230
  live: opts.live,
231
+ cwd,
232
+ ...(opts.agentDir ? { agentDir: opts.agentDir } : {}),
233
+ ...(opts.extensionPaths ? { extensionPaths: opts.extensionPaths } : {}),
234
+ // `noTools: "builtin"` leaves pi's built-ins in the registry; a lower-level replacement must also
235
+ // deny every omitted coding name so a loader cannot reactivate one later.
236
+ excludedToolNames: omittedBuiltinNames(opts.tools ?? [], cwd),
237
+ env,
238
+ });
239
+ opts.onAssembly?.({
240
+ lease,
241
+ sessionFactory,
242
+ // The control plane needs the registry and the configured pair as VALUES, and both only exist
243
+ // after the first credential read. Asking for them lazily keeps assembly synchronous without
244
+ // making the hub wait on a runtime it may never need (a control-less deployment never calls it).
245
+ engine: resolveEngine,
246
+ thinkingLevel: opts.thinkingLevel ?? DEFAULT_THINKING_LEVEL,
189
247
  });
190
- opts.onAssembly?.({ models, harnessFactory, lease, defaults });
191
- return createPiAgentFromHarness({ lease, observer: opts.observer, cwd: env.cwd, harnessFactory });
248
+ return createPiAgentFromSession({ lease, observer: opts.observer, sessionFactory });
192
249
  }
193
250
  /**
194
251
  * L1 system prompt: `instructions` ARE the prompt (no engine base, no wrapping); the skills listing
@@ -233,15 +290,21 @@ export async function createPiAgentFromDefinition(dir, options) {
233
290
  const env = options.env ?? new NodeExecutionEnv({ cwd });
234
291
  // Boot-time load: fail-visibly at startup on a broken directory, and give callers the snapshot to
235
292
  // report (skills/diagnostics/collisions). Serving does NOT close over it — see `live` below.
236
- const definition = await loadAgentDefinition(dir, { cwd: env.cwd, env });
293
+ // `cwd`, not `env.cwd`, for the same reason as the tools below: `cwd` is the run root whose
294
+ // ancestors carry ② project context. Reading it off the env pointed the AGENTS.md walk at the
295
+ // loader's directory whenever a caller supplied both.
296
+ const definition = await loadAgentDefinition(dir, { cwd, env });
297
+ // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
298
+ // it; a caller's own search_tools wins).
299
+ // `cwd`, not `env.cwd`: the workspace is what this option MEANS, and a caller may hand a custom env
300
+ // for definition loading whose root is a different directory. Taking it off the env would silently
301
+ // point the coding tools at the loader's directory.
302
+ const tools = withSearchTool(options.tools ?? piAllCodingTools(cwd));
237
303
  // Boot findings go through the SAME memoized reporter every later reader uses (report.ts, keyed by
238
304
  // the resolved dir): announced once here, and re-announced by a turn or by the control plane's
239
305
  // command list only when the set CHANGES — a runtime-written bad skill surfaces the moment it
240
306
  // appears, a static one does not spam. Log dedup, not session state (stateless invoke holds).
241
307
  reportFindingsIfChanged(definition.dir, definition);
242
- // Deferred tools need their loader on every rung (idempotent — the workspace opener already applied
243
- // it; a caller's own search_tools wins).
244
- const tools = withSearchTool(options.tools ?? piDefaultTools());
245
308
  // Dir-aware default: the same secrets-dir-derived file the opener uses for this dir (the opener
246
309
  // passes an explicit authPath, so this only affects direct L2 callers).
247
310
  const authPath = options.authPath ?? defaultAuthPath(resolveSecretsDir(dir));
@@ -264,7 +327,7 @@ export async function createPiAgentFromDefinition(dir, options) {
264
327
  // not silently vanish from the agent, and a static one must not spam every turn's log. The
265
328
  // next good edit heals both.
266
329
  live: async () => {
267
- const def = await loadAgentDefinition(dir, { cwd: env.cwd, env });
330
+ const def = await loadAgentDefinition(dir, { cwd, env });
268
331
  reportFindingsIfChanged(def.dir, def);
269
332
  return {
270
333
  systemPrompt: assembleSystemPrompt({
@@ -274,13 +337,20 @@ export async function createPiAgentFromDefinition(dir, options) {
274
337
  // ② project context: AGENTS.md files (agentDir + cwd-ancestor walk) via loadProjectContextFiles.
275
338
  contextFiles: def.contextFiles,
276
339
  skills: def.skills,
277
- cwd: env.cwd,
340
+ // The workspace, matching where the tools operate. Telling the model one directory while
341
+ // `read`/`bash` resolve relative paths against another is a lie it cannot detect.
342
+ cwd,
278
343
  }),
279
344
  skills: def.skills,
280
345
  };
281
346
  },
282
347
  tools,
283
348
  sessions: options.sessions,
349
+ // Discovered so the serving assembly can WARN that it does not run them (and so the refusals
350
+ // apply to the artifact either way) — `chat` is where they load. Boot-resolved: the set cannot
351
+ // change without a restart, which is why this sits outside `live` above.
352
+ extensionPaths: await loadExtensionPaths(dir, { cwd, env }),
353
+ cwd,
284
354
  env,
285
355
  lease: options.lease,
286
356
  observer: options.observer,
@@ -41,6 +41,47 @@ export interface LoadAgentDefinitionOptions {
41
41
  }
42
42
  /** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
43
43
  export declare function loadAgentDefinition(agentDir: string, options?: LoadAgentDefinitionOptions): Promise<LoadedDefinition>;
44
+ /**
45
+ * Extension entry-point FILES under `<agentDir>/extensions/`, empty when there are none.
46
+ *
47
+ * Paths, not loaded objects — unlike skills (data: content inline, serializable), an extension is
48
+ * CODE that pi loads with jiti and binds to its own eventBus/runtime, so loading it here would
49
+ * reimplement pi's loader. The engine binding hands these to pi as `additionalExtensionPaths`, which
50
+ * survives `noExtensions: true` — that flag suppresses MACHINE-GLOBAL discovery (`~/.pi`), and the
51
+ * definition's own extensions were only ever collateral to it. FILES, not the directory: pi's paths
52
+ * are module specifiers, and a directory fails as `Cannot find module` into a
53
+ * `LoadExtensionsResult.errors` entry nothing reads.
54
+ *
55
+ * SEPARATE from {@link loadAgentDefinition} on purpose. Prompt and skills are re-read every invoke
56
+ * ("the directory is the agent, LIVE"); scanning for extension entry points is not, since the set
57
+ * cannot change without a restart. Called once per assembly, like `tools/`.
58
+ *
59
+ * Discovery runs on BOTH paths; loading does not. `chat` hands these to pi and runs them fully.
60
+ * Serving only announces them — pi's extension runtime is shared across sessions, and serving has
61
+ * concurrent turns for unrelated conversations (see `PiAgentSessionFactoryOptions.extensionPaths`).
62
+ * The refusals below are therefore about what the ARTIFACT may contain, and hold for both.
63
+ *
64
+ * Discovery follows pi's own rules, so an extension that works in pi works here:
65
+ *
66
+ * 1. a direct `*.ts` / `*.js` file;
67
+ * 2. a subdirectory with `index.ts` / `index.js`.
68
+ *
69
+ * pi has a third rule — a subdirectory whose `package.json` declares a `pi` field — which is NOT
70
+ * implemented here. That shape is reported rather than skipped: a definition whose extension silently
71
+ * fails to load is the failure mode this whole path exists to remove.
72
+ *
73
+ * Containment matches skills/tools/channels/schedules (the fifth of five surfaces): a symlinked
74
+ * `extensions/` escaping the agent dir is refused, and — like `loadModuleDir`, whose `entry.isFile()`
75
+ * excludes them — a symlinked ENTRY is not loaded either. pi's own discovery does follow those, but
76
+ * an extension reached through a link out of the definition is code the artifact does not carry: it
77
+ * resolves on the authoring machine and is missing in the container. Refused loudly, never silently.
78
+ *
79
+ * Absent is normal and silent; a FILE at that path is not — that is an author who meant something.
80
+ */
81
+ export declare function loadExtensionPaths(agentDir: string, options?: {
82
+ cwd?: string;
83
+ env?: ExecutionEnv;
84
+ }): Promise<string[]>;
44
85
  /**
45
86
  * The definition's skills ALONE, resolved the same way `loadAgentDefinition` resolves them (same
46
87
  * loader, same containment guard, same first-wins collision rule) — for readers that need only the
@@ -56,13 +97,6 @@ export declare function loadAgentSkills(agentDir: string, options?: {
56
97
  collisions: SkillCollision[];
57
98
  dir: string;
58
99
  }>;
59
- /**
60
- * Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
61
- * this OUT of the agent?" — the startup report's redeploy notes, `add`'s printed `.env` label, and the
62
- * dev watcher's "your .env is not watched" warning all turn on that fact. Reporting only: fastagent
63
- * does not act on where a user's paths point.
64
- */
65
- export declare function isUnderDir(targetPath: string, baseDir: string): boolean;
66
100
  /** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath.
67
101
  * A non-existent path can't be realpath'd, so it stays as the plain absolute resolve. */
68
102
  export declare function canonicalPath(p: string): string;
@@ -14,10 +14,11 @@
14
14
  * (bad skill files, name collisions) are returned as data. An unreadable ② context file only warns (pi).
15
15
  */
16
16
  import { realpathSync } from "node:fs";
17
- import { isAbsolute, join, relative, resolve } from "node:path";
17
+ import { join, resolve } from "node:path";
18
18
  import { loadSkills } from "@earendil-works/pi-agent-core";
19
19
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
20
20
  import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent";
21
+ import { log } from "../../log.js";
21
22
  import { assertInsideAgentDir } from "../../paths.js";
22
23
  /** Read an agent definition. persona.md/skills come from `agentDir`; ② context = pi's loadProjectContextFiles({ cwd, agentDir }). */
23
24
  export async function loadAgentDefinition(agentDir, options = {}) {
@@ -45,6 +46,119 @@ export async function loadAgentDefinition(agentDir, options = {}) {
45
46
  const { skills, diagnostics, collisions } = await readSkills(e, root);
46
47
  return { contextFiles, persona, skills, diagnostics, collisions, dir: root };
47
48
  }
49
+ /**
50
+ * Extension entry-point FILES under `<agentDir>/extensions/`, empty when there are none.
51
+ *
52
+ * Paths, not loaded objects — unlike skills (data: content inline, serializable), an extension is
53
+ * CODE that pi loads with jiti and binds to its own eventBus/runtime, so loading it here would
54
+ * reimplement pi's loader. The engine binding hands these to pi as `additionalExtensionPaths`, which
55
+ * survives `noExtensions: true` — that flag suppresses MACHINE-GLOBAL discovery (`~/.pi`), and the
56
+ * definition's own extensions were only ever collateral to it. FILES, not the directory: pi's paths
57
+ * are module specifiers, and a directory fails as `Cannot find module` into a
58
+ * `LoadExtensionsResult.errors` entry nothing reads.
59
+ *
60
+ * SEPARATE from {@link loadAgentDefinition} on purpose. Prompt and skills are re-read every invoke
61
+ * ("the directory is the agent, LIVE"); scanning for extension entry points is not, since the set
62
+ * cannot change without a restart. Called once per assembly, like `tools/`.
63
+ *
64
+ * Discovery runs on BOTH paths; loading does not. `chat` hands these to pi and runs them fully.
65
+ * Serving only announces them — pi's extension runtime is shared across sessions, and serving has
66
+ * concurrent turns for unrelated conversations (see `PiAgentSessionFactoryOptions.extensionPaths`).
67
+ * The refusals below are therefore about what the ARTIFACT may contain, and hold for both.
68
+ *
69
+ * Discovery follows pi's own rules, so an extension that works in pi works here:
70
+ *
71
+ * 1. a direct `*.ts` / `*.js` file;
72
+ * 2. a subdirectory with `index.ts` / `index.js`.
73
+ *
74
+ * pi has a third rule — a subdirectory whose `package.json` declares a `pi` field — which is NOT
75
+ * implemented here. That shape is reported rather than skipped: a definition whose extension silently
76
+ * fails to load is the failure mode this whole path exists to remove.
77
+ *
78
+ * Containment matches skills/tools/channels/schedules (the fifth of five surfaces): a symlinked
79
+ * `extensions/` escaping the agent dir is refused, and — like `loadModuleDir`, whose `entry.isFile()`
80
+ * excludes them — a symlinked ENTRY is not loaded either. pi's own discovery does follow those, but
81
+ * an extension reached through a link out of the definition is code the artifact does not carry: it
82
+ * resolves on the authoring machine and is missing in the container. Refused loudly, never silently.
83
+ *
84
+ * Absent is normal and silent; a FILE at that path is not — that is an author who meant something.
85
+ */
86
+ export async function loadExtensionPaths(agentDir, options = {}) {
87
+ const cwd = options.cwd ?? agentDir;
88
+ const e = options.env ?? new NodeExecutionEnv({ cwd });
89
+ const rootResult = await e.absolutePath(agentDir);
90
+ if (!rootResult.ok)
91
+ throw new Error(`cannot resolve agent dir "${agentDir}": ${rootResult.error.message}`);
92
+ const root = rootResult.value;
93
+ await assertInsideAgentDir(root, "extensions");
94
+ const dir = join(root, "extensions");
95
+ const listed = await e.listDir(dir);
96
+ if (!listed.ok) {
97
+ if (listed.error.code === "not_found")
98
+ return [];
99
+ throw new Error(`cannot read ${dir}: ${listed.error.message}`);
100
+ }
101
+ const paths = [];
102
+ for (const entry of listed.value) {
103
+ if (entry.kind === "symlink") {
104
+ // EVERY symlink here is announced, without guessing whether it meant to be an extension. The
105
+ // two mistakes are not equal: a needless line about a symlinked README costs a glance, while
106
+ // staying quiet about a symlinked extension loses a feature silently and only shows up in the
107
+ // container. A name-based guess also cannot see through the link — `audit.ext -> some/dir` is
108
+ // a directory candidate to pi and a mystery here — so the warning says what it knows and
109
+ // tells the author when to ignore it.
110
+ warnSymlinkRefused(entry.path);
111
+ continue;
112
+ }
113
+ if (entry.name.endsWith(".ts") || entry.name.endsWith(".js")) {
114
+ if (entry.kind === "file")
115
+ paths.push(entry.path);
116
+ continue;
117
+ }
118
+ if (entry.kind === "file")
119
+ continue; // a README, a .json — not an extension, not a problem
120
+ const index = await firstRealFile(e, [join(entry.path, "index.ts"), join(entry.path, "index.js")]);
121
+ if (index.path) {
122
+ paths.push(index.path);
123
+ }
124
+ else if (!index.refused) {
125
+ // Silent when the index WAS found and refused for being a symlink: that warning already named
126
+ // the real problem, and "expected index.ts" on top of it describes a directory that has one.
127
+ log.warn(`[fastagent] ${entry.path} is not a loadable extension: expected index.ts or index.js ` +
128
+ `(pi's package.json "pi" manifest form is not supported here) — it will not be loaded`);
129
+ }
130
+ }
131
+ return paths.sort();
132
+ }
133
+ /**
134
+ * The first candidate that is a REAL file. `exists` would follow a symlink, which is how a
135
+ * subdirectory's `index.ts` could otherwise point outside the definition and slip past the rule the
136
+ * top-level entries already follow.
137
+ */
138
+ /** The first real file among the candidates, and whether one was found but REFUSED as a symlink. */
139
+ async function firstRealFile(e, candidates) {
140
+ let refused = false;
141
+ for (const candidate of candidates) {
142
+ const info = await e.fileInfo(candidate);
143
+ if (!info.ok) {
144
+ if (info.error.code === "not_found")
145
+ continue;
146
+ throw new Error(`cannot read ${candidate}: ${info.error.message}`);
147
+ }
148
+ if (info.value.kind === "file")
149
+ return { path: candidate, refused };
150
+ if (info.value.kind === "symlink") {
151
+ warnSymlinkRefused(candidate);
152
+ refused = true;
153
+ }
154
+ }
155
+ return { refused };
156
+ }
157
+ function warnSymlinkRefused(path) {
158
+ log.warn(`[fastagent] ${path} is a symlink and will not be loaded: an extension must be a real file inside ` +
159
+ `the definition so it travels with the artifact — move it in. (If it is not an extension, ` +
160
+ `keep it outside extensions/ to silence this.)`);
161
+ }
48
162
  /** The skills half, shared by the full load and {@link loadAgentSkills}. `root` is already resolved. */
49
163
  async function readSkills(e, root) {
50
164
  // Skills come ONLY from the definition's own skills/ (no external/global mount), so the same
@@ -81,16 +195,6 @@ export async function loadAgentSkills(agentDir, options = {}) {
81
195
  // (the findings memo) on it, and "./agent" vs an absolute path must not become two definitions.
82
196
  return { ...(await readSkills(e, rootResult.value)), dir: rootResult.value };
83
197
  }
84
- /**
85
- * Whether `targetPath` lives inside `baseDir` (same path counts). Used to ask "did an override move
86
- * this OUT of the agent?" — the startup report's redeploy notes, `add`'s printed `.env` label, and the
87
- * dev watcher's "your .env is not watched" warning all turn on that fact. Reporting only: fastagent
88
- * does not act on where a user's paths point.
89
- */
90
- export function isUnderDir(targetPath, baseDir) {
91
- const rel = relative(baseDir, targetPath);
92
- return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
93
- }
94
198
  /** Resolve to a canonical (symlink-free) absolute path so comparisons match `process.cwd()`'s realpath.
95
199
  * A non-existent path can't be realpath'd, so it stays as the plain absolute resolve. */
96
200
  export function canonicalPath(p) {
@@ -1,32 +1,35 @@
1
1
  /**
2
- * L0 over pi-coding-agent's `AgentSession`, in the `per-invoke` state locality
3
- * ([conformance-levels.md](../../../docs/design/conformance-levels.md) §2, top-right cell): build a
4
- * session per invoke over the SAME durable jsonl, run one turn, dispose.
2
+ * THE L0: pi's `AgentSession`, one per invoke, over the same durable record —
3
+ * [conformance-levels.md](../../../docs/design/conformance-levels.md) §2's `per-invoke` posture.
4
+ * Build a session, run one turn, dispose; continuity lives in the record, never in this process.
5
5
  *
6
- * Why this exists next to {@link createPiAgentFromHarness}: pi 0.84 replaced `AgentHarness` with an
7
- * unimplemented lane-based skeleton, and pi does not consume that class itself — its TUI, RPC and SDK
8
- * all run on `AgentSession`. This is the executable proof that the SPEC's four Agent-side MUSTs hold
9
- * on the class pi actually maintains (test/conformance-session.test.ts).
6
+ * Why this class: pi 0.84 replaced `AgentHarness` with an unimplemented lane-based skeleton, and pi
7
+ * does not consume that class itself — its TUI, RPC and SDK all run on `AgentSession`. Being the
8
+ * sole consumer of a surface nobody dogfoods is a position, not an architecture.
10
9
  *
11
- * SCOPE, deliberately narrow: the concurrency floor, the event stream, and cancellation. The
12
- * observation plane (SessionObserver / RunControls / the rich `SessionEvent` vocabulary), the tool
13
- * activation bridge, auto-compaction and session inheritance are NOT wired.
10
+ * Events are translated ONCE, into the rich `SessionEvent` vocabulary the observation plane speaks;
11
+ * the SPEC stream is a projection of that (`docs/design/session-control.md` §6 one translation
12
+ * plus one projection, never two parallel ones).
14
13
  *
15
- * WHICH L0 SERVES: {@link createPiAgentFromHarness}, still — this one is reachable only from its
16
- * conformance test (deliberately absent from `src/pi.ts`), because a serving path needs the pieces
17
- * above. Its one known debt is {@link toAgentEvent}: translating pi events straight to SPEC
18
- * `AgentEvent`s is the second parallel translation `docs/design/session-control.md` §6 forbids. It
19
- * retires the moment this L0 grows the observation plane — the rich `SessionEvent` layer comes back
20
- * with it, and the harness L0 goes away.
14
+ * Two disciplines this file exists to hold:
15
+ * - the turn's outcome comes from the EVENT STREAM, never from an index into session state, which
16
+ * compaction and overflow recovery both rewrite mid-turn;
17
+ * - `run_started` is published BEFORE the session is bound, so a dispatch racing the build queues on
18
+ * the run's controls instead of finding no run.
21
19
  */
22
20
  import type { AgentSession } from "@earendil-works/pi-coding-agent";
21
+ import type { SessionInheritance } from "./session-inheritance.ts";
23
22
  import { type Agent } from "../../agent.ts";
24
- import { type Lease } from "./turn-kit.ts";
25
- /** Open-or-create the session behind `sessionId` and bind an `AgentSession` to it, per invoke. */
26
- export type PiAgentSessionFactory = (sessionId: string) => Promise<AgentSession>;
23
+ import { type Lease, type SessionObserver } from "./turn-kit.ts";
24
+ /** Open-or-create the session behind `sessionId` and bind an `AgentSession` to it, per invoke.
25
+ * `inherit` reaches the CREATE path only — an existing session ignores it. */
26
+ export type PiAgentSessionFactory = (sessionId: string, inherit?: SessionInheritance) => Promise<AgentSession>;
27
27
  export interface CreatePiAgentFromSessionOptions {
28
28
  sessionFactory: PiAgentSessionFactory;
29
29
  /** Single-writer lease. Defaults to the in-process per-session fail-fast lease. */
30
30
  lease?: Lease;
31
+ /** Observation-plane tap: every rich event of every run, plus the run's live {@link RunControls}
32
+ * on `run_started`. Optional; the SPEC stream is identical with or without it. */
33
+ observer?: SessionObserver;
31
34
  }
32
35
  export declare function createPiAgentFromSession(options: CreatePiAgentFromSessionOptions): Agent;