@fastagent-sh/fastagent 0.18.0 → 0.20.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 (222) 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 +23 -0
  5. package/dist/atomic-write.js +53 -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-limits.d.ts +9 -0
  9. package/dist/channels/agentcore-limits.js +9 -0
  10. package/dist/channels/agentcore-service.d.ts +55 -0
  11. package/dist/channels/agentcore-service.js +107 -0
  12. package/dist/channels/agentcore.d.ts +22 -20
  13. package/dist/channels/agentcore.js +20 -11
  14. package/dist/channels/busy.d.ts +1 -1
  15. package/dist/channels/busy.js +1 -1
  16. package/dist/channels/control.d.ts +55 -7
  17. package/dist/channels/control.js +312 -67
  18. package/dist/channels/discover.d.ts +59 -0
  19. package/dist/{engines/pi/channel.js → channels/discover.js} +19 -34
  20. package/dist/channels/feishu/card.js +1 -1
  21. package/dist/channels/feishu/context-buffer.d.ts +1 -1
  22. package/dist/channels/feishu/context-buffer.js +4 -4
  23. package/dist/channels/feishu/feishu-api.js +6 -9
  24. package/dist/channels/feishu/feishu.d.ts +1 -1
  25. package/dist/channels/feishu/feishu.js +41 -24
  26. package/dist/channels/feishu/invoke-turn.d.ts +1 -1
  27. package/dist/channels/feishu/invoke-turn.js +3 -4
  28. package/dist/channels/feishu/preview.d.ts +1 -1
  29. package/dist/channels/feishu/preview.js +7 -3
  30. package/dist/channels/feishu/ws-ingress.d.ts +1 -1
  31. package/dist/channels/github/github.d.ts +1 -1
  32. package/dist/channels/http.d.ts +11 -7
  33. package/dist/channels/http.js +1 -109
  34. package/dist/channels/kit/attachment-path.d.ts +12 -0
  35. package/dist/channels/kit/attachment-path.js +43 -0
  36. package/dist/channels/{context-buffer.d.ts → kit/context-buffer.d.ts} +9 -0
  37. package/dist/channels/{context-buffer.js → kit/context-buffer.js} +13 -2
  38. package/dist/channels/{invoke-turn-kit.d.ts → kit/invoke-turn-kit.d.ts} +14 -3
  39. package/dist/channels/{invoke-turn-kit.js → kit/invoke-turn-kit.js} +17 -6
  40. package/dist/channels/{preview-kit.d.ts → kit/preview-kit.d.ts} +3 -1
  41. package/dist/channels/{seen.js → kit/seen.js} +1 -1
  42. package/dist/channels/kit/signature.d.ts +12 -0
  43. package/dist/channels/kit/signature.js +17 -0
  44. package/dist/channels/{state.js → kit/state.js} +4 -7
  45. package/dist/channels/{stop-command.d.ts → kit/stop-command.d.ts} +1 -1
  46. package/dist/channels/{stop-command.js → kit/stop-command.js} +5 -5
  47. package/dist/channels/{tasks.d.ts → kit/tasks.d.ts} +1 -1
  48. package/dist/channels/{tasks.js → kit/tasks.js} +11 -5
  49. package/dist/channels/{thread-participants.js → kit/thread-participants.js} +1 -1
  50. package/dist/channels/{turn-queue.js → kit/turn-queue.js} +2 -2
  51. package/dist/channels/{turn-store.d.ts → kit/turn-store.d.ts} +19 -3
  52. package/dist/channels/{turn-store.js → kit/turn-store.js} +30 -3
  53. package/dist/channels/lark/lark.d.ts +1 -1
  54. package/dist/channels/serve.d.ts +73 -0
  55. package/dist/channels/serve.js +243 -0
  56. package/dist/channels/slack/bot-auth.js +3 -14
  57. package/dist/channels/slack/context-buffer.d.ts +2 -2
  58. package/dist/channels/slack/context-buffer.js +2 -2
  59. package/dist/channels/slack/invoke-turn.d.ts +2 -2
  60. package/dist/channels/slack/invoke-turn.js +13 -6
  61. package/dist/channels/slack/onboard.js +5 -5
  62. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  63. package/dist/channels/slack/onboarding-state.js +13 -20
  64. package/dist/channels/slack/parse.js +2 -2
  65. package/dist/channels/slack/preview.d.ts +1 -1
  66. package/dist/channels/slack/preview.js +11 -3
  67. package/dist/channels/slack/register-webhook.js +1 -1
  68. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  69. package/dist/channels/slack/slack-api.js +23 -55
  70. package/dist/channels/slack/slack.d.ts +1 -1
  71. package/dist/channels/slack/slack.js +20 -20
  72. package/dist/channels/slack/welcomed.js +1 -1
  73. package/dist/channels/telegram/context-buffer.d.ts +2 -2
  74. package/dist/channels/telegram/context-buffer.js +2 -2
  75. package/dist/channels/telegram/invoke-turn.d.ts +1 -1
  76. package/dist/channels/telegram/invoke-turn.js +1 -1
  77. package/dist/channels/telegram/parse.d.ts +21 -1
  78. package/dist/channels/telegram/parse.js +67 -13
  79. package/dist/channels/telegram/preview.d.ts +1 -1
  80. package/dist/channels/telegram/preview.js +6 -2
  81. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  82. package/dist/channels/telegram/telegram-api.js +5 -6
  83. package/dist/channels/telegram/telegram.d.ts +3 -3
  84. package/dist/channels/telegram/telegram.js +60 -72
  85. package/dist/channels/telegram/turn-store.d.ts +2 -2
  86. package/dist/channels/telegram/turn-store.js +2 -2
  87. package/dist/cli/add-slack.js +6 -5
  88. package/dist/cli/commands/add.js +1 -5
  89. package/dist/cli/commands/attach.d.ts +2 -4
  90. package/dist/cli/commands/attach.js +17 -15
  91. package/dist/cli/commands/deploy.d.ts +50 -5
  92. package/dist/cli/commands/deploy.js +183 -134
  93. package/dist/cli/commands/dev.js +25 -40
  94. package/dist/cli/commands/fire.js +1 -1
  95. package/dist/cli/commands/info.js +7 -5
  96. package/dist/cli/commands/init.js +1 -2
  97. package/dist/cli/commands/schedule.js +1 -1
  98. package/dist/cli/commands/start.js +80 -121
  99. package/dist/cli/commands/tool.js +5 -7
  100. package/dist/cli/program.js +1 -1
  101. package/dist/cli/serve.d.ts +22 -70
  102. package/dist/cli/serve.js +66 -257
  103. package/dist/cli/shared.d.ts +40 -10
  104. package/dist/cli/shared.js +53 -6
  105. package/dist/core.d.ts +3 -5
  106. package/dist/core.js +7 -5
  107. package/dist/deploy/agentcore/logs.d.ts +2 -2
  108. package/dist/deploy/agentcore/logs.js +2 -2
  109. package/dist/deploy/agentcore/plan.d.ts +12 -10
  110. package/dist/deploy/agentcore/plan.js +39 -31
  111. package/dist/deploy/agentcore/run.d.ts +5 -4
  112. package/dist/deploy/agentcore/run.js +13 -36
  113. package/dist/deploy/channel-ingress.d.ts +73 -0
  114. package/dist/deploy/channel-ingress.js +101 -0
  115. package/dist/deploy/docker/plan.d.ts +3 -7
  116. package/dist/deploy/docker/plan.js +4 -15
  117. package/dist/deploy/docker/run.d.ts +18 -1
  118. package/dist/deploy/docker/run.js +10 -0
  119. package/dist/deploy/fly/plan.d.ts +4 -5
  120. package/dist/deploy/fly/plan.js +7 -22
  121. package/dist/deploy/fly/run.d.ts +9 -23
  122. package/dist/deploy/fly/run.js +32 -68
  123. package/dist/deploy/preflight.d.ts +6 -7
  124. package/dist/deploy/preflight.js +27 -23
  125. package/dist/deploy/railway/plan.d.ts +4 -5
  126. package/dist/deploy/railway/plan.js +11 -26
  127. package/dist/deploy/railway/run.d.ts +9 -9
  128. package/dist/deploy/railway/run.js +40 -40
  129. package/dist/deploy/runner.js +22 -2
  130. package/dist/deploy/secrets.d.ts +18 -9
  131. package/dist/deploy/secrets.js +64 -12
  132. package/dist/dev-supervisor.js +11 -6
  133. package/dist/engines/pi/agent-session-factory.d.ts +104 -0
  134. package/dist/engines/pi/agent-session-factory.js +295 -0
  135. package/dist/engines/pi/auth.js +43 -43
  136. package/dist/engines/pi/config.d.ts +5 -5
  137. package/dist/engines/pi/config.js +1 -1
  138. package/dist/engines/pi/create.d.ts +67 -42
  139. package/dist/engines/pi/create.js +142 -72
  140. package/dist/engines/pi/definition.d.ts +42 -8
  141. package/dist/engines/pi/definition.js +114 -11
  142. package/dist/engines/pi/invoke-session.d.ts +22 -19
  143. package/dist/engines/pi/invoke-session.js +286 -100
  144. package/dist/engines/pi/login.js +39 -20
  145. package/dist/engines/pi/models.d.ts +14 -0
  146. package/dist/engines/pi/models.js +9 -1
  147. package/dist/engines/pi/open.d.ts +10 -8
  148. package/dist/engines/pi/open.js +21 -11
  149. package/dist/engines/pi/report.d.ts +0 -4
  150. package/dist/engines/pi/report.js +0 -7
  151. package/dist/engines/pi/search-tools.js +1 -1
  152. package/dist/engines/pi/service.d.ts +23 -0
  153. package/dist/engines/pi/service.js +27 -0
  154. package/dist/engines/pi/session-builder.js +87 -118
  155. package/dist/engines/pi/session-control.d.ts +28 -14
  156. package/dist/engines/pi/session-control.js +570 -438
  157. package/dist/engines/pi/session-inheritance.d.ts +30 -0
  158. package/dist/engines/pi/session-inheritance.js +261 -0
  159. package/dist/engines/pi/session-markers.d.ts +48 -0
  160. package/dist/engines/pi/session-markers.js +59 -0
  161. package/dist/engines/pi/session-settings.d.ts +15 -3
  162. package/dist/engines/pi/session-settings.js +22 -1
  163. package/dist/engines/pi/session-store.d.ts +140 -0
  164. package/dist/engines/pi/session-store.js +666 -0
  165. package/dist/engines/pi/tool-context.d.ts +36 -13
  166. package/dist/engines/pi/tool-context.js +51 -4
  167. package/dist/engines/pi/tool.d.ts +5 -5
  168. package/dist/engines/pi/tool.js +3 -3
  169. package/dist/engines/pi/turn-kit.d.ts +32 -6
  170. package/dist/engines/pi/turn-kit.js +28 -1
  171. package/dist/engines/pi/wake-tool.js +1 -1
  172. package/dist/env.js +1 -2
  173. package/dist/index.d.ts +1 -0
  174. package/dist/index.js +1 -0
  175. package/dist/loader.d.ts +49 -7
  176. package/dist/loader.js +79 -18
  177. package/dist/log.d.ts +3 -0
  178. package/dist/log.js +5 -1
  179. package/dist/node.d.ts +16 -0
  180. package/dist/node.js +20 -0
  181. package/dist/paths.d.ts +26 -0
  182. package/dist/paths.js +37 -2
  183. package/dist/pi.d.ts +14 -8
  184. package/dist/pi.js +6 -5
  185. package/dist/scaffold/add-channel.js +2 -2
  186. package/dist/scaffold/init.js +7 -2
  187. package/dist/scaffold/templates/persona.md +2 -2
  188. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  189. package/dist/schedule/discover.js +3 -15
  190. package/dist/schedule/state.js +4 -9
  191. package/dist/schedule/wake-alarm.d.ts +13 -7
  192. package/dist/schedule/wake-alarm.js +88 -46
  193. package/dist/schedule/wakeups.d.ts +2 -2
  194. package/dist/schedule/wakeups.js +11 -8
  195. package/dist/service.d.ts +157 -0
  196. package/dist/service.js +376 -0
  197. package/dist/session-remote.d.ts +8 -3
  198. package/dist/session-remote.js +162 -92
  199. package/dist/session.d.ts +229 -93
  200. package/dist/session.js +61 -23
  201. package/dist/telegram.d.ts +1 -1
  202. package/dist/telegram.js +1 -1
  203. package/dist/tunnel.d.ts +18 -3
  204. package/dist/tunnel.js +32 -40
  205. package/package.json +14 -5
  206. package/dist/engines/pi/channel.d.ts +0 -37
  207. package/dist/engines/pi/harness.d.ts +0 -115
  208. package/dist/engines/pi/harness.js +0 -154
  209. package/dist/engines/pi/invoke.d.ts +0 -60
  210. package/dist/engines/pi/invoke.js +0 -459
  211. package/dist/engines/pi/read-image.d.ts +0 -4
  212. package/dist/engines/pi/read-image.js +0 -62
  213. package/dist/engines/pi/sessions.d.ts +0 -60
  214. package/dist/engines/pi/sessions.js +0 -464
  215. package/dist/host/node.js +0 -52
  216. /package/dist/channels/{preview-kit.js → kit/preview-kit.js} +0 -0
  217. /package/dist/channels/{seen.d.ts → kit/seen.d.ts} +0 -0
  218. /package/dist/channels/{state.d.ts → kit/state.d.ts} +0 -0
  219. /package/dist/channels/{text.d.ts → kit/text.d.ts} +0 -0
  220. /package/dist/channels/{text.js → kit/text.js} +0 -0
  221. /package/dist/channels/{thread-participants.d.ts → kit/thread-participants.d.ts} +0 -0
  222. /package/dist/channels/{turn-queue.d.ts → kit/turn-queue.d.ts} +0 -0
