@fastagent-sh/fastagent 0.13.0 → 0.15.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 (238) hide show
  1. package/README.md +5 -5
  2. package/dist/agent.d.ts +20 -0
  3. package/dist/agent.js +9 -0
  4. package/dist/channels/context-buffer.d.ts +30 -0
  5. package/dist/channels/context-buffer.js +98 -0
  6. package/dist/channels/control.d.ts +28 -0
  7. package/dist/channels/control.js +214 -0
  8. package/dist/channels/feishu/card.js +1 -1
  9. package/dist/channels/feishu/context-buffer.d.ts +43 -0
  10. package/dist/channels/feishu/context-buffer.js +72 -0
  11. package/dist/channels/feishu/crypto.d.ts +4 -2
  12. package/dist/channels/feishu/crypto.js +4 -2
  13. package/dist/channels/feishu/feishu-api.d.ts +15 -7
  14. package/dist/channels/feishu/feishu-api.js +22 -4
  15. package/dist/channels/feishu/feishu.d.ts +38 -16
  16. package/dist/channels/feishu/feishu.js +286 -151
  17. package/dist/channels/feishu/invoke-turn.d.ts +24 -31
  18. package/dist/channels/feishu/invoke-turn.js +61 -62
  19. package/dist/channels/feishu/model.d.ts +98 -0
  20. package/dist/channels/feishu/model.js +9 -0
  21. package/dist/channels/feishu/normalize.d.ts +23 -0
  22. package/dist/channels/feishu/normalize.js +132 -0
  23. package/dist/channels/feishu/owned-threads.d.ts +7 -0
  24. package/dist/channels/feishu/owned-threads.js +47 -0
  25. package/dist/channels/feishu/parse.d.ts +21 -103
  26. package/dist/channels/feishu/parse.js +35 -145
  27. package/dist/channels/feishu/preview.d.ts +4 -7
  28. package/dist/channels/feishu/preview.js +26 -142
  29. package/dist/channels/feishu/register-app.d.ts +2 -1
  30. package/dist/channels/feishu/register-webhook.d.ts +2 -1
  31. package/dist/channels/feishu/scaffold/channel.ts +10 -3
  32. package/dist/channels/feishu/setup-mode.d.ts +8 -0
  33. package/dist/channels/feishu/setup-mode.js +2 -0
  34. package/dist/channels/feishu/ws-ingress.d.ts +28 -0
  35. package/dist/channels/feishu/ws-ingress.js +136 -0
  36. package/dist/channels/github/github.js +8 -6
  37. package/dist/channels/http.d.ts +14 -0
  38. package/dist/channels/http.js +35 -2
  39. package/dist/channels/invoke-turn-kit.d.ts +65 -0
  40. package/dist/channels/invoke-turn-kit.js +87 -0
  41. package/dist/channels/lark/lark.d.ts +4 -2
  42. package/dist/channels/lark/lark.js +4 -1
  43. package/dist/channels/lark/onboard.d.ts +8 -4
  44. package/dist/channels/lark/onboard.js +8 -0
  45. package/dist/channels/lark/scaffold/channel.ts +10 -3
  46. package/dist/channels/preview-kit.d.ts +109 -0
  47. package/dist/channels/preview-kit.js +183 -0
  48. package/dist/channels/seen.d.ts +5 -0
  49. package/dist/channels/seen.js +35 -0
  50. package/dist/channels/slack/bot-auth.d.ts +15 -0
  51. package/dist/channels/slack/bot-auth.js +146 -0
  52. package/dist/channels/slack/config-api.d.ts +60 -0
  53. package/dist/channels/slack/config-api.js +149 -0
  54. package/dist/channels/slack/context-buffer.d.ts +24 -0
  55. package/dist/channels/slack/context-buffer.js +37 -0
  56. package/dist/channels/slack/invoke-turn.d.ts +19 -0
  57. package/dist/channels/slack/invoke-turn.js +63 -0
  58. package/dist/channels/slack/manifest.d.ts +49 -0
  59. package/dist/channels/slack/manifest.js +69 -0
  60. package/dist/channels/slack/model.d.ts +67 -0
  61. package/dist/channels/slack/model.js +2 -0
  62. package/dist/channels/slack/onboard.d.ts +41 -0
  63. package/dist/channels/slack/onboard.js +120 -0
  64. package/dist/channels/slack/onboarding-state.d.ts +31 -0
  65. package/dist/channels/slack/onboarding-state.js +69 -0
  66. package/dist/channels/slack/owned-threads.d.ts +6 -0
  67. package/dist/channels/slack/owned-threads.js +43 -0
  68. package/dist/channels/slack/parse.d.ts +23 -0
  69. package/dist/channels/slack/parse.js +81 -0
  70. package/dist/channels/slack/preview.d.ts +24 -0
  71. package/dist/channels/slack/preview.js +359 -0
  72. package/dist/channels/slack/reaction.d.ts +24 -0
  73. package/dist/channels/slack/reaction.js +62 -0
  74. package/dist/channels/slack/register-webhook.d.ts +10 -0
  75. package/dist/channels/slack/register-webhook.js +49 -0
  76. package/dist/channels/slack/scaffold/channel.ts +33 -0
  77. package/dist/channels/slack/scaffold/slack-send.ts +171 -0
  78. package/dist/channels/slack/setup-server.d.ts +17 -0
  79. package/dist/channels/slack/setup-server.js +103 -0
  80. package/dist/channels/slack/slack-api.d.ts +77 -0
  81. package/dist/channels/slack/slack-api.js +415 -0
  82. package/dist/channels/slack/slack.d.ts +58 -0
  83. package/dist/channels/slack/slack.js +451 -0
  84. package/dist/channels/slack/welcomed.d.ts +5 -0
  85. package/dist/channels/slack/welcomed.js +32 -0
  86. package/dist/channels/state.js +3 -3
  87. package/dist/channels/stop-command.d.ts +6 -0
  88. package/dist/channels/stop-command.js +36 -0
  89. package/dist/channels/tasks.d.ts +13 -0
  90. package/dist/channels/tasks.js +10 -0
  91. package/dist/channels/telegram/context-buffer.d.ts +8 -17
  92. package/dist/channels/telegram/context-buffer.js +6 -85
  93. package/dist/channels/telegram/invoke-turn.d.ts +5 -22
  94. package/dist/channels/telegram/invoke-turn.js +11 -58
  95. package/dist/channels/telegram/preview.d.ts +4 -7
  96. package/dist/channels/telegram/preview.js +24 -142
  97. package/dist/channels/telegram/telegram.js +23 -9
  98. package/dist/channels/{feishu/text.d.ts → text.d.ts} +1 -1
  99. package/dist/channels/{feishu/text.js → text.js} +1 -1
  100. package/dist/channels/turn-queue.js +1 -1
  101. package/dist/channels/turn-store.d.ts +1 -1
  102. package/dist/channels/turn-store.js +2 -3
  103. package/dist/cli/add-feishu.d.ts +27 -0
  104. package/dist/{cli-add-feishu.js → cli/add-feishu.js} +167 -44
  105. package/dist/cli/add-slack.d.ts +10 -0
  106. package/dist/cli/add-slack.js +204 -0
  107. package/dist/{cli-auth.d.ts → cli/auth-view.d.ts} +1 -1
  108. package/dist/{cli-auth.js → cli/auth-view.js} +1 -1
  109. package/dist/cli/commands/add.d.ts +13 -0
  110. package/dist/cli/commands/add.js +274 -0
  111. package/dist/cli/commands/attach.d.ts +82 -0
  112. package/dist/cli/commands/attach.js +559 -0
  113. package/dist/cli/commands/chat.d.ts +4 -0
  114. package/dist/cli/commands/chat.js +21 -0
  115. package/dist/cli/commands/deploy.d.ts +15 -0
  116. package/dist/cli/commands/deploy.js +394 -0
  117. package/dist/cli/commands/dev.d.ts +11 -0
  118. package/dist/cli/commands/dev.js +82 -0
  119. package/dist/cli/commands/fire.d.ts +7 -0
  120. package/dist/cli/commands/fire.js +45 -0
  121. package/dist/cli/commands/info.d.ts +7 -0
  122. package/dist/cli/commands/info.js +108 -0
  123. package/dist/cli/commands/init.d.ts +8 -0
  124. package/dist/cli/commands/init.js +81 -0
  125. package/dist/cli/commands/invoke.d.ts +7 -0
  126. package/dist/cli/commands/invoke.js +28 -0
  127. package/dist/cli/commands/login.d.ts +6 -0
  128. package/dist/cli/commands/login.js +52 -0
  129. package/dist/cli/commands/models.d.ts +1 -0
  130. package/dist/cli/commands/models.js +15 -0
  131. package/dist/cli/commands/schedule.d.ts +12 -0
  132. package/dist/cli/commands/schedule.js +89 -0
  133. package/dist/cli/commands/start.d.ts +10 -0
  134. package/dist/cli/commands/start.js +97 -0
  135. package/dist/cli/commands/tool.d.ts +1 -0
  136. package/dist/cli/commands/tool.js +38 -0
  137. package/dist/cli/fail.d.ts +17 -0
  138. package/dist/cli/fail.js +32 -0
  139. package/dist/{invoke-stream.d.ts → cli/invoke-stream.d.ts} +1 -1
  140. package/dist/{invoke-stream.js → cli/invoke-stream.js} +4 -0
  141. package/dist/cli/kernel.d.ts +90 -0
  142. package/dist/cli/kernel.js +190 -0
  143. package/dist/cli/models-view.d.ts +21 -0
  144. package/dist/cli/models-view.js +66 -0
  145. package/dist/cli/program.d.ts +11 -0
  146. package/dist/cli/program.js +479 -0
  147. package/dist/cli/serve.d.ts +48 -0
  148. package/dist/cli/serve.js +248 -0
  149. package/dist/cli/shared.d.ts +44 -0
  150. package/dist/cli/shared.js +246 -0
  151. package/dist/cli.js +8 -1329
  152. package/dist/collect.d.ts +14 -3
  153. package/dist/collect.js +24 -0
  154. package/dist/core.d.ts +3 -1
  155. package/dist/core.js +2 -0
  156. package/dist/deploy/docker/plan.d.ts +45 -0
  157. package/dist/deploy/docker/plan.js +141 -0
  158. package/dist/deploy/docker/run.d.ts +40 -0
  159. package/dist/deploy/docker/run.js +126 -0
  160. package/dist/deploy/fly/plan.d.ts +3 -1
  161. package/dist/deploy/fly/plan.js +12 -7
  162. package/dist/deploy/fly/run.d.ts +5 -4
  163. package/dist/deploy/fly/run.js +16 -5
  164. package/dist/deploy/preflight.d.ts +10 -4
  165. package/dist/deploy/preflight.js +57 -12
  166. package/dist/deploy/railway/plan.d.ts +3 -1
  167. package/dist/deploy/railway/plan.js +12 -4
  168. package/dist/deploy/railway/run.d.ts +5 -4
  169. package/dist/deploy/railway/run.js +15 -5
  170. package/dist/deploy/runner.d.ts +6 -2
  171. package/dist/deploy/runner.js +1 -0
  172. package/dist/deploy/secrets.d.ts +2 -1
  173. package/dist/deploy/secrets.js +23 -3
  174. package/dist/dev-supervisor.d.ts +0 -2
  175. package/dist/dev-supervisor.js +7 -3
  176. package/dist/engines/pi/auth.js +160 -46
  177. package/dist/engines/pi/channel.d.ts +22 -16
  178. package/dist/engines/pi/channel.js +90 -60
  179. package/dist/engines/pi/chat.d.ts +4 -16
  180. package/dist/engines/pi/chat.js +8 -188
  181. package/dist/engines/pi/config.d.ts +23 -9
  182. package/dist/engines/pi/config.js +35 -5
  183. package/dist/engines/pi/create.d.ts +36 -7
  184. package/dist/engines/pi/create.js +63 -22
  185. package/dist/engines/pi/harness.d.ts +65 -1
  186. package/dist/engines/pi/harness.js +166 -2
  187. package/dist/engines/pi/invoke.d.ts +56 -3
  188. package/dist/engines/pi/invoke.js +340 -20
  189. package/dist/engines/pi/login.d.ts +11 -0
  190. package/dist/engines/pi/login.js +17 -5
  191. package/dist/engines/pi/models.d.ts +56 -10
  192. package/dist/engines/pi/models.js +61 -23
  193. package/dist/engines/pi/search-tools.d.ts +10 -0
  194. package/dist/engines/pi/search-tools.js +138 -0
  195. package/dist/engines/pi/session-builder.d.ts +16 -0
  196. package/dist/engines/pi/session-builder.js +308 -0
  197. package/dist/engines/pi/session-control.d.ts +50 -0
  198. package/dist/engines/pi/session-control.js +604 -0
  199. package/dist/engines/pi/sessions.d.ts +17 -2
  200. package/dist/engines/pi/sessions.js +9 -0
  201. package/dist/engines/pi/tool-context.d.ts +46 -11
  202. package/dist/engines/pi/tool-context.js +11 -9
  203. package/dist/engines/pi/tool.d.ts +35 -6
  204. package/dist/engines/pi/tool.js +47 -1
  205. package/dist/engines/pi/wake-tool.d.ts +0 -3
  206. package/dist/engines/pi/wake-tool.js +9 -7
  207. package/dist/engines/pi/workspace.d.ts +56 -1
  208. package/dist/engines/pi/workspace.js +75 -16
  209. package/dist/feishu.d.ts +1 -1
  210. package/dist/feishu.js +1 -1
  211. package/dist/host/node.d.ts +23 -6
  212. package/dist/host/node.js +5 -4
  213. package/dist/index.d.ts +1 -0
  214. package/dist/index.js +1 -0
  215. package/dist/lark.d.ts +1 -1
  216. package/dist/lark.js +1 -1
  217. package/dist/observe.js +3 -0
  218. package/dist/pi.d.ts +6 -4
  219. package/dist/pi.js +2 -1
  220. package/dist/scaffold/add-channel.d.ts +19 -6
  221. package/dist/scaffold/add-channel.js +127 -16
  222. package/dist/scaffold/templates/fastagent.config.mjs +5 -3
  223. package/dist/schedule/wakeups.d.ts +0 -3
  224. package/dist/schedule/wakeups.js +1 -1
  225. package/dist/session-remote.d.ts +53 -0
  226. package/dist/session-remote.js +336 -0
  227. package/dist/session.d.ts +265 -0
  228. package/dist/session.js +37 -0
  229. package/dist/slack.d.ts +2 -0
  230. package/dist/slack.js +2 -0
  231. package/dist/tunnel.d.ts +7 -4
  232. package/dist/tunnel.js +21 -10
  233. package/package.json +22 -7
  234. package/dist/channels/feishu/seen.d.ts +0 -5
  235. package/dist/channels/feishu/seen.js +0 -47
  236. package/dist/cli-add-feishu.d.ts +0 -8
  237. package/dist/cli-models.d.ts +0 -11
  238. package/dist/cli-models.js +0 -20
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * The host-NEUTRAL deploy pre-flight: everything `fastagent deploy <host>` computes and checks BEFORE
3
- * the host branch (fly.ts / railway.ts). Model-travel gate, channel discovery, model-auth probe, the
3
+ * the target branch (Docker / Fly / Railway). Model-travel gate, channel discovery, model-auth probe, the
4
4
  * container facts + their warnings, and the hand-written-Dockerfile apt warning are identical on every
