@fastagent-sh/fastagent 0.16.0 → 0.16.2

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 (54) hide show
  1. package/README.md +1 -1
  2. package/dist/bind.d.ts +34 -0
  3. package/dist/bind.js +74 -0
  4. package/dist/channels/agentcore-state.js +21 -6
  5. package/dist/channels/control.js +3 -0
  6. package/dist/cli/commands/attach.d.ts +11 -1
  7. package/dist/cli/commands/attach.js +30 -2
  8. package/dist/cli/commands/deploy.d.ts +13 -0
  9. package/dist/cli/commands/deploy.js +18 -6
  10. package/dist/cli/commands/dev.d.ts +1 -0
  11. package/dist/cli/commands/dev.js +15 -4
  12. package/dist/cli/commands/info.js +1 -1
  13. package/dist/cli/commands/logs.d.ts +6 -0
  14. package/dist/cli/commands/logs.js +27 -0
  15. package/dist/cli/commands/start.d.ts +1 -0
  16. package/dist/cli/commands/start.js +11 -3
  17. package/dist/cli/commands/tool.js +10 -2
  18. package/dist/cli/program.js +35 -0
  19. package/dist/cli/serve.d.ts +26 -2
  20. package/dist/cli/serve.js +71 -17
  21. package/dist/cli/shared.d.ts +6 -0
  22. package/dist/cli/shared.js +14 -0
  23. package/dist/deploy/agentcore/logs.d.ts +30 -0
  24. package/dist/deploy/agentcore/logs.js +115 -0
  25. package/dist/deploy/agentcore/plan.d.ts +23 -0
  26. package/dist/deploy/agentcore/plan.js +41 -3
  27. package/dist/deploy/container.js +6 -3
  28. package/dist/deploy/preflight.js +18 -0
  29. package/dist/engines/pi/config.d.ts +7 -3
  30. package/dist/engines/pi/config.js +9 -3
  31. package/dist/engines/pi/create.d.ts +31 -18
  32. package/dist/engines/pi/create.js +46 -17
  33. package/dist/engines/pi/harness.d.ts +31 -33
  34. package/dist/engines/pi/harness.js +24 -76
  35. package/dist/engines/pi/open.d.ts +2 -2
  36. package/dist/engines/pi/open.js +2 -1
  37. package/dist/engines/pi/read-image.d.ts +4 -0
  38. package/dist/engines/pi/read-image.js +62 -0
  39. package/dist/engines/pi/search-tools.d.ts +6 -4
  40. package/dist/engines/pi/search-tools.js +3 -1
  41. package/dist/engines/pi/session-builder.js +7 -2
  42. package/dist/engines/pi/session-control.d.ts +12 -3
  43. package/dist/engines/pi/session-control.js +156 -27
  44. package/dist/engines/pi/session-settings.d.ts +51 -0
  45. package/dist/engines/pi/session-settings.js +73 -0
  46. package/dist/engines/pi/sessions.d.ts +21 -7
  47. package/dist/engines/pi/sessions.js +43 -0
  48. package/dist/engines/pi/tool.d.ts +13 -5
  49. package/dist/engines/pi/wake-tool.d.ts +3 -3
  50. package/dist/host/node.d.ts +2 -0
  51. package/dist/host/node.js +2 -1
  52. package/dist/pi.d.ts +1 -1
  53. package/dist/session.d.ts +34 -9
  54. package/package.json +4 -4
package/README.md CHANGED
@@ -196,7 +196,7 @@ FastAgent is pre-1.0. The stable design center is the Agent Handler contract in
196
196
  The neutral contract leaves room for capabilities that are not complete product features yet:
197
197
 
198
198
  - **Durable execution**: Telegram, Slack, and Feishu/Lark accepted turns replay at least once today; general durability and exactly-once execution remain future backend work.
199
- - **Sandboxed execution** — `ExecutionEnv` is an assembly seam, but the pi coding tools and project-context loader are still local; a complete sandbox adapter is future work.
199
+ - **Sandboxed execution** — `ExecutionEnv` governs the default coding tools, but project context and author-written `tools/` still reach the local process; a complete sandbox adapter is future work.
200
200
  - **Observability export** — leveled logs and per-turn traces exist today; an OpenTelemetry exporter does not.
201
201
  - **More harness bindings and channels** — pi is the built-in harness; another harness can implement the Agent contract, and community channels can use the channel kit.
202
202
  - **More deploy targets** — local Docker, Fly, Railway, and AWS Bedrock AgentCore ship today; the generated container is the portable path for other hosts.
