@fastagent-sh/fastagent 0.27.0 → 0.29.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 (75) hide show
  1. package/README.md +4 -3
  2. package/dist/authoring.d.ts +14 -7
  3. package/dist/authoring.js +11 -7
  4. package/dist/channels/feishu/feishu-api.d.ts +1 -0
  5. package/dist/channels/feishu/feishu-api.js +4 -0
  6. package/dist/channels/feishu/register-app.d.ts +2 -0
  7. package/dist/channels/feishu/register-app.js +1 -1
  8. package/dist/channels/lark/credentials-page.d.ts +25 -0
  9. package/dist/channels/lark/credentials-page.js +206 -0
  10. package/dist/channels/lark/onboard.d.ts +18 -2
  11. package/dist/channels/lark/onboard.js +36 -6
  12. package/dist/channels/slack/setup-server.js +75 -30
  13. package/dist/channels/wait-health.js +1 -1
  14. package/dist/cli/add-feishu.d.ts +0 -9
  15. package/dist/cli/add-feishu.js +62 -38
  16. package/dist/cli/commands/add.js +5 -12
  17. package/dist/cli/commands/content.js +1 -1
  18. package/dist/cli/commands/deploy.js +1 -8
  19. package/dist/cli/commands/info.js +4 -0
  20. package/dist/cli/commands/invoke.js +5 -3
  21. package/dist/cli/commands/mcp.d.ts +12 -0
  22. package/dist/cli/commands/mcp.js +156 -0
  23. package/dist/cli/content-view.d.ts +1 -1
  24. package/dist/cli/content-view.js +2 -2
  25. package/dist/cli/program.js +65 -9
  26. package/dist/content/declare.d.ts +1 -6
  27. package/dist/content/declare.js +13 -18
  28. package/dist/content/resolve.d.ts +0 -2
  29. package/dist/content/resolve.js +2 -4
  30. package/dist/content/source.d.ts +0 -1
  31. package/dist/content/source.js +0 -1
  32. package/dist/deploy/agentcore/destroy.js +1 -1
  33. package/dist/deploy/build-context.js +4 -0
  34. package/dist/deploy/container.js +1 -2
  35. package/dist/deploy/docker/run.d.ts +16 -3
  36. package/dist/deploy/docker/run.js +54 -1
  37. package/dist/deploy/fly/run.d.ts +0 -14
  38. package/dist/deploy/fly/run.js +4 -5
  39. package/dist/deploy/preflight.js +6 -14
  40. package/dist/dev-supervisor.js +3 -2
  41. package/dist/harnesses/pi-durable/mcp.d.ts +3 -0
  42. package/dist/harnesses/pi-durable/mcp.js +34 -0
  43. package/dist/harnesses/pi-durable/open.d.ts +3 -4
  44. package/dist/harnesses/pi-durable/open.js +13 -10
  45. package/dist/harnesses/pi-durable/serve.d.ts +5 -1
  46. package/dist/harnesses/pi-durable/serve.js +47 -17
  47. package/dist/harnesses/pi-durable/text.js +7 -13
  48. package/dist/http/contract.d.ts +3 -3
  49. package/dist/index.d.ts +9 -1
  50. package/dist/index.js +6 -1
  51. package/dist/locked-file.d.ts +2 -2
  52. package/dist/locked-file.js +8 -3
  53. package/dist/mcp/auth.d.ts +22 -0
  54. package/dist/mcp/auth.js +180 -0
  55. package/dist/mcp/client.d.ts +15 -0
  56. package/dist/mcp/client.js +170 -0
  57. package/dist/mcp/config.d.ts +34 -0
  58. package/dist/mcp/config.js +142 -0
  59. package/dist/mcp/grants.d.ts +4 -0
  60. package/dist/mcp/grants.js +132 -0
  61. package/dist/mcp/host.d.ts +16 -0
  62. package/dist/mcp/host.js +120 -0
  63. package/dist/mcp/network.d.ts +12 -0
  64. package/dist/mcp/network.js +150 -0
  65. package/dist/mcp/sign-in.d.ts +2 -0
  66. package/dist/mcp/sign-in.js +204 -0
  67. package/dist/mcp-api.d.ts +93 -0
  68. package/dist/mcp-api.js +10 -0
  69. package/dist/model-ref.d.ts +3 -0
  70. package/dist/model-ref.js +7 -0
  71. package/dist/setup-page.d.ts +51 -0
  72. package/dist/setup-page.js +149 -0
  73. package/dist/value-file.d.ts +1 -0
  74. package/dist/value-file.js +15 -16
  75. package/package.json +3 -2
@@ -8,13 +8,13 @@ function contentKind(c) {
8
8
  return c.clone ? `${github}, a clone brought up to date at each start` : `${github}, your checkout bound here`;
9
9
  }
10
10
  /**
11
- * The report lines for an agent's content — `works on` / `knows`, one per entry, then a `notice` line for each
11
+ * The report lines for an agent's content — `works on`, one per entry, then a `notice` line for each
12
12
  * thing its resolution has to say, then one for each directory this machine binds that is not here — shared by the
13
13
  * startup report, `info` and `content list`, so the three say the same thing.
14
14
  */
