@fastagent-sh/fastagent 0.19.0 → 0.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/dist/atomic-write.d.ts +11 -7
  2. package/dist/atomic-write.js +16 -11
  3. package/dist/channels/agentcore-limits.d.ts +9 -0
  4. package/dist/channels/agentcore-limits.js +9 -0
  5. package/dist/channels/agentcore-service.d.ts +12 -11
  6. package/dist/channels/agentcore-service.js +14 -18
  7. package/dist/channels/agentcore.d.ts +11 -19
  8. package/dist/channels/agentcore.js +14 -7
  9. package/dist/channels/control.d.ts +50 -12
  10. package/dist/channels/control.js +239 -96
  11. package/dist/channels/discover.d.ts +26 -4
  12. package/dist/channels/discover.js +12 -23
  13. package/dist/channels/feishu/feishu-api.js +5 -8
  14. package/dist/channels/feishu/feishu.js +35 -18
  15. package/dist/channels/kit/attachment-path.d.ts +12 -0
  16. package/dist/channels/kit/attachment-path.js +43 -0
  17. package/dist/channels/kit/context-buffer.d.ts +9 -0
  18. package/dist/channels/kit/context-buffer.js +11 -0
  19. package/dist/channels/kit/signature.d.ts +12 -0
  20. package/dist/channels/kit/signature.js +17 -0
  21. package/dist/channels/kit/stop-command.js +3 -3
  22. package/dist/channels/kit/tasks.d.ts +1 -1
  23. package/dist/channels/kit/tasks.js +10 -4
  24. package/dist/channels/kit/turn-store.d.ts +19 -3
  25. package/dist/channels/kit/turn-store.js +29 -2
  26. package/dist/channels/slack/invoke-turn.d.ts +1 -1
  27. package/dist/channels/slack/invoke-turn.js +5 -1
  28. package/dist/channels/slack/onboard.js +5 -5
  29. package/dist/channels/slack/onboarding-state.d.ts +7 -3
  30. package/dist/channels/slack/onboarding-state.js +13 -20
  31. package/dist/channels/slack/register-webhook.js +1 -1
  32. package/dist/channels/slack/scaffold/slack-send.ts +2 -17
  33. package/dist/channels/slack/slack-api.js +22 -54
  34. package/dist/channels/slack/slack.js +13 -13
  35. package/dist/channels/telegram/parse.d.ts +21 -1
  36. package/dist/channels/telegram/parse.js +65 -11
  37. package/dist/channels/telegram/scaffold/channel.ts +7 -3
  38. package/dist/channels/telegram/telegram-api.js +5 -6
  39. package/dist/channels/telegram/telegram.d.ts +2 -2
  40. package/dist/channels/telegram/telegram.js +57 -69
  41. package/dist/cli/add-slack.js +6 -5
  42. package/dist/cli/commands/add.js +1 -5
  43. package/dist/cli/commands/attach.d.ts +2 -4
  44. package/dist/cli/commands/attach.js +17 -15
  45. package/dist/cli/commands/deploy.d.ts +50 -5
  46. package/dist/cli/commands/deploy.js +183 -134
  47. package/dist/cli/commands/dev.js +4 -31
  48. package/dist/cli/commands/info.js +1 -2
  49. package/dist/cli/commands/init.js +1 -2
  50. package/dist/cli/commands/start.js +31 -41
  51. package/dist/cli/serve.d.ts +12 -21
  52. package/dist/cli/serve.js +20 -25
  53. package/dist/cli/shared.d.ts +40 -10
  54. package/dist/cli/shared.js +51 -4
  55. package/dist/deploy/agentcore/logs.d.ts +2 -2
  56. package/dist/deploy/agentcore/logs.js +2 -2
  57. package/dist/deploy/agentcore/plan.d.ts +12 -10
  58. package/dist/deploy/agentcore/plan.js +39 -31
  59. package/dist/deploy/agentcore/run.d.ts +5 -4
  60. package/dist/deploy/agentcore/run.js +13 -36
  61. package/dist/deploy/channel-ingress.d.ts +73 -0
  62. package/dist/deploy/channel-ingress.js +101 -0
  63. package/dist/deploy/docker/plan.d.ts +3 -7
  64. package/dist/deploy/docker/plan.js +4 -15
  65. package/dist/deploy/docker/run.d.ts +18 -1
  66. package/dist/deploy/docker/run.js +10 -0
  67. package/dist/deploy/fly/plan.d.ts +4 -5
  68. package/dist/deploy/fly/plan.js +7 -22
  69. package/dist/deploy/fly/run.d.ts +9 -23
  70. package/dist/deploy/fly/run.js +32 -68
  71. package/dist/deploy/preflight.d.ts +6 -7
  72. package/dist/deploy/preflight.js +26 -22
  73. package/dist/deploy/railway/plan.d.ts +4 -5
  74. package/dist/deploy/railway/plan.js +11 -26
  75. package/dist/deploy/railway/run.d.ts +9 -9
  76. package/dist/deploy/railway/run.js +40 -40
  77. package/dist/deploy/runner.js +22 -2
  78. package/dist/deploy/secrets.d.ts +18 -9
  79. package/dist/deploy/secrets.js +64 -12
  80. package/dist/dev-supervisor.js +3 -4
  81. package/dist/engines/pi/agent-session-factory.js +20 -39
  82. package/dist/engines/pi/auth.js +43 -43
  83. package/dist/engines/pi/config.d.ts +1 -1
  84. package/dist/engines/pi/create.d.ts +14 -2
  85. package/dist/engines/pi/create.js +2 -2
  86. package/dist/engines/pi/definition.d.ts +1 -1
  87. package/dist/engines/pi/definition.js +3 -4
  88. package/dist/engines/pi/login.js +32 -16
  89. package/dist/engines/pi/open.d.ts +1 -2
  90. package/dist/engines/pi/open.js +1 -1
  91. package/dist/engines/pi/search-tools.js +1 -1
  92. package/dist/engines/pi/service.d.ts +1 -1
  93. package/dist/engines/pi/service.js +8 -0
  94. package/dist/engines/pi/session-builder.js +6 -33
  95. package/dist/engines/pi/session-control.d.ts +19 -6
  96. package/dist/engines/pi/session-control.js +565 -470
  97. package/dist/engines/pi/session-inheritance.d.ts +8 -22
  98. package/dist/engines/pi/session-inheritance.js +95 -76
  99. package/dist/engines/pi/session-markers.d.ts +48 -0
  100. package/dist/engines/pi/session-markers.js +59 -0
  101. package/dist/engines/pi/session-settings.d.ts +4 -4
  102. package/dist/engines/pi/session-settings.js +7 -4
  103. package/dist/engines/pi/session-store.d.ts +83 -13
  104. package/dist/engines/pi/session-store.js +408 -55
  105. package/dist/engines/pi/tool-context.d.ts +21 -8
  106. package/dist/engines/pi/tool-context.js +29 -4
  107. package/dist/engines/pi/tool.js +2 -2
  108. package/dist/env.js +1 -2
  109. package/dist/loader.d.ts +49 -7
  110. package/dist/loader.js +79 -18
  111. package/dist/paths.d.ts +19 -0
  112. package/dist/paths.js +27 -2
  113. package/dist/scaffold/add-channel.js +2 -2
  114. package/dist/scaffold/init.js +7 -2
  115. package/dist/scaffold/templates/tools/fetch-url.ts +0 -2
  116. package/dist/schedule/discover.js +3 -15
  117. package/dist/schedule/wake-alarm.d.ts +13 -7
  118. package/dist/schedule/wake-alarm.js +88 -46
  119. package/dist/schedule/wakeups.d.ts +1 -1
  120. package/dist/schedule/wakeups.js +10 -7
  121. package/dist/service.d.ts +1 -2
  122. package/dist/service.js +27 -5
  123. package/dist/session-remote.d.ts +7 -2
  124. package/dist/session-remote.js +162 -92
  125. package/dist/session.d.ts +226 -92
  126. package/dist/session.js +61 -23
  127. package/dist/telegram.d.ts +1 -1
  128. package/dist/telegram.js +1 -1
  129. package/dist/tunnel.d.ts +18 -3
  130. package/dist/tunnel.js +32 -40
  131. package/package.json +1 -1