package/dist/bind.d.ts ADDED
@@ -0,0 +1,34 @@
1
+ /** A bindable host: an IP literal (v4/v6, brackets optional) or "localhost". No other DNS name — a
2
+ * bind address must be an address of THIS machine, and resolving one is not this module's job. */
3
+ export declare function isBindAddress(host: string): boolean;
4
+ /**
5
+ * The ADDRESS form of an accepted bind value: `localhost` becomes `127.0.0.1`, everything else is
6
+ * already an address. Applied where a value ENTERS (the flag, `http.host`), so nothing downstream ever
7
+ * holds a name — not deferring the resolution but removing it, which is the module's whole point.
8
+ *
9
+ * Two things go wrong otherwise, and both are silent. `server.listen` hands the name to `dns.lookup`,
10
+ * which picks ONE of 127.0.0.1/::1 by rules we do not control — so what got bound is unknown here. And
11
+ * `clientHost` would then write that NAME into control.json and the copyable curl, where a consumer
12
+ * resolves it again, possibly to the other one.
13
+ */
14
+ export declare function bindAddress(host: string): string;
15
+ /**
16
+ * How far a bind address reaches: `wildcard` (unset or all-interfaces) reaches every interface and
17
+ * answers on loopback too; `loopback` is this machine only; `specific` is one interface, reachable
18
+ * only as itself. Loopback covers the whole reserved range (127/8, ::1) — a `127.0.0.2` bind is no
19
+ * more LAN-reachable than `127.0.0.1`.
20
+ */
21
+ export declare function classifyBind(host: string | undefined): "wildcard" | "loopback" | "specific";
22
+ /**
23
+ * Does a serve bound to `host` answer a dial of the NAME `localhost`? Only the addresses that name
24
+ * resolves to (127.0.0.1 / ::1) and a wildcard bind do — `127.0.0.2` is loopback yet unreachable that
25
+ * way. cloudflared dials by name, so `--tunnel` needs this question, not `classifyBind`'s reach.
26
+ */
27
+ export declare function answersLocalhost(host: string | undefined): boolean;
28
+ /** How to NAME a bind in a message: the wildcard is every interface, so calling it one address would
29
+ * understate it; anything else is dialable as itself. THE renderer — the ready lines, the
30
+ * already-in-use refusal and the generic bind failure all read a bind through this one, so a reader
31
+ * never sees the same bind described two ways. */
32
+ export declare function bindLabel(host: string | undefined, port: number): string;
33
+ /** The address a local client should dial for a serve bound to `host` (control.json, the ready log). */
34
+ export declare function clientHost(host: string | undefined): string;
package/dist/bind.js ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * The ONE reading of a bind address, shared by everything that parses, binds, warns about, or ships
3
+ * one (the CLI flag, `http.host` validation, the Node host, the deploy pre-flight). Engine- and
4
+ * host-neutral on purpose: a bind address is a plain value, and two parsers of it would disagree.
5
+ */
6
+ import { isIP } from "node:net";
7
+ /** Lowercase, unbracketed, IPv4-mapped IPv6 reduced to its IPv4 form — the form the checks below read.
8
+ * Brackets come off only as a PAIR: a half-bracketed `[::1` must stay invalid, not become an address. */
9
+ function normalize(host) {
10
+ return host
11
+ .toLowerCase()
12
+ .replace(/^\[(.+)]$/, "$1")
13
+ .replace(/^::ffff:/, "");
14
+ }
15
+ /** A bindable host: an IP literal (v4/v6, brackets optional) or "localhost". No other DNS name — a
16
+ * bind address must be an address of THIS machine, and resolving one is not this module's job. */
17
+ export function isBindAddress(host) {
18
+ const h = normalize(host);
19
+ return h === "localhost" || isIP(h) !== 0;
20
+ }
21
+ /**
22
+ * The ADDRESS form of an accepted bind value: `localhost` becomes `127.0.0.1`, everything else is
23
+ * already an address. Applied where a value ENTERS (the flag, `http.host`), so nothing downstream ever
24
+ * holds a name — not deferring the resolution but removing it, which is the module's whole point.
25
+ *
26
+ * Two things go wrong otherwise, and both are silent. `server.listen` hands the name to `dns.lookup`,
27
+ * which picks ONE of 127.0.0.1/::1 by rules we do not control — so what got bound is unknown here. And
28
+ * `clientHost` would then write that NAME into control.json and the copyable curl, where a consumer
29
+ * resolves it again, possibly to the other one.
30
+ */
31
+ export function bindAddress(host) {
32
+ return normalize(host) === "localhost" ? "127.0.0.1" : host;
33
+ }
34
+ /**
35
+ * How far a bind address reaches: `wildcard` (unset or all-interfaces) reaches every interface and
36
+ * answers on loopback too; `loopback` is this machine only; `specific` is one interface, reachable
37
+ * only as itself. Loopback covers the whole reserved range (127/8, ::1) — a `127.0.0.2` bind is no
38
+ * more LAN-reachable than `127.0.0.1`.
39
+ */
40
+ export function classifyBind(host) {
41
+ if (host === undefined)
42
+ return "wildcard";
43
+ const h = normalize(host);
44
+ if (h === "0.0.0.0" || h === "::" || h === "::0")
45
+ return "wildcard";
46
+ if (h === "localhost" || h === "::1" || /^127\.\d+\.\d+\.\d+$/.test(h))
47
+ return "loopback";
48
+ return "specific";
49
+ }
50
+ /**
51
+ * Does a serve bound to `host` answer a dial of the NAME `localhost`? Only the addresses that name
52
+ * resolves to (127.0.0.1 / ::1) and a wildcard bind do — `127.0.0.2` is loopback yet unreachable that
53
+ * way. cloudflared dials by name, so `--tunnel` needs this question, not `classifyBind`'s reach.
54
+ */
55
+ export function answersLocalhost(host) {
56
+ if (classifyBind(host) === "wildcard")
57
+ return true;
58
+ const h = normalize(host);
59
+ return h === "localhost" || h === "127.0.0.1" || h === "::1";
60
+ }
61
+ /** How to NAME a bind in a message: the wildcard is every interface, so calling it one address would
62
+ * understate it; anything else is dialable as itself. THE renderer — the ready lines, the
63
+ * already-in-use refusal and the generic bind failure all read a bind through this one, so a reader
64
+ * never sees the same bind described two ways. */
65
+ export function bindLabel(host, port) {
66
+ return classifyBind(host) === "wildcard" ? `port ${port}` : `${clientHost(host)}:${port}`;
67
+ }
68
+ /** The address a local client should dial for a serve bound to `host` (control.json, the ready log). */
69
+ export function clientHost(host) {
70
+ if (classifyBind(host) === "wildcard")
71
+ return "127.0.0.1";
72
+ // biome-ignore lint/style/noNonNullAssertion: only a wildcard bind leaves host undefined
73
+ return host.includes(":") && !host.startsWith("[") ? `[${host}]` : host;
74
+ }
@@ -41,6 +41,13 @@ const PUT_TIMEOUT_MS = 60_000;
41
41
  * file from the builder machine, but the box's own copy is the one that has been REFRESHED, and this