@@ -4,6 +4,7 @@
4
4
  * binary and the command sequence differ. Tests inject a fake recorder; production spawns the real CLI.
5
5
  */
6
6
  import { spawn } from "node:child_process";
7
+ import { log } from "../log.js";
7
8
  /**
8
9
  * Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the
9
10
  * agent). stderr is always inherited to the terminal; stdout is inherited unless `capture`. A spawn
@@ -22,11 +23,30 @@ export function spawnRunner(bin, cwd) {
22
23
  });
23
24
  let out = "";
24
25
  let err = "";
26
+ let stdinError;
25
27
  child.stdout?.on("data", (d) => (out += String(d)));
26
28
  child.stderr?.on("data", (d) => (err += String(d)));
27
- if (opts?.input)
29
+ if (opts?.input) {
30
+ // A host CLI that rejects before reading (bad auth, a refused command) closes stdin under us:
31
+ // the write then emits `error` on a stream with no listener, which Node turns into an uncaught
32
+ // exception — crashing the deploy and losing the gate message the exit code was about to carry.
33
+ // HELD, not dropped: the exit code says it only when the CLI exits non-zero (see below).
34
+ child.stdin?.on("error", (error) => (stdinError ??= error));
28
35
  child.stdin?.end(opts.input);
29
- child.on("close", (code) => res({ code: code ?? 1, stdout: out, stderr: opts?.captureStderr ? err : undefined }));
36
+ }
37
+ child.on("close", (code) => {
38
+ // A CLI that exits 0 having refused part of its stdin took TRUNCATED input — an auth seed runs
39
+ // well past the 64KB pipe buffer, so "success" here would deploy a half-written secret and say
40
+ // nothing. The exit code is what callers read, so the failure has to reach them as one.
41
+ const truncated = stdinError !== undefined && (code ?? 1) === 0;
42
+ if (truncated) {
43
+ // `error`, not `warn`: this line is the ONLY evidence of the failure — the caller's gate says
44
+ // "see the output above", and above it the host CLI printed success. A log level that mutes
45
+ // it (CI often sets `error`) leaves a stopped deploy with no diagnosable cause.
46
+ log.error(`[fastagent] ${bin} exited 0 without reading all of its input — ${stdinError?.message}`);
47
+ }
48
+ res({ code: truncated ? 1 : (code ?? 1), stdout: out, stderr: opts?.captureStderr ? err : undefined });
49
+ });
30
50
  child.on("error", () => res({ code: 127, stdout: "", stderr: opts?.captureStderr ? "" : undefined })); // ENOENT
31
51
  });
32
52
  }
@@ -1,9 +1,4 @@
1
- /**
2
- * The secret set a deployed agent needs, computed from the definition — host-neutral. Required values
3
- * gate every target; optional channel values travel only when configured. Only the SET command differs
4
- * (`fly secrets import` vs `railway variables set`). The runbooks list both classes; `--run` reads local values.
5
- */
6
- import { type ChannelKind } from "../scaffold/add-channel.ts";
1
+ import type { DeclaredChannel } from "../channels/discover.ts";
7
2
  /**
8
3
  * Is this local auth source an env-var API key (→ becomes a deploy secret) vs OAuth / stored / none?
9
4
  * Positive match on the UPPER_SNAKE env-var naming shape, NOT a negative exclude of today's sentinel
@@ -17,7 +12,7 @@ export declare function isEnvKey(source: string | undefined): source is string;
17
12
  * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
18
13
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
19
14
  */
