@fastagent-sh/fastagent 0.20.0 → 0.21.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 (155) hide show
  1. package/README.md +5 -1
  2. package/dist/channels/agentcore-protocol.d.ts +112 -0
  3. package/dist/channels/agentcore-protocol.js +22 -0
  4. package/dist/channels/agentcore-service.d.ts +0 -4
  5. package/dist/channels/agentcore-service.js +1 -6
  6. package/dist/channels/agentcore-state.d.ts +5 -11
  7. package/dist/channels/agentcore-state.js +4 -1
  8. package/dist/channels/agentcore.d.ts +1 -67
  9. package/dist/channels/agentcore.js +90 -89
  10. package/dist/channels/control.d.ts +1 -1
  11. package/dist/channels/control.js +13 -62
  12. package/dist/channels/discover.d.ts +0 -1
  13. package/dist/channels/discover.js +1 -3
  14. package/dist/channels/feishu/context-buffer.d.ts +6 -0
  15. package/dist/channels/feishu/context-buffer.js +0 -38
  16. package/dist/channels/feishu/crypto.d.ts +0 -2
  17. package/dist/channels/feishu/crypto.js +3 -8
  18. package/dist/channels/feishu/feishu-api.js +2 -4
  19. package/dist/channels/feishu/feishu.js +56 -115
  20. package/dist/channels/feishu/parse.d.ts +4 -3
  21. package/dist/channels/feishu/parse.js +5 -4
  22. package/dist/channels/feishu/register-webhook.d.ts +5 -5
  23. package/dist/channels/feishu/register-webhook.js +43 -57
  24. package/dist/channels/feishu/scaffold/feishu-send.ts +12 -67
  25. package/dist/channels/feishu/setup-mode.d.ts +30 -0
  26. package/dist/channels/feishu/setup-mode.js +26 -0
  27. package/dist/channels/feishu/shared-api.d.ts +10 -0
  28. package/dist/channels/feishu/shared-api.js +38 -0
  29. package/dist/channels/http.d.ts +0 -8
  30. package/dist/channels/http.js +4 -56
  31. package/dist/channels/kit/turn-runner.d.ts +59 -0
  32. package/dist/channels/kit/turn-runner.js +84 -0
  33. package/dist/channels/lark/scaffold/lark-send.ts +12 -67
  34. package/dist/channels/registration.d.ts +36 -1
  35. package/dist/channels/registration.js +57 -1
  36. package/dist/channels/secret.d.ts +1 -0
  37. package/dist/channels/secret.js +16 -0
  38. package/dist/channels/slack/config-api.d.ts +35 -3
  39. package/dist/channels/slack/config-api.js +51 -9
  40. package/dist/channels/slack/manifest.js +5 -1
  41. package/dist/channels/slack/onboard.d.ts +5 -5
  42. package/dist/channels/slack/onboard.js +41 -20
  43. package/dist/channels/slack/register-webhook.d.ts +3 -2
  44. package/dist/channels/slack/register-webhook.js +34 -14
  45. package/dist/channels/slack/scaffold/channel.ts +3 -10
  46. package/dist/channels/slack/scaffold/slack-send.ts +18 -126
  47. package/dist/channels/slack/shared-api.d.ts +10 -0
  48. package/dist/channels/slack/shared-api.js +34 -0
  49. package/dist/channels/slack/slack-api.d.ts +20 -2
  50. package/dist/channels/slack/slack-api.js +81 -22
  51. package/dist/channels/slack/slack.d.ts +0 -10
  52. package/dist/channels/slack/slack.js +60 -99
  53. package/dist/channels/sse.d.ts +4 -0
  54. package/dist/channels/sse.js +66 -0
  55. package/dist/channels/telegram/register-webhook.d.ts +6 -9
  56. package/dist/channels/telegram/register-webhook.js +44 -42
  57. package/dist/channels/telegram/telegram.js +47 -142
  58. package/dist/channels/wait-health.js +7 -4
  59. package/dist/cli/add-feishu.js +3 -10
  60. package/dist/cli/add-slack.js +7 -20
  61. package/dist/cli/commands/add.d.ts +0 -1
  62. package/dist/cli/commands/add.js +22 -31
  63. package/dist/cli/commands/chat.js +6 -12
  64. package/dist/cli/commands/deploy/agentcore.d.ts +2 -0
  65. package/dist/cli/commands/deploy/agentcore.js +178 -0
  66. package/dist/cli/commands/deploy/docker.d.ts +2 -0
  67. package/dist/cli/commands/deploy/docker.js +119 -0
  68. package/dist/cli/commands/deploy/fly.d.ts +2 -0
  69. package/dist/cli/commands/deploy/fly.js +131 -0
  70. package/dist/cli/commands/deploy/railway.d.ts +2 -0
  71. package/dist/cli/commands/deploy/railway.js +71 -0
  72. package/dist/cli/commands/deploy/shared.d.ts +114 -0
  73. package/dist/cli/commands/deploy/shared.js +124 -0
  74. package/dist/cli/commands/deploy.d.ts +17 -49
  75. package/dist/cli/commands/deploy.js +39 -618
  76. package/dist/cli/commands/dev.js +17 -46
  77. package/dist/cli/commands/fire.js +6 -12
  78. package/dist/cli/commands/info.js +2 -1
  79. package/dist/cli/commands/invoke.js +4 -11
  80. package/dist/cli/commands/schedule.js +1 -1
  81. package/dist/cli/commands/start.js +17 -46
  82. package/dist/cli/commands/tool.js +3 -8
  83. package/dist/cli/kernel.d.ts +0 -2
  84. package/dist/cli/kernel.js +0 -2
  85. package/dist/cli/program.js +7 -12
  86. package/dist/cli/serve.d.ts +36 -19
  87. package/dist/cli/serve.js +91 -7
  88. package/dist/cli/shared.d.ts +13 -18
  89. package/dist/cli/shared.js +20 -4
  90. package/dist/deploy/agentcore/forwarder.js +250 -0
  91. package/dist/deploy/agentcore/plan.d.ts +23 -2
  92. package/dist/deploy/agentcore/plan.js +29 -244
  93. package/dist/deploy/agentcore/run.d.ts +4 -20
  94. package/dist/deploy/agentcore/run.js +22 -7
  95. package/dist/deploy/channel-ingress.js +1 -1
  96. package/dist/deploy/docker/run.d.ts +17 -3
  97. package/dist/deploy/docker/run.js +30 -8
  98. package/dist/deploy/fly/plan.js +7 -0
  99. package/dist/deploy/fly/run.d.ts +27 -0
  100. package/dist/deploy/fly/run.js +102 -20
  101. package/dist/deploy/hosts.d.ts +5 -0
  102. package/dist/deploy/hosts.js +4 -0
  103. package/dist/deploy/preflight.js +3 -3
  104. package/dist/deploy/railway/plan.d.ts +5 -0
  105. package/dist/deploy/railway/plan.js +7 -0
  106. package/dist/deploy/railway/run.d.ts +2 -1
  107. package/dist/deploy/railway/run.js +5 -4
  108. package/dist/deploy/secrets.js +0 -17
  109. package/dist/engines/pi/agent-session-factory.d.ts +37 -17
  110. package/dist/engines/pi/agent-session-factory.js +109 -85
  111. package/dist/engines/pi/config.d.ts +1 -1
  112. package/dist/engines/pi/create.d.ts +22 -39
  113. package/dist/engines/pi/create.js +58 -73
  114. package/dist/engines/pi/definition.js +8 -8
  115. package/dist/engines/pi/invoke-session.js +13 -30
  116. package/dist/engines/pi/open.d.ts +2 -2
  117. package/dist/engines/pi/open.js +31 -41
  118. package/dist/engines/pi/retry-event.d.ts +6 -0
  119. package/dist/engines/pi/retry-event.js +15 -0
  120. package/dist/engines/pi/session-builder.js +26 -113
  121. package/dist/engines/pi/session-control.d.ts +10 -18
  122. package/dist/engines/pi/session-control.js +47 -81
  123. package/dist/engines/pi/session-settings.d.ts +1 -1
  124. package/dist/engines/pi/session-settings.js +1 -1
  125. package/dist/engines/pi/session-store.d.ts +12 -17
  126. package/dist/engines/pi/session-store.js +18 -40
  127. package/dist/engines/pi/tool-context.d.ts +3 -3
  128. package/dist/engines/pi/tool.d.ts +6 -9
  129. package/dist/engines/pi/tool.js +1 -0
  130. package/dist/feishu.d.ts +1 -0
  131. package/dist/feishu.js +1 -0
  132. package/dist/lark.d.ts +1 -0
  133. package/dist/lark.js +1 -0
  134. package/dist/loader.d.ts +2 -0
  135. package/dist/loader.js +5 -0
  136. package/dist/log.d.ts +9 -17
  137. package/dist/log.js +25 -30
  138. package/dist/paths.d.ts +7 -3
  139. package/dist/paths.js +17 -4
  140. package/dist/scaffold/add-channel.d.ts +6 -1
  141. package/dist/scaffold/add-channel.js +48 -65
  142. package/dist/schedule/wake-alarm.d.ts +1 -12
  143. package/dist/schedule/wake-alarm.js +2 -3
  144. package/dist/service.d.ts +23 -22
  145. package/dist/service.js +7 -73
  146. package/dist/session-remote.d.ts +5 -5
  147. package/dist/session-remote.js +25 -28
  148. package/dist/session.d.ts +1 -1
  149. package/dist/slack.d.ts +2 -0
  150. package/dist/slack.js +1 -0
  151. package/dist/tunnel.d.ts +16 -6
  152. package/dist/tunnel.js +53 -12
  153. package/package.json +5 -4
  154. package/dist/channels/slack/bot-auth.d.ts +0 -15
  155. package/dist/channels/slack/bot-auth.js +0 -135