@@ -2,8 +2,20 @@
2
2
  * The secret set a deployed agent needs, computed from the definition — host-neutral. Required values
3
3
  * gate every target; optional channel values travel only when configured. Only the SET command differs
4
4
  * (`fly secrets import` vs `railway variables set`). The runbooks list both classes; `--run` reads local values.
5
+ *
6
+ * Both DIRECTIONS of the credential carry live here: the deploy-time assembly below, and the boot-time
7
+ * seed read at the bottom. They were split across a host driver (`fly/run.ts`), which left `start`
8
+ * — a serving path that deploys nothing, on Fly or anywhere — importing from it to boot a container.
5
9
  */
6
- import { channelSetup } from "../scaffold/add-channel.js";
10
+ import { CONTROL_TOKEN_ENV } from "../channels/control.js";
11
+ import { CHANNEL_KINDS, channelSetup } from "../scaffold/add-channel.js";
12
+ /** The declared channels this tool has setup metadata for. A custom channel carries its own secrets;
13
+ * nothing here can name them, and guessing would print a runbook line no one can act on. */
14
+ function firstPartyChannels(channels) {
15
+ return channels.flatMap((channel) => CHANNEL_KINDS.includes(channel.name)
16
+ ? [{ kind: channel.name, ingress: channel.ingress }]
17
+ : []);
18
+ }
7
19
  /**
8
20
  * Is this local auth source an env-var API key (→ becomes a deploy secret) vs OAuth / stored / none?
9
21
  * Positive match on the UPPER_SNAKE env-var naming shape, NOT a negative exclude of today's sentinel
@@ -19,20 +31,30 @@ export function isEnvKey(source) {
19
31
  * deployment prerequisites. An OAuth/stored login has no env key here — it carries as
20
32
  * `FASTAGENT_AUTH_SEED` on the `--run` path (see each host's run module), not as a named runbook secret.
21
33
  */