42
42
  * snapshot is its volume: the same rule every other host states ("a credential already refreshed on
43
43
  * the volume is never overwritten"). The seed is bootstrap for a snapshot that has none.
44
+ *
45
+ * That sentence is only true because the generated template points FASTAGENT_SECRETS_DIR INSIDE the
46
+ * state root (deploy/agentcore/plan.ts `SECRETS_DIR`) — nothing here special-cases credentials, the
47
+ * walk below simply reaches them. Moving the secrets dir back out (e.g. to the sibling layout the
48
+ * volume-backed hosts use) silently un-does it: the file stops being copied, the platform wipes it
49
+ * with the microVM, and a single-use OAuth refresh token dies with it. `restores VERBATIM` is a
50
+ * statement about where that directory is, not a property this module can enforce on its own.
44
51
  */
45
52
  const EXCLUDED = new Set(["control.json"]);
46
53
  /** Every regular file under `root`, as root-relative POSIX paths (stable across platforms). */
@@ -126,17 +133,25 @@ export function createStateSync(options) {
126
133
  let looping = false;
127
134
  const runRestore = async (urls) => {
128
135
  const res = await doFetch(urls.getUrl, { method: "GET" });
129
- // ONLY a proven 404 is "first deploy". The signer holds s3:GetObject on exactly this key, so a
130
- // missing object answers NoSuchKey/404; a 403 means an expired or malformed signature, or a
131
- // revoked permission — i.e. the snapshot may well exist. Reading 403 as "absent" would serve an
132
- // empty agent and then overwrite the real snapshot with that emptiness.
136
+ // ONLY a proven 404 is "first deploy". A missing key answers 404 only because the generated
137
+ // template grants the signer s3:ListBucket on the snapshot prefix without it S3 folds "absent"
138
+ // into 403 (anti-enumeration). A 403 therefore means an expired or malformed signature, a
139
+ // revoked permission, or a template from before that grant — i.e. the snapshot may well exist.
140
+ // Reading 403 as "absent" would serve an empty agent and then overwrite the real snapshot with
141
+ // that emptiness.
133
142
  if (res.status === 404) {
134
143
  log.info("[agentcore] no state snapshot yet — starting from an empty state root (first deploy)");
135
144
  restored = true;
136
145
  return;
137
146
  }
138
- if (!res.ok)
139
- throw new Error(`state snapshot GET failed: ${res.status}`);
147
+ if (!res.ok) {
148
+ const hint = res.status === 403
149
+ ? " (an expired presigned URL, a revoked permission, or a template generated before the " +
150
+ "ForwarderRole granted s3:ListBucket — S3 answers 403 even for a MISSING first-deploy " +
151
+ "snapshot without it; regenerate with `fastagent deploy agentcore --force` and redeploy)"
152
+ : "";
153
+ throw new Error(`state snapshot GET failed: ${res.status}${hint}`);
154
+ }
140
155
  const written = await unpackIntoStateRoot(stateRoot, Buffer.from(await res.arrayBuffer()));
141
156
  log.info(`[agentcore] restored ${written} state file(s) from the snapshot`);
142
157
  restored = true;
@@ -25,6 +25,7 @@ function parseWireCommand(raw) {
25
25
  compact: true,
26
26
  set_model: true,
27
27
  set_thinking: true,
28
+ navigate: true,
28
29
  };
29
30
  void _commandDriftGuard;
30
31
  if (typeof raw !== "object" || raw === null)
@@ -76,6 +77,8 @@ function parseWireCommand(raw) {
76
77
  return typeof c.model === "string" ? { type: "set_model", model: c.model } : undefined;
77
78
  case "set_thinking":
78
79
  return typeof c.level === "string" ? { type: "set_thinking", level: c.level } : undefined;
80
+ case "navigate":
81
+ return typeof c.targetId === "string" ? { type: "navigate", targetId: c.targetId } : undefined;
79
82
  default:
80
83
  return undefined;
81
84
  }
@@ -1,4 +1,4 @@
1
- import type { SessionControl } from "../../session.ts";
1
+ import type { SessionControl, SessionEntry } from "../../session.ts";
2
2
  export interface AttachOptions {
3
3
  /** Override the control endpoint (skip control.json discovery) — for a remote serve. */
4
4
  url?: string;
@@ -62,6 +62,16 @@ export interface AttachIo {
62
62
  write: (chunk: string) => void;
63
63
  warn: (line: string) => void;
64
64
  }
65
+ /**
66
+ * The backfill slice REDUCED TO THE ACTIVE PATH — a session is a tree (a `navigate`, or a
67
+ * compaction, leaves sibling branches behind), and printing the slice raw renders the abandoned
68
+ * branch interleaved with the live one as if it were one conversation. Reachability is computed
69
+ * backwards from the leaf; an entry whose parent lies BEFORE the slice is path-connected by
70
+ * construction (its ancestors were rendered in an earlier round), so only what the walk cannot
71
+ * reach is dropped. An engine that reports NO leaf says nothing about branches, so its slice stands
72
+ * whole — the one case where "unknown" must not read as "off-path".
73
+ */
74
+ export declare function activePathSlice(entries: SessionEntry[], leafEntryId: string | undefined): SessionEntry[];
65
75
  /**
66
76
  * ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
67
77
  * until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
@@ -423,6 +423,32 @@ const isAuthError = (error) => error instanceof ControlRequestError && error.sta
423
423
  /** `failStartup` borrowed for its print-one-line-and-exit behavior — attach can fail long after
424
424
  * startup (a serve restart hours in), so the local name must not imply "startup only". */
425
425
  const exitWith = failStartup;
426
+ /**
427
+ * The backfill slice REDUCED TO THE ACTIVE PATH — a session is a tree (a `navigate`, or a
428
+ * compaction, leaves sibling branches behind), and printing the slice raw renders the abandoned
429
+ * branch interleaved with the live one as if it were one conversation. Reachability is computed
430
+ * backwards from the leaf; an entry whose parent lies BEFORE the slice is path-connected by
431
+ * construction (its ancestors were rendered in an earlier round), so only what the walk cannot
432
+ * reach is dropped. An engine that reports NO leaf says nothing about branches, so its slice stands
433
+ * whole — the one case where "unknown" must not read as "off-path".
434
+ */
435
+ export function activePathSlice(entries, leafEntryId) {
436
+ if (leafEntryId === undefined)
437
+ return entries;
438
+ const byId = new Map(entries.map((e) => [e.id, e]));
439
+ // An append always moves the leaf, so a leaf BEHIND the slice means every entry in it was
440
+ // appended and then abandoned — a navigate backwards with no new turn since. Nothing here is on
441
+ // the active path.
442
+ if (!byId.has(leafEntryId))
443
+ return [];
444
+ const onPath = new Set();
445
+ for (let cur = byId.get(leafEntryId); cur; cur = cur.parentId ? byId.get(cur.parentId) : undefined) {
446
+ if (onPath.has(cur.id))
447
+ break;
448
+ onPath.add(cur.id);
449
+ }
450
+ return entries.filter((e) => onPath.has(e.id));
451
+ }
426
452
  /**
427
453
  * ONE attach round: subscribe → backfill (render the durable record since `cursor`) → drain live
428
454
  * until the stream drops. Returns the advanced cursor. Subscribing first + the server's eager
@@ -490,9 +516,11 @@ settleMs = 300) {
490
516
  // append-position cursor (design §7), and a leaf that sits before later appends (abandoned
491
517
  // branches) would make every reconnect permanently replay the same tail.
492
518
  next = backfill.entries.at(-1)?.id ?? cursor;
493
- if (backfill.entries.length > 0) {
519
+ // Cursor advancement is APPEND ORDER over the whole slice; RENDERING is the active path only.
520
+ const replay = activePathSlice(backfill.entries, backfill.leafEntryId);
521
+ if (replay.length > 0) {
494
522
  io.println("[replaying the record since the last sync (may overlap what you saw live)]");
495
- for (const entry of backfill.entries) {
523
+ for (const entry of replay) {
496
524
  let line;
497
525
  try {
498
526
  line = renderEntry(entry);
@@ -13,3 +13,16 @@ export interface DeployOptions {
13
13
  input?: boolean;
14
14
  }
15
15
  export declare function runDeploy(host: DeployHost, dirArg: string, opts: DeployOptions): Promise<void>;
16
+ /**
17
+ * Write the plan's artifacts under `target`, honouring the ownership rule: a file we did not generate is
18
+ * never touched, ours is kept unless `--force`. Exported for its own test — this is a four-branch state
19
+ * machine over (exists, ours, force) that used to be proven by spawning the CLI eight times, which is
20
+ * command LOGIC re-run through a subprocess (see vitest.config.ts) and the suite's slowest test.
21
+ */
22
+ export declare function writeArtifacts(target: string, artifacts: {
23
+ path: string;
24
+ content: string;
25
+ }[], options: {
26
+ force: boolean;
27
+ alwaysWrite?: string[];
28
+ }): Promise<void>;
@@ -15,7 +15,7 @@ import { registerFeishuWebhook } from "../../channels/feishu/register-webhook.js
15
15
  import { readSlackBotAuthEnv } from "../../channels/slack/bot-auth.js";
16
16
  import { registerSlackWebhook } from "../../channels/slack/register-webhook.js";
17
17
  import { registerTelegramWebhook } from "../../channels/telegram/register-webhook.js";
18
- import { FORWARDER_FILE, TEMPLATE_FILE, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../deploy/agentcore/plan.js";
18
+ import { FORWARDER_FILE, TEMPLATE_FILE, agentcoreName, isGeneratedAgentcoreTemplate, planAgentcoreDeploy, } from "../../deploy/agentcore/plan.js";
19
19
  import { deployAgentcoreRun } from "../../deploy/agentcore/run.js";
20
20
  import { isGeneratedDockerfile, isGeneratedDockerignore } from "../../deploy/container.js";
21
21
  import { composeHasTunnelService, dockerWebhookPaths, isGeneratedCompose, planDockerDeploy, toDockerProjectName, } from "../../deploy/docker/plan.js";
@@ -37,6 +37,10 @@ import { exists } from "../../paths.js";
37
37
  import { announceWebhooks } from "../../tunnel.js";
38
38
  import { failStartup, failUsage, placementOrExit } from "../fail.js";
39
39
  import { resolveFirstRunModel } from "../shared.js";
40
+ /** A copy/paste-safe POSIX shell argument for the command hints deploy prints. */
41
+ function shellArg(value) {
42
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", `'"'"'`)}'`;
43
+ }
40
44
  export async function runDeploy(host, dirArg, opts) {
41
45
  // ONE deploy semantic: bake the WORKSPACE (WYSIWYG). Artifacts land under the agent dir
42
46
  // (`fastagent/`) plus the one workspace-root `.dockerignore` the packers require; host CLIs run
@@ -212,10 +216,7 @@ export async function runDeploy(host, dirArg, opts) {
212
216
  if (loaded.failures.length > 0) {
213
217
  failStartup(new Error(`deploy stopped: cannot load schedules: ${loaded.failures.map((x) => `${x.label}: ${x.message}`).join("; ")}`));
214
218
  }
215
- const acName = basename(workspace)
216
- .toLowerCase()
217
- .replace(/[^a-z0-9-]+/g, "-")
218
- .replace(/^-+|-+$/g, "") || "agent";
219
+ const acName = agentcoreName(basename(workspace));
219
220
  // Every derived AWS name embeds acName; the tightest ceiling is the Lambda function name
220
221
  // (`fastagent-<name>-forwarder` ≤ 64 chars). Gate the base instead of silently truncating —
221
222
  // truncation would break the redeploy identity (a renamed stack starts blank state).
@@ -413,7 +414,13 @@ function isOurArtifact(path, content) {
413
414
  return isGeneratedAgentcoreTemplate(content);
414
415
  return false;
415
416
  }
416
- async function writeArtifacts(target, artifacts, options) {
417
+ /**
418
+ * Write the plan's artifacts under `target`, honouring the ownership rule: a file we did not generate is
419
+ * never touched, ours is kept unless `--force`. Exported for its own test — this is a four-branch state
420
+ * machine over (exists, ours, force) that used to be proven by spawning the CLI eight times, which is
421
+ * command LOGIC re-run through a subprocess (see vitest.config.ts) and the suite's slowest test.
422
+ */
423
+ export async function writeArtifacts(target, artifacts, options) {
417
424
  for (const a of artifacts) {
418
425
  const abs = join(target, a.path);
419
426
  // Pure build output, not operator-owned configuration. It must track the generated template/runbook.
@@ -599,6 +606,11 @@ async function runDeployAgentcore(params) {
599
606
  console.error(`[fastagent] deployed → ${outcome.runtimeArn}`);
600
607
  if (outcome.url)
601
608
  console.error(`[fastagent] webhook ingress → ${outcome.url}`);
609
+ const logsDir = shellArg(workspace);
610
+ console.error(`[fastagent] runtime logs → fastagent logs agentcore ${logsDir} --follow`);
611
+ if (params.needsForwarder) {
612
+ console.error(`[fastagent] forwarder logs → fastagent logs agentcore ${logsDir} --source forwarder --follow`);
613
+ }
602
614
  console.error(`[fastagent] invoke: aws bedrock-agentcore invoke-agent-runtime --agent-runtime-arn ${outcome.runtimeArn} \\\n` +
603
615
  ` --runtime-session-id "my-conversation-000000000000000000" \\\n` +
604
616
  ` --payload '{"kind":"invoke","session":"cli","text":"hello"}' --cli-binary-format raw-in-base64-out /dev/stdout`);
@@ -1,5 +1,6 @@
1
1
  export interface DevOptions {
2
2
  port?: string;
3
+ bind?: string;
3
4
  model?: string;
4
5
  authPath?: string;
5
6
  /** false ⇔ `--no-watch`. */
@@ -12,9 +12,10 @@ import { setLogLevel } from "../../log.js";
12
12
  import { logAgentLoop } from "../../observe.js";
13
13
  import { installProxyFetch } from "../../proxy.js";
14
14
  import { workspaceHint } from "../../paths.js";
15
+ import { bindAddress } from "../../bind.js";
15
16
  import { failStartup, placementOrExit } from "../fail.js";
16
- import { maybeTunnel, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
17
- import { parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
17
+ import { assertTunnelBindable, maybeTunnel, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
18
+ import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
18
19
  export async function runDev(dirArg, opts) {
19
20
  const dir = resolve(dirArg);
20
21
  const placement = placementOrExit(dir);
@@ -33,12 +34,16 @@ export async function runDev(dirArg, opts) {
33
34
  await serveOnce(dir, opts);
34
35
  return;
35
36
  }
36
- parsePort(opts.port, "--port", "flag"); // flag-shape check before spawning
37
+ parsePort(opts.port, "--port", "flag"); // flag-shape checks before spawning
38
+ // The --bind/--tunnel conflict is decidable from flags alone: refuse it HERE, before a worker and a
39
+ // tunnel exist. The worker repeats the check because `http.host` can supply the address instead.
40
+ assertTunnelBindable(parseBind(opts.bind), opts.tunnel ?? false, "flag");
37
41
  await runDevSupervisor(placement, { tunnel: opts.tunnel ?? false });
38
42
  }
39
43
  /** Assemble the agent and serve it once (the dev worker; also the --no-watch path). */
40
44
  async function serveOnce(dir, opts) {
41
45
  const portFlag = parsePort(opts.port, "--port", "flag");
46
+ const bindFlag = parseBind(opts.bind);
42
47
  loadDotEnv(placementOrExit(dir).agentDir);
43
48
  installProxyFetch();
44
49
  const a = await createPiAgentFromDir(dir, {
@@ -57,12 +62,18 @@ async function serveOnce(dir, opts) {
57
62
  // out in start (level info), keeping end-user content out of production logs. Wired in both postures.
58
63
  const traced = logAgentLoop(a.agent);
59
64
  const routed = await routesFor(a.agentDir, traced, a.stateRoot, a.sessionControl).catch(failStartup);
65
+ // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
66
+ // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
67
+ const configured = a.config.http?.host;
68
+ const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
69
+ assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
60
70
  const withControl = mountSessionControl(routed.routes, a.sessionControl, a.stateRoot, {
61
71
  tunnel: opts.tunnel ?? false,
62
72
  agent: traced, // the remote data plane (POST /control/invoke) drives the SAME traced agent
73
+ host,
63
74
  });
64
75
  await startSchedules(a.agentDir, traced, a.stateRoot, a.config.selfSchedule ?? false);
65
- serve({ ...routed, routes: withControl.routes }, portFlag ?? a.config.http?.port ?? 8787, (p) => {
76
+ serve({ ...routed, routes: withControl.routes }, { port: portFlag ?? a.config.http?.port ?? 8787, host }, (p) => {
66
77
  withControl.announce(p);
67
78
  maybeTunnel(a.agentDir, routed.routeChannels, p, opts.tunnel ?? false, a.stateRoot);
68
79
  });
@@ -24,7 +24,7 @@ export async function runInfo(dirArg, opts) {
24
24
  // tool), is isolated the same way everywhere (G2): info, dev, AND start report it and keep going with
25
25
  // the tools that loaded. The `error`/`.catch` below only fires for a whole-load fault (an unreadable
26
26
  // tools/ dir), not a single bad file.
27
- const tools = await resolveAgentTools(config, agentDir, workspace)
27
+ const tools = await resolveAgentTools(config, agentDir)
28
28
  .then((r) => ({
29
29
  names: r.toolNames,
30
30
  deferred: r.deferredToolNames,
@@ -0,0 +1,6 @@
1
+ export interface AgentcoreLogsOptions {
2
+ source?: string;
3
+ since?: string;
4
+ follow?: boolean;
5
+ }
6
+ export declare function runLogs(host: string, dirArg: string, opts: AgentcoreLogsOptions): Promise<void>;
@@ -0,0 +1,27 @@
1
+ /** `fastagent logs agentcore [dir]`: discover and tail the deployed AgentCore CloudWatch logs. */
2
+ import { basename, resolve } from "node:path";
3
+ import { agentcoreName } from "../../deploy/agentcore/plan.js";
4
+ import { tailAgentcoreLogs } from "../../deploy/agentcore/logs.js";
5
+ import { spawnRunner } from "../../deploy/runner.js";
6
+ import { loadDotEnv } from "../../env.js";
7
+ import { failStartup, failUsage, placementOrExit } from "../fail.js";
8
+ export async function runLogs(host, dirArg, opts) {
9
+ // The host argument is DISPATCHED here, as in runDeploy — the parser's `choices` happens to have a
10
+ // single member today, and a future second host must land on its own reader, not silently on this one.
11
+ if (host !== "agentcore")
12
+ failUsage(`logs: unsupported host "${host}" — only agentcore has remote logs`);
13
+ const placement = placementOrExit(resolve(dirArg));
14
+ loadDotEnv(placement.agentDir); // AWS_PROFILE/region/proxy may be definition-local, as on deploy
15
+ const source = opts.source ?? "runtime";
16
+ if (source !== "runtime" && source !== "forwarder") {
17
+ failUsage(`logs: --source must be "runtime" or "forwarder"`);
18
+ }
19
+ const outcome = await tailAgentcoreLogs({
20
+ name: agentcoreName(basename(placement.workspace)),
21
+ source: source,
22
+ since: opts.since,
23
+ follow: opts.follow === true,
24
+ }, spawnRunner("aws", placement.workspace), (message) => console.error(`[fastagent] logs: ${message}`));
25
+ if (!outcome.ok)
26
+ failStartup(new Error(`logs stopped: ${outcome.gate}`));
27
+ }
@@ -1,5 +1,6 @@
1
1
  export interface StartOptions {
2
2
  port?: string;
3
+ bind?: string;
3
4
  model?: string;
4
5
  sessionsDir?: string;
5
6
  authPath?: string;
@@ -17,14 +17,16 @@ import { setWakeupsSink } from "../../schedule/wakeups.js";
17
17
  import { logAgentLoop } from "../../observe.js";
18
18
  import { installProxyFetch } from "../../proxy.js";
19
19
  import { exists } from "../../paths.js";
20
+ import { bindAddress } from "../../bind.js";
20
21
  import { failStartup, placementOrExit } from "../fail.js";
21
- import { maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules } from "../serve.js";
22
- import { parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
22
+ import { assertTunnelBindable, maybeTunnel, mountAgentcore, mountSessionControl, routesFor, serve, startSchedules, } from "../serve.js";
23
+ import { parseBind, parsePort, reportAuth, reportLine, resolveFirstRunModel, reportWorkspaceHint } from "../shared.js";
23
24
  export async function runStart(dirArg, opts) {
24
25
  const dir = resolve(dirArg);
25
26
  // Flag validation first: a bad --port is a USAGE error (exit 2), and reporting it must not depend on
26
27
  // the directory being an agent (which is a runtime/environment failure, exit 1).
27
28
  const portFlag = parsePort(opts.port, "--port", "flag");
29
+ const bindFlag = parseBind(opts.bind);
28
30
  const placement = placementOrExit(dir);
29
31
  setLogLevel("info"); // production posture: info+, the debug turn trace (and its end-user content) gated out
30
32
  loadDotEnv(placement.agentDir);
@@ -88,9 +90,15 @@ export async function runStart(dirArg, opts) {
88
90
  // Same debug turn trace as dev; gated out here by the info level (see dev.ts serveOnce).
89
91
  const traced = logAgentLoop(agent);
90
92
  const routed = await routesFor(agentDir, traced, stateRoot, sessionControl, { builtinInvoke: !agentcore }).catch(failStartup);
93
+ // `http.host` enters here the way the flag enters `parseBind` — through `bindAddress`, so a
94
+ // configured `localhost` is an ADDRESS by the time anything binds, renders or dials it.
95
+ const configured = config.http?.host;
96
+ const host = bindFlag ?? (configured === undefined ? undefined : bindAddress(configured));
97
+ assertTunnelBindable(host, opts.tunnel ?? false, bindFlag ? "flag" : "config");
91
98
  const withControl = mountSessionControl(routed.routes, sessionControl, stateRoot, {
92
99
  tunnel: opts.tunnel ?? false,
93
100
  agent: traced,
101
+ host,
94
102
  });
95
103
  // AgentCore + selfSchedule: register the wake-ALARM sink BEFORE the scheduler starts — the boot
96
104
  // wake pump may advance a recurring entry (a store save) and that save must already re-arm its
@@ -126,7 +134,7 @@ export async function runStart(dirArg, opts) {
126
134
  }
127
135
  log.info(`[fastagent] agentcore: serving POST /invocations + GET /ping (FASTAGENT_AGENTCORE=1)`);
128
136
  }
129
- serve({ ...routed, routes }, portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, (p) => {
137
+ serve({ ...routed, routes }, { port: portFlag ?? parsePort(process.env.PORT, "PORT env", "env") ?? config.http?.port ?? 8787, host }, (p) => {
130
138
  withControl.announce(p);
131
139
  maybeTunnel(agentDir, routed.routeChannels, p, opts.tunnel ?? false, stateRoot);
132
140
  });
@@ -1,5 +1,6 @@
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";
3
4
  import { loadDotEnv } from "../../env.js";
4
5
  import { loadConfig } from "../../engines/pi/config.js";
5
6
  import { resolveAgentTools } from "../../engines/pi/create.js";
@@ -16,7 +17,7 @@ export async function runTool(name, argsJson, dirArg) {
16
17
  // The same tool set dev/start mount (defaults + config.tools + discovered, deduped), so the runner
17
18
  // exercises exactly what gets served — a shadowed tool is surfaced, not silently run. Resolve the
18
19
  // placement like the openers, so `fastagent tool` finds the SAME tools/ as dev/start.
19
- const { tools, toolCollisions, toolFailures } = await resolveAgentTools(config, agentDir, workspace).catch(failStartup);
20
+ const { tools, toolCollisions, toolFailures } = await resolveAgentTools(config, agentDir).catch(failStartup);
20
21
  for (const c of toolCollisions) {
21
22
  console.error(`[fastagent] warn: tool "${c.name}" (${c.source}) is shadowed by a default/config tool — not mounted`);
22
23
  }
@@ -25,7 +26,14 @@ export async function runTool(name, argsJson, dirArg) {
25
26
  if (!tool) {
26
27
  failStartup(new Error(`unknown tool "${name}". available: ${tools.map((t) => t.name).join(", ") || "(none)"}`));
27
28
  }
28
- const result = await turnContext.run({ cwd: workspace }, () => tool.execute(`cli-${name}`, args)).catch(failStartup);
29
+ // Two contexts, because the two tool families read different ones and this command must serve both
30
+ // exactly as serving does: fastagent's own tools take cwd/session/activation from `turnContext`
31
+ // (AsyncLocalStorage), and pi's default coding tools take the ExecutionEnv from the harness's TOOL
32
+ // context — which no harness supplies here, so this stands in for it, rooted at the same workspace.
33
+ const env = new NodeExecutionEnv({ cwd: workspace });
34
+ const result = await turnContext
35
+ .run({ cwd: workspace }, () => tool.execute(`cli-${name}`, args, undefined, undefined, { env }))
36
+ .catch(failStartup);
29
37
  const out = result?.details !== undefined
30
38
  ? result.details
31
39
  : (result?.content ?? []).map((c) => ("text" in c ? c.text : "")).join("");
@@ -27,6 +27,10 @@ const NO_INPUT = {
27
27
  description: "never prompt (CI/scripts) — missing information becomes an error instead of a question",
28
28
  };
29
29
  const PORT = { flags: "--port <n>", description: "HTTP port" };
30
+ const BIND = {
31
+ flags: "--bind <addr>",
32
+ description: "bind address (default: all interfaces; 127.0.0.1 keeps it off the LAN)",
33
+ };
30
34
  const TUNNEL = {
31
35
  flags: "--tunnel",
32
36
  description: "expose a public HTTPS URL via a Cloudflare quick tunnel (needs cloudflared) and auto-register " +
@@ -85,6 +89,7 @@ const dev = {
85
89
  args: [DIR_ARG],
86
90
  flags: [
87
91
  PORT,
92
+ BIND,
88
93
  MODEL,
89
94
  AUTH_PATH,
90
95
  { flags: "--no-watch", description: "serve once, no file-watching" },
@@ -97,6 +102,7 @@ const dev = {
97
102
  ],
98
103
  run: async (args, f) => (await import("./commands/dev.js")).runDev(args[0], {
99
104
  port: f.port,
105
+ bind: f.bind,
100
106
  model: f.model,
101
107
  authPath: f.authPath,
102
108
  watch: f.watch !== false,
@@ -218,6 +224,7 @@ const start = {
218
224
  args: [DIR_ARG],
219
225
  flags: [
220
226
  PORT,
227
+ BIND,
221
228
  MODEL,
222
229
  { flags: "--sessions-dir <dir>", description: "sessions directory override" },
223
230
  AUTH_PATH,
@@ -230,6 +237,7 @@ const start = {
230
237
  ],
231
238
  notes: "Precedence chains:\n" +
232
239
  " port: --port > PORT env > fastagent.config.ts http.port > 8787\n" +
240
+ " bind: --bind > fastagent.config.ts http.host > all interfaces\n" +
233
241
  " state: FASTAGENT_STATE_DIR > <agent dir>/.state — mutable machine state\n" +
234
242
  " (sessions, channel state, schedule state); point it at a mounted\n" +
235
243
  " volume so a redeploy that replaces the directory never wipes it\n" +
@@ -240,6 +248,7 @@ const start = {
240
248
  " share one credential across projects)",
241
249
  run: async (args, f) => (await import("./commands/start.js")).runStart(args[0], {
242
250
  port: f.port,
251
+ bind: f.bind,
243
252
  model: f.model,
244
253
  sessionsDir: f.sessionsDir,
245
254
  authPath: f.authPath,
@@ -444,6 +453,31 @@ const schedule = {
444
453
  },
445
454
  ],
446
455
  };
456
+ const logs = {
457
+ name: "logs",
458
+ summary: "find and tail a deployed host's application logs",
459
+ description: "Find the CloudWatch log group for the AgentCore stack derived from dir, then run aws logs tail. " +
460
+ "The default Runtime source selects only [runtime-logs], so application stdout/stderr is not mixed " +
461
+ "with OTEL/spans in the same AWS log group; the forwarder source shows Lambda ingress transport logs.",
462
+ args: [{ name: "<host>", description: "deployed host", choices: ["agentcore"] }, DIR_ARG],
463
+ flags: [
464
+ { flags: "--source <source>", description: "agentcore log source: runtime (default) or forwarder" },
465
+ { flags: "--since <duration>", description: "history window accepted by AWS CLI (for example 30m or 2h)" },
466
+ { flags: "--follow", description: "keep polling for new log events until interrupted" },
467
+ ],
468
+ examples: [
469
+ { cmd: "fastagent logs agentcore --follow", note: "the agent process" },
470
+ { cmd: "fastagent logs agentcore --source forwarder --follow", note: "Lambda ingress" },
471
+ ],
472
+ notes: "Read-only. Run it against the same workspace passed to deploy so it derives the same CloudFormation " +
473
+ "stack name. It never changes FASTAGENT_LOG_LEVEL: AgentCore keeps start's production default, and " +
474
+ "setting that environment knob to debug exposes the existing detailed turn trace when needed.",
475
+ run: async (args, f) => (await import("./commands/logs.js")).runLogs(args[0], args[1], {
476
+ source: f.source,
477
+ since: f.since,
478
+ follow: f.follow === true,
479
+ }),
480
+ };
447
481
  const login = {
448
482
  name: "login",
449
483
  summary: "authenticate a model provider (subscription/OAuth or API key)",
@@ -477,6 +511,7 @@ export const specs = [
477
511
  start,
478
512
  add,
479
513
  deploy,
514
+ logs,
480
515
  login,
481
516
  ];
482
517
  /**