15
15
  export function contentLines(content, absent = []) {
16
16
  const lines = content.flatMap((c) => [
17
- [c.readonly ? "knows" : "works on", `${c.name.padEnd(width(content))} ${c.location} (${contentKind(c)})`],
17
+ ["works on", `${c.name.padEnd(width(content))} ${c.location} (${contentKind(c)})`],
18
18
  ...c.notices.map((notice) => ["notice", `${c.name}: ${notice}`]),
19
19
  ]);
20
20
  for (const binding of absent)
@@ -56,7 +56,7 @@ const init = {
56
56
  ],
57
57
  notes: "An agent is a directory holding a fastagent.config.ts, and it is also the agent's working directory. " +
58
58
  "It lives in a directory of its own, never inside a project or another agent: what it works on is declared " +
59
- "as its content. Add a directory it only knows later with `fastagent content add <dir> --readonly`.",
59
+ "as its content. Add a directory later with `fastagent content add <dir>`.",
60
60
  run: async (args, f) => (await import("./commands/init.js")).runInit(args[0], {
61
61
  install: f.install !== false,
62
62
  content: f.content ?? [],
@@ -434,14 +434,14 @@ const env = {
434
434
  };
435
435
  const content = {
436
436
  name: "content",
437
- summary: "list, add or remove what the agent works on and knows",
438
- description: "Content is a directory the agent works on (or only knows, with --readonly): a GitHub repository declared " +
437
+ summary: "list, add or remove what the agent works on",
438
+ description: "Content is a directory the agent works on: a GitHub repository declared " +
439
439
  "by name in context.json, shared with the agent and cloned at content/<name>, or a directory of this " +
440
440
  "machine bound in context.local.json, which never leaves it.",
441
441
  subcommands: [
442
442
  {
443
443
  name: "list",
444
- summary: "each content entry, where it is, and whether the agent works on it or only knows it",
444
+ summary: "each content entry and where it is",
445
445
  args: [AGENT_ARG],
446
446
  flags: [JSON_FLAG],
447
447
  examples: [{ cmd: "fastagent content list" }],
@@ -452,15 +452,13 @@ const content = {
452
452
  summary: "add a GitHub repository, or bind a directory of this machine, as content",
453
453
  args: [{ name: "<source>", description: "a directory, or github:owner/repo" }, AGENT_ARG],
454
454
  flags: [
455
- { flags: "--readonly", description: "the agent knows it and does not write it" },
456
455
  { flags: "--ref <ref>", description: "a repository: the branch, tag or commit to clone" },
457
456
  { flags: "--name <name>", description: "its name (default: the directory's or repository's)" },
458
457
  { flags: "--description <text>", description: "one sentence for the agent: what it is, how to treat it" },
459
458
  ],
460
459
  examples: [
461
- { cmd: "fastagent content add ~/code/app", note: "works on" },
462
- { cmd: "fastagent content add ~/handbook --readonly", note: "knows" },
463
- { cmd: "fastagent content add github:acme/docs --readonly", note: "knows a clone" },
460
+ { cmd: "fastagent content add ~/code/app", note: "a directory of this machine" },
461
+ { cmd: "fastagent content add github:acme/docs", note: "a clone" },
464
462
  ],
465
463
  notes: "The root of a GitHub checkout is declared in context.json as that repository, and bound in " +
466
464
  "context.local.json to the checkout, used as it is on this machine. github:owner/repo is cloned into " +
@@ -469,7 +467,6 @@ const content = {
469
467
  "Any other directory is bound in context.local.json only: a deployed instance works without it. A bound " +
470
468
  "directory may not contain the agent directory, nor sit inside it.",
471
469
  run: async (args, f) => (await import("./commands/content.js")).runContentAdd(args[0], args[1], {
472
- readonly: f.readonly === true,
473
470
  ...(typeof f.ref === "string" ? { ref: f.ref } : {}),
474
471
  ...(typeof f.name === "string" ? { name: f.name } : {}),
475
472
  ...(typeof f.description === "string" ? { description: f.description } : {}),
@@ -706,6 +703,64 @@ const auth = {
706
703
  },
707
704
  ],
708
705
  };
706
+ const mcp = {
707
+ name: "mcp",
708
+ summary: "inspect MCP servers and manage explicit OAuth bindings",
709
+ subcommands: [
710
+ {
711
+ name: "list",
712
+ summary: "inspect declarations and local auth",
713
+ description: "Read .mcp.json and local grant metadata without contacting servers.",
714
+ examples: [{ cmd: "fastagent mcp list --json" }],
715
+ args: [AGENT_ARG],
716
+ flags: [CHANNEL_ENVIRONMENT, JSON_FLAG],
717
+ run: async (args, f) => (await import("./commands/mcp.js")).runMcp("list", undefined, args[0], {
718
+ json: f.json === true,
719
+ environment: f.env,
720
+ }),
721
+ },
722
+ {
723
+ name: "login",
724
+ summary: "sign in to an MCP server",
725
+ description: "Authorize in the browser and save an explicit grant binding for this instance.",
726
+ examples: [
727
+ { cmd: "fastagent mcp login linear --scopes read --json" },
728
+ { cmd: "fastagent mcp login --check <ticket> --json" },
729
+ ],
730
+ args: [{ name: "[server]", description: "the server name in .mcp.json" }, AGENT_ARG],
731
+ flags: [
732
+ CHANNEL_ENVIRONMENT,
733
+ ...UNATTENDED_SETUP,
734
+ { flags: "--scopes <scopes>", description: "comma-separated OAuth scopes; required without a terminal" },
735
+ { flags: "--client-id <id>", description: "a pre-registered public client ID or client metadata URL" },
736
+ { flags: "--callback-port <port>", description: "local callback port (default: allocate an available port)" },
737
+ ],
738
+ run: async (args, f) => (await import("./commands/mcp.js")).runMcp("login", args[0], args[1], {
739
+ json: f.json === true,
740
+ input: f.input !== false,
741
+ environment: f.env,
742
+ scopes: f.scopes,
743
+ clientId: f.clientId,
744
+ callbackPort: f.callbackPort,
745
+ check: f.check,
746
+ ticket: f.ticket,
747
+ timeout: f.timeout,
748
+ }),
749
+ },
750
+ {
751
+ name: "disconnect",
752
+ summary: "remove a local OAuth binding",
753
+ description: "Remove this instance's binding without revoking or deleting a shared grant. Reopen running instances afterward.",
754
+ examples: [{ cmd: "fastagent mcp disconnect linear" }],
755
+ args: [{ name: "<server>", description: "the server name" }, AGENT_ARG],
756
+ flags: [CHANNEL_ENVIRONMENT, JSON_FLAG],
757
+ run: async (args, f) => (await import("./commands/mcp.js")).runMcp("disconnect", args[0], args[1], {
758
+ json: f.json === true,
759
+ environment: f.env,
760
+ }),
761
+ },
762
+ ],
763
+ };
709
764
  /**
710
765
  * Registration order = help order — the ORIGINAL usage wall's order, kept verbatim (this is a commander refactor of
711
766
  * the same CLI, not a redesign).
@@ -713,6 +768,7 @@ const auth = {
713
768
  export const specs = [
714
769
  init,
715
770
  models,
771
+ mcp,
716
772
  info,
717
773
  content,
718
774
  env,
@@ -4,8 +4,6 @@ export interface ContentEntry {
4
4
  github: string;
5
5
  /** The branch, tag or commit to clone. */
6
6
  ref?: string;
7
- /** The agent knows it and does not write it. */
8
- readonly?: boolean;
9
7
  /** One sentence for the agent: what this is, and how to treat it. */
10
8
  description?: string;
11
9
  }
@@ -15,7 +13,6 @@ export interface DeclaredContent {
15
13
  /** `owner/repo`. */
16
14
  repo: string;
17
15
  ref?: string;
18
- readonly: boolean;
19
16
  description?: string;
20
17
  }
21
18
  /**
@@ -25,7 +22,6 @@ export interface DeclaredContent {
25
22
  export interface ContentBinding {
26
23
  /** Absolute, `~/`, or relative to the agent directory. */
27
24
  path: string;
28
- readonly?: boolean;
29
25
  description?: string;
30
26
  }
31
27
  /** A binding, read: `path` absolute. */
@@ -38,7 +34,6 @@ export type BoundContent = {
38
34
  } | {
39
35
  /** A directory only this machine has. */
40
36
  kind: "local";
41
- readonly: boolean;
42
37
  description?: string;
43
38
  });
44
39
  /** What an agent's content is declared as: the shared declaration, and this machine's bindings. */
@@ -64,7 +59,7 @@ export declare function declareContent(raw: unknown): DeclaredContent[];
64
59
  export declare function declareBindings(raw: unknown, declared: readonly DeclaredContent[], agentDir: string): BoundContent[];
65
60
  /**
66
61
  * A directory the agent is bound to and the agent directory are kept apart (agent-model.md §2): a definition is
67
- * released, writable content is kept, and one directory cannot be both. The ONE statement of the rule; resolve.ts asks
62
+ * released, content is kept, and one directory cannot be both. The ONE statement of the rule; resolve.ts asks
68
63
  * it of the real paths.
69
64
  */
70
65
  export declare function nestingError(agentDir: string, location: string, name: string): string | undefined;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * WHAT AN AUTHOR DECLARES an agent works on or knows, read and refused in ONE place (file.ts reads the files through
2
+ * WHAT AN AUTHOR DECLARES an agent works on, read and refused in ONE place (file.ts reads the files through
3
3
  * it): the `content` map of `context.json`, which is shared and so holds only repositories every instance reaches, and
4
4
  * the `content` map of `context.local.json`, the directories of this machine it is bound to. Pure: what is on the disk
5
5
  * is resolve.ts's question. docs/design/agent-model.md §3 is the rule.
@@ -11,8 +11,8 @@ import { CONTEXT_FILE, LOCAL_CONTEXT_FILE, isUnderDir } from "../paths.js";
11
11
  * The keys an entry and a binding carry, in the order they are written: where it is, then how it is treated. One list
12
12
  * each, so a key the reader accepts is one the writer keeps.
13
13
  */
14
- const ENTRY_KEYS = ["github", "ref", "readonly", "description"];
15
- const BINDING_KEYS = ["path", "readonly", "description"];
14
+ const ENTRY_KEYS = ["github", "ref", "description"];
15
+ const BINDING_KEYS = ["path", "description"];
16
16
  /** A name becomes a directory name, so it is one path segment (the spelling a release's agent name has). */
17
17
  const NAME = /^[A-Za-z0-9_-]+$/;
18
18
  /** Whether `name` can name a content entry. */
@@ -62,16 +62,14 @@ export function declareBindings(raw, declared, agentDir) {
62
62
  if (shared.name !== name) {
63
63
  throw new Error(`${at} binds the entry ${CONTEXT_FILE} names "${shared.name}": spell it the same`);
64
64
  }
65
- for (const key of ["readonly", "description"]) {
66
- if (e[key] !== undefined) {
67
- throw new Error(`${at} binds this machine's checkout of the entry ${CONTEXT_FILE} declares, which says "${key}" for ` +
68
- `every instance: give it only "path"`);
69
- }
65
+ if (e.description !== undefined) {
66
+ throw new Error(`${at} binds this machine's checkout of the entry ${CONTEXT_FILE} declares, which says "description" for ` +
67
+ `every instance: give it only "path"`);
70
68
  }
71
69
  return { name, path, kind: "checkout" };
72
70
  }
73
- const treatment = treatmentOf(at, e);
74
- return { name, path, kind: "local", ...treatment };
71
+ const description = descriptionOf(at, e);
72
+ return { name, path, kind: "local", ...(description !== undefined ? { description } : {}) };
75
73
  });
76
74
  }
77
75
  /** `path` as written in `context.local.json`, absolute: `~/` is the home directory, a relative one the agent's. */
@@ -112,14 +110,11 @@ function objectWithKeys(at, value, keys) {
112
110
  }
113
111
  return e;
114
112
  }
115
- function treatmentOf(at, e) {
113
+ function descriptionOf(at, e) {
116
114
  if (e.description !== undefined && (typeof e.description !== "string" || e.description === "")) {
117
115
  throw new Error(`${at}: "description" must be a non-empty string`);
118
116
  }
119
- if (e.readonly !== undefined && typeof e.readonly !== "boolean")
120
- throw new Error(`${at}: "readonly" must be a boolean`);
121
- const description = e.description;
122
- return { readonly: e.readonly === true, ...(description !== undefined ? { description } : {}) };
117
+ return e.description;
123
118
  }
124
119
  function declareOne(name, entry) {
125
120
  const at = `content "${name}"`;
@@ -129,7 +124,7 @@ function declareOne(name, entry) {
129
124
  throw new Error(`${at}: "${key}" must be a non-empty string`);
130
125
  }
131
126
  }
132
- const treatment = treatmentOf(at, e);
127
+ const description = descriptionOf(at, e);
133
128
  if (e.github === undefined) {
134
129
  throw new Error(`${at} needs "github": ${CONTEXT_FILE} is shared, so it declares only what every instance reaches — bind a ` +
135
130
  `directory of this machine in ${LOCAL_CONTEXT_FILE} instead (fastagent content add <dir>)`);
@@ -141,11 +136,11 @@ function declareOne(name, entry) {
141
136
  // git reads a leading "-" as an option, and no branch, tag or commit name has one.
142
137
  if (ref?.startsWith("-"))
143
138
  throw new Error(`${at}: "ref" must name a branch, tag or commit, got "${ref}"`);
144
- return { name, repo, ...(ref !== undefined ? { ref } : {}), ...treatment };
139
+ return { name, repo, ...(ref !== undefined ? { ref } : {}), ...(description !== undefined ? { description } : {}) };
145
140
  }
146
141
  /**
147
142
  * A directory the agent is bound to and the agent directory are kept apart (agent-model.md §2): a definition is
148
- * released, writable content is kept, and one directory cannot be both. The ONE statement of the rule; resolve.ts asks
143
+ * released, content is kept, and one directory cannot be both. The ONE statement of the rule; resolve.ts asks
149
144
  * it of the real paths.
150
145
  */
151
146
  export function nestingError(agentDir, location, name) {
@@ -4,8 +4,6 @@ import { type CloneOutcome } from "./git.ts";
4
4
  export type ResolvedContent = {
5
5
  /** Unique within the agent, ignoring case; one path segment. */
6
6
  name: string;
7
- /** The agent knows it and does not write it. */
8
- readonly: boolean;
9
7
  /** What the declaration tells the agent about it. */
10
8
  description?: string;
11
9
  /** Where the agent reaches it on this instance, absolute. */
@@ -33,10 +33,9 @@ export function absentNotice(binding) {
33
33
  `not told of it`);
34
34
  }
35
35
  function resolveRepository(agentDir, entry, checkout) {
36
- const { name, readonly, description } = entry;
36
+ const { name, description } = entry;
37
37
  const repository = {
38
38
  name,
39
- readonly,
40
39
  ...(description !== undefined ? { description } : {}),
41
40
  kind: "github",
42
41
  repo: entry.repo,
@@ -62,12 +61,11 @@ function resolveRepository(agentDir, entry, checkout) {
62
61
  return { ...repository, location, notices, clone: true };
63
62
  }
64
63
  function resolveLocal(agentDir, binding) {
65
- const { name, readonly, description } = binding;
64
+ const { name, description } = binding;
66
65
  const location = realpathSync(binding.path);
67
66
  checkBinding(agentDir, name, location);
68
67
  return {
69
68
  name,
70
- readonly,
71
69
  ...(description !== undefined ? { description } : {}),
72
70
  location,
73
71
  notices: [],
@@ -1,6 +1,5 @@
1
1
  import { type ContentBinding, type ContentEntry } from "./declare.ts";
2
2
  export interface SourceOptions {
3
- readonly?: boolean;
4
3
  name?: string;
5
4
  /** A github entry's branch, tag or commit. */
6
5
  ref?: string;
@@ -14,7 +14,6 @@ import { checkoutOf, githubRepoOf } from "./git.js";
14
14
  */
15
15
  export function readContentSource(source, cwd, options = {}) {
16
16
  const treated = {
17
- ...(options.readonly ? { readonly: true } : {}),
18
17
  ...(options.description !== undefined ? { description: options.description } : {}),
19
18
  };
20
19
  const ref = options.ref !== undefined ? { ref: options.ref } : {};
@@ -142,7 +142,7 @@ export async function destroyAgentcoreDeployment(plan, runner, announce = () =>
142
142
  // FAILURE vs WARNING is decided by whether the resource SURVIVES it, not by whether a call failed. The
143
143
  // forwarder's group is named exactly, so a `--run` deletes it by name and the failed listing changed nothing
144
144
  // — reporting `1 resource(s) survived` for a teardown that removed everything is the same false signal as the
145
- // reverse, and `test/live/env.ts` treats it as a leak. In the inventory, where no delete follows, the same
145
+ // reverse. In the inventory, where no delete follows, the same
146
146
  // unreadable listing DOES mean the answer is incomplete.
147
147
  const failures = [];
148
148
  const warnings = [];
@@ -3,6 +3,7 @@ import { isAbsolute, relative, join, sep } from "node:path";
3
3
  import ignore from "ignore";
4
4
  import { AGENT_CONFIG_FILE, CONTENT_DIRNAME, LOCAL_CONTEXT_FILE, SECRETS_DIRNAME, exists, readTextIfExists, resolveSecretsDir, resolveStateRoot, } from "../paths.js";
5
5
  import { isGeneratedDockerignore } from "./container.js";
6
+ import { MCP_FILE } from "../mcp/config.js";
6
7
  /**
7
8
  * "Would docker's packer drop this path?" — built from a `.dockerignore`'s text via the `ignore` matcher (the same
8
9
  * library the scaffolded ignore files use), so `!` negation and last-match-wins are the library's problem, not ours.
@@ -120,6 +121,9 @@ export async function checkKeptIgnoreFiles(ctx, report) {
120
121
  `config (the deployed box has no agent to open and crash-loops). Remove that rule before deploying.`;
121
122
  report.issue(text);
122
123
  }
124
+ if (excluded(MCP_FILE) && (await exists(join(agentDir, MCP_FILE)))) {
125
+ report.issue(`your ${rel} excludes ${MCP_FILE}; the deployment would lose its MCP servers. Remove that rule before deploying.`);
126
+ }
123
127
  // Resolved paths, not spellings: dockerignore patterns are root-anchored (unlike .gitignore).
124
128
  const leaks = leakCandidates.filter((p) => !excluded(p));
125
129
  if (leaks.length > 0) {
@@ -149,8 +149,7 @@ ${deployment}`;
149
149
  //
150
150
  // `*.tgz` RIDES ALONG with the manifest, because the install layer runs BEFORE `COPY . .` and a
151
151
  // `"dep": "file:./something.tgz"` resolves against the directory it is installing in. Without this the file is
152
- // not there yet and the build fails on a dependency the author can see sitting in the agent directory. It is
153
- // also what lets a live probe deploy the CHECKOUT (`npm pack` output) rather than the published release.
152
+ // not there yet and the build fails on a dependency the author can see sitting in the agent directory.
154
153
  if (isBun) {
155
154
  // `--frozen-lockfile` needs bun.lock and hard-fails without it.
156
155
  const install = input.hasLockfile ? "bun install --frozen-lockfile" : "bun install";
@@ -1,8 +1,7 @@
1
- /** `fastagent deploy docker --run` — reconcile the generated/user-owned Compose application locally. */
2
1
  import type { BoxLoginStep } from "../box-shell.ts";
3
2
  import type { DeclaredChannel } from "../../channels/discover.ts";
4
3
  import type { RegistrationOutcome } from "../../channels/registration.ts";
5
- import type { CliRunner } from "../runner.ts";
4
+ import { type CliRunner } from "../runner.ts";
6
5
  export interface DockerRunPlan {
7
6
  /** Compose file relative to the runner cwd (the agent directory). */
8
7
  composeFile: string;
@@ -54,6 +53,11 @@ type DockerAnnounce = (baseUrl: string) => Promise<{
54
53
  * budget.
55
54
  */
56
55
  export type DockerHealthProbe = (healthUrl: string, stillStarting: () => Promise<boolean>) => Promise<boolean>;
56
+ /**
57
+ * Whether something already accepts connections at `host:port`. The answer to "is the port the agent is about to
58
+ * publish free", asked before `up`, because afterwards a `/health` that answers cannot say whose it is.
59
+ */
60
+ export type HostPortProbe = (host: string, port: number) => Promise<boolean>;
57
61
  /** A published Quick Tunnel URL, and whether its tunnel ever reported an edge connection. */
58
62
  export interface ComposeTunnel {
59
63
  url: string;
@@ -62,6 +66,15 @@ export interface ComposeTunnel {
62
66
  export type DockerTunnelUrlProbe = (docker: CliRunner, composeFile: string) => Promise<ComposeTunnel | undefined>;
63
67
  /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
64
68
  export declare function localUrlFromComposePort(stdout: string): string | undefined;
69
+ /**
70
+ * The host ports the `agent` service publishes, from `docker compose config --format json` (Compose has already
71
+ * normalised every short or long syntax into `ports[]`). A port with no fixed host side (`published` unset: Docker picks
72
+ * one) cannot collide and is left out. A wildcard bind is asked on loopback, which is where the URL we report points.
73
+ */
74
+ export declare function agentPublishedPorts(stdout: string): {
75
+ host: string;
76
+ port: number;
77
+ }[];
65
78
  /**
66
79
  * Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel reports an edge
67
80
  * connection.
@@ -71,5 +84,5 @@ export declare function waitForComposeTunnelUrl(docker: CliRunner, composeFile:
71
84
  intervalMs?: number;
72
85
  sleep?: (ms: number) => Promise<void>;
73
86
  }): Promise<ComposeTunnel | undefined>;
74
- export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, tunnelUrlProbe?: DockerTunnelUrlProbe): Promise<DockerRunOutcome>;
87
+ export declare function deployDockerRun(plan: DockerRunPlan, docker: CliRunner, log: (message: string) => void, healthProbe?: DockerHealthProbe, tunnelUrlProbe?: DockerTunnelUrlProbe, hostPortBusy?: HostPortProbe): Promise<DockerRunOutcome>;
75
88
  export {};
@@ -1,8 +1,11 @@
1
+ /** `fastagent deploy docker --run` — reconcile the generated/user-owned Compose application locally. */
2
+ import { connect } from "node:net";
1
3
  import { waitForHealth } from "../../channels/wait-health.js";
2
4
  import { loginGate } from "../channel-ingress.js";
3
5
  import { TUNNEL_DNS_LAG_MS, hasTunnelConnection, parseTunnelUrl } from "../../tunnel.js";
4
6
  import { registrationGate } from "../registration-gate.js";
5
7
  import { MIN_DOCKER_COMPOSE_VERSION } from "./plan.js";
8
+ import { readOutput } from "../runner.js";
6
9
  import { missingValuesGate } from "../secrets.js";
7
10
  /** Resolve Docker Compose's `host:port` output to a loopback URL (safe for 0.0.0.0/[::] bindings too). */
8
11
  export function localUrlFromComposePort(stdout) {
@@ -18,6 +21,36 @@ export function localUrlFromComposePort(stdout) {
18
21
  * so this budget covers a copy on a slow Docker Desktop disk, not a listen.
19
22
  */
20
23
  const defaultHealthProbe = (healthUrl, stillStarting) => waitForHealth(healthUrl, 180_000, 500, stillStarting);
24
+ const defaultHostPortBusy = (host, port) => new Promise((resolve) => {
25
+ const socket = connect({ host, port });
26
+ const answer = (busy) => {
27
+ socket.destroy();
28
+ resolve(busy);
29
+ };
30
+ socket.setTimeout(1_000, () => answer(false)); // a filtered port is not one something is serving
31
+ socket.once("connect", () => answer(true));
32
+ socket.once("error", () => answer(false));
33
+ });
34
+ /**
35
+ * The host ports the `agent` service publishes, from `docker compose config --format json` (Compose has already
36
+ * normalised every short or long syntax into `ports[]`). A port with no fixed host side (`published` unset: Docker picks
37
+ * one) cannot collide and is left out. A wildcard bind is asked on loopback, which is where the URL we report points.
38
+ */
39
+ export function agentPublishedPorts(stdout) {
40
+ const config = JSON.parse(stdout);
41
+ const ports = config?.services?.agent?.ports;
42
+ if (ports === undefined)
43
+ return [];
44
+ if (!Array.isArray(ports))
45
+ throw new Error("services.agent.ports is not a list");
46
+ return ports.flatMap((entry) => {
47
+ const port = Number(entry?.published);
48
+ if (!Number.isInteger(port) || port <= 0 || (entry.protocol ?? "tcp") !== "tcp")
49
+ return [];
50
+ const wildcard = !entry.host_ip || entry.host_ip === "0.0.0.0" || entry.host_ip === "::";
51
+ return [{ host: wildcard ? "127.0.0.1" : entry.host_ip, port }];
52
+ });
53
+ }
21
54
  const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
22
55
  /**
23
56
  * Poll the detached cloudflared service's logs until its Quick Tunnel URL is assigned AND the tunnel reports an edge
@@ -59,7 +92,7 @@ async function containerHealthy(docker, compose, port, stillStarting) {
59
92
  }
60
93
  return false;
61
94
  }
62
- export async function deployDockerRun(plan, docker, log, healthProbe = defaultHealthProbe, tunnelUrlProbe = defaultTunnelUrlProbe) {
95
+ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHealthProbe, tunnelUrlProbe = defaultTunnelUrlProbe, hostPortBusy = defaultHostPortBusy) {
63
96
  const gate = (message) => ({ ok: false, gate: message });
64
97
  const compose = ["compose", "-f", plan.composeFile];
65
98
  // CLI/plugin gate first: unlike a daemon error, spawn ENOENT becomes 127 at the shared runner seam.
@@ -99,6 +132,26 @@ export async function deployDockerRun(plan, docker, log, healthProbe = defaultHe
99
132
  return gate(`--tunnel was requested but the kept ${plan.composeFile} has no "tunnel" service — ` +
100
133
  `edit it, delete it and regenerate, or pass --force`);
101
134
  }
135
+ // A port something else already serves would answer the `/health` this run waits for, and the run would report that
136
+ // process's answer as the agent's. A container this project already runs holds its own port, so only a first start
137
+ // (or a start after `down`) asks. Before the first side effect: a refusal leaves nothing to undo.
138
+ const hostPorts = await readOutput(docker, "docker", [...compose, "config", "--format", "json"], agentPublishedPorts);
139
+ if ("gate" in hostPorts)
140
+ return gate(hostPorts.gate);
141
+ if (hostPorts.value.length > 0) {
142
+ const running = await docker([...compose, "ps", "--status", "running", "--services"], { capture: true });
143
+ const ours = running.code === 0 && running.stdout.split(/\s+/).includes("agent");
144
+ if (!ours) {
145
+ for (const { host, port } of hostPorts.value) {
146
+ if (await hostPortBusy(host, port)) {
147
+ return gate(`${host}:${port} is already in use by another process, so the agent's published port is not free and a ` +
148
+ `health check there would answer for that process, not for the agent — stop it, or move the agent: ` +
149
+ `edit the published port in ${plan.composeFile}, or change http.port in fastagent.config.ts and re-run ` +
150
+ `with --force (a generated Compose file is kept otherwise)`);
151
+ }
152
+ }
153
+ }
154
+ }
102
155
  // Quick Tunnel logs are the control-plane output (the assigned URL).
103
156
  if (hasTunnel) {
104
157
  log("recreating the ephemeral tunnel service…");
@@ -37,18 +37,4 @@ export type FlyRunOutcome = {
37
37
  ok: false;
38
38
  gate: string;
39
39
  };
40
- /**
41
- * Which PUBLIC ingress families `fly ips list --json` shows — asked per family, because "has an ingress address" is
42
- * not the question the two allocate commands answer.
43
- */
44
- export declare function ingressAddresses(stdout: string): {
45
- v4: boolean;
46
- v6: boolean;
47
- };
48
- /**
49
- * Whether `fly apps list --json` lists an app called `name`. flyctl capitalizes the field; the live probe
50
- * (test/live/fly.live.test.ts) is what pins that against real output, since an offline fixture can only repeat what
51
- * we already believe.
52
- */
53
- export declare function listHasName(stdout: string, name: string): boolean;
54
40
  export declare function deployFlyRun(plan: FlyRunPlan, fly: CliRunner, log: (msg: string) => void, registrars: Registrars, healthProbe?: PublicHealthProbe): Promise<FlyRunOutcome>;
@@ -10,7 +10,7 @@ const INGRESS_TYPES = { v4: ["v4", "shared_v4"], v6: ["v6"] };
10
10
  * Which PUBLIC ingress families `fly ips list --json` shows — asked per family, because "has an ingress address" is
11
11
  * not the question the two allocate commands answer.
12
12
  */
13
- export function ingressAddresses(stdout) {
13
+ function ingressAddresses(stdout) {
14
14
  const entries = JSON.parse(stdout);
15
15
  if (!Array.isArray(entries))
16
16
  throw new Error(`expected a JSON array, got ${typeof entries}`);
@@ -18,11 +18,10 @@ export function ingressAddresses(stdout) {
18
18
  return { v4: has(INGRESS_TYPES.v4), v6: has(INGRESS_TYPES.v6) };
19
19
  }
20
20
  /**
21
- * Whether `fly apps list --json` lists an app called `name`. flyctl capitalizes the field; the live probe
22
- * (test/live/fly.live.test.ts) is what pins that against real output, since an offline fixture can only repeat what
23
- * we already believe.
21
+ * Whether `fly apps list --json` lists an app called `name`. flyctl capitalizes the field, and an
22
+ * offline fixture can only repeat what we already believe, so a change in flyctl's output is not caught here.
24
23
  */
25
- export function listHasName(stdout, name) {
24
+ function listHasName(stdout, name) {
26
25
  const entries = JSON.parse(stdout);
27
26
  if (!Array.isArray(entries))
28
27
  throw new Error(`expected a JSON array, got ${typeof entries}`);
@@ -27,33 +27,25 @@ import { checkPackages, MISE_PACKAGE_NAMES, miseBinary, packageSpecs } from "../
27
27
  /**
28
28
  * What each content entry becomes on the host, one line each, so none is missing there unsaid (agent-model.md §3). A
29
29
  * repository `context.json` declares is cloned there, as on this machine without a checkout bound. A directory only
30
- * this machine binds stays here: the deployed agent works without it, which the line says and says how to change. One
31
- * the agent works on is a warning, since the deployed agent lacks data it was meant to work on; one it only knows, a
32
- * note.
30
+ * this machine binds stays here: the deployed agent works without it, which the line says and says how to change. It
31
+ * is a warning, since the deployed agent lacks data it was meant to work on.
33
32
  */
34
33
  function checkContent({ declared, bound }, storageResets, report) {
35
34
  for (const entry of declared) {
36
35
  const fate = storageResets
37
36
  ? "cloned afresh on every deployment, since the host's storage starts over; what the agent did not push is lost"
38
37
  : "cloned on the host, and brought up to date in place at each start";
39
- report.note(`${roleOf(entry)} ${entry.name}: github ${entry.repo}${entry.ref ? `@${entry.ref}` : ""}, ${fate}`);
38
+ report.note(`works on ${entry.name}: github ${entry.repo}${entry.ref ? `@${entry.ref}` : ""}, ${fate}`);
40
39
  }
41
40
  for (const binding of bound) {
42
41
  if (binding.kind !== "local")
43
42
  continue;
44
- const stays = `${roleOf(binding)} ${binding.name}: a directory of this machine (${binding.path}, bound in ` +
43
+ const stays = `works on ${binding.name}: a directory of this machine (${binding.path}, bound in ` +
45
44
  `${LOCAL_CONTEXT_FILE}), stays here, and the deployed agent works without it`;
46
- if (binding.readonly) {
47
- report.note(`${stays}; to ship what the agent reads there, copy it into the agent directory, which every release carries`);
48
- }
49
- else {
50
- report.warn(`${stays}; to work on it from a host, move it to a GitHub repository and add it as github:owner/repo`);
51
- }
45
+ report.warn(`${stays}; to work on it from a host, move it to a GitHub repository and add it as github:owner/repo, or copy ` +
46
+ `what it only needs to read there into the agent directory, which every release carries`);
52
47
  }
53
48
  }
54
- function roleOf(entry) {
55
- return entry.readonly ? "knows" : "works on";
56
- }
57
49
  /**
58
50
  * What the image needs to install the packages the agent declares: the agent's own mise for linux in its package.json,
59
51
  * so the image's install brings it, packages FastAgent installs from, and a PyPI package's runtime. A machine without
@@ -7,6 +7,7 @@ import { relative, sep } from "node:path";
7
7
  import { watch as watchTree } from "chokidar";
8
8
  import { AGENT_CONFIG_FILE, CONTEXT_FILE, LOCAL_CONTEXT_FILE, resolveStateRoot, isUnderDir } from "./paths.js";
9
9
  import { dotEnvPath } from "./value-file.js";
10
+ import { MCP_FILE } from "./mcp/config.js";
10
11
  import { log } from "./log.js";
11
12
  import { openExternalUrl } from "./open-url.js";
12
13
  import { declaredChannels } from "./channels/discover.js";
@@ -14,7 +15,7 @@ import { activeWork } from "./channels/busy.js";
14
15
  import { announceWebhooks, startCloudflareTunnel } from "./tunnel.js";
15
16
  /** The agent's code (its tools, extensions, channels and schedules), loaded ONCE per worker: a restart is its only re-read. */
16
17
  const CODE_DIR = "src";
17
- const WATCHED_HINT = `${CODE_DIR}/, package.json, fastagent.config.ts, context.json, context.local.json, .secrets/.env`;
18
+ const WATCHED_HINT = `${CODE_DIR}/, package.json, fastagent.config.ts, context.json, context.local.json, .mcp.json, .secrets/.env`;
18
19
  /** chokidar `ignored` matcher for the narrow watch scope (true = ignore), rooted at the AGENT DIR. */
19
20
  export function devWatchIgnored(root, envFile) {
20
21
  // The `.env` is allow-listed by its RESOLVED path, not by the `.secrets` name.
@@ -27,7 +28,7 @@ export function devWatchIgnored(root, envFile) {
27
28
  // their only re-read).
28
29
  if (rel === AGENT_CONFIG_FILE || rel === CONTEXT_FILE || rel === LOCAL_CONTEXT_FILE)
29
30
  return false;
30
- if (rel === "package.json")
31
+ if (rel === "package.json" || rel === MCP_FILE)
31
32
  return false;
32
33
  const segments = rel.split(sep);
33
34
  if (segments[0] === CODE_DIR)
@@ -0,0 +1,3 @@
1
+ import { type ToolRegistration } from "@earendil-works/pi-durable";
2
+ import type { McpTool } from "../../mcp/client.ts";
3
+ export declare function mcpTools(tools: readonly McpTool[]): ToolRegistration[];
@@ -0,0 +1,34 @@
1
+ /** MCP tools on pi-durable. No replay promise is inferred from a server's annotations. */
2
+ import { Type } from "@earendil-works/pi-ai";
3
+ import { defineTool } from "@earendil-works/pi-durable";
4
+ import { McpError } from "../../mcp-api.js";
5
+ export function mcpTools(tools) {
6
+ return tools.map((tool) => defineTool({
7
+ name: tool.name,
8
+ description: tool.definition.description ?? `MCP tool ${tool.definition.name} from ${tool.server}`,
9
+ parameters: Type.Unsafe(tool.definition.inputSchema),
10
+ replay: "unsafe",
11
+ execute: async (args, api, context) => {
12
+ try {
13
+ const result = await tool.call(args, context.abortSignal, (text) => api.output(text));
14
+ if (result.content.some((part) => part.type !== "text"))
15
+ throw new McpError("protocol", "MCP tool returned unsupported content; only text/JSON is supported", tool.server);
16
+ const output = result.content.flatMap((part) => part.type === "text" ? [{ type: "text", text: part.text }] : []);
17
+ if (result.structuredContent !== undefined)
18
+ output.push({ type: "text", text: JSON.stringify(result.structuredContent) });
19
+ return { output, ...(result.isError ? { isError: true } : {}) };
20
+ }
21
+ catch (error) {
22
+ context.abortSignal?.throwIfAborted();
23
+ if (!(error instanceof McpError))
24
+ throw error;
25
+ // Preserve actionable auth/connection classification in the existing tool-result details.
26
+ return {
27
+ output: [{ type: "text", text: error.message }],
28
+ isError: true,
29
+ details: { mcp: { server: tool.server, code: error.code } },
30
+ };
31
+ }
32
+ },
33
+ }));
34
+ }