@fastagent-sh/fastagent 0.12.1 → 0.14.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 (151) hide show
  1. package/README.md +6 -4
  2. package/dist/channels/feishu/bootstrap-token.d.ts +42 -0
  3. package/dist/channels/feishu/bootstrap-token.js +94 -0
  4. package/dist/channels/feishu/card.d.ts +32 -0
  5. package/dist/channels/feishu/card.js +66 -0
  6. package/dist/channels/feishu/cloud.d.ts +17 -0
  7. package/dist/channels/feishu/cloud.js +19 -0
  8. package/dist/channels/feishu/context-buffer.d.ts +46 -0
  9. package/dist/channels/feishu/context-buffer.js +133 -0
  10. package/dist/channels/feishu/crypto.d.ts +15 -0
  11. package/dist/channels/feishu/crypto.js +43 -0
  12. package/dist/channels/feishu/feishu-api.d.ts +108 -0
  13. package/dist/channels/feishu/feishu-api.js +325 -0
  14. package/dist/channels/feishu/feishu.d.ts +46 -0
  15. package/dist/channels/feishu/feishu.js +472 -0
  16. package/dist/channels/feishu/invoke-turn.d.ts +65 -0
  17. package/dist/channels/feishu/invoke-turn.js +157 -0
  18. package/dist/channels/feishu/model.d.ts +97 -0
  19. package/dist/channels/feishu/model.js +9 -0
  20. package/dist/channels/feishu/normalize.d.ts +22 -0
  21. package/dist/channels/feishu/normalize.js +132 -0
  22. package/dist/channels/feishu/owned-threads.d.ts +11 -0
  23. package/dist/channels/feishu/owned-threads.js +47 -0
  24. package/dist/channels/feishu/parse.d.ts +43 -0
  25. package/dist/channels/feishu/parse.js +65 -0
  26. package/dist/channels/feishu/preview.d.ts +36 -0
  27. package/dist/channels/feishu/preview.js +387 -0
  28. package/dist/channels/feishu/register-app.d.ts +70 -0
  29. package/dist/channels/feishu/register-app.js +141 -0
  30. package/dist/channels/feishu/register-webhook.d.ts +22 -0
  31. package/dist/channels/feishu/register-webhook.js +106 -0
  32. package/dist/channels/feishu/scaffold/channel.ts +41 -0
  33. package/dist/channels/feishu/scaffold/feishu-send.ts +87 -0
  34. package/dist/channels/feishu/seen.d.ts +5 -0
  35. package/dist/channels/feishu/seen.js +47 -0
  36. package/dist/channels/feishu/text.d.ts +13 -0
  37. package/dist/channels/feishu/text.js +63 -0
  38. package/dist/channels/lark/lark.d.ts +15 -0
  39. package/dist/channels/lark/lark.js +10 -0
  40. package/dist/channels/lark/onboard.d.ts +39 -0
  41. package/dist/channels/lark/onboard.js +58 -0
  42. package/dist/channels/lark/scaffold/channel.ts +39 -0
  43. package/dist/channels/lark/scaffold/lark-send.ts +87 -0
  44. package/dist/channels/registration.d.ts +15 -0
  45. package/dist/channels/registration.js +1 -0
  46. package/dist/channels/{telegram/state.js → state.js} +6 -4
  47. package/dist/channels/telegram/context-buffer.js +1 -1
  48. package/dist/channels/telegram/register-webhook.d.ts +4 -1
  49. package/dist/channels/telegram/register-webhook.js +17 -26
  50. package/dist/channels/telegram/telegram.js +2 -2
  51. package/dist/channels/telegram/turn-store.d.ts +8 -21
  52. package/dist/channels/telegram/turn-store.js +11 -130
  53. package/dist/channels/{telegram/turn-queue.js → turn-queue.js} +3 -3
  54. package/dist/channels/turn-store.d.ts +42 -0
  55. package/dist/channels/turn-store.js +139 -0
  56. package/dist/channels/wait-health.d.ts +6 -0
  57. package/dist/channels/wait-health.js +27 -0
  58. package/dist/cli/commands/add.d.ts +9 -0
  59. package/dist/cli/commands/add.js +142 -0
  60. package/dist/cli/commands/chat.d.ts +3 -0
  61. package/dist/cli/commands/chat.js +16 -0
  62. package/dist/cli/commands/deploy.d.ts +13 -0
  63. package/dist/cli/commands/deploy.js +338 -0
  64. package/dist/cli/commands/dev.d.ts +11 -0
  65. package/dist/cli/commands/dev.js +76 -0
  66. package/dist/cli/commands/fire.d.ts +7 -0
  67. package/dist/cli/commands/fire.js +45 -0
  68. package/dist/cli/commands/info.d.ts +7 -0
  69. package/dist/cli/commands/info.js +108 -0
  70. package/dist/cli/commands/init.d.ts +8 -0
  71. package/dist/cli/commands/init.js +81 -0
  72. package/dist/cli/commands/invoke.d.ts +7 -0
  73. package/dist/cli/commands/invoke.js +29 -0
  74. package/dist/cli/commands/login.d.ts +6 -0
  75. package/dist/cli/commands/login.js +63 -0
  76. package/dist/cli/commands/models.d.ts +1 -0
  77. package/dist/cli/commands/models.js +15 -0
  78. package/dist/cli/commands/schedule.d.ts +12 -0
  79. package/dist/cli/commands/schedule.js +89 -0
  80. package/dist/cli/commands/start.d.ts +10 -0
  81. package/dist/cli/commands/start.js +90 -0
  82. package/dist/cli/commands/tool.d.ts +1 -0
  83. package/dist/cli/commands/tool.js +37 -0
  84. package/dist/cli/fail.d.ts +19 -0
  85. package/dist/cli/fail.js +32 -0
  86. package/dist/cli/kernel.d.ts +89 -0
  87. package/dist/cli/kernel.js +190 -0
  88. package/dist/cli/program.d.ts +11 -0
  89. package/dist/cli/program.js +421 -0
  90. package/dist/cli/serve.d.ts +28 -0
  91. package/dist/cli/serve.js +90 -0
  92. package/dist/cli/shared.d.ts +24 -0
  93. package/dist/cli/shared.js +116 -0
  94. package/dist/cli-add-feishu.d.ts +8 -0
  95. package/dist/cli-add-feishu.js +223 -0
  96. package/dist/cli.js +8 -1284
  97. package/dist/deploy/container.js +10 -6
  98. package/dist/deploy/docker/plan.d.ts +45 -0
  99. package/dist/deploy/docker/plan.js +139 -0
  100. package/dist/deploy/docker/run.d.ts +40 -0
  101. package/dist/deploy/docker/run.js +126 -0
  102. package/dist/deploy/fly/plan.d.ts +1 -1
  103. package/dist/deploy/fly/plan.js +15 -4
  104. package/dist/deploy/fly/run.d.ts +7 -4
  105. package/dist/deploy/fly/run.js +26 -5
  106. package/dist/deploy/preflight.js +4 -3
  107. package/dist/deploy/railway/plan.d.ts +1 -1
  108. package/dist/deploy/railway/plan.js +17 -5
  109. package/dist/deploy/railway/run.d.ts +6 -3
  110. package/dist/deploy/railway/run.js +26 -4
  111. package/dist/deploy/registration-gate.d.ts +20 -0
  112. package/dist/deploy/registration-gate.js +20 -0
  113. package/dist/deploy/runner.d.ts +4 -1
  114. package/dist/deploy/runner.js +1 -0
  115. package/dist/deploy/secrets.d.ts +10 -9
  116. package/dist/deploy/secrets.js +15 -14
  117. package/dist/dev-supervisor.js +2 -1
  118. package/dist/engines/pi/auth.js +160 -46
  119. package/dist/engines/pi/chat.js +80 -7
  120. package/dist/engines/pi/config.d.ts +12 -3
  121. package/dist/engines/pi/config.js +16 -1
  122. package/dist/engines/pi/create.d.ts +14 -6
  123. package/dist/engines/pi/create.js +52 -16
  124. package/dist/engines/pi/harness.d.ts +16 -1
  125. package/dist/engines/pi/harness.js +77 -1
  126. package/dist/engines/pi/invoke.d.ts +1 -1
  127. package/dist/engines/pi/invoke.js +37 -2
  128. package/dist/engines/pi/login.js +1 -1
  129. package/dist/engines/pi/search-tools.d.ts +10 -0
  130. package/dist/engines/pi/search-tools.js +138 -0
  131. package/dist/engines/pi/tool-context.d.ts +28 -0
  132. package/dist/engines/pi/tool-context.js +8 -0
  133. package/dist/engines/pi/tool.d.ts +32 -1
  134. package/dist/engines/pi/tool.js +42 -1
  135. package/dist/engines/pi/workspace.d.ts +4 -1
  136. package/dist/engines/pi/workspace.js +3 -1
  137. package/dist/feishu.d.ts +2 -0
  138. package/dist/feishu.js +2 -0
  139. package/dist/lark.d.ts +3 -0
  140. package/dist/lark.js +3 -0
  141. package/dist/open-url.d.ts +2 -0
  142. package/dist/open-url.js +6 -0
  143. package/dist/pi.d.ts +2 -1
  144. package/dist/scaffold/add-channel.d.ts +9 -5
  145. package/dist/scaffold/add-channel.js +75 -7
  146. package/dist/scaffold/templates/fastagent.config.mjs +1 -0
  147. package/dist/tunnel.d.ts +9 -6
  148. package/dist/tunnel.js +48 -31
  149. package/package.json +18 -4
  150. /package/dist/channels/{telegram/state.d.ts → state.d.ts} +0 -0
  151. /package/dist/channels/{telegram/turn-queue.d.ts → turn-queue.d.ts} +0 -0