22
- export function deploymentSecrets(modelAuth, channels, extraSecrets = [], longConnectionChannels = []) {
34
+ export function deploymentSecrets(modelAuth, channels, extraSecrets = []) {
23
35
  const secrets = [];
24
36
  if (isEnvKey(modelAuth))
25
37
  secrets.push({ name: modelAuth, hint: "your model provider key", required: true });
26
- for (const kind of channels) {
27
- const setupMode = longConnectionChannels.includes(kind) ? "websocket" : "webhook";
28
- for (const e of channelSetup(kind, setupMode).env) {
38
+ for (const { kind, ingress } of firstPartyChannels(channels)) {
39
+ for (const e of channelSetup(kind, ingress === "long-connection" ? "websocket" : "webhook").env) {
29
40
  secrets.push({ name: e.name, hint: e.hint, required: e.required });
30
41
  }
31
42
  }
32
43
  // Dedup: a name already covered by the model key / a channel secret must not appear twice in the runbook.
33
44
  for (const name of extraSecrets) {
34
45
  if (!secrets.some((s) => s.name === name)) {
35
- secrets.push({ name, hint: "declared in fastagent.config deploy.secrets", required: true });
46
+ const control = name === CONTROL_TOKEN_ENV;
47
+ secrets.push({
48
+ name,
49
+ hint: control
50
+ ? "the /control/* bearer token — mint one (uuidgen) and give the same value to callers"
51
+ : "declared in fastagent.config deploy.secrets",
52
+ // OPTIONAL, unlike every other extra: unset, the box mints a per-boot token and still serves,
53
+ // and every host with a shell can read it back out of control.json. Gating would stop deploys
54
+ // that work today to enforce a convenience — the pre-flight warning is where that argument
55
+ // belongs.
56
+ required: !control,
57
+ });
36
58
  }
37
59
  }
38
60
  return secrets;
@@ -74,9 +96,8 @@ export function assembleSecrets(input) {
74
96
  else {
75
97
  needsModelCredential = true; // no env key, no auth.json — `fastagent login` remediation
76
98
  }
77
- for (const kind of input.channels) {
78
- const setupMode = input.longConnectionChannels?.includes(kind) ? "websocket" : "webhook";
79
- for (const e of channelSetup(kind, setupMode).env) {
99
+ for (const { kind, ingress } of firstPartyChannels(input.channels)) {
100
+ for (const e of channelSetup(kind, ingress === "long-connection" ? "websocket" : "webhook").env) {
80
101
  const v = input.env[e.name];
81
102
  if (v)
82
103
  secrets[e.name] = v; // optional channel values travel when configured
@@ -88,7 +109,7 @@ export function assembleSecrets(input) {
88
109
  // Slack bot-token rotation is an all-or-nothing credential bundle. Its fields remain optional so a
89
110
  // manually configured long-lived token works, but a partial bundle must gate before the container
90
111
  // reaches slackChannel construction.
91
- if (input.channels.includes("slack")) {
112
+ if (input.channels.some((channel) => channel.name === "slack")) {
92
113
  const rotation = [
93
114
  "SLACK_BOT_REFRESH_TOKEN",
94
115
  "SLACK_BOT_TOKEN_EXPIRES_AT",
@@ -108,8 +129,39 @@ export function assembleSecrets(input) {
108
129
  const v = input.env[name];
109
130
  if (v)
110
131
  secrets[name] = v;
111
- else
112
- missingSecrets.push(name); // declared in config but no local value same .env remediation
132
+ // The control token is CARRIED, never gated — see {@link deploymentSecrets}: unset, the box mints
133
+ // one and serves; every other extra is declared as needed, so its absence is a stop.
134
+ else if (name !== CONTROL_TOKEN_ENV)
135
+ missingSecrets.push(name);
113
136
  }
114
137
  return { secrets, missingSecrets, needsModelCredential };
115
138
  }
139
+ /**
140
+ * The bytes to seed to the auth file, or undefined to leave it alone — the pure core of `start`'s
141
+ * FASTAGENT_AUTH_SEED materialization (the read side of {@link assembleSecrets}'s carry). ABSENT-ONLY
142
+ * by design: a present file (a refreshed volume copy) is never overwritten by the stale seed, so a box
143
+ * that ran its own OAuth refresh is not rolled back.
144
+ */
145
+ export function authSeedBytes(seed, fileExists) {
146
+ return !seed || fileExists ? undefined : Buffer.from(seed, "base64");
147
+ }
148
+ /**
149
+ * Collect the (possibly CHUNKED) auth seed from the environment: `FASTAGENT_AUTH_SEED` plus numbered
150
+ * continuations (`_2`, `_3`, …) concatenated in order. Hosts whose env values carry a small max
151
+ * length (AgentCore: 2048 chars — a real OAuth auth.json's base64 exceeds it) split the seed across
152
+ * them at deploy time; single-var hosts (Fly/Railway) never set a continuation and are unchanged.
153
+ * Collection stops at the first absent/empty continuation — the writer fills them contiguously.
154
+ */
155
+ export function collectAuthSeed(env) {
156
+ const first = env.FASTAGENT_AUTH_SEED;
157
+ if (!first)
158
+ return undefined;
159
+ let seed = first;
160
+ for (let i = 2;; i++) {
161
+ const part = env[`FASTAGENT_AUTH_SEED_${i}`];
162
+ if (!part)
163
+ break;
164
+ seed += part;
165
+ }
166
+ return seed;
167
+ }
@@ -13,12 +13,12 @@
13
13
  import { spawn } from "node:child_process";
14
14
  import { relative, sep } from "node:path";
15
15
  import { watch as watchTree } from "chokidar";
16
- import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot } from "./paths.js";
17
- import { isUnderDir } from "./paths.js";
16
+ import { AGENT_CONFIG_NAMES, AGENT_MODELS_FILE, resolveStateRoot, isUnderDir, } from "./paths.js";
18
17
  import { dotEnvPath } from "./env.js";
19
18
  import { log } from "./log.js";
20
19
  import { installProxyFetch } from "./proxy.js";
21
20
  import { openExternalUrl } from "./open-url.js";
21
+ import { declaredChannels } from "./channels/discover.js";
22
22
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
23
23
  /** What the dev watcher restarts on (agent-dir-relative): the process-bound code inputs only. */
24
24
  /**
@@ -108,9 +108,8 @@ export async function runDevSupervisor(placement, options = {}) {
108
108
  void startCloudflareTunnel(m.port).then((t) => {
109
109
  if (t) {
110
110
  tunnel = t;
111
- void announceWebhooks(placement.agentDir, t.url, {
111
+ void announceWebhooks(placement.agentDir, t.url, declaredChannels(m.routeChannels ?? []), {
112
112
  openUrl: openExternalUrl,
113
- routeChannels: m.routeChannels,
114
113
  stateRoot: resolveStateRoot(placement.agentDir),
115
114
  });
116
115
  }
@@ -18,7 +18,7 @@ import { log } from "../../log.js";
18
18
  import { isDeferredTool } from "./tool.js";
19
19
  import { activePath, resolveSessionSettings } from "./session-settings.js";
20
20
  import { DEFAULT_THINKING_LEVEL } from "./models.js";
21
- import { additiveActivation, agentSessionManager, turnContext } from "./tool-context.js";
21
+ import { agentSessionManager, sessionToolActivation, turnContext } from "./tool-context.js";
22
22
  /**
23
23
  * The session custom-entry type recording ONE activation delta: `{ names }` — exactly the deferred
24
24
  * tools a loader activated in that call.
@@ -47,37 +47,6 @@ function recordedActivations(session) {
47
47
  /** Warned once per session+missing set: a fresh session is built per invoke and channel sessions run
48
48
  * for weeks, so an un-deduped warn would repeat every turn and dilute its own signal. */
49
49
  const warnedDroppedActivations = new Set();
50
- /**
51
- * The turn's {@link ToolActivation} over a live session — the same bridge chat uses, so one
52
- * built-in `search_tools` serves both.
53
- *
54
- * Activations are PERSISTED as deltas, so a tool discovered in one turn stays callable in the next.
55
- * pi's own chat session does not do this (it has no place to put the record); a served session does,
56
- * because the alternative is an agent that re-discovers the same capability every single turn.
57
- */
58
- function sessionToolActivation(session) {
59
- // Serialize activations: the read-modify-write below is only race-free while nothing awaits
60
- // between read and write, and parallel tool calls in one batch would otherwise double-stamp.
61
- let chain = Promise.resolve([]);
62
- return {
63
- active: () => session.getActiveToolNames(),
64
- registered: () => session.getAllTools().map((t) => ({ name: t.name, description: t.description ?? "" })),
65
- activate(names) {
66
- const run = async () => {
67
- const current = session.getActiveToolNames();
68
- const added = additiveActivation(session.getAllTools().map((t) => t.name), current, names);
69
- if (added.length > 0) {
70
- session.setActiveToolsByName([...current, ...added]);
71
- session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added });
72
- }
73
- return added;
74
- };
75
- const result = chain.then(run, run);
76
- chain = result.catch(() => []);
77
- return result;
78
- },
79
- };
80
- }
81
50
  /**
82
51
  * fastagent's tools as pi tool definitions, bound to ONE session.
83
52
  *
@@ -85,8 +54,12 @@ function sessionToolActivation(session) {
85
54
  * tool needs the session to reach the turn context. A tool that somehow runs before that binding
86
55
  * throws rather than executing outside the turn: a broken lifecycle must not look like a normal
87
56
  * out-of-turn call.
57
+ *
58
+ * It carries the whole turn CONTEXT, not just the session: the activation bridge holds the lock that
59
+ * orders concurrent activations, so it has to live as long as the session the activations mutate.
60
+ * Building it here, per call, would hand each parallel tool its own.
88
61
  */
89
- function toolDefinitions(tools, cwd, env, sessionId, bound) {
62
+ function toolDefinitions(tools, env, bound) {
90
63
  return tools.map((tool) => ({
91
64
  name: tool.name,
92
65
  label: tool.name,
@@ -96,10 +69,10 @@ function toolDefinitions(tools, cwd, env, sessionId, bound) {
96
69
  // without it pi's outer active-set diff double-stamps parallel calls.
97
70
  executionMode: tool.executionMode,
98
71
  execute: (id, params, signal) => {
99
- const session = bound.session;
100
- if (!session)
101
- throw new Error("tool executed before its session was bound (lifecycle invariant broken)");
102
- return turnContext.run({ cwd, sessionManager: agentSessionManager(session, sessionId), tools: sessionToolActivation(session) },
72
+ const context = bound.context;
73
+ if (!context)
74
+ throw new Error("tool executed before its turn context was bound (lifecycle invariant broken)");
75
+ return turnContext.run(context,
103
76
  // Lower-level MountedTools may consume the fifth-argument env. Directory coding tools are
104
77
  // cwd-bound and ignore it; authored tools read FastAgent's turnContext instead.
105
78
  () => tool.execute(id, params, signal, undefined, { env }));
@@ -252,9 +225,17 @@ export function piAgentSessionFactory(options) {
252
225
  // Lower-level callers with an explicit list also rely on omitted built-ins staying omitted.
253
226
  noTools: "builtin",
254
227
  ...(excludedToolNames.length > 0 ? { excludeTools: [...excludedToolNames] } : {}),
255
- customTools: toolDefinitions(tools, cwd, env, sessionId, bound),
228
+ customTools: toolDefinitions(tools, env, bound),
256
229
  });
257
- bound.session = session;
230
+ // One context for the whole session: it describes the SESSION, not the call. The activation
231
+ // bridge above all — a tool call has to see what the previous one activated.
232
+ bound.context = {
233
+ cwd,
234
+ sessionManager: agentSessionManager(session, sessionId),
235
+ // A served session HAS somewhere to record the discovery, so it does: the delta is what makes
236
+ // the tool still callable next turn (see {@link TOOL_ACTIVATION_ENTRY}).
237
+ tools: sessionToolActivation(session, (added) => session.sessionManager.appendCustomEntry(TOOL_ACTIVATION_ENTRY, { names: added })),
238
+ };
258
239
  // An extension handler that throws is otherwise dropped: pi fans errors out to registered
259
240
  // listeners and has none by default, which on a server means a broken extension looks like an
260
241
  // extension that simply did nothing.
@@ -20,16 +20,20 @@
20
20
  * Locking is vendored here on `proper-lockfile`, with the same parameters pi's file backend used
21
21
  * before pi 0.80.8 stopped exporting it (upstream's stated migration path for SDK consumers is a
22
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).
23
+ * is pi-ai's per-request hot path, so it stays UNLOCKED safe because every write that carries a
24
+ * CREDENTIAL publishes by rename ({@link writeFileAtomic}): a reader sees one whole version of the
25
+ * file or another, never a partial one, so a rotation has no torn-read window for an unlocked read
26
+ * to absorb. The one in-place write left is the `{}` bootstrap below, whose window an unlocked read
27
+ * observes as an empty file — reported as corrupt, which for a zero-byte auth.json is the right
28
+ * answer either way. The write path refuses to overwrite a corrupt file (never clobbering other
29
+ * providers' credentials).
26
30
  */
27
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
31
+ import { chmodSync, existsSync, readFileSync, writeFileSync } from "node:fs";
28
32
  import { homedir } from "node:os";
29
33
  import { dirname, join } from "node:path";
30
- import { GLOBAL_HOME_DIR, SECRETS_DIRNAME } from "../../paths.js";
34
+ import { GLOBAL_HOME_DIR, SECRETS_DIRNAME, SECRET_FILE_MODE, ensureSecretsDir } from "../../paths.js";
35
+ import { writeFileAtomic } from "../../atomic-write.js";
31
36
  import { log } from "../../log.js";
32
- import { setTimeout as sleep } from "node:timers/promises";
33
37
  import lockfile from "proper-lockfile";
34
38
  /**
35
39
  * The GLOBAL fastagent credentials file (distinct from pi's `~/.pi`), under the user-global machinery
@@ -46,7 +50,7 @@ function pick(creds, providerId) {
46
50
  const cred = creds[providerId];
47
51
  return cred && (cred.type === "oauth" || cred.type === "api_key") ? cred : undefined;
48
52
  }
49
- const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: 0o600 };
53
+ const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: SECRET_FILE_MODE };
50
54
  /**
51
55
  * Serialized cross-process read-modify-write of the credentials file: exponential-backoff retries,
52
56
  * 30s staleness, and compromise detection (the parameters pi's `FileAuthStorageBackend` used).
@@ -56,13 +60,15 @@ const AUTH_FILE_WRITE_OPTIONS = { encoding: "utf8", mode: 0o600 };
56
60
  * failed unlock after a successful operation rejects instead of leaving a stale lock silently.
57
61
  */
58
62
  async function withLockedAuthFile(authPath, fn) {
59
- const dir = dirname(authPath);
60
- if (!existsSync(dir))
61
- mkdirSync(dir, { recursive: true, mode: 0o700 });
63
+ // 0700 unconditionally, including on a directory an operator named with `--auth-path`: pointing a
64
+ // credential file somewhere is asking for that somewhere to hold a credential. Where the process
65
+ // cannot chmod (a mount it does not own), this raises — the write fails visibly instead of
66
+ // quietly leaving the directory readable, which is the trade this repo takes everywhere else.
67
+ await ensureSecretsDir(dirname(authPath));
62
68
  if (!existsSync(authPath)) {
63
69
  try {
64
70
  writeFileSync(authPath, "{}", { ...AUTH_FILE_WRITE_OPTIONS, flag: "wx" });
65
- chmodSync(authPath, 0o600);
71
+ chmodSync(authPath, SECRET_FILE_MODE);
66
72
  }
67
73
  catch (error) {
68
74
  // EEXIST: another process created the file between the existence check and this exclusive
@@ -89,10 +95,12 @@ async function withLockedAuthFile(authPath, fn) {
89
95
  const current = existsSync(authPath) ? readFileSync(authPath, "utf8") : undefined;
90
96
  const out = await fn(current);
91
97
  throwIfCompromised();
92
- if (out.next !== undefined) {
93
- writeFileSync(authPath, out.next, AUTH_FILE_WRITE_OPTIONS);
94
- chmodSync(authPath, 0o600);
95
- }
98
+ // Rename, not an in-place rewrite: it is what lets `read` stay unlocked, and it is the only
99
+ // spelling that applies the mode before the content is reachable — `writeFileSync`'s `mode` is
100
+ // a no-op on an existing file, so a chmod after it leaves the new credential briefly readable
101
+ // at whatever mode the old file carried.
102
+ if (out.next !== undefined)
103
+ writeFileAtomic(authPath, out.next, SECRET_FILE_MODE);
96
104
  throwIfCompromised();
97
105
  result = out.result;
98
106
  }
@@ -139,34 +147,26 @@ function decodeCreds(raw) {
139
147
  return parsed;
140
148
  }
141
149
  /**
142
- * Tolerant UNLOCKED read of the whole credentials file, shared by `read` and `list`. The only race
143
- * is a sub-millisecond in-place write during an OAuth rotation, which can yield an empty/partial
144
- * file; re-read a few times before concluding it is corrupt. A missing file reads as undefined
145
- * silently (normal not-configured); a valid file returns immediately, so the common case costs one
146
- * read.
150
+ * UNLOCKED read of the whole credentials file, shared by `read` and `list`. ONE read: the write
151
+ * publishes by rename, so this observes one whole version or another and has nothing to absorb by
152
+ * re-reading. A missing file reads as undefined silently (normal not-configured); anything that
153
+ * does not decode is a real corruption a hand-edit — and says so immediately, rather than after
154
+ * retrying a race that cannot happen.
147
155
  */
148
- async function readCreds(authPath, warn) {
149
- for (let attempt = 0; attempt < 3; attempt++) {
150
- let raw;
151
- try {
152
- raw = readFileSync(authPath, "utf8");
153
- }
154
- catch (error) {
155
- if (error.code === "ENOENT")
156
- return undefined; // missing/deleted
157
- warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
158
- return undefined;
159
- }
160
- if (raw !== "") {
161
- const creds = decodeCreds(raw);
162
- if (creds !== undefined)
163
- return creds;
164
- // A partial read mid-write parses as garbage; fall through and retry. A structurally invalid
165
- // root lands here too and is reported as corrupt below.
166
- }
167
- if (attempt < 2)
168
- await sleep(2);
156
+ function readCreds(authPath, warn) {
157
+ let raw;
158
+ try {
159
+ raw = readFileSync(authPath, "utf8");
160
+ }
161
+ catch (error) {
162
+ if (error.code === "ENOENT")
163
+ return undefined; // missing/deleted
164
+ warn(`[fastagent] cannot read ${authPath}: ${error.message}`);
165
+ return undefined;
169
166
  }
167
+ const creds = raw === "" ? undefined : decodeCreds(raw);
168
+ if (creds !== undefined)
169
+ return creds;
170
170
  warn(`[fastagent] corrupt auth file ${authPath}: fix or remove it`);
171
171
  return undefined;
172
172
  }
@@ -190,13 +190,13 @@ export function fastagentCredentialStore(authPath = GLOBAL_AUTH_PATH, options =
190
190
  const warn = options.warn ?? ((message) => log.warn(message));
191
191
  return {
192
192
  async read(providerId) {
193
- const creds = await readCreds(authPath, warn);
193
+ const creds = readCreds(authPath, warn);
194
194
  return creds ? pick(creds, providerId) : undefined;
195
195
  },
196
196
  async list() {
197
197
  // Metadata only, never secrets (the pi-ai `list` contract). Foreign/old entries are filtered
198
198
  // with the same validation as `read`, so both surfaces agree on what "configured" means.
199
- const creds = await readCreds(authPath, warn);
199
+ const creds = readCreds(authPath, warn);
200
200
  if (!creds)
201
201
  return [];
202
202
  const infos = [];
@@ -24,7 +24,7 @@ export interface FastagentConfig {
24
24
  selfSchedule?: boolean;
25
25
  /**
26
26
  * Serve the session control plane over HTTP (`/control/*`: state/entries/events + dispatch —
27
- * steer/abort/compact/set_model…) for remote consumers: a Web panel, a desktop app, `fastagent
27
+ * steer/abort/compact + session properties and lifecycle) for remote consumers: a Web panel, a desktop app, `fastagent
28
28
  * attach`. Default off (it is a remote-control surface). When on, `dev`/`start` generate a
29
29
  * per-boot bearer token and write `<stateRoot>/control.json` for local discovery. The serve
30
30
  * binds all interfaces by default, so the routes are LAN-reachable with the token as the only
@@ -1,4 +1,17 @@
1
- import type { ExecutionEnv, Skill, ThinkingLevel } from "@earendil-works/pi-agent-core";
1
+ /**
2
+ * Agent assembly (configuration-time): the engine assets (tools, prompt) plus the reusable ladder
3
+ * that puts a pi agent together.
4
+ *
5
+ * L2 createPiAgentFromDefinition(dir, options) — load a definition directory, assemble, then L1.
6
+ * L1 createPiAgent(options) — assemble from typed parts (the canonical ctor).
7
+ * L0 createPiAgentFromSession({ sessionFactory }) — in invoke-session.ts (the turn mechanism).
8
+ *
9
+ * Above L2 sits the agent opener createPiAgentFromDir (open.ts), which both `dev` and
10
+ * `start` drive. Each rung calls the one below; options narrow as you go up (L2 owns systemPrompt/skills —
11
+ * they come from the definition; the openers own model/tools — from config resolution).
12
+ */
13
+ import { type ExecutionEnv, type Skill, type ThinkingLevel } from "@earendil-works/pi-agent-core";
14
+ import { type ModelRuntime } from "@earendil-works/pi-coding-agent";
2
15
  import type { Provider } from "@earendil-works/pi-ai";
3
16
  import type { Agent } from "../../agent.ts";
4
17
  import { type FastagentConfig } from "./config.ts";
@@ -7,7 +20,6 @@ import type { ModuleLoadFailure } from "../../loader.ts";
7
20
  import { type ToolCollision, type MountedTool } from "./tool.ts";
8
21
  import { type PiAgentSessionFactory } from "./invoke-session.ts";
9
22
  import { type AnyModel } from "./models.ts";
10
- import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
11
23
  import { type PiSessionRecordStore } from "./session-store.ts";
12
24
  import { type Lease, type SessionObserver } from "./turn-kit.ts";
13
25
  /**
@@ -10,9 +10,9 @@
10
10
  * `start` drive. Each rung calls the one below; options narrow as you go up (L2 owns systemPrompt/skills —
11
11
  * they come from the definition; the openers own model/tools — from config resolution).
12
12
  */
13
- import { formatSkillsForSystemPrompt } from "@earendil-works/pi-agent-core";
13
+ import { formatSkillsForSystemPrompt, } from "@earendil-works/pi-agent-core";
14
14
  import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
15
- import { createCodingTools, createPowerShellTool, createReadOnlyTools } from "@earendil-works/pi-coding-agent";
15
+ import { createCodingTools, createPowerShellTool, createReadOnlyTools, } from "@earendil-works/pi-coding-agent";
16
16
  import { defaultAuthPath, resolveModel } from "./config.js";
17
17
  import { resolveSecretsDir } from "../../paths.js";
18
18
  import { loadAgentDefinition, loadExtensionPaths } from "./definition.js";
@@ -1,4 +1,4 @@
1
- import type { ExecutionEnv, Skill, SkillDiagnostic } from "@earendil-works/pi-agent-core";
1
+ import { type ExecutionEnv, type Skill, type SkillDiagnostic } from "@earendil-works/pi-agent-core";
2
2
  /** A same-name skill collision (the discarded side). Surfaced, never swallowed. */
3
3
  export interface SkillCollision {
4
4
  name: string;
@@ -131,11 +131,10 @@ export async function loadExtensionPaths(agentDir, options = {}) {
131
131
  return paths.sort();
132
132
  }
133
133
  /**
134
- * The first candidate that is a REAL file. `exists` would follow a symlink, which is how a
135
- * subdirectory's `index.ts` could otherwise point outside the definition and slip past the rule the
136
- * top-level entries already follow.
134
+ * The first candidate that is a REAL file, and whether one was found but REFUSED as a symlink.
135
+ * `exists` would follow the link, which is how a subdirectory's `index.ts` could otherwise point
136
+ * outside the definition and slip past the rule the top-level entries already follow.
137
137
  */
138
- /** The first real file among the candidates, and whether one was found but REFUSED as a symlink. */
139
138
  async function firstRealFile(e, candidates) {
140
139
  let refused = false;
141
140
  for (const candidate of candidates) {
@@ -30,6 +30,18 @@ function authCallbacks(io, userSignal, doneSignal) {
30
30
  signal: userSignal ?? new AbortController().signal,
31
31
  prompt: async (p) => {
32
32
  if (p.type === "select") {
33
+ // This branch is UNCANCELLABLE: `LoginIO.select` takes no signal, so all three the line
34
+ // below composes — the provider's own, the caller's `loginFlow({ signal })`, and the
35
+ // pending-prompt backstop — are dropped here.
36
+ //
37
+ // It costs nothing against pi 0.84's providers, where every `select` is the FIRST call of
38
+ // `login()` (bedrock, vertex, openai-codex, radius — all of them asking which login method
39
+ // to use). Nothing is racing it: no callback server is up yet, and `doneSignal` fires when
40
+ // `auth.login()` RETURNS, which it cannot do while blocked on this await.
41
+ //
42
+ // That is a property of the providers, not a guarantee, and nothing here holds it. A
43
+ // provider that ever issues a `select` after starting its callback server shows up as the
44
+ // CLI parked on stdin — widen `LoginIO.select` with a signal then.
33
45
  const v = await io.select(p.message, p.options.map((o) => ({ value: o.id, label: o.label, hint: o.description })));
34
46
  if (v === undefined)
35
47
  throw new LoginCancelled("cancelled");
@@ -94,9 +106,14 @@ async function selectProvider(io, providers, method, store) {
94
106
  return { value: p.id, label: auth?.name ?? p.name, hint: cred ? `configured (${cred.type})` : undefined };
95
107
  }));
96
108
  const id = await io.select("Select a provider", options);
97
- if (!id)
109
+ // Answers the PROVIDER, not its id: the caller needs the object, and resolving it here means the
110
+ // one place that can fail to is the one that just offered the list. Cancel and an id that was
111
+ // never offered are the same answer — nothing was chosen — and `find` gives both, since a
112
+ // provider id is always a string and `undefined` matches none of them.
113
+ const chosen = candidates.find((p) => p.id === id);
114
+ if (!chosen)
98
115
  throw new LoginCancelled("no provider selected");
99
- return id;
116
+ return chosen;
100
117
  }
101
118
  /**
102
119
  * Resolve method + provider (asking only what is not given), run the login flow, and persist. A no-op
@@ -106,26 +123,25 @@ export async function loginFlow(io, options = {}) {
106
123
  const store = options.store ?? fastagentCredentialStore(options.authPath ?? GLOBAL_AUTH_PATH);
107
124
  const providers = options.providers ?? builtinProviders();
108
125
  let method;
109
- let providerId;
126
+ let provider;
110
127
  if (options.provider) {
111
- providerId = options.provider;
112
- const p = providers.find((x) => x.id === providerId);
113
- if (!p)
114
- throw new Error(`unknown provider "${providerId}"`);
115
- method = options.method ?? (await methodForProvider(io, p));
128
+ const named = providers.find((x) => x.id === options.provider);
129
+ if (!named)
130
+ throw new Error(`unknown provider "${options.provider}"`);
131
+ provider = named;
132
+ method = options.method ?? (await methodForProvider(io, provider));
116
133
  }
117
134
  else {
118
135
  method = options.method ?? (await selectMethod(io));
119
- providerId = await selectProvider(io, providers, method, store);
136
+ provider = await selectProvider(io, providers, method, store);
120
137
  }
121
138
  // Preflight: a no-op modify runs the refuse-corrupt / writability check BEFORE the flow.
122
- await store.modify(providerId, async () => undefined);
123
- const provider = providers.find((p) => p.id === providerId);
124
- if (!provider)
125
- throw new Error(`unknown provider "${providerId}"`);
139
+ await store.modify(provider.id, async () => undefined);
140
+ // Reachable even though both branches above resolved a provider: an explicit `method` bypasses
141
+ // methodForProvider, so `{ provider: "openai-codex", method: "api_key" }` arrives here.
126
142
  const auth = method === "oauth" ? provider.auth.oauth : provider.auth.apiKey;
127
143
  if (!auth?.login)
128
- throw new Error(`provider "${providerId}" has no ${method} login`);
144
+ throw new Error(`provider "${provider.id}" has no ${method} login`);
129
145
  // `done` cancels any prompt left pending when login resolves (manual-code race backstop).
130
146
  const done = new AbortController();
131
147
  let credential;
@@ -135,6 +151,6 @@ export async function loginFlow(io, options = {}) {
135
151
  finally {
136
152
  done.abort();
137
153
  }
138
- await store.modify(providerId, async () => credential);
139
- return { provider: providerId, method };
154
+ await store.modify(provider.id, async () => credential);
155
+ return { provider: provider.id, method };
140
156
  }
@@ -5,8 +5,7 @@ import type { SessionObserver } from "./turn-kit.ts";
5
5
  import type { ModuleLoadFailure } from "../../loader.ts";
6
6
  import { type LoadedDefinition } from "./definition.ts";
7
7
  import { type PiSessionRecordStore } from "./session-store.ts";
8
- import type { ToolCollision } from "./tool.ts";
9
- import type { MountedTool } from "./tool.ts";
8
+ import type { ToolCollision, MountedTool } from "./tool.ts";
10
9
  export interface CreatePiAgentFromDirOptions {
11
10
  /** Model spec override (e.g. the CLI --model flag). Precedence: this > FASTAGENT_MODEL > config.model. */
12
11
  model?: string;
@@ -95,7 +95,7 @@ export async function createPiAgentFromDir(dir, options = {}) {
95
95
  },
96
96
  // The caller tap's boundary-event half: state_changed/compaction_* originate in the hub
97
97
  // and never cross the data plane's observer seam — without this, an audit tap wired here
98
- // would miss exactly the mutations it most needs to see (set_model).
98
+ // would miss exactly the mutations it most needs to see (`update({ model })`).
99
99
  tap: caller ? (session, event) => caller(session, event) : undefined,
100
100
  })
101
101
  : undefined;
@@ -124,7 +124,7 @@ export function makeSearchToolsTool() {
124
124
  .map((t) => t.name)
125
125
  .join(", ")}${more > 0 ? ` … and ${more} more` : ""}.${activeNote ? ` ${activeNote}` : ""}`;
126
126
  }
127
- const activated = await ctx.tools.activate(inactiveMatches.map((t) => t.name));
127
+ const activated = ctx.tools.activate(inactiveMatches.map((t) => t.name));
128
128
  // Report what actually happened, not what was attempted: a parallel sibling call may have
129
129
  // activated the same matches first, leaving nothing new here — an empty "Activated:" would lie.
130
130
  if (activated.length === 0) {
@@ -6,7 +6,7 @@
6
6
  * pi-specific is opening a DIRECTORY into one, which is why this shortcut lives here and the
7
7
  * assembly does not. A second engine ships its own opener and reuses `mountAgentService` unchanged.
8
8
  */
9
- import type { AgentService, MountAgentServiceOptions } from "../../service.ts";
9
+ import { type AgentService, type MountAgentServiceOptions } from "../../service.ts";
10
10
  export interface CreateAgentServiceOptions extends MountAgentServiceOptions {
11
11
  model?: string;
12
12
  authPath?: string;
@@ -1,3 +1,11 @@
1
+ /**
2
+ * `createAgentService` — the product as one call, with pi supplying the engine.
3
+ *
4
+ * The ASSEMBLY is engine-neutral and lives in `src/service.ts`: it takes a {@link MountableAgent}
5
+ * (the SPEC contract plus three paths) and knows nothing about how that agent was built. What is
6
+ * pi-specific is opening a DIRECTORY into one, which is why this shortcut lives here and the
7
+ * assembly does not. A second engine ships its own opener and reuses `mountAgentService` unchanged.
8
+ */
1
9
  import { mountAgentService } from "../../service.js";
2
10
  import { createPiAgentFromDir } from "./open.js";
3
11
  /**