@@ -3,34 +3,23 @@
3
3
  * assemble + serve, restarting it on agent edits. A fresh process per reload means what is served
4
4
  * is always the latest code, including modules a tool/config imports.
5
5
  */
6
- import { resolve } from "node:path";
7
6
  import { runDevSupervisor } from "../../dev-supervisor.js";
8
- import { loadDotEnv } from "../../env.js";
9
7
  import { setLogLevel } from "../../log.js";
10
8
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
11
9
  import { mountAgentService } from "../../service.js";
12
10
  import { logAgentLoop } from "../../observe.js";
13
- import { installProxyFetch } from "../../proxy.js";
14
- import { bindAddress } from "../../bind.js";
15
- import { failStartup, placementOrExit } from "../fail.js";
16
- import { SHUTDOWN_GRACE_MS, assertTunnelBindable, maybeTunnel, reportServing, serve } from "../serve.js";
17
- import { parseBind, parsePort, reportAssembly, resolveFirstRunModel } from "../shared.js";
11
+ import { failStartup } from "../fail.js";
12
+ import { assertTunnelBindable, cliMountOptions, resolveBindHost, serveService } from "../serve.js";
13
+ import { enterAgentCommand, parseBind, parsePort, reportAssembly } from "../shared.js";
18
14
  export async function runDev(dirArg, opts) {
19
- const dir = resolve(dirArg);
20
- const placement = placementOrExit(dir);
21
15
  setLogLevel("debug"); // dev posture: verbose, includes the debug turn trace (content) — supervisor and worker both
22
16
  const isWorker = process.env.FASTAGENT_DEV_WORKER === "1";
23
- // Pick a model interactively once, in the parent (both watch and --no-watch have a TTY); a spawned
24
- // watch worker inherits the choice via FASTAGENT_MODEL, so it must not prompt again. Load .env and
25
- // the proxy FIRST (as invoke/start do): the picker reads FASTAGENT_MODEL and provider keys from
26
- // .env, and getAuth's OAuth refresh must go through HTTPS_PROXY. The worker re-loads both in serveOnce.
27
- if (!isWorker) {
28
- loadDotEnv(placement.agentDir);
29
- installProxyFetch();
30
- await resolveFirstRunModel(placement.agentDir, opts);
31
- }
17
+ // The model is picked ONCE, in the parent process (a TTY; watch and --no-watch both); a spawned
18
+ // worker inherits the choice through FASTAGENT_MODEL and must not prompt even when the pick was
19
+ // cancelled.
20
+ const placement = await enterAgentCommand(dirArg, { ...opts, input: isWorker ? false : opts.input });
32
21
  if (isWorker || opts.watch === false) {
33
- await serveOnce(dir, opts);
22
+ await serveOnce(placement, opts);
34
23
  return;
35
24
  }
36
25
  parsePort(opts.port, "--port", "flag"); // flag-shape checks before spawning
@@ -40,41 +29,23 @@ export async function runDev(dirArg, opts) {
40
29
  await runDevSupervisor(placement, { tunnel: opts.tunnel ?? false });
41
30
  }
42
31
  /** Assemble the agent and serve it once (the dev worker; also the --no-watch path). */
43
- async function serveOnce(dir, opts) {
32
+ async function serveOnce(placement, opts) {
44
33
  const portFlag = parsePort(opts.port, "--port", "flag");
45
34
  const bindFlag = parseBind(opts.bind);
46
- loadDotEnv(placementOrExit(dir).agentDir);
47
- installProxyFetch();
48
- const a = await createPiAgentFromDir(dir, {
35
+ const tunnel = opts.tunnel ?? false;
36
+ const a = await createPiAgentFromDir(placement.workspace, {
49
37
  model: opts.model,
50
38
  authPath: opts.authPath, // flag > FASTAGENT_AUTH_PATH > default — resolved by the opener (one owner)
51
39
  serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
52
40
  }).catch(failStartup);
53
41
  // The same report `start` prints; `config:` is dev's own extra (see reportAssembly on the asymmetry).
54
42
  await reportAssembly(a, { beforeModel: [["config", a.configPath ?? "(none)"]] });
55
- // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
56
- // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
57
- const configured = a.config.http?.host;
58
- const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
59
- assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
43
+ const host = resolveBindHost(bindFlag, a.config.http?.host, tunnel);
60
44
  // The SAME assembly an embedder gets from `createAgentService` — channels, control plane,
61
45
  // schedules, long connections. `dev` opens the directory itself only because its startup report
62
- // prints the opened values before anything mounts.
63
- const service = await mountAgentService(a, {
64
- // Trace each turn's agent loop (tool calls + reply) to the log at debug level — shown in dev,
65
- // gated out in start (level info), keeping end-user content out of production logs.
66
- wrapAgent: logAgentLoop,
67
- closeTimeoutMs: SHUTDOWN_GRACE_MS,
68
- control: { tunnel: opts.tunnel ?? false, ...(host !== undefined ? { host } : {}) },
69
- onChannelClosed: (name, error) => failStartup(new Error(`${name} ${error === undefined ? "closed unexpectedly" : `failed: ${String(error)}`}`)),
70
- }).catch(failStartup);
71
- serve(service.handler, { port: portFlag ?? a.config.http?.port ?? 8787, host }, {
72
- ready: service.ready,
73
- onListening: (p) => {
74
- reportServing(service, host, p);
75
- service.announce(p);
76
- maybeTunnel(a.agentDir, service.channels.routes, p, opts.tunnel ?? false, a.stateRoot);
77
- },
78
- onShutdown: () => service.close(),
79
- });
46
+ // prints the opened values before anything mounts. The turn trace (tool calls + reply) logs at
47
+ // debug level: shown here, gated out in start (level info), keeping end-user content out of
48
+ // production logs.
49
+ const service = await mountAgentService(a, cliMountOptions(logAgentLoop)).catch(failStartup);
50
+ serveService(service, { port: portFlag ?? a.config.http?.port ?? 8787, host }, { tunnel, agentDir: a.agentDir, stateRoot: a.stateRoot });
80
51
  }
@@ -4,23 +4,17 @@
4
4
  * the schedule's stable session (faithful to the served behavior). Does NOT advance the schedule's fire
5
5
  * state — a test run must never make the scheduler skip the real next run.
6
6
  */
7
- import { join, resolve } from "node:path";
8
- import { loadDotEnv } from "../../env.js";
7
+ import { join } from "node:path";
9
8
  import { displayPath } from "../../paths.js";
10
- import { reportModuleLoadFailures } from "../../log.js";
9
+ import { reportModuleLoadFailures } from "../../loader.js";
11
10
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
12
11
  import { runInvokeStream } from "../invoke-stream.js";
13
- import { installProxyFetch } from "../../proxy.js";
14
12
  import { loadSchedules } from "../../schedule/discover.js";
15
13
  import { scheduleSession } from "../../schedule/scheduler.js";
16
- import { failStartup, placementOrExit } from "../fail.js";
17
- import { reportAuth, resolveFirstRunModel } from "../shared.js";
14
+ import { failStartup } from "../fail.js";
15
+ import { enterAgentCommand, reportAuth } from "../shared.js";
18
16
  export async function runFire(name, dirArg, opts) {
19
- const fireDir = resolve(dirArg);
20
- const placement = placementOrExit(fireDir);
21
- loadDotEnv(placement.agentDir);
22
- installProxyFetch();
23
- await resolveFirstRunModel(placement.agentDir, opts);
17
+ const placement = await enterAgentCommand(dirArg, opts);
24
18
  // Schedules are agent surface — discover them where dev/start/`schedule list` do (the agent
25
19
  // dir), so `fire` sees the same set the scheduler serves.
26
20
  const { schedules, failures } = await loadSchedules(placement.agentDir).catch(failStartup);
@@ -32,7 +26,7 @@ export async function runFire(name, dirArg, opts) {
32
26
  failStartup(new Error(`unknown schedule "${name}" (looked in ${displayPath(process.cwd(), join(placement.agentDir, "schedules")) ?? "schedules"}). ` +
33
27
  `available: ${schedules.map((s) => s.name).join(", ") || "(none)"}`));
34
28
  }
35
- const { agent, modelSpec, authPath } = await createPiAgentFromDir(fireDir, {
29
+ const { agent, modelSpec, authPath } = await createPiAgentFromDir(placement.workspace, {
36
30
  model: opts.model,
37
31
  authPath: opts.authPath, // flag > FASTAGENT_AUTH_PATH > default — resolved by the opener (one owner)
38
32
  }).catch(failStartup);
@@ -8,7 +8,8 @@ import { resolveStateRoot, workspaceHint } from "../../paths.js";
8
8
  import { CODING_TOOL_NAMES, resolveAgentTools } from "../../engines/pi/create.js";
9
9
  import { loadAgentDefinition } from "../../engines/pi/definition.js";
10
10
  import { reportFindingsIfChanged, reportToolCollisions } from "../../engines/pi/report.js";
11
- import { reportModuleLoadFailures, log } from "../../log.js";
11
+ import { log } from "../../log.js";
12
+ import { reportModuleLoadFailures } from "../../loader.js";
12
13
  import { nextRun } from "../../schedule/cron.js";
13
14
  import { loadSchedules } from "../../schedule/discover.js";
14
15
  import { failStartup, placementOrExit } from "../fail.js";
@@ -1,19 +1,12 @@
1
1
  /** `fastagent invoke <message> [dir]`: run ONE turn against the assembled agent, then exit. */
2
2
  import { randomUUID } from "node:crypto";
3
- import { resolve } from "node:path";
4
- import { loadDotEnv } from "../../env.js";
5
3
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
6
4
  import { runInvokeStream } from "../invoke-stream.js";
7
- import { installProxyFetch } from "../../proxy.js";
8
- import { failStartup, placementOrExit } from "../fail.js";
9
- import { reportAuth, resolveFirstRunModel } from "../shared.js";
5
+ import { failStartup } from "../fail.js";
6
+ import { enterAgentCommand, reportAuth } from "../shared.js";
10
7
  export async function runInvoke(message, dirArg, opts) {
11
- const invokeDir = resolve(dirArg);
12
- const placement = placementOrExit(invokeDir);
13
- loadDotEnv(placement.agentDir);
14
- installProxyFetch();
15
- await resolveFirstRunModel(placement.agentDir, opts);
16
- const { agent, modelSpec, authPath } = await createPiAgentFromDir(invokeDir, {
8
+ const placement = await enterAgentCommand(dirArg, opts);
9
+ const { agent, modelSpec, authPath } = await createPiAgentFromDir(placement.workspace, {
17
10
  model: opts.model,
18
11
  authPath: opts.authPath, // flag > FASTAGENT_AUTH_PATH > default — resolved by the opener (one owner)
19
12
  }).catch(failStartup);
@@ -6,7 +6,7 @@
6
6
  import { resolve } from "node:path";
7
7
  import { loadDotEnv } from "../../env.js";
8
8
  import { resolveStateRoot } from "../../paths.js";
9
- import { reportModuleLoadFailures } from "../../log.js";
9
+ import { reportModuleLoadFailures } from "../../loader.js";
10
10
  import { readRuns } from "../../schedule/audit.js";
11
11
  import { nextRun } from "../../schedule/cron.js";
12
12
  import { loadSchedules } from "../../schedule/discover.js";
@@ -2,42 +2,40 @@
2
2
  * `fastagent start [dir]`: run the agent in production posture — the SAME assembly as dev (your
3
3
  * directory is the agent), just no file-watching. No build step: start reads the definition directly.
4
4
  */
5
- import { dirname, resolve } from "node:path";
5
+ import { dirname } from "node:path";
6
6
  import { writeFileAtomic } from "../../atomic-write.js";
7
7
  import { authSeedBytes, collectAuthSeed } from "../../deploy/secrets.js";
8
- import { loadDotEnv } from "../../env.js";
9
8
  import { resolveAuthPath, resolveSessionsDirOverride } from "../../engines/pi/config.js";
10
9
  import { SECRET_FILE_MODE, ensureSecretsDir, resolveSecretsDir, isUnderDir, exists } from "../../paths.js";
11
10
  import { log, setLogLevel } from "../../log.js";
12
11
  import { createPiAgentFromDir } from "../../engines/pi/open.js";
13
12
  import { mountAgentService } from "../../service.js";
14
13
  import { logAgentLoop } from "../../observe.js";
15
- import { installProxyFetch } from "../../proxy.js";
16
- import { bindAddress } from "../../bind.js";
17
14
  import { isAgentcoreRuntime, mountAgentcoreService } from "../../channels/agentcore-service.js";
18
15
  import { createWakeAlarmSink } from "../../schedule/wake-alarm.js";
19
16
  import { setWakeupsSink } from "../../schedule/wakeups.js";
20
- import { failStartup, placementOrExit } from "../fail.js";
21
- import { SHUTDOWN_GRACE_MS, assertTunnelBindable, maybeTunnel, reportServing, serve } from "../serve.js";
22
- import { parseBind, parsePort, reportAssembly, resolveFirstRunModel } from "../shared.js";
17
+ import { failStartup } from "../fail.js";
18
+ import { cliMountOptions, resolveBindHost, serveService } from "../serve.js";
19
+ import { enterAgentCommand, parseBind, parsePort, reportAssembly } from "../shared.js";
23
20
  export async function runStart(dirArg, opts) {
24
- const dir = resolve(dirArg);
25
21
  // Flag validation first: a bad --port is a USAGE error (exit 2), and reporting it must not depend on
26
22
  // the directory being an agent (which is a runtime/environment failure, exit 1).
27
23
  const portFlag = parsePort(opts.port, "--port", "flag");
28
24
  const bindFlag = parseBind(opts.bind);
29
- const placement = placementOrExit(dir);
25
+ const tunnel = opts.tunnel ?? false;
30
26
  setLogLevel("info"); // production posture: info+, the debug turn trace (and its end-user content) gated out
31
- loadDotEnv(placement.agentDir);
32
- installProxyFetch();
33
- await resolveFirstRunModel(placement.agentDir, opts);
27
+ const placement = await enterAgentCommand(dirArg, opts);
34
28
  // A `deploy --run` may carry the operator's local credential as FASTAGENT_AUTH_SEED —
35
29
  // materialize it into the writable secrets dir BEFORE the opener resolves auth (once, absent-only).
36
30
  // Same resolveAuthPath the opener uses — ONE owner of the flag > env > default chain.
37
31
  await maybeSeedAuth(resolveAuthPath(placement.agentDir, opts.authPath));
38
32
  // The same opener dev uses (single assembly source), just no watch.
39
- const sessionsDirOverride = resolveSessionsDirOverride(opts.sessionsDir);
40
- const opened = await openStartDir(dir, opts, sessionsDirOverride);
33
+ const opened = await createPiAgentFromDir(placement.workspace, {
34
+ model: opts.model,
35
+ sessionsDir: resolveSessionsDirOverride(opts.sessionsDir),
36
+ authPath: opts.authPath,
37
+ serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
38
+ }).catch(failStartup);
41
39
  const { agent, agentDir, config, stateRoot, sessionsDir } = opened;
42
40
  // The same report `dev` prints; `state:`/`sessions:` are start's own extras, and the persistence
43
41
  // notes below are why (see reportAssembly on the asymmetry).
@@ -65,14 +63,9 @@ export async function runStart(dirArg, opts) {
65
63
  log.info(`[fastagent] note: secrets (.env, rotated auth.json) live under the definition dir; point ` +
66
64
  `FASTAGENT_SECRETS_DIR at a persistent volume so a redeploy that replaces the dir does not wipe them.`);
67
65
  }
68
- // Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
66
+ // Same debug turn trace as dev; gated out here by the info level.
69
67
  const traced = logAgentLoop(agent);
70
- // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
71
- // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
72
- const configured = config.http?.host;
73
- const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
74
- assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
75
- const control = { tunnel: opts.tunnel ?? false, ...(host !== undefined ? { host } : {}) };
68
+ const host = resolveBindHost(bindFlag, config.http?.host, tunnel);
76
69
  // ONE branch for the whole posture. AgentCore assembles differently — lazy channels over a
77
70
  // pre-restore state mount, an external clock, no resident connections — but it yields the same
78
71
  // AgentService, so everything below this point is common.
@@ -81,36 +74,14 @@ export async function runStart(dirArg, opts) {
81
74
  // handed to something that can, without inventing an ownership the singleton does not have.
82
75
  const onStateReady = isAgentcoreRuntime() && config.selfSchedule ? armWakeAlarms(stateRoot) : undefined;
83
76
  const service = await (isAgentcoreRuntime()
84
- ? mountAgentcoreService(opened, { wrapAgent: () => traced, control, onStateReady })
85
- : mountAgentService(opened, {
86
- wrapAgent: () => traced,
87
- closeTimeoutMs: SHUTDOWN_GRACE_MS,
88
- control,
89
- onChannelClosed: (name, error) => failStartup(new Error(`${name} ${error === undefined ? "closed unexpectedly" : `failed: ${String(error)}`}`)),
90
- })).catch(failStartup);
91
- serve(service.handler, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, {
92
- ready: service.ready,
93
- onListening: (p) => {
94
- reportServing(service, host, p);
95
- service.announce(p);
96
- maybeTunnel(agentDir, service.channels.routes, p, opts.tunnel ?? false, stateRoot);
97
- },
98
- onShutdown: () => service.close(),
99
- });
77
+ ? mountAgentcoreService(opened, { wrapAgent: () => traced, onStateReady })
78
+ : mountAgentService(opened, cliMountOptions(() => traced))).catch(failStartup);
79
+ serveService(service, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, { tunnel, agentDir, stateRoot });
100
80
  // No graceful drain: webhook turns run fire-and-forget; SIGTERM just exits mid-turn. Whether an
101
81
  // in-flight turn is LOST depends on the channel: the Telegram channel persists turn intent pre-ACK
102
82
  // and replays it next start (turn-store.ts, L1 durable execution, at-least-once); HTTP and other
103
83
  // channels have no such layer, so their in-flight turns are still lost (the asker re-invokes).
104
84
  }
105
- /** The opener, kept as its own call so `opened` can also feed the shared assembly below. */
106
- function openStartDir(dir, opts, sessionsDir) {
107
- return createPiAgentFromDir(dir, {
108
- model: opts.model,
109
- sessionsDir,
110
- authPath: opts.authPath,
111
- serving: true, // long-running serve: the scheduler poller runs (wake mounts iff config.selfSchedule)
112
- }).catch(failStartup);
113
- }
114
85
  /**
115
86
  * Materialize `FASTAGENT_AUTH_SEED` (base64 of an auth.json, set by `deploy --run`) into the
116
87
  * writable secrets dir ONCE — only when the seed is set AND the auth file is absent, so a refreshed
@@ -1,10 +1,9 @@
1
1
  /** `fastagent tool <name> '<json>' [dir]`: run one tool's body directly with JSON args — no model. */
2
2
  import { resolve } from "node:path";
3
- import { NodeExecutionEnv } from "@earendil-works/pi-agent-core/node";
4
3
  import { loadDotEnv } from "../../env.js";
5
4
  import { loadConfig } from "../../engines/pi/config.js";
6
5
  import { resolveAgentTools } from "../../engines/pi/create.js";
7
- import { reportModuleLoadFailures } from "../../log.js";
6
+ import { reportModuleLoadFailures } from "../../loader.js";
8
7
  import { turnContext } from "../../engines/pi/tool-context.js";
9
8
  import { failStartup, failUsage, placementOrExit } from "../fail.js";
10
9
  export async function runTool(name, argsJson, dirArg) {
@@ -26,12 +25,8 @@ export async function runTool(name, argsJson, dirArg) {
26
25
  if (!tool) {
27
26
  failStartup(new Error(`unknown tool "${name}". available: ${tools.map((t) => t.name).join(", ") || "(none)"}`));
28
27
  }
29
- // Authored tools read cwd from turnContext; coding tools are already rooted at the workspace. Keep
30
- // the fifth-argument env for lower-level MountedTools that explicitly consume it.
31
- const env = new NodeExecutionEnv({ cwd: workspace });
32
- const result = await turnContext
33
- .run({ cwd: workspace }, () => tool.execute(`cli-${name}`, args, undefined, undefined, { env }))
34
- .catch(failStartup);
28
+ // Authored tools read cwd from turnContext; coding tools are already rooted at the workspace.
29
+ const result = await turnContext.run({ cwd: workspace }, () => tool.execute(`cli-${name}`, args)).catch(failStartup);
35
30
  const out = result?.details !== undefined
36
31
  ? result.details
37
32
  : (result?.content ?? []).map((c) => ("text" in c ? c.text : "")).join("");
@@ -24,8 +24,6 @@ interface ArgSpec {
24
24
  export interface FlagSpec {
25
25
  flags: string;
26
26
  description: string;
27
- /** Parses but does not appear in help — for retired flags that should still explain themselves. */
28
- hidden?: boolean;
29
27
  /** Mutually exclusive with these {@link optionKey} values — validated at build time. */
30
28
  conflicts?: string[];
31
29
  }
@@ -114,8 +114,6 @@ function register(parent, spec) {
114
114
  }
115
115
  for (const f of spec.flags ?? []) {
116
116
  const opt = new Option(f.flags, f.description);
117
- if (f.hidden)
118
- opt.hideHelp();
119
117
  if (f.conflicts)
120
118
  opt.conflicts(f.conflicts);
121
119
  cmd.addOption(opt);
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { fastagentVersion } from "../version.js";
8
8
  import { buildProgram } from "./kernel.js";
9
+ import { DEPLOY_HOSTS } from "../deploy/hosts.js";
9
10
  // Help groups (clig: most common commands first) — the authoring loop leads, operations close.
10
11
  // Shared flags — same name, same meaning, on every command that supports them (clig: consistency).
11
12
  const DIR_ARG = {
@@ -256,8 +257,6 @@ const start = {
256
257
  input: f.input !== false,
257
258
  }),
258
259
  };
259
- /** The retired app-creation flag — parsed so it can explain itself, hidden from help. */
260
- const CREATE_APP = { flags: "--create-app", description: "(retired)", hidden: true };
261
260
  const INGRESS = {
262
261
  flags: "--ingress <mode>",
263
262
  description: "Feishu/Lark ingress: websocket or webhook (interactive when omitted)",
@@ -280,18 +279,14 @@ const channelSub = (kind, summary, description, notes) => ({
280
279
  summary,
281
280
  description,
282
281
  args: [DIR_ARG],
283
- flags: [
284
- CREATE_APP,
285
- ...(kind === "feishu" || kind === "lark"
286
- ? [INGRESS, GROUP_BEHAVIOR]
287
- : kind === "slack"
288
- ? [GROUP_BEHAVIOR, NO_ONBOARD, REPLACE_CONFIG]
289
- : []),
290
- ],
282
+ flags: kind === "feishu" || kind === "lark"
283
+ ? [INGRESS, GROUP_BEHAVIOR]
284
+ : kind === "slack"
285
+ ? [GROUP_BEHAVIOR, NO_ONBOARD, REPLACE_CONFIG]
286
+ : [],
291
287
  examples: [{ cmd: `fastagent add ${kind}` }],
292
288
  ...(notes ? { notes } : {}),
293
289
  run: async (args, f) => (await import("./commands/add.js")).runAddChannel(kind, args[0], {
294
- createApp: f.createApp === true,
295
290
  ingress: f.ingress,
296
291
  groupBehavior: f.groupBehavior,
297
292
  onboard: f.onboard !== false,
@@ -357,7 +352,7 @@ const deploy = {
357
352
  "CloudFormation stack (AWS Bedrock AgentCore Runtime + forwarder Lambda for webhooks + " +
358
353
  "EventBridge rules for schedules; linux/arm64 image built locally). Durable ingress " +
359
354
  "remains operator-owned (agentcore's forwarder URL is the exception — the stack owns it).",
360
- args: [{ name: "<host>", description: "deploy target", choices: ["docker", "fly", "railway", "agentcore"] }, DIR_ARG],
355
+ args: [{ name: "<host>", description: "deploy target", choices: [...DEPLOY_HOSTS] }, DIR_ARG],
361
356
  flags: [
362
357
  {
363
358
  flags: "--run",
@@ -1,5 +1,5 @@
1
- import type { ChannelHandler } from "../channel.ts";
2
- import type { AgentService } from "../service.ts";
1
+ import type { Agent } from "../agent.ts";
2
+ import type { AgentService, MountAgentServiceOptions } from "../service.ts";
3
3
  /**
4
4
  * Refuse `--tunnel` with a bind that cloudflared cannot reach: it dials the NAME `localhost:<port>`
5
5
  * (the dev supervisor's tunnel too), so anything outside `127.0.0.1`/`::1`/wildcard — including a
@@ -10,6 +10,26 @@ import type { AgentService } from "../service.ts";
10
10
  * came from config is broken runtime configuration (1).
11
11
  */
12
12
  export declare function assertTunnelBindable(host: string | undefined, tunnel: boolean, source: "flag" | "config"): void;
13
+ /**
14
+ * The bind address a serve uses: the flag, else `http.host` from the config — through `bindAddress`,
15
+ * so a configured `localhost` is an ADDRESS by the time anything binds, renders or dials it — checked
16
+ * against `--tunnel` with the exit code the SOURCE of the value earns (serve.ts assertTunnelBindable).
17
+ */
18
+ export declare function resolveBindHost(bindFlag: string | undefined, configured: string | undefined, tunnel: boolean): string | undefined;
19
+ /** What the CLI adds to the assembly: its shutdown grace, and exit on a connection that drops. */
20
+ export declare function cliMountOptions(wrapAgent: (agent: Agent) => Agent): MountAgentServiceOptions;
21
+ /**
22
+ * Bind, report, announce the control plane, open the tunnel, and close in order on a signal — the
23
+ * tail dev's worker and start share once the service is assembled.
24
+ */
25
+ export declare function serveService(service: AgentService, bind: {
26
+ port: number;
27
+ host?: string;
28
+ }, posture: {
29
+ tunnel: boolean;
30
+ agentDir: string;
31
+ stateRoot: string;
32
+ }): void;
13
33
  /**
14
34
  * The "we are serving" report: the supervisor message `dev`'s watcher waits for, the addresses, and
15
35
  * what mounted. One function because both commands must say the same thing at the same moment —
@@ -26,23 +46,20 @@ export declare function reportServing(service: AgentService, host: string | unde
26
46
  * still needs one to dial, which is what `clientHost` gives (loopback for a wildcard, itself otherwise).
27
47
  */
28
48
  export declare function readyAddressLines(host: string | undefined, boundPort: number, builtinInvoke: boolean): string[];
29
- /** What the CLI gives a service to stop in, and the hard exit that follows it. The order matters:
30
- * a forced exit before the service answers would report a clean shutdown over a stuck channel. */
31
- export declare const SHUTDOWN_GRACE_MS = 800;
32
49
  /**
33
- * Bind HTTP and report ready but only once the SERVICE is, which is not the same moment: a bound
34
- * socket is not a serving agent while a declared long-connection channel is still dialling, so this
35
- * awaits `hooks.ready` (mountAgentService owns the connections themselves) before announcing
36
- * anything. Signals are the sole clean-shutdown command; `host` unset binds all interfaces.
50
+ * Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the control plane
51
+ * once the port is known, and say what reaches it. Returns the file's removal wired into the
52
+ * shutdown so a stale file cannot point a client at a dead port (`attach` then fails with "cannot
53
+ * read", accurate, instead of a stale token's misleading 401/ECONNREFUSED). A hard exit leaves the
54
+ * file behind: advisory, overwritten by the next boot.
55
+ *
56
+ * Here and not in the assembly: the file, its permissions and the warnings are how THIS process's
57
+ * operator finds and protects the plane. An embedder distributes `service.control` itself.
37
58
  */
38
- export declare function serve(handler: ChannelHandler, bind: {
39
- port: number;
59
+ export declare function announceControl(control: {
60
+ token: string;
61
+ prefix: string;
62
+ } | undefined, stateRoot: string, bind: {
40
63
  host?: string;
41
- }, hooks?: {
42
- /** Awaited before anything is reported ready — see the listening handler. */
43
- ready?: Promise<void>;
44
- onListening?: (boundPort: number) => void;
45
- onShutdown?: () => Promise<void> | void;
46
- }): void;
47
- /** Start a Cloudflare tunnel for route channels only. */
48
- export declare function maybeTunnel(agentDir: string, routeChannels: string[], boundPort: number, tunnel: boolean, stateRoot?: string): void;
64
+ tunnel: boolean;
65
+ }, boundPort: number): () => void;
package/dist/cli/serve.js CHANGED
@@ -1,12 +1,16 @@
1
1
  /**
2
2
  * What `dev` (its worker) and `start` need beyond the service itself: binding a port, the shutdown
3
- * order, the startup report, and the optional Cloudflare quick tunnel.
3
+ * order, the startup report, the optional Cloudflare quick tunnel, and the options the CLI hands the
4
+ * assembly (`cliMountOptions`, `resolveBindHost` — policy ABOUT the assembly, decided per command).
4
5
  *
5
- * The ASSEMBLY is not here — it lives in `src/service.ts`, which a public entry may import and this
6
- * directory may not be (it decides process-level things: `fail.ts` calls `process.exit`).
6
+ * The ASSEMBLY itself is not here — it lives in `src/service.ts`, which a public entry may import and
7
+ * this directory may not be (it decides process-level things: `fail.ts` calls `process.exit`).
7
8
  */
9
+ import { mkdirSync, rmSync } from "node:fs";
10
+ import { join } from "node:path";
8
11
  import { INVOKE_EXAMPLE_BODY } from "../channels/http.js";
9
- import { answersLocalhost, bindLabel, classifyBind, clientHost } from "../bind.js";
12
+ import { answersLocalhost, bindAddress, bindLabel, classifyBind, clientHost } from "../bind.js";
13
+ import { writeFileAtomic } from "../atomic-write.js";
10
14
  import { serveNode } from "../channels/serve.js";
11
15
  import { log } from "../log.js";
12
16
  import { openExternalUrl } from "../open-url.js";
@@ -35,6 +39,45 @@ export function assertTunnelBindable(host, tunnel, source) {
35
39
  failUsage(message);
36
40
  failStartup(new Error(message));
37
41
  }
42
+ /**
43
+ * The bind address a serve uses: the flag, else `http.host` from the config — through `bindAddress`,
44
+ * so a configured `localhost` is an ADDRESS by the time anything binds, renders or dials it — checked
45
+ * against `--tunnel` with the exit code the SOURCE of the value earns (serve.ts assertTunnelBindable).
46
+ */
47
+ export function resolveBindHost(bindFlag, configured, tunnel) {
48
+ const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
49
+ assertTunnelBindable(host, tunnel, bindFlag ? "flag" : "config");
50
+ return host;
51
+ }
52
+ /** What the CLI adds to the assembly: its shutdown grace, and exit on a connection that drops. */
53
+ export function cliMountOptions(wrapAgent) {
54
+ return {
55
+ wrapAgent,
56
+ closeTimeoutMs: SHUTDOWN_GRACE_MS,
57
+ onChannelClosed: (name, error) => failStartup(new Error(`${name} ${error === undefined ? "closed unexpectedly" : `failed: ${String(error)}`}`)),
58
+ };
59
+ }
60
+ /**
61
+ * Bind, report, announce the control plane, open the tunnel, and close in order on a signal — the
62
+ * tail dev's worker and start share once the service is assembled.
63
+ */
64
+ export function serveService(service, bind, posture) {
65
+ const { host } = bind;
66
+ const { tunnel, agentDir, stateRoot } = posture;
67
+ let unannounce = () => { };
68
+ serve(service.handler, bind, {
69
+ ready: service.ready,
70
+ onListening: (p) => {
71
+ reportServing(service, host, p);
72
+ unannounce = announceControl(service.control, stateRoot, { host, tunnel }, p);
73
+ maybeTunnel(agentDir, service.channels.routes, p, tunnel, stateRoot);
74
+ },
75
+ onShutdown: () => {
76
+ unannounce();
77
+ return service.close();
78
+ },
79
+ });
80
+ }
38
81
  /**
39
82
  * The "we are serving" report: the supervisor message `dev`'s watcher waits for, the addresses, and
40
83
  * what mounted. One function because both commands must say the same thing at the same moment —
@@ -68,9 +111,50 @@ export function readyAddressLines(host, boundPort, builtinInvoke) {
68
111
  }
69
112
  return lines;
70
113
  }
114
+ /**
115
+ * Write `<stateRoot>/control.json` so a LOCAL client (`fastagent attach`) can find the control plane
116
+ * once the port is known, and say what reaches it. Returns the file's removal — wired into the
117
+ * shutdown so a stale file cannot point a client at a dead port (`attach` then fails with "cannot
118
+ * read", accurate, instead of a stale token's misleading 401/ECONNREFUSED). A hard exit leaves the
119
+ * file behind: advisory, overwritten by the next boot.
120
+ *
121
+ * Here and not in the assembly: the file, its permissions and the warnings are how THIS process's
122
+ * operator finds and protects the plane. An embedder distributes `service.control` itself.
123
+ */
124
+ export function announceControl(control, stateRoot, bind, boundPort) {
125
+ if (!control)
126
+ return () => { };
127
+ mkdirSync(stateRoot, { recursive: true, mode: 0o700 });
128
+ const path = join(stateRoot, "control.json");
129
+ const url = `http://${clientHost(bind.host)}:${boundPort}`;
130
+ writeFileAtomic(path, `${JSON.stringify({ url, token: control.token })}\n`, 0o600);
131
+ log.info(`[fastagent] session control on ${control.prefix}/* (token in ${path})`);
132
+ // LAN-reachable with the bearer token as the only protection — the tunnel and deploy paths warn
133
+ // loudly, and the LAN path must not be the silent third way past the local trust story. A
134
+ // loopback bind closes exactly that reach, so it earns silence.
135
+ const reach = classifyBind(bind.host);
136
+ if (reach !== "loopback") {
137
+ log.warn(`[fastagent] the port binds ${reach === "wildcard" ? "all interfaces" : `${bind.host} (off this machine)`}: ` +
138
+ "/control/* is reachable on your LAN, protected only by the bearer token — bind loopback " +
139
+ "(--bind 127.0.0.1), firewall the port, or wrap it for real exposure (docs/design/session-control.md §14)");
140
+ }
141
+ if (bind.tunnel) {
142
+ // Local trust = the token + its file permissions; --tunnel takes the whole port PUBLIC.
143
+ log.warn("[fastagent] --tunnel exposes /control/* (steer, stop, rewrite or delete a session) at the public tunnel URL, " +
144
+ "protected ONLY by the bearer token — wrap it with real auth before sharing that URL (docs/design/session-control.md §14)");
145
+ }
146
+ return () => {
147
+ try {
148
+ rmSync(path, { force: true });
149
+ }
150
+ catch {
151
+ /* the file is advisory — shutdown must not fail on it */
152
+ }
153
+ };
154
+ }
71
155
  /** What the CLI gives a service to stop in, and the hard exit that follows it. The order matters:
72
156
  * a forced exit before the service answers would report a clean shutdown over a stuck channel. */
73
- export const SHUTDOWN_GRACE_MS = 800;
157
+ const SHUTDOWN_GRACE_MS = 800;
74
158
  const FORCED_EXIT_MS = 1_500;
75
159
  /**
76
160
  * Bind HTTP and report ready — but only once the SERVICE is, which is not the same moment: a bound
@@ -78,7 +162,7 @@ const FORCED_EXIT_MS = 1_500;
78
162
  * awaits `hooks.ready` (mountAgentService owns the connections themselves) before announcing
79
163
  * anything. Signals are the sole clean-shutdown command; `host` unset binds all interfaces.
80
164
  */
81
- export function serve(handler, bind, hooks = {}) {
165
+ function serve(handler, bind, hooks = {}) {
82
166
  const { port, host } = bind;
83
167
  const hosted = serveNode(handler, { port, host });
84
168
  let stopping = false;
@@ -141,7 +225,7 @@ export function serve(handler, bind, hooks = {}) {
141
225
  });
142
226
  }
143
227
  /** Start a Cloudflare tunnel for route channels only. */
144
- export function maybeTunnel(agentDir, routeChannels, boundPort, tunnel, stateRoot) {
228
+ function maybeTunnel(agentDir, routeChannels, boundPort, tunnel, stateRoot) {
145
229
  if (!tunnel || process.env.FASTAGENT_DEV_WORKER === "1")
146
230
  return;
147
231
  void startCloudflareTunnel(boundPort).then((instance) => {