@@ -1,3 +1,4 @@
1
+ import { registrationGate } from "../registration-gate.js";
1
2
  /**
2
3
  * Whether `railway status --json` shows a linked project: non-empty stdout. Unlinked prints its message
3
4
  * to stderr and leaves stdout EMPTY (the exit code is 0 either way, so it can't be the signal). ANY
@@ -61,10 +62,12 @@ export function parseHasVolume(stdout, mountPath) {
61
62
  return jsonStrings(stdout).includes(mountPath);
62
63
  }
63
64
  /**
64
- * Run the deploy through `railway`. `log` reports progress; `registerTelegram(baseUrl)` performs the
65
- * post-deploy webhook step (the CLI passes its telegram registrar). Every gate is fail-visible.
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
68
+ * instruction is printed. Every gate is fail-visible.
66
69
  */
67
- export async function deployRailwayRun(plan, railway, log, registerTelegram) {
70
+ export async function deployRailwayRun(plan, railway, log, registerTelegram, registerFeishu) {
68
71
  const gate = (g) => ({ ok: false, gate: g });
69
72
  // Every --service below targets plan.name — the name this tool gives BOTH the project and the service
70
73
  // (`init --name` + `add --service`). On a fresh create they match; on `--into-linked` into a hand-made
@@ -165,12 +168,31 @@ export async function deployRailwayRun(plan, railway, log, registerTelegram) {
165
168
  if (!url) {
166
169
  return gate("couldn't read a domain from `railway domain` — run `railway domain` manually, then set any webhook");
167
170
  }
171
+ // 7. Post-deploy webhook — gate policy is the shared registration-gate kernel (registrars report
172
+ // facts, it owns the policy); all channels are attempted first.
173
+ const reg = registrationGate(log, "re-run with --into-linked to retry registration");
168
174
  if (plan.channels.includes("telegram")) {
169
175
  log("registering telegram webhook…");
170
- await registerTelegram(url);
176
+ reg.track("telegram", await registerTelegram(url));
171
177
  }
172
178
  if (plan.channels.includes("github")) {
173
179
  log(`github: set the webhook in the repo (Settings → Webhooks) → ${url}/webhook`);
180
+ reg.track("github", "manual"); // always a human step — re-surface it after the registrar output
181
+ }
182
+ for (const kind of ["feishu", "lark"]) {
183
+ if (!plan.channels.includes(kind))
184
+ continue;
185
+ if (registerFeishu) {
186
+ log(`registering ${kind} event URL…`);
187
+ reg.track(kind, await registerFeishu(url, kind));
188
+ }
189
+ else {
190
+ log(`${kind}: set the event Request URL in the developer console (Events & Callbacks) → ${url}/${kind} (the service must be running when you save)`);
191
+ reg.track(kind, "manual"); // no registrar wired — the console step above is the operator's
192
+ }
174
193
  }
194
+ const registrationGateMsg = reg.gate();
195
+ if (registrationGateMsg)
196
+ return gate(registrationGateMsg);
175
197
  return { ok: true, url };
176
198
  }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Host-NEUTRAL step-7 gate policy, shared by the host runners: the registrars report facts
3
+ * ({@link RegistrationOutcome}), this module owns what to do about them.
4
+ *
5
+ * - "failed" gates — exit 0 would tell a coding agent "done" while the deployed agent cannot receive
6
+ * messages, and the runners' earlier steps are idempotent so a re-run just retries registration.
7
+ * - "manual" does NOT gate — re-running can never change it (an unclearable gate would spin a coding
8
+ * agent forever) — but is re-surfaced after all registrar output (and before any gate message) so
9
+ * the operator cannot miss it under the registrar's earlier log lines.
10
+ *
11
+ * The runners attempt ALL channels first (one failure doesn't skip the rest), then apply this policy
12
+ * once. Only the retry remediation differs per host (`retryHint`).
13
+ */
14
+ import type { RegistrationOutcome } from "../channels/registration.ts";
15
+ export declare function registrationGate(log: (msg: string) => void, retryHint: string): {
16
+ track: (kind: string, outcome: RegistrationOutcome) => void;
17
+ /** Logs the manual notices; returns the gate message (composes with cli.ts's "deploy stopped:"
18
+ * prefix — it leads with the failure, not "the deploy succeeded"), or undefined for no gate. */
19
+ gate: () => string | undefined;
20
+ };
@@ -0,0 +1,20 @@
1
+ export function registrationGate(log, retryHint) {
2
+ const unregistered = [];
3
+ const manual = [];
4
+ return {
5
+ track(kind, outcome) {
6
+ if (outcome === "failed")
7
+ unregistered.push(kind);
8
+ if (outcome === "manual")
9
+ manual.push(kind);
10
+ },
11
+ gate() {
12
+ for (const kind of manual) {
13
+ log(`${kind}: webhook registration needs a one-time manual step — see the instructions above`);
14
+ }
15
+ if (unregistered.length === 0)
16
+ return undefined;
17
+ return `webhook registration failed for: ${unregistered.join(", ")} — the app itself deployed; fix the error above, then ${retryHint}`;
18
+ },
19
+ };
20
+ }
@@ -5,10 +5,13 @@ export interface RunResult {
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
@@ -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 = "";
@@ -1,8 +1,7 @@
1
1
  /**
2
- * The secret set a deployed agent needs, computed from the definition — host-neutral. WHAT secrets are
3
- * required (the model key when local auth is an env key + every discovered channel's secrets) is the
4
- * same on every target; only the SET command differs (`fly secrets import` vs `railway variables set`).
5
- * The runbooks list these NAMES; `--run` (per host) reads their VALUES from the local env.
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.
6
5
  */
7
6
  import { type ChannelKind } from "../scaffold/add-channel.ts";
8
7
  /**
@@ -13,13 +12,15 @@ import { type ChannelKind } from "../scaffold/add-channel.ts";
13
12
  */
14
13
  export declare function isEnvKey(source: string | undefined): source is string;
15
14
  /**
16
- * The required secret NAMES + hints for a runbook: the model key (when local auth is an env key) plus
17
- * every discovered channel's secrets. An OAuth/stored login has no env key here it carries as
15
+ * Secret NAMES + hints for a runbook: the model key (when local auth is an env key), discovered channel
16
+ * secrets, and config extras. Channel metadata keeps optional values visible without presenting them as
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 requiredSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
20
+ export declare function deploymentSecrets(modelAuth: string | undefined, channels: ChannelKind[], extraSecrets?: string[]): {
21
21
  name: string;
22
22
  hint: string;
23
+ required: boolean;
23
24
  }[];
24
25
  /**
25
26
  * Assemble the secret VALUES a `--run` deploy sets on the host, from the local credential + channels —
@@ -32,8 +33,8 @@ export declare function requiredSecrets(modelAuth: string | undefined, channels:
32
33
  *
33
34
  * Channel secrets come from the local env only — NEVER minted. A random mint would be wrong for a
34
35
  * human-shared secret (github's webhook secret must match the value the operator enters in the repo,
35
- * which a silent mint never surfaces) and would rotate every run (breaking idempotency). Absent
36
- * `missingSecrets`, same as the plain runbook's operator-filled placeholders.
36
+ * which a silent mint never surfaces) and would rotate every run (breaking idempotency). An absent
37
+ * required value enters `missingSecrets`; an absent optional value is simply omitted.
37
38
  */
38
39
  export declare function assembleSecrets(input: {
39
40
  modelAuth: string | undefined;
@@ -1,8 +1,7 @@
1
1
  /**
2
- * The secret set a deployed agent needs, computed from the definition — host-neutral. WHAT secrets are
3
- * required (the model key when local auth is an env key + every discovered channel's secrets) is the
4
- * same on every target; only the SET command differs (`fly secrets import` vs `railway variables set`).
5
- * The runbooks list these NAMES; `--run` (per host) reads their VALUES from the local env.
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.
6
5
  */
7
6
  import { channelSetup } from "../scaffold/add-channel.js";
8
7
  /**
@@ -15,22 +14,23 @@ export function isEnvKey(source) {
15
14
  return source !== undefined && /^[A-Z][A-Z0-9_]*$/.test(source);
16
15
  }
17
16
  /**
18
- * The required secret NAMES + hints for a runbook: the model key (when local auth is an env key) plus
19
- * every discovered channel's secrets. An OAuth/stored login has no env key here it carries as
17
+ * Secret NAMES + hints for a runbook: the model key (when local auth is an env key), discovered channel
18
+ * secrets, and config extras. Channel metadata keeps optional values visible without presenting them as
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 requiredSecrets(modelAuth, channels, extraSecrets = []) {
22
+ export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
23
23
  const secrets = [];
24
24
  if (isEnvKey(modelAuth))
25
- secrets.push({ name: modelAuth, hint: "your model provider key" });
25
+ secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
26
26
  for (const kind of channels) {
27
27
  for (const e of channelSetup(kind).env)
28
- secrets.push({ name: e.name, hint: e.hint });
28
+ secrets.push({ name: e.name, hint: e.hint, required: e.required });
29
29
  }
30
30
  // Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
31
31
  for (const name of extraSecrets) {
32
32
  if (!secrets.some((s) => s.name === name)) {
33
- secrets.push({ name, hint: "declared in fastagent.config deploy.secrets" });
33
+ secrets.push({ name, hint: "declared in fastagent.config deploy.secrets", required: true });
34
34
  }
35
35
  }
36
36
  return secrets;
@@ -46,8 +46,8 @@ export function requiredSecrets(modelAuth, channels, extraSecrets = []) {
46
46
  *
47
47
  * Channel secrets come from the local env only — NEVER minted. A random mint would be wrong for a
48
48
  * human-shared secret (github's webhook secret must match the value the operator enters in the repo,
49
- * which a silent mint never surfaces) and would rotate every run (breaking idempotency). Absent
50
- * `missingSecrets`, same as the plain runbook's operator-filled placeholders.
49
+ * which a silent mint never surfaces) and would rotate every run (breaking idempotency). An absent
50
+ * required value enters `missingSecrets`; an absent optional value is simply omitted.
51
51
  */
52
52
  export function assembleSecrets(input) {
53
53
  const secrets = {};
@@ -70,9 +70,10 @@ export function assembleSecrets(input) {
70
70
  for (const e of channelSetup(kind).env) {
71
71
  const v = input.env[e.name];
72
72
  if (v)
73
- secrets[e.name] = v;
74
- else
73
+ secrets[e.name] = v; // optional channel values travel when configured
74
+ else if (e.required) {
75
75
  missingSecrets.push(e.name); // operator-provided (in .env); a human-shared secret can't be minted
76
+ }
76
77
  }
77
78
  }
78
79
  for (const name of input.extraSecrets ?? []) {
@@ -16,6 +16,7 @@ import { watch as watchTree } from "chokidar";
16
16
  import { loadConfig, resolveAgentDir } from "./engines/pi/config.js";
17
17
  import { log } from "./log.js";
18
18
  import { installProxyFetch } from "./proxy.js";
19
+ import { openExternalUrl } from "./open-url.js";
19
20
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
20
21
  /** What the dev watcher restarts on (workspace-relative): the process-bound code inputs only. */
21
22
  export const WATCHED_HINT = "tools/, channels/, package.json (agent dir), fastagent.config.*, .env (run root)";
@@ -99,7 +100,7 @@ export async function runDevSupervisor(dir, options = {}) {
99
100
  void startCloudflareTunnel(m.port).then((t) => {
100
101
  if (t) {
101
102
  tunnel = t;
102
- void announceWebhooks(agentDir, t.url);
103
+ void announceWebhooks(agentDir, t.url, { openUrl: openExternalUrl });
103
104
  }
104
105
  });
105
106
  }
@@ -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
@@ -7,7 +7,7 @@
7
7
  * AGENTS.md up to the repo root and discover skills from pi's own global dirs). So fastagent's
8
8
  * assembly is INJECTED into pi's session:
9
9
  * - prompt → systemPromptOverride = base + instructions ONLY; pi appends the skill section and env
10
- * (date/cwd) itself (including them here would duplicate both).
10
+ * (cwd) itself (including it here would duplicate it).
11
11
  * - skills → skillsOverride (fastagent's skills, for the section + invocation).
12
12
  * - tools → default coding tools by NAME (pi rebuilds them cwd-bound for rich rendering) +
13
13
  * fastagent's custom tools via pi's customTools path (so they survive /new, /resume, fork).
@@ -28,7 +28,9 @@ import { loadConfig, resolveAgentDir, resolveModel, resolveModelSpec } from "./c
28
28
  import { assembleSystemPrompt, piBasePrompt, piDefaultTools, resolveTools } from "./create.js";
29
29
  import { createPiModels } from "./models.js";
30
30
  import { canonicalPath, loadAgentDefinition } from "./definition.js";
31
- import { loadTools, mergeDiscoveredTools } from "./tool.js";
31
+ import { isDeferredTool, loadTools, mergeDiscoveredTools } from "./tool.js";
32
+ import { withSearchTool } from "./search-tools.js";
33
+ import { additiveActivation, turnContext } from "./tool-context.js";
32
34
  import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollisions } from "./report.js";
33
35
  /**
34
36
  * Build pi's interactive runtime driven by fastagent's assembled agent (model, prompt, tools,
@@ -38,6 +40,34 @@ import { reportDefinitionWarnings, reportModuleLoadFailures, reportToolCollision
38
40
  export async function buildChatRuntime(dir, options = {},
39
41
  /** Session backend. Defaults to pi's project-scoped store; tests inject SessionManager.inMemory(). */
40
42
  sessionManager) {
43
+ /** The turn's {@link ToolActivation} over pi's AgentSession — the chat counterpart of invoke.ts's
44
+ * harness bridge, so the SAME builtin search_tools serves both paths. Additive; unknown names
45
+ * filtered (`setActiveToolsByName` is authoritative on the session and rebuilds its prompt — our
46
+ * static override keeps the prompt identical to serving). */
47
+ function chatToolActivation(session) {
48
+ // Same serialization as invoke.ts's bridge (there per turn; here per session — chat turns are
49
+ // interactive, so per-session is equivalent): the read-modify-write below is only race-free while
50
+ // nothing awaits between read and write, and pi's session setters happening to be synchronous today
51
+ // is not a contract worth betting parallel tool batches on. Built ONCE per session (createRuntime),
52
+ // so parallel calls actually share the chain.
53
+ let chain = Promise.resolve([]);
54
+ return {
55
+ active: () => session.getActiveToolNames(),
56
+ registered: () => session.getAllTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
57
+ activate(names) {
58
+ const run = async () => {
59
+ const current = session.getActiveToolNames();
60
+ const added = additiveActivation(session.getAllTools().map((t) => t.name), current, names);
61
+ if (added.length > 0)
62
+ session.setActiveToolsByName([...current, ...added]);
63
+ return added;
64
+ };
65
+ const result = chain.then(run, run); // run after the predecessor settles, success or failure
66
+ chain = result.catch(() => []); // the caller sees a rejection on `result`; the chain stays usable
67
+ return result;
68
+ },
69
+ };
70
+ }
41
71
  async function resolveAssembly(cwd) {
42
72
  const { config } = await loadConfig(cwd);
43
73
  const modelSpec = resolveModelSpec(options.model, config);
@@ -55,21 +85,42 @@ sessionManager) {
55
85
  // Same tool resolution as the dev opener, then split: defaults go to pi by NAME (rebuilt cwd-bound
56
86
  // for rich rendering); customs go through pi's `customTools` path so they survive /new, /resume, fork.
57
87
  const discovered = await loadTools(agentDir);
58
- const { tools, collisions: crossCollisions } = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
88
+ const merged = mergeDiscoveredTools(resolveTools(config, cwd), discovered.tools);
89
+ // Chat EMULATES deferral, like serving (what you iterate is what you serve): the builtin loader
90
+ // mounts when a deferred tool exists, the initial active set excludes deferred tools (applied on
91
+ // the session in createRuntime below — pi's TUI session starts all-active), and the activation
92
+ // bridge below rides the same turn context the serving path uses, so the SAME search_tools works
93
+ // against pi's AgentSession instead of fastagent's harness.
94
+ const tools = withSearchTool(merged.tools);
95
+ const crossCollisions = merged.collisions;
59
96
  reportToolCollisions([...discovered.collisions, ...crossCollisions]);
60
97
  reportModuleLoadFailures(discovered.failures);
61
98
  const defaultNames = piDefaultTools(cwd).map((t) => t.name);
62
99
  const customTools = tools.filter((t) => !defaultNames.includes(t.name));
63
- // Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts it).
100
+ // Adapt fastagent's AgentTool to pi's ToolDefinition (`parameters` is plain JSON-Schema; pi accepts
101
+ // it). Each execute runs inside the turn context with the CURRENT session's activation bridge — the
102
+ // assembly is memoized across /new//resume/fork rebuilds while the session changes, so the bridge
103
+ // resolves through sessionRef at call time, exactly like the serving path resolves its harness.
64
104
  const customToolDefs = customTools.map((t) => ({
65
105
  name: t.name,
66
106
  label: t.name,
67
107
  description: t.description ?? "",
68
108
  parameters: t.parameters,
69
- execute: (id, params, signal) => t.execute(id, params, signal),
109
+ // Propagate the execution mode — an activating tool (the builtin loader) declares "sequential"
110
+ // so pi serializes its batch; without this, pi's outer active-set diff double-stamps parallels.
111
+ executionMode: t.executionMode,
112
+ execute: (id, params, signal) => {
113
+ const bound = sessionRef.current;
114
+ // Unreachable by construction (createRuntime sets sessionRef before any turn can run a tool).
115
+ // Throw rather than silently run outside the turn context — that would disguise a broken
116
+ // session-lifecycle invariant as a normal out-of-turn call (fail visibly).
117
+ if (!bound)
118
+ throw new Error("chat tool executed before its session was built (lifecycle invariant broken)");
119
+ return turnContext.run({ session: bound.session.sessionId, tools: bound.activation }, () => t.execute(id, params, signal));
120
+ },
70
121
  }));
71
- // base + instructions ONLY — pi appends the skill section and env (date/cwd) itself (including
72
- // them here would duplicate both).
122
+ // base + instructions ONLY — pi appends the skill section and env (cwd) itself (including
123
+ // them here would duplicate them).
73
124
  const systemPrompt = assembleSystemPrompt({
74
125
  base: piBasePrompt({ tools, persona: definition.persona }),
75
126
  contextFiles: definition.contextFiles,
@@ -82,6 +133,13 @@ sessionManager) {
82
133
  // edits. And keep it workspace-scoped — `.env` is process-global, so a switch to another cwd would
83
134
  // leak env or require mutating global env at runtime.
84
135
  const rootCwd = canonicalPath(dir);
136
+ // The CURRENT pi session + its activation bridge, BOUND TOGETHER — rebuilt on /new//resume/fork
137
+ // while the memoized assembly (and its tool execute closures) stays. The bridge must share the
138
+ // session's lifetime, NOT be rebuilt per tool call (a per-call chain serializes nothing). Note on
139
+ // parallel batches: pi wraps SDK customTools in its own before/after active-set diff, so an
140
+ // activating tool must carry `executionMode: "sequential"` (the builtin loader does) — pi then runs
141
+ // the whole batch serially and the outer diff sees correct snapshots.
142
+ const sessionRef = {};
85
143
  let assembly;
86
144
  const assemblyFor = (cwd) => {
87
145
  // Canonical paths: pi's process.cwd() fallback is a realpath, so a symlinked workspace would
@@ -135,6 +193,21 @@ sessionManager) {
135
193
  tools: [...defaultNames, ...customTools.map((t) => t.name)],
136
194
  customTools: customToolDefs,
137
195
  });
196
+ sessionRef.current = { session: result.session, activation: chatToolActivation(result.session) };
197
+ // Deferral emulation: pi's TUI session starts with everything active — narrow it by SUBTRACTING
198
+ // the deferred names from whatever is active (robust to pi mounting tools of its own; an
199
+ // exact-set-equality gate would silently stop narrowing the day pi adds one). Applied on EVERY
200
+ // build including /resume: pi's chat session does not record activations (its SessionContext has
201
+ // no activeToolNames), so "restore prior activations" is not implementable here — deferral stays
202
+ // consistently ON and a resumed conversation re-discovers via search_tools (documented divergence
203
+ // from serving, where activations persist in the session).
204
+ const deferredNames = customTools.filter(isDeferredTool).map((t) => t.name);
205
+ if (deferredNames.length > 0) {
206
+ const active = result.session.getActiveToolNames();
207
+ if (deferredNames.some((n) => active.includes(n))) {
208
+ result.session.setActiveToolsByName(active.filter((n) => !deferredNames.includes(n)));
209
+ }
210
+ }
138
211
  return { ...result, services, diagnostics: services.diagnostics };
139
212
  };
140
213
  const runtime = await createAgentSessionRuntime(createRuntime, {