5
5
  * host — so they live here, out of the CLI dispatcher, testable in isolation (call it against a temp dir
6
6
  * and assert the gate / messages / facts). The CLI stays thin: run this, print the messages, branch by host.
@@ -11,8 +11,8 @@
11
11
  */
12
12
  import { readFile } from "node:fs/promises";
13
13
  import { join, relative, sep } from "node:path";
14
- import { defaultAuthPath, resolveStateRoot } from "../engines/pi/config.js";
15
- import { discoverChannelFiles } from "../engines/pi/channel.js";
14
+ import { resolveAuthPath } from "../engines/pi/config.js";
15
+ import { inspectChannels } from "../engines/pi/channel.js";
16
16
  import { discoverScheduleFiles } from "../schedule/discover.js";
17
17
  import { createPiModels, probeAuthSource } from "../engines/pi/models.js";
18
18
  import { CHANNEL_KINDS } from "../scaffold/add-channel.js";
@@ -25,7 +25,7 @@ import { isGeneratedDockerfile } from "./container.js";
25
25
  * provider) — the CLI wraps the call in its `failStartup` so the fault surfaces and exits, never silently.
26
26
  */
27
27
  export async function preflightDeploy(input) {
28
- const { target, agentDir, config, modelSpec, run, force, authPathOverride } = input;
28
+ const { target, agentDir, config, modelSpec, run, force, authPathFlag } = input;
29
29
  const messages = [];
30
30
  // The deployed box resolves the model from fastagent.config.ts ONLY (in the image); a model set via
31
31
  // env/flag/.env doesn't travel. `--run` would ship a known crash-loop — hard gate; generate-only warns.
@@ -35,20 +35,52 @@ export async function preflightDeploy(input) {
35
35
  return { ok: false, gate: modelIssue };
36
36
  messages.push({ level: "warn", text: modelIssue });
37
37
  }
38
+ // The control plane on a deployed box: `start` honors `sessionControl: true`, so `/control/*`
39
+ // (steer/abort/set_model) rides the PUBLIC host URL — protected only by a per-boot bearer token
40
+ // minted INSIDE the container (`<stateRoot>/control.json`), which external consumers cannot read.
41
+ // Publicly reachable yet unusable is the worst of both; the tunnel path warns loudly and deploy
42
+ // must not be the silent second way to break the loopback trust story.
43
+ if (config.sessionControl === true) {
44
+ messages.push({
45
+ level: "warn",
46
+ text: `sessionControl: true — the deployed box serves /control/* (steer/abort/set_model) at its public URL, ` +
47
+ `protected only by a per-boot token written inside the container. Read the TOKEN from ` +
48
+ `<stateRoot>/control.json on the box (its url field is container-loopback — pair the token with the ` +
49
+ `public host URL: attach --url <public-url> --token …), or front the endpoint with real auth (design §14)`,
50
+ });
51
+ }
38
52
  // Known channel kinds only — a custom channel's secrets/webhook are unknown to us; note and let the
39
53
  // author wire them.
40
- const discovered = await discoverChannelFiles(agentDir);
54
+ const inspected = await inspectChannels(agentDir);
55
+ if (inspected.failures.length > 0) {
56
+ throw new Error(`cannot inspect channel modules: ${inspected.failures.map((failure) => `${failure.label}: ${failure.message}`).join("; ")}`);
57
+ }
58
+ const discovered = inspected.channels;
41
59
  const channels = discovered.filter((c) => CHANNEL_KINDS.includes(c));
60
+ const routeChannels = inspected.routeChannels;
61
+ const longConnectionChannels = inspected.longConnectionChannels;
42
62
  for (const c of discovered) {
43
- if (!channels.includes(c)) {
44
- messages.push({ level: "note", text: `channel "${c}" is custom — set its secrets and webhook yourself` });
45
- }
63
+ if (channels.includes(c))
64
+ continue;
65
+ messages.push({
66
+ level: "note",
67
+ text: longConnectionChannels.includes(c)
68
+ ? `long-connection channel "${c}" is custom — configure its secrets yourself; generated deploy plans keep the process running and skip webhook registration`
69
+ : `route channel "${c}" is custom — configure its secrets and webhook yourself`,
70
+ });
46
71
  }
47
72
  // Time triggers (static schedules or self-scheduling) need a machine kept running — unlike a webhook,
48
73
  // nothing external wakes a scale-to-zero box for a cron instant or a wake-up. The note is CONDITIONAL
49
74
  // ("the generated plan…"): in KEEP mode an existing fly.toml is not rewritten — the CLI warns separately
50
75
  // when a kept fly.toml still scales to zero.
51
76
  const hasTimeTriggers = (await discoverScheduleFiles(agentDir)).length > 0 || !!config.selfSchedule;
77
+ if (longConnectionChannels.length > 0) {
78
+ messages.push({
79
+ level: "note",
80
+ text: `long-connection channel present (${longConnectionChannels.join(", ")}) — a GENERATED plan keeps one machine running ` +
81
+ `(an outbound connection cannot wake a scaled-to-zero service).`,
82
+ });
83
+ }
52
84
  if (hasTimeTriggers) {
53
85
  messages.push({
54
86
  level: "note",
@@ -58,7 +90,7 @@ export async function preflightDeploy(input) {
58
90
  }
59
91
  // Probe auth from the SAME project-level file the opener/login use — not the global default, which would
60
92
  // miss a `fastagent login` credential and falsely report "none configured".
61
- const authPath = authPathOverride ?? defaultAuthPath(resolveStateRoot(target));
93
+ const authPath = resolveAuthPath(target, authPathFlag);
62
94
  const modelAuth = modelSpec ? await probeAuthSource(createPiModels({ authPath }), modelSpec) : undefined;
63
95
  // Container facts (shared by every host) + the warnings that follow. Repo-as-workspace layout
64
96
  // (agentDir ≠ target): the facts describe the KIT — its package.json/runtime/lockfile drive the
@@ -68,8 +100,9 @@ export async function preflightDeploy(input) {
68
100
  const kitDir = agentDir === target ? undefined : relative(target, agentDir).split(sep).join("/");
69
101
  const factsDir = kitDir ? agentDir : target;
70
102
  if (kitDir && run) {
71
- // Generate + runbook fully support the layout; the fly/railway --run drivers do not yet (their
72
- // command sequences assume root artifacts). Fail at the gate, not mid-drive.
103
+ // The repo-as-workspace deployment shape remains experimental for every target. Generation +
104
+ // runbook are supported; automated runners stay gated until an explicit end-to-end smoke validates
105
+ // context packing, ignore rules, installed deps, state, and write-back for this layout.
73
106
  return {
74
107
  ok: false,
75
108
  gate: `--run is not yet supported for the agentDir layout — run the same deploy without --run and follow the printed runbook`,
@@ -172,7 +205,19 @@ export async function preflightDeploy(input) {
172
205
  });
173
206
  }
174
207
  }
175
- return { ok: true, messages, channels, hasTimeTriggers, modelAuth, authPath, container, port, extraSecrets };
208
+ return {
209
+ ok: true,
210
+ messages,
211
+ channels,
212
+ routeChannels,
213
+ longConnectionChannels,
214
+ hasTimeTriggers,
215
+ modelAuth,
216
+ authPath,
217
+ container,
218
+ port,
219
+ extraSecrets,
220
+ };
176
221
  }
177
222
  /**
178
223
  * Why the resolved model won't reach the deployed box, or undefined if it will — host-neutral. `fastagent.config.ts`
@@ -29,8 +29,10 @@ export interface RailwayPlanInput extends ContainerInput {
29
29
  serviceName: string;
30
30
  /** What satisfies model auth locally: an env-var name, an OAuth/stored label, or undefined. */
31
31
  modelAuth: string | undefined;
32
- /** Channels discovered in the workspace — each contributes its secret metadata + webhook step. */
32
+ /** Known first-party channels — each contributes its secret metadata + webhook step. */
33
33
  channels: ChannelKind[];
34
+ /** All long-connection channel basenames, including custom channels — no App Sleeping. */
35
+ longConnectionChannels?: string[];
34
36
  /** Extra secret env-var names (fastagent.config deploy.secrets) — added to the runbook's secret list. */
35
37
  extraSecrets?: string[];
36
38
  /** Time triggers present (schedules/ or selfSchedule) — the runbook forbids App Sleeping: cron/wake has
@@ -23,7 +23,7 @@ export function planRailwayDeploy(input) {
23
23
  { path: configPath, content: railwayJson(input.kitDir) },
24
24
  ...containerArtifacts(input),
25
25
  ];
26
- const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets);
26
+ const secrets = deploymentSecrets(modelAuth, channels, input.extraSecrets, input.longConnectionChannels);
27
27
  const requiredSecrets = secrets.filter((secret) => secret.required);
28
28
  const optionalSecrets = secrets.filter((secret) => !secret.required);
29
29
  // Order matters, not cosmetics: `railway init` creates a PROJECT with no service, but the volume and
@@ -76,7 +76,10 @@ export function planRailwayDeploy(input) {
76
76
  // The public URL is minted, not deterministic (unlike Fly's <app>.fly.dev) — ONE mint step, then each
77
77
  // channel's webhook uses that domain (mint once even when both channels are present).
78
78
  const hasFeishuCloudChannel = ["feishu", "lark"].some((kind) => channels.includes(kind));
79
- if (channels.includes("telegram") || channels.includes("github") || hasFeishuCloudChannel) {
79
+ if (channels.includes("telegram") ||
80
+ channels.includes("github") ||
81
+ channels.includes("slack") ||
82
+ hasFeishuCloudChannel) {
80
83
  runbook.push(``, `# Public URL — Railway mints a *.up.railway.app domain (NOT deterministic). Generate it, then read`, `# the printed https URL and use it as <your-domain> in the webhook step(s) below:`, `railway domain`);
81
84
  }
82
85
  if (channels.includes("telegram")) {
@@ -85,8 +88,11 @@ export function planRailwayDeploy(input) {
85
88
  if (channels.includes("github")) {
86
89
  runbook.push(`# Set the GitHub webhook (repo Settings → Webhooks). Default route POST /webhook; if you remapped it`, `# in channels/github.ts, use your path:`, `# Payload URL = https://<your-domain>/webhook, content type application/json, secret = GITHUB_WEBHOOK_SECRET`);
87
90
  }
91
+ if (channels.includes("slack")) {
92
+ runbook.push(`# Set Slack Event Subscriptions → Request URL (default route POST /slack; the running service`, `# answers Slack's challenge), and match scopes/subscriptions to channels/slack.ts groupBehavior:`, `# Request URL = https://<your-domain>/slack`);
93
+ }
88
94
  for (const kind of ["feishu", "lark"]) {
89
- if (!channels.includes(kind))
95
+ if (!channels.includes(kind) || input.longConnectionChannels?.includes(kind))
90
96
  continue;
91
97
  const label = kind === "feishu" ? "Feishu" : "Lark";
92
98
  runbook.push(`# Set the ${label} event Request URL (developer console → Events & Callbacks). Default route`, `# POST /${kind}; the service must be RUNNING when you save (the console verifies with a challenge):`, `# Request URL = https://<your-domain>/${kind}`);
@@ -98,6 +104,8 @@ export function planRailwayDeploy(input) {
98
104
  ? `# Scale-to-zero: do NOT enable App Sleeping — github turns have no replay, a sleep mid-review is lost.`
99
105
  : input.hasTimeTriggers
100
106
  ? `# Scale-to-zero: do NOT enable App Sleeping — schedules/wake-ups have no external wake-up; a sleeping service sleeps through them.`
101
- : `# Scale-to-zero (optional, dashboard-only — no CLI/API): Settings → Deploy → Serverless → App Sleeping.`, `# Keep this a SINGLE service: the ${MOUNT} volume is tied to one service; extra replicas split state.`);
107
+ : (input.longConnectionChannels?.length ?? 0) > 0
108
+ ? `# Scale-to-zero: do NOT enable App Sleeping — a long-connection channel must remain connected.`
109
+ : `# Scale-to-zero (optional, dashboard-only — no CLI/API): Settings → Deploy → Serverless → App Sleeping.`, `# Keep this a SINGLE service: the ${MOUNT} volume is tied to one service; extra replicas split state.`);
102
110
  return { artifacts, runbook };
103
111
  }
@@ -38,6 +38,7 @@ export interface RailwayRunPlan {
38
38
  /** Required secret names with NO local value — the run gates on these before any side effect. */
39
39
  missingSecrets: string[];
40
40
  channels: ChannelKind[];
41
+ longConnectionChannels?: string[];
41
42
  /** Opt-in (CLI `--into-linked`) to provision INTO the project this directory is already linked to. Off
42
43
  * by default so `--run` only creates on an unlinked dir and never deploys into a pre-existing (possibly
43
44
  * unrelated/production) project; the flag is the operator's explicit "yes, this project". */
@@ -76,9 +77,9 @@ export declare function parseDomainUrl(stdout: string): string | undefined;
76
77
  */
77
78
  export declare function parseHasVolume(stdout: string, mountPath: string): boolean;
78
79
  /**
79
- * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` /
80
- * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
81
- * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
80
+ * Run the deploy through `railway`. `log` reports progress; the injected Telegram/Feishu/Slack
81
+ * registrars perform post-deploy webhook steps from the builder machine (Slack's control credential
82
+ * never travels to the host). Absent, the manual console
82
83
  * instruction is printed. Every gate is fail-visible.
83
84
  */
84
- export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>): Promise<RailwayRunOutcome>;
85
+ export declare function deployRailwayRun(plan: RailwayRunPlan, railway: CliRunner, log: (msg: string) => void, registerTelegram: (baseUrl: string) => Promise<RegistrationOutcome>, registerFeishu?: (baseUrl: string, kind: "feishu" | "lark") => Promise<RegistrationOutcome>, registerSlack?: (baseUrl: string) => Promise<RegistrationOutcome>): Promise<RailwayRunOutcome>;
@@ -62,12 +62,12 @@ export function parseHasVolume(stdout, mountPath) {
62
62
  return jsonStrings(stdout).includes(mountPath);
63
63
  }
64
64
  /**
65
- * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` /
66
- * `registerFeishu(baseUrl, kind)` perform the post-deploy webhook steps (the CLI passes its canonical
67
- * Feishu registrar, which also serves the Lark compatibility profile). Absent, the manual console
65
+ * Run the deploy through `railway`. `log` reports progress; the injected Telegram/Feishu/Slack
66
+ * registrars perform post-deploy webhook steps from the builder machine (Slack's control credential
67
+ * never travels to the host). Absent, the manual console
68
68
  * instruction is printed. Every gate is fail-visible.
69
69
  */
70
- export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu) {
70
+ export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu, registerSlack) {
71
71
  const gate = (g) => ({ ok: false, gate: g });
72
72
  // Every --service below targets plan.name — the name this tool gives BOTH the project and the service
73
73
  // (`init --name` + `add --service`). On a fresh create they match; on `--into-linked` into a hand-made
@@ -179,8 +179,18 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram, reg
179
179
  log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
180
180
  reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
181
181
  }
182
+ if (plan.channels.includes("slack")) {
183
+ if (registerSlack) {
184
+ log("registering slack event URL…");
185
+ reg.track("slack", await registerSlack(url));
186
+ }
187
+ else {
188
+ log(`slack: set Event Subscriptions → Request URL → ${url}/slack`);
189
+ reg.track("slack", "manual");
190
+ }
191
+ }
182
192
  for (const kind of ["feishu", "lark"]) {
183
- if (!plan.channels.includes(kind))
193
+ if (!plan.channels.includes(kind) || plan.longConnectionChannels?.includes(kind))
184
194
  continue;
185
195
  if (registerFeishu) {
186
196
  log(`registering ${kind} event URL…`);
@@ -1,14 +1,17 @@
1
- export interface RunResult {
1
+ interface RunResult {
2
2
  code: number;
3
3
  /** Captured stdout (for `--json` queries); empty when the command streamed to the terminal. The CLI's
4
4
  * stderr is always inherited straight to the terminal, so it is not a field here. */
5
5
  stdout: string;
6
6
  }
7
7
  /** Run `bin args`: `capture` collects stdout (for `--json` queries), else the command streams to the
8
- * terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv). */
8
+ * terminal (create/deploy) and stdout is empty; `input` is fed to stdin (secrets over stdin, never argv).
9
+ * `env` adds child-only environment values — Docker Compose interpolates secrets from it without putting
10
+ * values in argv or mutating the long-lived CLI process. */
9
11
  export type CliRunner = (args: string[], opts?: {
10
12
  capture?: boolean;
11
13
  input?: string;
14
+ env?: NodeJS.ProcessEnv;
12
15
  }) => Promise<RunResult>;
13
16
  /**
14
17
  * Production {@link CliRunner}: spawn `bin` in `cwd` (the workspace, so a build/upload context is the
@@ -16,3 +19,4 @@ export type CliRunner = (args: string[], opts?: {
16
19
  * ENOENT (the CLI not on PATH) resolves to code 127 so the caller can gate with an install hint.
17
20
  */
18
21
  export declare function spawnRunner(bin: string, cwd: string): CliRunner;
22
+ export {};
@@ -13,6 +13,7 @@ export function spawnRunner(bin, cwd) {
13
13
  return (args, opts) => new Promise((res) => {
14
14
  const child = spawn(bin, args, {
15
15
  cwd,
16
+ env: opts?.env ? { ...process.env, ...opts.env } : process.env,
16
17
  stdio: [opts?.input ? "pipe" : "inherit", opts?.capture ? "pipe" : "inherit", "inherit"],
17
18
  });
18
19
  let out = "";
@@ -17,7 +17,7 @@ export declare function isEnvKey(source: string | undefined): source is string;
17
17
  * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
18
18
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
19
19
  */
20
- export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
20
+ export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[], longConnectionChannels?: string[]): {
21
21
  name: string;
22
22
  hint: string;
23
23
  required: boolean;
@@ -40,6 +40,7 @@ export declare function assembleSecrets(input: {
40
40
  modelAuth: string | undefined;
41
41
  authFile: Buffer | undefined;
42
42
  channels: ChannelKind[];
43
+ longConnectionChannels?: string[];
43
44
  /** Extra secret env-var names from `fastagent.config` deploy.secrets — carried like channel secrets. */
44
45
  extraSecrets?: string[];
45
46
  env: NodeJS.ProcessEnv;
@@ -19,13 +19,15 @@ export function isEnvKey(source) {
19
19
  * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
20
20
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
21
21
  */
22
- export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
22
+ export function deploymentSecrets(modelAuth, channels, extraSecrets = [], longConnectionChannels = []) {
23
23
  const secrets = [];
24
24
  if (isEnvKey(modelAuth))
25
25
  secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
26
26
  for (const kind of channels) {
27
- for (const e of channelSetup(kind).env)
27
+ const setupMode = longConnectionChannels.includes(kind) ? "websocket" : "webhook";
28
+ for (const e of channelSetup(kind, setupMode).env) {
28
29
  secrets.push({ name: e.name, hint: e.hint, required: e.required });
30
+ }
29
31
  }
30
32
  // Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
31
33
  for (const name of extraSecrets) {
@@ -67,7 +69,8 @@ export function assembleSecrets(input) {
67
69
  needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
68
70
  }
69
71
  for (const kind of input.channels) {
70
- for (const e of channelSetup(kind).env) {
72
+ const setupMode = input.longConnectionChannels?.includes(kind) ? "websocket" : "webhook";
73
+ for (const e of channelSetup(kind, setupMode).env) {
71
74
  const v = input.env[e.name];
72
75
  if (v)
73
76
  secrets[e.name] = v; // optional channel values travel when configured
@@ -76,6 +79,23 @@ export function assembleSecrets(input) {
76
79
  }
77
80
  }
78
81
  }
82
+ // Slack bot-token rotation is an all-or-nothing credential bundle. Its fields remain optional so a
83
+ // manually configured long-lived token works, but a partial bundle must gate before the container
84
+ // reaches slackChannel construction.
85
+ if (input.channels.includes("slack")) {
86
+ const rotation = [
87
+ "SLACK_BOT_REFRESH_TOKEN",
88
+ "SLACK_BOT_TOKEN_EXPIRES_AT",
89
+ "SLACK_CLIENT_ID",
90
+ "SLACK_CLIENT_SECRET",
91
+ ];
92
+ if (rotation.some((name) => !!input.env[name])) {
93
+ for (const name of rotation) {
94
+ if (!input.env[name] && !missingSecrets.includes(name))
95
+ missingSecrets.push(name);
96
+ }
97
+ }
98
+ }
79
99
  for (const name of input.extraSecrets ?? []) {
80
100
  if (name in secrets || missingSecrets.includes(name))
81
101
  continue; // already covered by model/channel — no dup
@@ -1,5 +1,3 @@
1
- /** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
2
- export declare const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
3
1
  /**
4
2
  * chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
5
3
  * the whole subtree, so everything outside the allowlist — .fastagent state, node_modules, .git, and
@@ -13,13 +13,13 @@
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 { loadConfig, resolveAgentDir } from "./engines/pi/config.js";
16
+ import { loadConfig, resolveAgentDir, resolveStateRoot } from "./engines/pi/config.js";
17
17
  import { log } from "./log.js";
18
18
  import { installProxyFetch } from "./proxy.js";
19
19
  import { openExternalUrl } from "./open-url.js";
20
20
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
21
21
  /** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
22
- export const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
22
+ const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
23
23
  /**
24
24
  * chokidar `ignored` matcher for the narrow watch scope (true = ignore). Ignoring a directory prunes
25
25
  * the whole subtree, so everything outside the allowlist — .fastagent state, node_modules, .git, and
@@ -100,7 +100,11 @@ export async function runDevSupervisor(dir, options = {}) {
100
100
  void startCloudflareTunnel(m.port).then((t) => {
101
101
  if (t) {
102
102
  tunnel = t;
103
- void announceWebhooks(agentDir, t.url, { openUrl: openExternalUrl });
103
+ void announceWebhooks(dir, t.url, {
104
+ openUrl: openExternalUrl,
105
+ routeChannels: m.routeChannels,
106
+ stateRoot: resolveStateRoot(dir),
107
+ });
104
108
  }
105
109
  });
106
110
  }
@@ -12,22 +12,24 @@
12
12
  * token and persisting the new one elsewhere leaves global stale for every other consumer.
13
13
  *
14
14
  * Sharing is still SAFE the right way: point everything at ONE file (`FASTAGENT_AUTH_PATH` → the
15
- * global path). One file means one refresh lifecycle under `FileAuthStorageBackend`'s cross-process
16
- * lock (refresh re-reads the latest token under the lock) the documented same-machine pattern.
15
+ * global path). One file means one refresh lifecycle under the store's cross-process write lock
16
+ * (refresh re-reads the latest token under the lock), the documented same-machine pattern.
17
17
  * fastagent's store stays SEPARATE from the pi CLI's `~/.pi/agent/auth.json` for the same single-
18
18
  * lifecycle reason: two uncoordinated files over one grant would each rotate and break the other.
19
19
  *
20
- * Persistence + locking reuse pi's `FileAuthStorageBackend` (a cross-process file lock) on the WRITE
21
- * path only. `read` is pi-ai's per-request hot path, so it stays UNLOCKED; the backend's in-place
22
- * write opens only a sub-millisecond torn-read window, which `read` absorbs by re-reading. The write
23
- * path refuses to overwrite a corrupt file (never clobbering other providers' credentials).
20
+ * Locking is vendored here on `proper-lockfile`, with the same parameters pi's file backend used
21
+ * before pi 0.80.8 stopped exporting it (upstream's stated migration path for SDK consumers is a
22
+ * custom pi-ai `CredentialStore`, which this file is). The lock guards the WRITE path only. `read`
23
+ * is pi-ai's per-request hot path, so it stays UNLOCKED; the in-place locked write opens only a
24
+ * sub-millisecond torn-read window, which `read` absorbs by re-reading. The write path refuses to
25
+ * overwrite a corrupt file (never clobbering other providers' credentials).
24
26
  */
25
- import { existsSync, readFileSync } from "node:fs";
27
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
26
28
  import { homedir } from "node:os";
27
- import { join } from "node:path";
29
+ import { dirname, join } from "node:path";
28
30
  import { log } from "../../log.js";
29
31
  import { setTimeout as sleep } from "node:timers/promises";
30
- import { FileAuthStorageBackend } from "@earendil-works/pi-coding-agent";
32
+ import lockfile from "proper-lockfile";
31
33
  /**
32
34
  * The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`). The project-level default is
33
35
  * `<dir>/.fastagent/auth.json` (computed by the opener and by `fastagent login`); this is only the
@@ -42,57 +44,169 @@ function pick(creds, providerId) {
42
44
  const cred = creds[providerId];
43
45
  return cred && (cred.type === "oauth" || cred.type === "api_key") ? cred : undefined;
44
46
  }
47
+ const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: 0o600 };
45
48
  /**
46
- * Parse the credentials JSON for a WRITE: a corrupt file must THROW — serializing `{}` over it would
47
- * wipe every other provider's credentials. The throw aborts the locked write, leaving the file intact.
49
+ * Serialized cross-process read-modify-write of the credentials file: exponential-backoff retries,
50
+ * 30s staleness, and compromise detection (the parameters pi's `FileAuthStorageBackend` used).
51
+ * Ensures the file exists first (0700 dir, 0600 file, EXCLUSIVE create: a concurrent first write
52
+ * must never be clobbered by the init) because `proper-lockfile` locks an existing path. A
53
+ * compromised lock aborts before the write rather than clobbering a concurrent writer, and a
54
+ * failed unlock after a successful operation rejects instead of leaving a stale lock silently.
48
55
  */
49
- function parseForWrite(raw, where) {
50
- if (!raw)
51
- return {};
56
+ async function withLockedAuthFile(authPath, fn) {
57
+ const dir = dirname(authPath);
58
+ if (!existsSync(dir))
59
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
60
+ if (!existsSync(authPath)) {
61
+ try {
62
+ writeFileSync(authPath, "{}", { ...AUTH_FILE_WRITE_OPTIONS, flag: "wx" });
63
+ chmodSync(authPath, 0o600);
64
+ }
65
+ catch (error) {
66
+ // EEXIST: another process created the file between the existence check and this exclusive
67
+ // create; its content (possibly already-written credentials) must not be clobbered.
68
+ if (error.code !== "EEXIST")
69
+ throw error;
70
+ }
71
+ }
72
+ let compromised;
73
+ const throwIfCompromised = () => {
74
+ if (compromised)
75
+ throw compromised;
76
+ };
77
+ const release = await lockfile.lock(authPath, {
78
+ retries: { retries: 10, factor: 2, minTimeout: 100, maxTimeout: 10_000, randomize: true },
79
+ stale: 30_000,
80
+ onCompromised: (error) => {
81
+ compromised = error;
82
+ },
83
+ });
84
+ let result;
85
+ try {
86
+ throwIfCompromised();
87
+ const current = existsSync(authPath) ? readFileSync(authPath, "utf8") : undefined;
88
+ const out = await fn(current);
89
+ throwIfCompromised();
90
+ if (out.next !== undefined) {
91
+ writeFileSync(authPath, out.next, AUTH_FILE_WRITE_OPTIONS);
92
+ chmodSync(authPath, 0o600);
93
+ }
94
+ throwIfCompromised();
95
+ result = out.result;
96
+ }
97
+ catch (error) {
98
+ // The primary failure stays the signal; unlock noise must not mask it.
99
+ try {
100
+ await release();
101
+ }
102
+ catch {
103
+ // Secondary: a compromised or stale-reclaimed lock often cannot release cleanly.
104
+ }
105
+ throw error;
106
+ }
107
+ // Success path: a failed release is a real cleanup failure (the leftover auth.json.lock stalls
108
+ // the next writer for the staleness window with zero diagnostics), so it surfaces instead of
109
+ // resolving a silently degraded operation. A compromise detected after the last in-band check
110
+ // surfaces here too.
111
+ try {
112
+ await release();
113
+ }
114
+ catch (releaseError) {
115
+ if (compromised === undefined)
116
+ throw releaseError;
117
+ }
118
+ throwIfCompromised();
119
+ return result;
120
+ }
121
+ /**
122
+ * Decode the credentials JSON, shared by the read and write paths. The root must be a plain
123
+ * non-null, non-array object: `[]`, `null`, and scalar roots pass JSON.parse but break the record
124
+ * semantics (an array root even swallows writes, since JSON.stringify drops string keys on arrays).
125
+ * Structurally invalid = corrupt, exactly like unparsable text.
126
+ */
127
+ function decodeCreds(raw) {
128
+ let parsed;
52
129
  try {
53
- return JSON.parse(raw);
130
+ parsed = JSON.parse(raw);
54
131
  }
55
132
  catch {
56
- throw new Error(`refusing to overwrite corrupt auth file ${where} — fix or remove it`);
133
+ return undefined;
57
134
  }
135
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed))
136
+ return undefined;
137
+ return parsed;
138
+ }
139
+ /**
140
+ * Tolerant UNLOCKED read of the whole credentials file, shared by `read` and `list`. The only race
141
+ * is a sub-millisecond in-place write during an OAuth rotation, which can yield an empty/partial
142
+ * file; re-read a few times before concluding it is corrupt. A missing file reads as undefined
143
+ * silently (normal not-configured); a valid file returns immediately, so the common case costs one
144
+ * read.
145
+ */
146
+ async function readCreds(authPath, warn) {
147
+ for (let attempt = 0; attempt < 3; attempt++) {
148
+ let raw;
149
+ try {
150
+ raw = readFileSync(authPath, "utf8");
151
+ }
152
+ catch (error) {
153
+ if (error.code === "ENOENT")
154
+ return undefined; // missing/deleted
155
+ warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
156
+ return undefined;
157
+ }
158
+ if (raw !== "") {
159
+ const creds = decodeCreds(raw);
160
+ if (creds !== undefined)
161
+ return creds;
162
+ // A partial read mid-write parses as garbage; fall through and retry. A structurally invalid
163
+ // root lands here too and is reported as corrupt below.
164
+ }
165
+ if (attempt < 2)
166
+ await sleep(2);
167
+ }
168
+ warn(`[fastagent] corrupt auth file ${authPath}: fix or remove it`);
169
+ return undefined;
170
+ }
171
+ /**
172
+ * Parse the credentials JSON for a WRITE: a corrupt file must THROW, because serializing `{}` over
173
+ * it would wipe every other provider's credentials. The throw aborts the locked write, leaving the
174
+ * file intact.
175
+ */
176
+ function parseForWrite(raw, where) {
177
+ if (!raw)
178
+ return {};
179
+ const creds = decodeCreds(raw);
180
+ if (creds === undefined) {
181
+ throw new Error(`refusing to overwrite corrupt auth file ${where}: fix or remove it`);
182
+ }
183
+ return creds;
58
184
  }
59
185
  /** A read-write `CredentialStore` backed by the given credentials file (default {@link GLOBAL_AUTH_PATH};
60
186
  * the directory opener passes the project-level `<dir>/.fastagent/auth.json`). */
61
187
  export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options = {}) {
62
188
  const warn = options.warn ?? ((message) => log.warn(message));
63
- const backend = new FileAuthStorageBackend(authPath);
64
189
  return {
65
190
  async read(providerId) {
66
- // UNLOCKED hot path: the only race is a sub-millisecond in-place write during an OAuth rotation,
67
- // which can yield an empty/partial file. Re-read a few times before concluding it is corrupt;
68
- // a valid `{}` (provider absent) returns immediately, so a not-configured read costs nothing.
69
- for (let attempt = 0; attempt < 3; attempt++) {
70
- let raw;
71
- try {
72
- raw = readFileSync(authPath, "utf8");
73
- }
74
- catch (error) {
75
- if (error.code === "ENOENT")
76
- return undefined; // missing/deleted
77
- warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
78
- return undefined;
79
- }
80
- if (raw !== "") {
81
- try {
82
- return pick(JSON.parse(raw), providerId);
83
- }
84
- catch {
85
- // A partial read mid-write parses as garbage — fall through and retry.
86
- }
191
+ const creds = await readCreds(authPath, warn);
192
+ return creds ? pick(creds, providerId) : undefined;
193
+ },
194
+ async list() {
195
+ // Metadata only, never secrets (the pi-ai `list` contract). Foreign/old entries are filtered
196
+ // with the same validation as `read`, so both surfaces agree on what "configured" means.
197
+ const creds = await readCreds(authPath, warn);
198
+ if (!creds)
199
+ return [];
200
+ const infos = [];
201
+ for (const [providerId, cred] of Object.entries(creds)) {
202
+ if (cred && (cred.type === "oauth" || cred.type === "api_key")) {
203
+ infos.push({ providerId, type: cred.type });
87
204
  }
88
- if (attempt < 2)
89
- await sleep(2);
90
205
  }
91
- warn(`[fastagent] corrupt auth file ${authPath} — fix or remove it`);
92
- return undefined;
206
+ return infos;
93
207
  },
94
208
  modify(providerId, fn) {
95
- return backend.withLockAsync(async (current) => {
209
+ return withLockedAuthFile(authPath, async (current) => {
96
210
  const creds = parseForWrite(current, authPath); // corrupt → throw → no clobber
97
211
  const next = await fn(pick(creds, providerId));
98
212
  if (next === undefined)
@@ -102,11 +216,11 @@ export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options =
102
216
  });
103
217
  },
104
218
  async delete(providerId) {
105
- // No-op when nothing is stored: do NOT take the lock (which would create the file via the
106
- // backend's ensureFileExists) on a machine that never stored this provider.
219
+ // No-op when nothing is stored: do NOT take the lock (which would create the file) on a
220
+ // machine that never stored this provider.
107
221
  if (!existsSync(authPath))
108
222
  return;
109
- await backend.withLockAsync(async (current) => {
223
+ await withLockedAuthFile(authPath, async (current) => {
110
224
  const creds = parseForWrite(current, authPath);
111
225
  if (!(providerId in creds))
112
226
  return { result: undefined }; // absent: no write