20
- export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[], longConnectionChannels?: string[]): {
15
+ export declare function deploymentSecrets(modelAuth: string | undefined, channels: readonly DeclaredChannel[], extraSecrets?: string[]): {
21
16
  name: string;
22
17
  hint: string;
23
18
  required: boolean;
@@ -42,8 +37,7 @@ export declare function assembleSecrets(input: {
42
37
  * no value to carry and no gate to raise — see {@link modelCredentialCarry}. */
43
38
  modelKeyInDefinition?: boolean;
44
39
  authFile: Buffer | undefined;
45
- channels: ChannelKind[];
46
- longConnectionChannels?: string[];
40
+ channels: readonly DeclaredChannel[];
47
41
  /** Extra secret env-var names from `fastagent.config` deploy.secrets — carried like channel secrets. */
48
42
  extraSecrets?: string[];
49
43
  env: NodeJS.ProcessEnv;
@@ -52,3 +46,18 @@ export declare function assembleSecrets(input: {
52
46
  missingSecrets: string[];
53
47
  needsModelCredential: boolean;
54
48
  };
49
+ /**
50
+ * The bytes to seed to the auth file, or undefined to leave it alone — the pure core of `start`'s
51
+ * FASTAGENT_AUTH_SEED materialization (the read side of {@link assembleSecrets}'s carry). ABSENT-ONLY
52
+ * by design: a present file (a refreshed volume copy) is never overwritten by the stale seed, so a box
53
+ * that ran its own OAuth refresh is not rolled back.
54
+ */
55
+ export declare function authSeedBytes(seed: string | undefined, fileExists: boolean): Buffer | undefined;
56
+ /**
57
+ * Collect the (possibly CHUNKED) auth seed from the environment: `FASTAGENT_AUTH_SEED` plus numbered
58
+ * continuations (`_2`, `_3`, …) concatenated in order. Hosts whose env values carry a small max
59
+ * length (AgentCore: 2048 chars — a real OAuth auth.json's base64 exceeds it) split the seed across
60
+ * them at deploy time; single-var hosts (Fly/Railway) never set a continuation and are unchanged.
61
+ * Collection stops at the first absent/empty continuation — the writer fills them contiguously.
62
+ */
63
+ export declare function collectAuthSeed(env: NodeJS.ProcessEnv): string | undefined;
@@ -2,8 +2,20 @@
2
2
  * The secret set a deployed agent needs, computed from the definition — host-neutral. Required values
3
3
  * gate every target; optional channel values travel only when configured. Only the SET command differs
4
4
  * (`fly secrets import` vs `railway variables set`). The runbooks list both classes; `--run` reads local values.
5
+ *
6
+ * Both DIRECTIONS of the credential carry live here: the deploy-time assembly below, and the boot-time
7
+ * seed read at the bottom. They were split across a host driver (`fly/run.ts`), which left `start`
8
+ * — a serving path that deploys nothing, on Fly or anywhere — importing from it to boot a container.
5
9
  */
6
- import { channelSetup } from "../scaffold/add-channel.js";
10
+ import { CONTROL_TOKEN_ENV } from "../channels/control.js";
11
+ import { CHANNEL_KINDS, channelSetup } from "../scaffold/add-channel.js";
12
+ /** The declared channels this tool has setup metadata for. A custom channel carries its own secrets;
13
+ * nothing here can name them, and guessing would print a runbook line no one can act on. */
14
+ function firstPartyChannels(channels) {
15
+ return channels.flatMap((channel) => CHANNEL_KINDS.includes(channel.name)
16
+ ? [{ kind: channel.name, ingress: channel.ingress }]
17
+ : []);
18
+ }
7
19
  /**
8
20
  * Is this local auth source an env-var API key (→ becomes a deploy secret) vs OAuth / stored / none?
9
21
  * Positive match on the UPPER_SNAKE env-var naming shape, NOT a negative exclude of today's sentinel
@@ -19,20 +31,30 @@ export function isEnvKey(source) {
19
31
  * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
20
32
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
21
33
  */
22
- export function deploymentSecrets(modelAuth, channels, extraSecrets = [], longConnectionChannels = []) {
34
+ export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
23
35
  const secrets = [];
24
36
  if (isEnvKey(modelAuth))
25
37
  secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
26
- for (const kind of channels) {
27
- const setupMode = longConnectionChannels.includes(kind) ? "websocket" : "webhook";
28
- for (const e of channelSetup(kind, setupMode).env) {
38
+ for (const { kind, ingress } of firstPartyChannels(channels)) {
39
+ for (const e of channelSetup(kind, ingress === "long-connection" ? "websocket" : "webhook").env) {
29
40
  secrets.push({ name: e.name, hint: e.hint, required: e.required });
30
41
  }
31
42
  }
32
43
  // Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
33
44
  for (const name of extraSecrets) {
34
45
  if (!secrets.some((s) => s.name === name)) {
35
- secrets.push({ name, hint: "declared in fastagent.config deploy.secrets", required: true });
46
+ const control = name === CONTROL_TOKEN_ENV;
47
+ secrets.push({
48
+ name,
49
+ hint: control
50
+ ? "the /control/* bearer token — mint one (uuidgen) and give the same value to callers"
51
+ : "declared in fastagent.config deploy.secrets",
52
+ // OPTIONAL, unlike every other extra: unset, the box mints a per-boot token and still serves,
53
+ // and every host with a shell can read it back out of control.json. Gating would stop deploys
54
+ // that work today to enforce a convenience — the pre-flight warning is where that argument
55
+ // belongs.
56
+ required: !control,
57
+ });
36
58
  }
37
59
  }
38
60
  return secrets;
@@ -74,9 +96,8 @@ export function assembleSecrets(input) {
74
96
  else {
75
97
  needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
76
98
  }
77
- for (const kind of input.channels) {
78
- const setupMode = input.longConnectionChannels?.includes(kind) ? "websocket" : "webhook";
79
- for (const e of channelSetup(kind, setupMode).env) {
99
+ for (const { kind, ingress } of firstPartyChannels(input.channels)) {
100
+ for (const e of channelSetup(kind, ingress === "long-connection" ? "websocket" : "webhook").env) {
80
101
  const v = input.env[e.name];
81
102
  if (v)
82
103
  secrets[e.name] = v; // optional channel values travel when configured
@@ -88,7 +109,7 @@ export function assembleSecrets(input) {
88
109
  // Slack bot-token rotation is an all-or-nothing credential bundle. Its fields remain optional so a
89
110
  // manually configured long-lived token works, but a partial bundle must gate before the container
90
111
  // reaches slackChannel construction.
91
- if (input.channels.includes("slack")) {
112
+ if (input.channels.some((channel) => channel.name === "slack")) {
92
113
  const rotation = [
93
114
  "SLACK_BOT_REFRESH_TOKEN",
94
115
  "SLACK_BOT_TOKEN_EXPIRES_AT",
@@ -108,8 +129,39 @@ export function assembleSecrets(input) {
108
129
  const v = input.env[name];
109
130
  if (v)
110
131
  secrets[name] = v;
111
- else
112
- missingSecrets.push(name); // declared in config but no local value same .env remediation
132
+ // The control token is CARRIED, never gated — see {@link deploymentSecrets}: unset, the box mints
133
+ // one and serves; every other extra is declared as needed, so its absence is a stop.
134
+ else if (name !== CONTROL_TOKEN_ENV)
135
+ missingSecrets.push(name);
113
136
  }
114
137
  return { secrets, missingSecrets, needsModelCredential };
115
138
  }
139
+ /**
140
+ * The bytes to seed to the auth file, or undefined to leave it alone — the pure core of `start`'s
141
+ * FASTAGENT_AUTH_SEED materialization (the read side of {@link assembleSecrets}'s carry). ABSENT-ONLY
142
+ * by design: a present file (a refreshed volume copy) is never overwritten by the stale seed, so a box
143
+ * that ran its own OAuth refresh is not rolled back.
144
+ */
145
+ export function authSeedBytes(seed, fileExists) {
146
+ return !seed || fileExists ? undefined : Buffer.from(seed, "base64");
147
+ }
148
+ /**
149
+ * Collect the (possibly CHUNKED) auth seed from the environment: `FASTAGENT_AUTH_SEED` plus numbered
150
+ * continuations (`_2`, `_3`, …) concatenated in order. Hosts whose env values carry a small max
151
+ * length (AgentCore: 2048 chars — a real OAuth auth.json's base64 exceeds it) split the seed across
152
+ * them at deploy time; single-var hosts (Fly/Railway) never set a continuation and are unchanged.
153
+ * Collection stops at the first absent/empty continuation — the writer fills them contiguously.
154
+ */
155
+ export function collectAuthSeed(env) {
156
+ const first = env.FASTAGENT_AUTH_SEED;
157
+ if (!first)
158
+ return undefined;
159
+ let seed = first;
160
+ for (let i = 2;; i++) {
161
+ const part = env[`FASTAGENT_AUTH_SEED_${i}`];
162
+ if (!part)
163
+ break;
164
+ seed += part;
165
+ }
166
+ return seed;
167
+ }
@@ -13,15 +13,21 @@
13
13
  import { spawn } from "node:child_process";
14
14
  import { relative, sep } from "node:path";
15
15
  import { watch as watchTree } from "chokidar";
16
- import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot } from "./paths.js";
17
- import { isUnderDir } from "./engines/pi/definition.js";
16
+ import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot, isUnderDir, } from "./paths.js";
18
17
  import { dotEnvPath } from "./env.js";
19
18
  import { log } from "./log.js";
20
19
  import { installProxyFetch } from "./proxy.js";
21
20
  import { openExternalUrl } from "./open-url.js";
21
+ import { declaredChannels } from "./channels/discover.js";
22
22
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
23
23
  /** What the dev watcher restarts on (agent-dir-relative): the process-bound code inputs only. */
24
- const WATCHED_HINT = "tools/, channels/, schedules/, package.json, fastagent.config.*, models.json, .secrets/.env";
24
+ /**
25
+ * The agent-dir directories loaded ONCE per worker: a restart is their only re-read. One list, so
26
+ * the watcher and the line printed at startup cannot disagree — a directory watched but unannounced
27
+ * looks broken, and one announced but unwatched silently strands the author mid-edit.
28
+ */
29
+ const CODE_INPUT_DIRS = ["tools", "channels", "schedules", "extensions"];
30
+ const WATCHED_HINT = `${CODE_INPUT_DIRS.map((dir) => `${dir}/`).join(", ")}, package.json, fastagent.config.*, models.json, .secrets/.env`;
25
31
  /**
26
32
  * chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. When
27
33
  * the agent sits INSIDE the workspace that means the surrounding tree never triggers a restart at all;
@@ -56,7 +62,7 @@ export function devWatchIgnored(root, envFile) {
56
62
  if (rel === AGENT_MODELS_FILE)
57
63
  return false;
58
64
  const segments = rel.split(sep);
59
- if (segments[0] === "tools" || segments[0] === "channels" || segments[0] === "schedules")
65
+ if (CODE_INPUT_DIRS.includes(segments[0]))
60
66
  return false;
61
67
  // The `.env` restarts too (credentials are process-bound). Keep it AND its ancestor directories
62
68
  // un-pruned so chokidar can descend to it; every sibling inside them (auth.json, .env.example)
@@ -102,9 +108,8 @@ export async function runDevSupervisor(placement, options = {}) {
102
108
  void startCloudflareTunnel(m.port).then((t) => {
103
109
  if (t) {
104
110
  tunnel = t;
105
- void announceWebhooks(placement.agentDir, t.url, {
111
+ void announceWebhooks(placement.agentDir, t.url, declaredChannels(m.routeChannels ?? []), {
106
112
  openUrl: openExternalUrl,
107
- routeChannels: m.routeChannels,
108
113
  stateRoot: resolveStateRoot(placement.agentDir),
109
114
  });
110
115
  }
@@ -0,0 +1,104 @@
1
+ import type { ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
2
+ import type { Model } from "@earendil-works/pi-ai";
3
+ import { type AgentSessionServices, type CreateAgentSessionServicesOptions, type ModelRuntime } from "@earendil-works/pi-coding-agent";
4
+ import type { PiAgentSessionFactory } from "./invoke-session.ts";
5
+ import type { PiSessionRecordStore } from "./session-store.ts";
6
+ import { type MountedTool } from "./tool.ts";
7
+ /** pi's Model with the API-shape generic erased; fastagent only passes models through. */
8
+ type AnyModel = Model<any>;
9
+ export interface PiAgentSessionFactoryOptions {
10
+ /** Where conversations live. Continuity = same store + same session id. */
11
+ sessions: PiSessionRecordStore;
12
+ /**
13
+ * The model to run and the hub that authenticates it, resolved on FIRST USE and kept.
14
+ *
15
+ * A thunk because building a `ModelRuntime` is async while assembling an agent is not: the L1
16
+ * surface hands back an `Agent` synchronously, so the credential read that a runtime performs
17
+ * belongs on the first turn rather than in the caller's constructor. The two travel together
18
+ * because a model must be resolved against the runtime that holds its provider's auth.
19
+ */
20
+ engine: () => Promise<{
21
+ modelRuntime: ModelRuntime;
22
+ model: AnyModel;
23
+ }>;
24
+ thinkingLevel?: ThinkingLevel;
25
+ tools?: MountedTool[];
26
+ /** Final assembled prompt, or a factory re-evaluated per turn. */
27
+ systemPrompt?: string | (() => string);
28
+ skills?: Skill[];
29
+ /** Per-turn source for the prompt+skills PAIR; supersedes the two above. This is what keeps
30
+ * "the directory is the agent, LIVE" true on a shared `services`: the ResourceLoader is built
31
+ * once, but what it serves is re-read here. */
32
+ live?: () => Promise<{
33
+ systemPrompt?: string;
34
+ skills?: Skill[];
35
+ }>;
36
+ /** The agent's working directory — what fastagent-defined tools see as `cwd`. */
37
+ cwd: string;
38
+ /**
39
+ * Where pi looks for ITS settings (retry budget, compaction thresholds, default thinking level).
40
+ *
41
+ * Deliberately NOT pi's machine-global `~/.pi/agent`: a served agent must behave the same on the
42
+ * author's laptop and in a container, and reading the operator's personal pi configuration is the
43
+ * artifact losing to the machine. Point it at a definition-scoped path; a missing directory simply
44
+ * means pi's own defaults, which is the intended baseline.
45
+ */
46
+ agentDir?: string;
47
+ /**
48
+ * The definition's own extension entry points, for ANNOUNCING that serving does not run them.
49
+ *
50
+ * pi's extension machinery is built for one process serving one session: `bindCore()` copies the
51
+ * session's actions into a runtime the pi source itself calls "the shared runtime", extension
52
+ * modules are cached per assembly, and `session_start`/`session_shutdown` are a matched pair.
53
+ * Serving breaks every one of those assumptions — concurrent turns for unrelated conversations —
54
+ * and the failure is silent cross-talk: with two turns in flight, an extension calling
55
+ * `pi.sendMessage()` can deliver into the other conversation.
56
+ *
57
+ * Loading them anyway would be a correctness bug dressed as a feature, so serving does not, and
58
+ * warns when a definition ships some. `chat` runs them fully: one session, one runtime, which is
59
+ * exactly the shape pi is built for.
60
+ *
61
+ * Isolating them per session is mechanically possible — pi's uncached loader path builds a fresh
62
+ * module (jiti with `moduleCache: false`) and takes the runtime as an argument — but that function
63
+ * is not exported and the deep path is blocked by the package's `exports`. Reopening this needs
64
+ * that entry point upstream, not a workaround here.
65
+ */
66
+ extensionPaths?: string[];
67
+ /** Built-ins omitted by an explicit lower-level tool list. */
68
+ excludedToolNames?: readonly string[];
69
+ /** Filesystem/process environment: definition loading, and the turn context for tools that read one. */
70
+ env: ExecutionEnv;
71
+ }
72
+ /**
73
+ * Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and
74
+ * carries on with the rest — sound for a TUI that shows them, silent for a server that never looks.
75
+ * A definition running without the extension it ships is exactly the "quietly missing" failure this
76
+ * exists to remove. CHAT calls it, once per built services — serving does not load extensions at
77
+ * all, and announces that instead (see PiAgentSessionFactoryOptions.extensionPaths).
78
+ */
79
+ export declare function reportExtensionErrors(services: AgentSessionServices): void;
80
+ /** What pi is allowed to discover, minus the parts each assembly fills in itself. */
81
+ type DefinitionLoaderOptions = NonNullable<CreateAgentSessionServicesOptions["resourceLoaderOptions"]>;
82
+ /**
83
+ * The resource posture a fastagent definition asks pi for — ONE definition of it, for both
84
+ * assemblies. Serving (`piAgentSessionFactory`) and chat (`buildAgentSessionRuntime`) build
85
+ * different sessions on top, but what pi is allowed to DISCOVER is not one of the differences:
86
+ * everything comes from the definition, nothing from the machine that happens to be running it.
87
+ *
88
+ * Two copies of this drifted once already: `additionalExtensionPaths` was added to both, and only
89
+ * one of them also passed the resulting tool names through pi's `tools` allowlist — so extensions
90
+ * worked when served and vanished in chat. A difference between the two has to be visible AS a
91
+ * difference, which is what the parameters are for: serving reads a prompt and skills that change
92
+ * per turn and passes NO extension paths (it does not run them — see
93
+ * {@link PiAgentSessionFactoryOptions.extensionPaths}); chat reads a fixed assembly and passes its
94
+ * own. Both are arguments now, rather than two files that happen to disagree.
95
+ */
96
+ export declare function definitionResourceLoaderOptions(source: {
97
+ systemPrompt: () => string | undefined;
98
+ skills: () => Skill[];
99
+ /** Omitted by serving, which does not run them. */
100
+ extensionPaths?: readonly string[];
101
+ }): DefinitionLoaderOptions;
102
+ /** Open-or-create the record, then bind a fresh session to it. One call per invoke. */
103
+ export declare function piAgentSessionFactory(options: PiAgentSessionFactoryOptions): PiAgentSessionFactory;
104
+ export {};
@@ -0,0 +1,295 @@
1
+ /**
2
+ * The AgentSession L0's engine binding: fastagent's assembled agent — model, prompt, skills, tools —
3
+ * bound to one durable record, per invoke.
4
+ *
5
+ * The chat path builds the same pi class from the same assembly (session-builder.ts) but keeps ONE
6
+ * resident session. What is specific here is the posture: many sessions, one turn each, nothing in
7
+ * memory between turns.
8
+ *
9
+ * Shared once, rebuilt per turn:
10
+ * - `services` (ResourceLoader, settings, model runtime) is built lazily and reused — it is the
11
+ * expensive half, and it holds nothing session-specific;
12
+ * - the `AgentSession` and its tool bindings are per turn, because a tool's `execute` closes over the
13
+ * session it runs in and this posture has several in flight at once.
14
+ */
15
+ import { dirname, join } from "node:path";
16
+ import { createAgentSessionFromServices, createAgentSessionServices, } from "@earendil-works/pi-coding-agent";
17
+ import { log } from "../../log.js";
18
+ import { isDeferredTool } from "./tool.js";
19
+ import { activePath, resolveSessionSettings } from "./session-settings.js";
20
+ import { DEFAULT_THINKING_LEVEL } from "./models.js";
21
+ import { agentSessionManager, sessionToolActivation, turnContext } from "./tool-context.js";
22
+ /**
23
+ * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
24
+ * tools a loader activated in that call.
25
+ *
26
+ * A DEDICATED record, not pi's own `active_tools_change`: that one is a full SNAPSHOT of everything
27
+ * active at the moment, so replaying it would keep a tool active in old sessions after the author
28
+ * flips it to `deferred` — the session never discovered it. A delta carries only what was actually
29
+ * found, and is layered onto whatever the workspace mounts TODAY.
30
+ */
31
+ const TOOL_ACTIVATION_ENTRY = "fastagent:tool-activation";
32
+ /** Every deferred tool this session has ever discovered, oldest first. */
33
+ function recordedActivations(session) {
34
+ const names = [];
35
+ for (const entry of session.sessionManager.getBranch()) {
36
+ const record = entry;
37
+ if (record.type !== "custom" || record.customType !== TOOL_ACTIVATION_ENTRY)
38
+ continue;
39
+ if (Array.isArray(record.data?.names)) {
40
+ for (const name of record.data.names)
41
+ if (typeof name === "string")
42
+ names.push(name);
43
+ }
44
+ }
45
+ return names;
46
+ }
47
+ /** Warned once per session+missing set: a fresh session is built per invoke and channel sessions run
48
+ * for weeks, so an un-deduped warn would repeat every turn and dilute its own signal. */
49
+ const warnedDroppedActivations = new Set();
50
+ /**
51
+ * fastagent's tools as pi tool definitions, bound to ONE session.
52
+ *
53
+ * `bound` is filled after the session exists — pi needs the definitions to build the session, and a
54
+ * tool needs the session to reach the turn context. A tool that somehow runs before that binding
55
+ * throws rather than executing outside the turn: a broken lifecycle must not look like a normal
56
+ * out-of-turn call.
57
+ *
58
+ * It carries the whole turn CONTEXT, not just the session: the activation bridge holds the lock that
59
+ * orders concurrent activations, so it has to live as long as the session the activations mutate.
60
+ * Building it here, per call, would hand each parallel tool its own.
61
+ */
62
+ function toolDefinitions(tools, env, bound) {
63
+ return tools.map((tool) => ({
64
+ name: tool.name,
65
+ label: tool.name,
66
+ description: tool.description ?? "",
67
+ parameters: tool.parameters,
68
+ // An activating tool (the built-in loader) declares "sequential" so pi serializes its batch;
69
+ // without it pi's outer active-set diff double-stamps parallel calls.
70
+ executionMode: tool.executionMode,
71
+ execute: (id, params, signal) => {
72
+ const context = bound.context;
73
+ if (!context)
74
+ throw new Error("tool executed before its turn context was bound (lifecycle invariant broken)");
75
+ return turnContext.run(context,
76
+ // Lower-level MountedTools may consume the fifth-argument env. Directory coding tools are
77
+ // cwd-bound and ignore it; authored tools read FastAgent's turnContext instead.
78
+ () => tool.execute(id, params, signal, undefined, { env }));
79
+ },
80
+ }));
81
+ }
82
+ /**
83
+ * Announce extensions pi failed to load. pi collects them into `LoadExtensionsResult.errors` and
84
+ * carries on with the rest — sound for a TUI that shows them, silent for a server that never looks.
85
+ * A definition running without the extension it ships is exactly the "quietly missing" failure this
86
+ * exists to remove. CHAT calls it, once per built services — serving does not load extensions at
87
+ * all, and announces that instead (see PiAgentSessionFactoryOptions.extensionPaths).
88
+ */
89
+ export function reportExtensionErrors(services) {
90
+ for (const { path, error } of services.resourceLoader.getExtensions().errors) {
91
+ log.warn(`[fastagent] extension ${path} failed to load: ${error}`);
92
+ }
93
+ }
94
+ /**
95
+ * The resource posture a fastagent definition asks pi for — ONE definition of it, for both
96
+ * assemblies. Serving (`piAgentSessionFactory`) and chat (`buildAgentSessionRuntime`) build
97
+ * different sessions on top, but what pi is allowed to DISCOVER is not one of the differences:
98
+ * everything comes from the definition, nothing from the machine that happens to be running it.
99
+ *
100
+ * Two copies of this drifted once already: `additionalExtensionPaths` was added to both, and only
101
+ * one of them also passed the resulting tool names through pi's `tools` allowlist — so extensions
102
+ * worked when served and vanished in chat. A difference between the two has to be visible AS a
103
+ * difference, which is what the parameters are for: serving reads a prompt and skills that change
104
+ * per turn and passes NO extension paths (it does not run them — see
105
+ * {@link PiAgentSessionFactoryOptions.extensionPaths}); chat reads a fixed assembly and passes its
106
+ * own. Both are arguments now, rather than two files that happen to disagree.
107
+ */
108
+ export function definitionResourceLoaderOptions(source) {
109
+ return {
110
+ // Definition-only, like dev/start: pi's machine-global discovery (the operator's own ~/.pi
111
+ // extensions, slash commands, global AGENTS.md, APPEND_SYSTEM.md) stays out, so the agent that
112
+ // runs is the artifact, not the artifact plus whoever's laptop it is.
113
+ noExtensions: true,
114
+ // ...except the definition's OWN extensions/: pi honours additionalExtensionPaths even under
115
+ // noExtensions, which is exactly the split wanted here — the artifact travels with its
116
+ // extensions, the machine's stay out.
117
+ ...(source.extensionPaths?.length ? { additionalExtensionPaths: [...source.extensionPaths] } : {}),
118
+ noPromptTemplates: true,
119
+ noContextFiles: true,
120
+ // A SPACE, not "", when the assembly has no prompt: pi treats an empty custom prompt as absent
121
+ // and substitutes its own coding-assistant identity, which an L1 agent
122
+ // (`createPiAgent({ model, tools })`) never asked for. pi appends its own working-directory line
123
+ // either way — that is engine behaviour this binding does not fight.
124
+ systemPromptOverride: () => source.systemPrompt() ?? " ",
125
+ appendSystemPromptOverride: () => [],
126
+ skillsOverride: (base) => ({
127
+ skills: toPiSkills(source.skills()),
128
+ diagnostics: base.diagnostics,
129
+ }),
130
+ };
131
+ }
132
+ /** Open-or-create the record, then bind a fresh session to it. One call per invoke. */
133
+ export function piAgentSessionFactory(options) {
134
+ const { sessions, thinkingLevel, cwd, env } = options;
135
+ const extensionPaths = options.extensionPaths ?? [];
136
+ const excludedToolNames = options.excludedToolNames ?? [];
137
+ if (extensionPaths.length > 0) {
138
+ log.warn(`[fastagent] ${extensionPaths.length} extension(s) in the definition are NOT loaded when serving ` +
139
+ "(they run in `fastagent chat`): pi's extension runtime is shared across sessions, and serving " +
140
+ "runs concurrent turns for different conversations. See docs/configuration.md#extensions.");
141
+ }
142
+ const tools = options.tools ?? [];
143
+ const deferred = tools.filter(isDeferredTool).map((t) => t.name);
144
+ // What the shared ResourceLoader serves, refreshed per turn before the session is built.
145
+ let prompt = typeof options.systemPrompt === "function" ? options.systemPrompt() : options.systemPrompt;
146
+ let skills = options.skills ?? [];
147
+ let services;
148
+ let engine;
149
+ const buildServices = async (modelRuntime) => createAgentSessionServices({
150
+ cwd,
151
+ agentDir: options.agentDir ?? join(cwd, ".fastagent", "pi"),
152
+ modelRuntime,
153
+ // No extensionPaths: serving does not run them (see PiAgentSessionFactoryOptions), which is
154
+ // the one resource question the two assemblies answer differently. The accessors read the
155
+ // CURRENT prompt/skills — serving refreshes both per turn, so a snapshot taken here would
156
+ // serve a stale definition after the first edit.
157
+ resourceLoaderOptions: definitionResourceLoaderOptions({
158
+ systemPrompt: () => prompt,
159
+ skills: () => skills,
160
+ }),
161
+ });
162
+ return async (sessionId, inherit) => {
163
+ const fresh = options.live ? await options.live() : undefined;
164
+ const nextPrompt = fresh
165
+ ? fresh.systemPrompt
166
+ : typeof options.systemPrompt === "function"
167
+ ? options.systemPrompt()
168
+ : prompt;
169
+ const nextSkills = fresh ? (fresh.skills ?? []) : skills;
170
+ engine ??= options.engine();
171
+ const { modelRuntime, model } = await engine;
172
+ if (services === undefined) {
173
+ prompt = nextPrompt;
174
+ skills = nextSkills;
175
+ services = buildServices(modelRuntime); // assigned before any await: concurrent turns share it
176
+ }
177
+ else {
178
+ // The ResourceLoader reads the overrides once and caches, so a re-read of the definition only
179
+ // reaches the model after a reload. Reload only when the definition ACTUALLY changed — an
180
+ // author edits persona.md far less often than the agent takes a turn, and a reload costs ~5ms
181
+ // against ~0.6ms to bind a session.
182
+ //
183
+ // "Changed" is measured against the LOADER, not against what this factory last wrote. Serving
184
+ // is concurrent across sessions, and a shared variable makes the check lie: one turn writes
185
+ // its new prompt, awaits before reloading, and the next turn sees that value already present,
186
+ // concludes nothing changed, and skips the reload — so the edit reaches neither the loader nor
187
+ // any error. Asking the loader what it is actually serving cannot go stale that way. The cost
188
+ // of losing the race is one redundant reload, not a swallowed edit.
189
+ //
190
+ // Skill CONTENT is not part of this — pi reads a skill from its file at invocation time, so
191
+ // only the declared set matters.
192
+ //
193
+ // What this deliberately does NOT provide is a per-turn snapshot. The definition is an AGENT
194
+ // property, not a session one: two turns running either side of an edit each get a definition
195
+ // that genuinely existed, and the product promise — an edit is live on the next turn — holds
196
+ // for both. Pinning a snapshot per turn would cost either a loader per turn or a queue in
197
+ // front of every bind, to buy a guarantee nothing asks for.
198
+ const loader = (await services).resourceLoader;
199
+ const definitionChanged = loader.getSystemPrompt() !== nextPrompt || loadedSkillSet(loader.getSkills().skills) !== skillSet(nextSkills);
200
+ if (definitionChanged) {
201
+ prompt = nextPrompt;
202
+ skills = nextSkills;
203
+ await loader.reload();
204
+ }
205
+ }
206
+ const sessionManager = await sessions.openOrCreate(sessionId, inherit);
207
+ // What the session RUNS on: the boundary plane records model/thinking overrides as entries, and
208
+ // pi does not read them back — a binding that ignored them would silently run every turn on the
209
+ // assembly default, and `state()` would report a setting no turn uses.
210
+ // The SAME read the control plane performs, including its integrity check: a record whose chain
211
+ // is broken must not run on assembly defaults while `state()` rejects it — one of the two planes
212
+ // would be lying. A throw here becomes this turn's `failed` event, which is where the fault has
213
+ // a channel to be reported through.
214
+ const settings = resolveSessionSettings(activePath(sessionManager), modelRuntime, {
215
+ model,
216
+ thinkingLevel: thinkingLevel ?? DEFAULT_THINKING_LEVEL,
217
+ });
218
+ const bound = {};
219
+ const { session } = await createAgentSessionFromServices({
220
+ services: await services,
221
+ sessionManager,
222
+ model: settings.model,
223
+ thinkingLevel: settings.thinkingLevel,
224
+ // pi would otherwise mount its built-ins on top of fastagent's copies, offering duplicate names.
225
+ // Lower-level callers with an explicit list also rely on omitted built-ins staying omitted.
226
+ noTools: "builtin",
227
+ ...(excludedToolNames.length > 0 ? { excludeTools: [...excludedToolNames] } : {}),
228
+ customTools: toolDefinitions(tools, env, bound),
229
+ });
230
+ // One context for the whole session: it describes the SESSION, not the call. The activation
231
+ // bridge above all — a tool call has to see what the previous one activated.
232
+ bound.context = {
233
+ cwd,
234
+ sessionManager: agentSessionManager(session, sessionId),
235
+ // A served session HAS somewhere to record the discovery, so it does: the delta is what makes
236
+ // the tool still callable next turn (see {@link TOOL_ACTIVATION_ENTRY}).
237
+ tools: sessionToolActivation(session, (added) => session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added })),
238
+ };
239
+ // An extension handler that throws is otherwise dropped: pi fans errors out to registered
240
+ // listeners and has none by default, which on a server means a broken extension looks like an
241
+ // extension that simply did nothing.
242
+ //
243
+ // Deferral, then restoration: pi starts every mounted tool active, so narrow by SUBTRACTING the
244
+ // deferred names (robust to pi mounting tools of its own, unlike an exact-set replacement), then
245
+ // add back what THIS session has already discovered.
246
+ if (deferred.length > 0) {
247
+ const active = session.getActiveToolNames();
248
+ const mounted = new Set(session.getAllTools().map((tool) => tool.name));
249
+ const recorded = recordedActivations(session);
250
+ // A recorded name that is no longer mounted is dropped rather than replayed: pi's setter
251
+ // THROWS on an unknown name, so replaying one would brick every future turn of this session.
252
+ const restored = recorded.filter((name) => mounted.has(name));
253
+ const dropped = recorded.filter((name) => !mounted.has(name));
254
+ if (dropped.length > 0) {
255
+ const key = `${sessionId}\u0000${[...new Set(dropped)].sort().join(",")}`;
256
+ const emit = warnedDroppedActivations.has(key) ? log.debug : log.warn;
257
+ warnedDroppedActivations.add(key);
258
+ emit(`[fastagent] session ${sessionId}: dropping recorded activation(s) no longer mounted: ${[...new Set(dropped)].join(", ")}`);
259
+ }
260
+ const next = [...new Set([...active.filter((name) => !deferred.includes(name)), ...restored])];
261
+ if (next.length !== active.length || next.some((name) => !active.includes(name))) {
262
+ session.setActiveToolsByName(next);
263
+ }
264
+ }
265
+ return session;
266
+ };
267
+ }
268
+ /** What a reload has to notice: the declared set, not the files behind it. */
269
+ function skillSet(skills) {
270
+ return skills.map((s) => `${s.name}\u0000${s.filePath}\u0000${s.description}`).join("\u0001");
271
+ }
272
+ /** The same signature, read back off the loader. */
273
+ function loadedSkillSet(skills) {
274
+ return skills.map((s) => `${s.name}\u0000${s.filePath ?? ""}\u0000${s.description}`).join("\u0001");
275
+ }
276
+ /** fastagent's Skill (content inline) as pi's (read from filePath at invocation time). */
277
+ function toPiSkills(skills) {
278
+ return skills.map((skill) => {
279
+ const baseDir = dirname(skill.filePath);
280
+ return {
281
+ name: skill.name,
282
+ description: skill.description,
283
+ filePath: skill.filePath,
284
+ baseDir,
285
+ sourceInfo: {
286
+ path: skill.filePath,
287
+ source: "fastagent",
288
+ scope: "project",
289
+ origin: "top-level",
290
+ baseDir,
291
+ },
292
+ disableModelInvocation: skill.disableModelInvocation ?? false,
293
+ };
294
+ });
295
+ }