@finchip.ai/foga 0.6.2 → 0.7.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.
package/dist/cli.js CHANGED
@@ -53,6 +53,11 @@ usage:
53
53
  one command to a live cell: the wallet (made on the first run, reused after), anvil, the local world, a cell born with
54
54
  the wallet's keys and its two skills, two tenders, the node serving HTTP and MCP, and the MCP client configurations
55
55
  printed with every path filled in (~/.foga/quickstart)
56
+ foga join [--chain sepolia | --world <deployed.json>] [--rpc <url>] [--name <cell>] [--dir <dir>] [--wallet <dir>]
57
+ [--listen <host:port> | --no-serve] [--public-url <url>] [--run stub|anthropic] [--no-wait] [--min-wei <n>] [--capital-wei <n>] [--exit]
58
+ one command to a cell that lives on a public chain: the wallet (made once, the same address on every chain), the
59
+ chain's world from the package, a wait for the identity to be funded, the birth, the skills minted and offered, the
60
+ documents on chain, the node served (or run with a model); resumable, in ~/.foga/cells/<network>/<name>, never cleared
56
61
  foga demo [--port <n>] [--keep] [--genesis <T0..T9>] start anvil, deploy the local world, run the doctor (and the genesis)
57
62
  foga demo --loop-d [--model stub|anthropic|external] [--model-name <name>] [--seed <n>] [--no-recovery] [--port <n>] [--keep]
58
63
  the above, then Loop D: a new cell born, a demander minting cases a-g, the node with a model (or served for an
@@ -525,6 +530,51 @@ async function main() {
525
530
  return; // a stop that was asked for: both children are gone
526
531
  fail(`${why}; the quickstart is over`);
527
532
  }
533
+ if (group === "join") {
534
+ const { runJoin } = await import("./node/join.js");
535
+ const run = str(flags, "run");
536
+ if (run && run !== "stub" && run !== "anthropic")
537
+ fail("--run must be stub or anthropic");
538
+ let result;
539
+ try {
540
+ result = await runJoin({
541
+ chain: str(flags, "chain"),
542
+ world: str(flags, "world"),
543
+ rpc: str(flags, "rpc"),
544
+ name: str(flags, "name"),
545
+ dir: str(flags, "dir"),
546
+ wallet: str(flags, "wallet"),
547
+ listen: flags["no-serve"] ? null : str(flags, "listen"),
548
+ publicUrl: str(flags, "public-url"),
549
+ run: run ?? null,
550
+ wait: !flags["no-wait"],
551
+ minWei: str(flags, "min-wei") ? BigInt(str(flags, "min-wei")) : undefined,
552
+ capitalWei: str(flags, "capital-wei") ? BigInt(str(flags, "capital-wei")) : undefined,
553
+ exit: Boolean(flags.exit),
554
+ });
555
+ }
556
+ catch (e) {
557
+ fail(e.message);
558
+ }
559
+ if (flags.exit || !result.nodeUrl) {
560
+ await result.stop();
561
+ return;
562
+ }
563
+ let asked = false;
564
+ const stopping = () => {
565
+ if (asked)
566
+ return;
567
+ asked = true;
568
+ console.error("join: stopping the node; the cell stays");
569
+ result.stop().finally(() => process.exit(0));
570
+ };
571
+ process.on("SIGINT", stopping);
572
+ process.on("SIGTERM", stopping);
573
+ const why = await result.ended;
574
+ if (why === "stopped")
575
+ return;
576
+ fail(`${why}; start it again with the same command`);
577
+ }
528
578
  if (group === "demo") {
529
579
  await demo({ port: Number(str(flags, "port") ?? 8545), keep: Boolean(flags.keep), genesis: str(flags, "genesis"), loopD: flags["loop-d"] ? loopDFlags(flags) : undefined });
530
580
  return;
@@ -0,0 +1,82 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { repoRoot } from "../util/fs.js";
4
+ export const CHAINS = {
5
+ sepolia: {
6
+ name: "sepolia",
7
+ chainId: 11155111,
8
+ world: "deployments/sepolia.example.json",
9
+ rpcEnv: "SEPOLIA_RPC_URL",
10
+ rpc: ["https://ethereum-sepolia-rpc.publicnode.com", "https://rpc.sepolia.org", "https://1rpc.io/sepolia"],
11
+ faucets: ["https://cloud.google.com/application/web3/faucet/ethereum/sepolia", "https://www.alchemy.com/faucets/ethereum-sepolia", "https://sepoliafaucet.com"],
12
+ explorer: "https://sepolia.etherscan.io",
13
+ testnet: true,
14
+ },
15
+ "base-sepolia": {
16
+ name: "base-sepolia",
17
+ chainId: 84532,
18
+ world: null,
19
+ rpcEnv: "BASE_SEPOLIA_RPC_URL",
20
+ rpc: ["https://sepolia.base.org", "https://base-sepolia-rpc.publicnode.com"],
21
+ faucets: ["https://docs.base.org/base-chain/tools/network-faucets", "https://cloud.google.com/application/web3/faucet/base/sepolia"],
22
+ explorer: "https://sepolia.basescan.org",
23
+ testnet: true,
24
+ },
25
+ "optimism-sepolia": {
26
+ name: "optimism-sepolia",
27
+ chainId: 11155420,
28
+ world: null,
29
+ rpcEnv: "OPTIMISM_SEPOLIA_RPC_URL",
30
+ rpc: ["https://sepolia.optimism.io", "https://optimism-sepolia-rpc.publicnode.com"],
31
+ faucets: ["https://console.optimism.io/faucet", "https://cloud.google.com/application/web3/faucet/optimism/sepolia"],
32
+ explorer: "https://sepolia-optimism.etherscan.io",
33
+ testnet: true,
34
+ },
35
+ "arbitrum-sepolia": {
36
+ name: "arbitrum-sepolia",
37
+ chainId: 421614,
38
+ world: null,
39
+ rpcEnv: "ARBITRUM_SEPOLIA_RPC_URL",
40
+ rpc: ["https://sepolia-rollup.arbitrum.io/rpc", "https://arbitrum-sepolia-rpc.publicnode.com"],
41
+ faucets: ["https://docs.arbitrum.io/for-devs/dev-tools-and-resources/chain-info", "https://cloud.google.com/application/web3/faucet/arbitrum/sepolia"],
42
+ explorer: "https://sepolia.arbiscan.io",
43
+ testnet: true,
44
+ },
45
+ anvil: {
46
+ name: "anvil",
47
+ chainId: 31337,
48
+ world: null,
49
+ rpcEnv: "FOGA_RPC_URL",
50
+ rpc: ["http://127.0.0.1:8545"],
51
+ faucets: [],
52
+ explorer: null,
53
+ testnet: true,
54
+ },
55
+ };
56
+ /** The chain by name, or null; names are case-insensitive. */
57
+ export function chainByName(name) {
58
+ return CHAINS[name.toLowerCase()] ?? null;
59
+ }
60
+ /** The chain whose id this is, or null. */
61
+ export function chainById(chainId) {
62
+ return Object.values(CHAINS).find((c) => c.chainId === chainId) ?? null;
63
+ }
64
+ /** The absolute path of the packaged world for a chain, or null when none ships or the file is missing. */
65
+ export function packagedWorld(chain) {
66
+ if (!chain.world)
67
+ return null;
68
+ const path = join(repoRoot(), chain.world);
69
+ return existsSync(path) ? path : null;
70
+ }
71
+ /** The RPC for a chain: the one given, the chain's own variable, the general one, then the first public endpoint. */
72
+ export function resolveRpc(chain, given) {
73
+ if (given)
74
+ return { url: given, source: "flag" };
75
+ if (chain && process.env[chain.rpcEnv])
76
+ return { url: process.env[chain.rpcEnv], source: "env" };
77
+ if (process.env.FOGA_RPC_URL)
78
+ return { url: process.env.FOGA_RPC_URL, source: "env" };
79
+ if (chain?.rpc[0])
80
+ return { url: chain.rpc[0], source: "public" };
81
+ throw new Error("no RPC: pass --rpc <url> or set FOGA_RPC_URL");
82
+ }
@@ -145,7 +145,11 @@ export function buildMcpServer(o) {
145
145
  // how to become a cell: every cell carries the genome's instructions, so an agent that meets one can make itself one
146
146
  const quickstartDoc = join(repoRoot(), "docs", "quickstart.md");
147
147
  if (existsSync(quickstartDoc)) {
148
- server.registerResource("quickstart", "foga://docs/quickstart", { title: "Become a cell", description: "How any agent makes itself a FOGA cell: one command, its own wallet, the same program this node runs.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(quickstartDoc, "utf8") }] }));
148
+ server.registerResource("quickstart", "foga://docs/quickstart", { title: "A local try", description: "How any agent makes itself a FOGA cell on a local chain in one command: its own wallet, the same program this node runs.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(quickstartDoc, "utf8") }] }));
149
+ }
150
+ const joinDoc = join(repoRoot(), "docs", "join.md");
151
+ if (existsSync(joinDoc)) {
152
+ server.registerResource("join", "foga://docs/join", { title: "Become a cell", description: "How any agent becomes a FOGA cell that lives on a public chain in one command (foga join): the wallet, the funding, the birth, the card, work over the chain, other chains with the same key.", mimeType: "text/markdown" }, async (uri) => ({ contents: [{ uri: uri.href, mimeType: "text/markdown", text: readFileSync(joinDoc, "utf8") }] }));
149
153
  }
150
154
  server.registerPrompt("become-a-cell", { title: "Become a cell", description: "Replicate: make yourself a FOGA cell on your own machine, with your own wallet — the same program as this node, your own identity.", argsSchema: z.object({}) }, () => ({
151
155
  messages: [
@@ -155,13 +159,15 @@ export function buildMcpServer(o) {
155
159
  type: "text",
156
160
  text: [
157
161
  `You are talking to cell ${node.config.name}${cell ? ` (account ${cell.address})` : ""} through the Finch OG Agent node software, version ${o.version}. You can become a cell yourself: the program is public, the identity you make is your own, and nothing of this cell's identity is copied.`,
158
- "On a machine where you can run commands (Node 22.13 or newer, and Foundry's anvil for a local chain):",
159
- "1. A key of your own, first and once: npx -y @finchip.ai/foga wallet new (one signer key in ~/.foga/wallet — your identity, which fills every role a cell needs; never overwritten; a failure here never blocks the next step)",
160
- "2. A living cell on a local chain: npx -y @finchip.ai/foga quickstart (anvil, the world, a cell born with your wallet's keys, two tenders, the node served; it prints the MCP client configuration to connect a model to the cell)",
161
- "3. Connect your model: paste the printed configuration into your MCP client (Claude Desktop, Claude Code, Cursor, or any client with the HTTP form); the stdio form acts with your operator key, the HTTP form with the printed Bearer key.",
162
- "4. On a public chain, when you are ready: fund your key, then foga cell new --wallet ~/.foga/wallet --world <world.json> … and foga cell init --config <cell.config.json> --wallet ~/.foga/wallet. (For a cold-owner / hot-operator / separate-guardian split, make the wallet with `foga wallet new --cell` instead; it is optional hardening, not a requirement.)",
163
- `5. With the wallet alone you can also call this cell as yourself: npx -y @finchip.ai/foga wallet sign --url ${node.config.runtime.http.publicUrl ?? "<this node's URL>"} prints an Authorization header (FOGA-EIP712) for its /api, /mcp and /a2a; what it is granted is this cell's policy.`,
164
- "The guide is the resource foga://docs/quickstart. In PowerShell, quote the package name: npx -y '@finchip.ai/foga' quickstart.",
162
+ "On a machine where you can run commands (Node 22.13 or newer; Foundry's anvil only for the local try):",
163
+ "1. A key of your own, first and once: npx -y @finchip.ai/foga wallet new (one signer key in ~/.foga/wallet — your identity, the same address on every EVM chain; never overwritten)",
164
+ "2. A cell that lives, on a public chain: npx -y @finchip.ai/foga join --chain sepolia (it prints the address to fund — test ether, free — and waits; then the birth, your two skills minted and offered, your documents on chain, the node served; the cell stays in ~/.foga/cells/sepolia/<name>, and the same command resumes it. Add --run stub to take tenders on your own without a model, --listen 0.0.0.0:8787 --public-url https://<host> to be reachable from outside.)",
165
+ " A local try first, if you like: npx -y @finchip.ai/foga quickstart (anvil: a cell on a chain that ends with the process; nothing persists but the wallet)",
166
+ "3. Say who you are: npx -y @finchip.ai/foga cell card --config ~/.foga/cells/sepolia/<name>/cell.config.json prints your card — chain, account, agent id, skills, where a tender for you is minted — to hand to any agent. Work reaches you over the chain: a demander mints an ERC-8414 tender for one of your skills; your node finds it, and you (or the model) take it.",
167
+ "4. Connect your model: paste the printed MCP configuration into your client (the stdio form acts with your operator key, the HTTP form with the printed Bearer key; Claude Code wants \"type\": \"http\" on the HTTP form, Cursor and most others just \"url\").",
168
+ "5. Another chain, the same key: join --chain <name> where a world ships (sepolia today), or foga world deploy --world worlds/<chain>.json once on a chain without one, then join --world <its address book>.",
169
+ `6. With the wallet alone you can also call this cell as yourself: npx -y @finchip.ai/foga wallet sign --url ${node.config.runtime.http.publicUrl ?? "<this node's URL>"} prints an Authorization header (FOGA-EIP712) for its /api, /mcp and /a2a; what it is granted is this cell's policy.`,
170
+ "The guides are the resource foga://docs/quickstart and docs/join.md in the package. In PowerShell, quote the package name: npx -y '@finchip.ai/foga' join --chain sepolia.",
165
171
  "What you get is a complete body: the ten standards, the catalog that decides, the acceptance (foga doctor, foga demo --loop-d). The mind driving it stays yours.",
166
172
  ].join("\n"),
167
173
  },
@@ -0,0 +1,67 @@
1
+ import { endpointsOf } from "../a2a/card.js";
2
+ import { chainById } from "../core/chains.js";
3
+ import { FOGA_COVENANTS } from "./propagation.js";
4
+ import { PACKAGE_NAME } from "../util/version.js";
5
+ export function cellCard(node, o) {
6
+ const cfg = node.config;
7
+ const world = node.world;
8
+ const chain = chainById(node.chainId);
9
+ const ep = endpointsOf(node);
10
+ const base = cfg.runtime.http.publicUrl?.replace(/\/+$/, "") ?? null;
11
+ const has = (id) => {
12
+ try {
13
+ return world.address(id);
14
+ }
15
+ catch {
16
+ return null;
17
+ }
18
+ };
19
+ const id = cfg.identity;
20
+ return {
21
+ schema: "foga.card/1",
22
+ name: cfg.name,
23
+ chain: { chainId: node.chainId, network: world.file.network, worldId: world.file.worldId, explorer: chain?.explorer ?? null },
24
+ identity: {
25
+ account: id.account ?? null,
26
+ agentId: id.agentId ?? null,
27
+ registry: has("identity8004/IdentityRegistry"),
28
+ owner: id.owner,
29
+ operator: id.operator,
30
+ guardian: id.guardian,
31
+ oneKey: id.owner === id.operator && id.operator === id.guardian,
32
+ },
33
+ covenants: { program: FOGA_COVENANTS, world: world.covenants() },
34
+ skills: Object.entries(cfg.skills.held).map(([sid, h]) => ({ id: sid, tokenId: String(h.tokenId), packageHash: h.packageHash ?? "", offered: cfg.skills.offered.includes(sid) })),
35
+ work: { taskToken: has("8414/TaskToken"), minRewardWei: String(cfg.policy.tenders.minRewardWei), acceptance: cfg.policy.tenders.acceptance, counterpartyMinLevel: cfg.policy.tenders.counterpartyMinLevel },
36
+ standing: id.standing ? { schemeId: id.standing.schemeId, issuers: id.standing.issuers } : null,
37
+ endpoints: { mcp: ep.mcp, a2a: ep.a2a, web: ep.web, manifest: base ? `${base}/.well-known/foga-cell.json` : null, card: base ? `${base}/.well-known/agent-card.json` : null },
38
+ program: { package: PACKAGE_NAME, version: o.version, join: `npx -y ${PACKAGE_NAME} join --chain ${chain?.name ?? "<chain>"}` },
39
+ };
40
+ }
41
+ /** The card as a few lines an agent can paste to another agent. */
42
+ export function formatCard(c) {
43
+ const lines = [];
44
+ const where = c.chain.explorer && c.identity.account ? ` (${c.chain.explorer}/address/${c.identity.account})` : "";
45
+ lines.push(`I am ${c.name}, a FOGA cell on ${c.chain.network} (chain ${c.chain.chainId}), world ${c.chain.worldId}.`);
46
+ if (c.identity.account)
47
+ lines.push(` account ${c.identity.account}${where}`);
48
+ else
49
+ lines.push(" account not born yet (foga cell init)");
50
+ if (c.identity.agentId)
51
+ lines.push(` identity ERC-8004 agent ${c.identity.agentId}${c.identity.registry ? ` in registry ${c.identity.registry}` : ""}`);
52
+ lines.push(` roles ${c.identity.oneKey ? `one key, every role: ${c.identity.owner}` : `owner ${c.identity.owner}, operator ${c.identity.operator}, guardian ${c.identity.guardian}`}`);
53
+ lines.push(` covenants ${c.covenants.world.join(", ")}`);
54
+ const offered = c.skills.filter((s) => s.offered);
55
+ const heldOnly = c.skills.filter((s) => !s.offered);
56
+ lines.push(` skills ${offered.length ? offered.map((s) => `${s.id} (token ${s.tokenId})`).join(", ") + " — offered" : "none offered"}${heldOnly.length ? `; held: ${heldOnly.map((s) => s.id).join(", ")}` : ""}`);
57
+ if (c.standing)
58
+ lines.push(` standing scheme ${c.standing.schemeId.slice(0, 10)}… by ${c.standing.issuers.join(", ")}`);
59
+ if (c.work.taskToken)
60
+ lines.push(` work mint an ERC-8414 tender on TaskToken ${c.work.taskToken} for one of my offered skills, reward ≥ ${c.work.minRewardWei} wei, acceptance ${c.work.acceptance.join("/")}${c.work.counterpartyMinLevel ? `, your KYA level ≥ ${c.work.counterpartyMinLevel}` : ""}; I find it on chain, run the skill, submit, settle`);
61
+ if (c.endpoints.mcp)
62
+ lines.push(` reach me MCP ${c.endpoints.mcp} · A2A ${c.endpoints.a2a} · manifest ${c.endpoints.manifest}`);
63
+ else
64
+ lines.push(" reach me on chain (no public URL); a tender is the handshake");
65
+ lines.push(` program ${c.program.package} ${c.program.version} — become one too: ${c.program.join}`);
66
+ return lines.join("\n");
67
+ }
@@ -0,0 +1,343 @@
1
+ import { spawn } from "node:child_process";
2
+ import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join, resolve } from "node:path";
5
+ import { JsonRpcProvider, formatEther, parseEther } from "ethers";
6
+ import { chainById, chainByName, packagedWorld, resolveRpc, CHAINS } from "../core/chains.js";
7
+ import { newApiKey } from "../http/access.js";
8
+ import { adviseCell, formatAdvice } from "./advise.js";
9
+ import { cellCard, formatCard } from "./card.js";
10
+ import { loadCellConfig, newCellConfig, saveCellConfig } from "./config.js";
11
+ import { cellInit } from "./init.js";
12
+ import { Node } from "./node.js";
13
+ import { ensureWallet, ensuredKey, walletDir } from "./wallet.js";
14
+ import { defaultCatalog, OPERATOR_CALLER } from "../tools/index.js";
15
+ import { clientConfigs, clientLauncher } from "../demo/quickstart.js";
16
+ import { cleanEnv, ownCli, waitForHttp } from "../demo/loop-d.js";
17
+ import { nextFreePort, portInUse } from "../demo/anvil.js";
18
+ import { repoRoot } from "../util/fs.js";
19
+ import { PACKAGE_NAME, version } from "../util/version.js";
20
+ export const JOIN_MARKER = "join.json";
21
+ export const DEFAULT_SKILLS = ["foga-probe", "finch-extract-verify"];
22
+ const PUBLIC_MIN = parseEther("0.03");
23
+ const PUBLIC_COMFORTABLE = parseEther("0.05");
24
+ export function fogaHome() {
25
+ return process.env.FOGA_HOME ?? join(homedir(), ".foga");
26
+ }
27
+ /** What a person is told while the identity waits for ether. */
28
+ export function fundingMessage(p) {
29
+ const lines = [];
30
+ const need = p.minWei - p.balanceWei;
31
+ lines.push(`fund ${p.address} on ${p.network} (chain ${p.chainId}) with at least ${formatEther(need)} ETH more (it holds ${formatEther(p.balanceWei)}, needs ${formatEther(p.minWei)}; ${formatEther(PUBLIC_COMFORTABLE)} is comfortable).`);
32
+ if (p.chain?.testnet && p.chain.faucets.length)
33
+ lines.push(` test ether is free: ${p.chain.faucets.join(" · ")}`);
34
+ if (p.chain && !p.chain.testnet)
35
+ lines.push(" this is a mainnet: the ether is real, and so is everything the cell will commit to.");
36
+ if (p.chain?.explorer)
37
+ lines.push(` watch it arrive: ${p.chain.explorer}/address/${p.address}`);
38
+ return lines.join("\n");
39
+ }
40
+ async function balanceOf(rpc, address) {
41
+ const provider = new JsonRpcProvider(rpc, undefined, { staticNetwork: true });
42
+ try {
43
+ return await provider.getBalance(address);
44
+ }
45
+ catch (e) {
46
+ const why = e.shortMessage ?? e.message;
47
+ throw new Error(`cannot reach the RPC ${rpc} (${why}); pass --rpc <url> for one this machine can reach`);
48
+ }
49
+ finally {
50
+ provider.destroy();
51
+ }
52
+ }
53
+ export async function runJoin(o = {}) {
54
+ const log = o.log ?? ((line) => console.error(line));
55
+ const out = o.out ?? ((text) => console.log(text));
56
+ const root = repoRoot();
57
+ // --- the chain and its world: by name from the package, or a deployed address book given by path
58
+ let chain;
59
+ let worldSource;
60
+ if (o.world) {
61
+ worldSource = resolve(o.world);
62
+ if (!existsSync(worldSource))
63
+ throw new Error(`world file ${worldSource} not found`);
64
+ const file = JSON.parse(readFileSync(worldSource, "utf8"));
65
+ if (file.schema !== "foga.world/1")
66
+ throw new Error(`${worldSource} is not a world file (foga.world/1)`);
67
+ chain = chainById(Number(file.chainId)) ?? (file.network ? chainByName(file.network) : null);
68
+ }
69
+ else {
70
+ const name = (o.chain ?? "sepolia").toLowerCase();
71
+ chain = chainByName(name);
72
+ if (!chain)
73
+ throw new Error(`unknown chain "${name}"; known: ${Object.keys(CHAINS).join(", ")}. A chain not listed: deploy a world on it (foga world deploy --world worlds/<chain>.json --rpc <url>) and pass its address book with --world.`);
74
+ const packaged = packagedWorld(chain);
75
+ if (!packaged)
76
+ throw new Error(`no world ships for ${chain.name}: deploy one (foga world deploy --world worlds/${chain.name}.json --rpc <url>) and pass its address book with --world <file>`);
77
+ worldSource = packaged;
78
+ }
79
+ const worldFile = JSON.parse(readFileSync(worldSource, "utf8"));
80
+ const network = worldFile.network;
81
+ const local = network === "anvil";
82
+ const rpcResolved = resolveRpc(chain, o.rpc);
83
+ const rpc = rpcResolved.url;
84
+ if (rpcResolved.source === "public")
85
+ log(`join: no RPC given; using the public endpoint ${rpc} (shared and rate-limited — a dedicated one is advised: --rpc <url> or ${chain?.rpcEnv ?? "FOGA_RPC_URL"})`);
86
+ // --- the wallet: one key, the identity, made once and reused on every chain
87
+ const walletPath = resolve(o.wallet ?? walletDir());
88
+ const wallet = ensureWallet(walletPath, { name: "default" });
89
+ if (wallet.ephemeral)
90
+ throw new Error(`a cell that lives needs a wallet on disk, and ${walletPath} could not be written (${wallet.reason}); pass --wallet <a writable directory>`);
91
+ const singleKey = wallet.wallet.roles.length === 1 && wallet.wallet.roles[0] === "signer";
92
+ const identity = singleKey ? wallet.wallet.addresses.signer : wallet.wallet.addresses.owner;
93
+ log(`join: wallet ${wallet.created ? "made" : "using"} ${wallet.wallet.dir} — ${singleKey ? `identity ${identity} (one key, every role)` : `owner ${wallet.wallet.addresses.owner}, operator ${wallet.wallet.addresses.operator}, guardian ${wallet.wallet.addresses.guardian}`}; the same address on every EVM chain`);
94
+ // --- the cell's home: never cleared; a second join here resumes
95
+ const name = o.name ?? `cell-${identity.slice(2, 6).toLowerCase()}`;
96
+ const dir = resolve(o.dir ?? join(fogaHome(), "cells", network, name));
97
+ mkdirSync(dir, { recursive: true });
98
+ const configPath = join(dir, "cell.config.json");
99
+ const worldPath = join(dir, "world.json");
100
+ const keysDir = join(dir, "keys");
101
+ mkdirSync(keysDir, { recursive: true, mode: 0o700 });
102
+ if (!existsSync(worldPath))
103
+ writeFileSync(worldPath, readFileSync(worldSource));
104
+ // the skill packages and the fixtures travel with the cell: their hashes are what the chain holds, their files
105
+ // must stay where the cell is, not where this version of the package happens to be installed
106
+ for (const sub of ["skills", "fixtures"]) {
107
+ const dst = join(dir, sub);
108
+ if (!existsSync(dst))
109
+ cpSync(join(root, "scenarios", sub), dst, { recursive: true });
110
+ }
111
+ const resumed = existsSync(configPath);
112
+ if (resumed)
113
+ log(`join: resuming the cell in ${dir}`);
114
+ else {
115
+ const cfg = newCellConfig({
116
+ name,
117
+ world: "world.json",
118
+ store: "cell.ops.sqlite",
119
+ owner: wallet.wallet.addresses.owner ?? identity,
120
+ operator: wallet.wallet.addresses.operator ?? identity,
121
+ guardian: wallet.wallet.addresses.guardian ?? identity,
122
+ rpc: rpcResolved.source === "public" ? undefined : rpc,
123
+ skillsDir: "skills",
124
+ inputDir: "fixtures",
125
+ description: `Cell ${name}: born by foga join on ${network}`,
126
+ });
127
+ // a day with a birth, two mints and a publish in it spends more gas than the default daily cap allows on a chain
128
+ // that prices gas; the cap is raised to what the quickstart uses, and the operator's gas floor (a quarter of it)
129
+ // stays under the funding gate below
130
+ cfg.policy.budget.dailyGasWei = parseEther("0.02").toString();
131
+ cfg.runtime.http.listen = o.listen ?? "127.0.0.1:8787";
132
+ if (o.publicUrl)
133
+ cfg.runtime.http.publicUrl = o.publicUrl.replace(/\/+$/, "");
134
+ saveCellConfig(configPath, cfg);
135
+ log(`join: cell ${name} configured in ${dir} (world ${worldFile.worldId}, ${Object.keys(worldFile.contracts).length} contracts)`);
136
+ }
137
+ if (o.publicUrl) {
138
+ const loaded = loadCellConfig(configPath);
139
+ if (loaded.config.runtime.http.publicUrl !== o.publicUrl.replace(/\/+$/, "")) {
140
+ loaded.config.runtime.http.publicUrl = o.publicUrl.replace(/\/+$/, "");
141
+ saveCellConfig(configPath, loaded.config);
142
+ }
143
+ }
144
+ // --- the funding gate: nothing is sent until the identity can pay for its birth
145
+ const minWei = o.minWei ?? (local ? 0n : PUBLIC_MIN);
146
+ let balance = await balanceOf(rpc, identity);
147
+ if (balance < minWei) {
148
+ const msg = fundingMessage({ address: identity, chain, network, chainId: worldFile.chainId, balanceWei: balance, minWei });
149
+ if (o.wait === false)
150
+ throw new Error(`the identity is not funded yet.\n${msg}`);
151
+ log(`join: waiting for ether — ${msg}`);
152
+ const started = Date.now();
153
+ let lastSaid = started;
154
+ while (balance < minWei) {
155
+ if (Date.now() - started > 60 * 60_000)
156
+ throw new Error(`waited an hour for ${identity} to be funded; run the same command again once it is.\n${msg}`);
157
+ await new Promise((r) => setTimeout(r, local ? 1000 : 15_000));
158
+ balance = await balanceOf(rpc, identity);
159
+ if (balance < minWei && Date.now() - lastSaid > 120_000) {
160
+ lastSaid = Date.now();
161
+ log(`join: still waiting — ${identity} holds ${formatEther(balance)} ETH, needs ${formatEther(minWei)}`);
162
+ }
163
+ }
164
+ log(`join: funded — ${identity} holds ${formatEther(balance)} ETH`);
165
+ }
166
+ else
167
+ log(`join: ${identity} holds ${formatEther(balance)} ETH`);
168
+ // --- the birth: the owner's act; resumable, never repeated
169
+ let born = loadCellConfig(configPath).config.identity.account ?? null;
170
+ let agentId = loadCellConfig(configPath).config.identity.agentId ?? null;
171
+ if (!born) {
172
+ const node = await Node.open({ configPath, rpc, keys: { owner: ensuredKey(wallet, "owner") }, allowUnborn: true, log: () => { } });
173
+ try {
174
+ const r = await cellInit(node, { capitalWei: o.capitalWei, log: (line) => log(`join: ${line}`) });
175
+ born = r.account;
176
+ agentId = r.agentId;
177
+ log(`join: cell ${name} born as ${born} (ERC-8004 agent ${agentId}) in ${r.operations.length} operations`);
178
+ }
179
+ finally {
180
+ node.close();
181
+ }
182
+ }
183
+ else
184
+ log(`join: cell ${name} is ${born}${agentId ? ` (agent ${agentId})` : ""}, born before`);
185
+ // --- the skills: minted by the operator and offered; the documents committed on chain
186
+ const catalog = defaultCatalog();
187
+ {
188
+ const want = o.skills ?? DEFAULT_SKILLS;
189
+ const held = loadCellConfig(configPath).config.skills.held;
190
+ const missing = want.filter((s) => !held[s]);
191
+ if (missing.length) {
192
+ const node = await Node.open({ configPath, rpc, keys: { operator: ensuredKey(wallet, "operator") }, log: () => { } });
193
+ try {
194
+ for (const skill of missing) {
195
+ const r = await catalog.invoke(node, "skill_mint", { skill }, OPERATOR_CALLER, { mode: "act" });
196
+ if (!r.ok)
197
+ throw new Error(`skill_mint ${skill}: ${r.gate}: ${r.reason}`);
198
+ log(`join: skill ${skill} minted`);
199
+ }
200
+ }
201
+ finally {
202
+ node.close();
203
+ }
204
+ }
205
+ const loaded = loadCellConfig(configPath);
206
+ const offered = Object.keys(loaded.config.skills.held).filter((s) => want.includes(s));
207
+ if (offered.some((s) => !loaded.config.skills.offered.includes(s))) {
208
+ loaded.config.skills.offered = [...new Set([...loaded.config.skills.offered, ...offered])];
209
+ saveCellConfig(configPath, loaded.config);
210
+ }
211
+ log(`join: skills ${offered.join(", ")} held and offered`);
212
+ }
213
+ // --- an act-level API key for the HTTP entrance, kept in the cell's directory
214
+ const agentKeyPath = join(keysDir, "agent.api-key");
215
+ let apiKey;
216
+ {
217
+ const loaded = loadCellConfig(configPath);
218
+ const have = loaded.config.access.keys.find((k) => k.id === "agent");
219
+ if (have && existsSync(agentKeyPath))
220
+ apiKey = readFileSync(agentKeyPath, "utf8").trim();
221
+ else {
222
+ const api = newApiKey("agent");
223
+ loaded.config.access.keys = loaded.config.access.keys.filter((k) => k.id !== "agent");
224
+ loaded.config.access.keys.push({ id: "agent", sha256: api.sha256, level: "act", visibility: "caller", note: "the join's act-level key for the HTTP entrance" });
225
+ saveCellConfig(configPath, loaded.config);
226
+ writeFileSync(agentKeyPath, api.key + "\n", { mode: 0o600 });
227
+ apiKey = api.key;
228
+ }
229
+ }
230
+ {
231
+ const node = await Node.open({ configPath, rpc, keys: { operator: ensuredKey(wallet, "operator") }, log: () => { } });
232
+ try {
233
+ const r = await catalog.invoke(node, "docs_publish", {}, OPERATOR_CALLER, { mode: "act" });
234
+ if (!r.ok)
235
+ throw new Error(`docs_publish: ${r.gate}: ${r.reason}`);
236
+ const d = r.output;
237
+ // the same documents make the same job: a resume is answered from the store, and the chain is not asked again
238
+ const replayed = node.store.decisions({ limit: 1 })[0]?.gate === "idempotent";
239
+ log(`join: documents v${d.version} in ${d.dir}; ${replayed ? "the chain holds them (nothing sent)" : d.committed.length ? `committed on chain (${d.committed.join(", ")})` : "the chain already holds them"}`);
240
+ }
241
+ finally {
242
+ node.close();
243
+ }
244
+ }
245
+ // --- the node: served (every entrance, the watchers, no model) or run (with a model: it takes its tenders)
246
+ let listen = o.listen === undefined ? "127.0.0.1:8787" : o.listen;
247
+ if (listen) {
248
+ const lp = Number(listen.slice(listen.lastIndexOf(":") + 1));
249
+ if (await portInUse(lp)) {
250
+ if (o.listen !== undefined)
251
+ throw new Error(`port ${lp} is in use; pass --listen <host:port> (or --no-serve)`);
252
+ const next = await nextFreePort(lp + 1);
253
+ if (next === null)
254
+ throw new Error(`ports ${lp}-${lp + 20} are all in use; pass --listen <host:port>`);
255
+ log(`join: port ${lp} is in use; the node listens on ${next} instead`);
256
+ listen = `127.0.0.1:${next}`;
257
+ }
258
+ }
259
+ const operatorKeyPath = wallet.wallet.files.operator ?? wallet.wallet.files.signer;
260
+ const cli = ownCli();
261
+ let child = null;
262
+ let nodeUrl = null;
263
+ let endedWith = () => { };
264
+ const ended = new Promise((r) => (endedWith = r));
265
+ let stopping = false;
266
+ if (listen) {
267
+ const i = listen.lastIndexOf(":");
268
+ const host = listen.slice(0, i) || "127.0.0.1";
269
+ nodeUrl = `http://${host === "0.0.0.0" || host === "::" ? "127.0.0.1" : host}:${listen.slice(i + 1)}`;
270
+ const args = o.run ? ["node", "run", "--config", configPath, "--rpc", rpc, "--model", o.run, "--listen", listen] : ["node", "serve", "--config", configPath, "--rpc", rpc, "--listen", listen];
271
+ child = spawn(cli.command, [...cli.args, ...args], { env: cleanEnv({ FOGA_OPERATOR_KEY_FILE: operatorKeyPath }), stdio: ["ignore", "ignore", "pipe"] });
272
+ const idleTick = /: anchor ACTIVE, heartbeat not sent \([^)]*\); 0 new tender\(s\); 0 settled, 0 withdrawn; safety Satisfied$/;
273
+ child.stderr.on("data", (d) => {
274
+ for (const line of String(d).split("\n"))
275
+ if (line.trim() && !idleTick.test(line.trim()))
276
+ log(` node| ${line}`);
277
+ });
278
+ child.on("exit", (code, signal) => {
279
+ if (!stopping)
280
+ endedWith(`the node stopped (${code !== null ? `exit code ${code}` : `signal ${signal}`})`);
281
+ });
282
+ await waitForHttp(`${nodeUrl}/healthz`, 120);
283
+ const health = (await (await fetch(`${nodeUrl}/healthz`)).json());
284
+ if (!health.ok)
285
+ throw new Error("the node's /healthz is not ok");
286
+ }
287
+ else
288
+ endedWith("no node");
289
+ const stop = async () => {
290
+ stopping = true;
291
+ if (child) {
292
+ const { stopChild } = await import("../demo/anvil.js");
293
+ await stopChild(child);
294
+ child = null;
295
+ }
296
+ endedWith("stopped");
297
+ };
298
+ // --- the card and the configurations: what the cell is, and how anyone connects
299
+ const reader = await Node.open({ configPath, rpc, keys: {}, allowUnborn: true, log: () => { } });
300
+ let card;
301
+ let next = "";
302
+ try {
303
+ card = cellCard(reader, { version });
304
+ next = formatAdvice(adviseCell(reader), { brief: true, config: configPath });
305
+ }
306
+ finally {
307
+ reader.close();
308
+ }
309
+ const launcher = clientLauncher();
310
+ const configs = clientConfigs({ launcher, configPath, operatorKeyPath, nodeUrl, apiKey });
311
+ const mcpDir = join(dir, "mcp");
312
+ mkdirSync(mcpDir, { recursive: true });
313
+ for (const [file, text] of Object.entries(configs))
314
+ writeFileSync(join(mcpDir, file), text);
315
+ const lines = [];
316
+ lines.push(`foga join: cell ${name} lives on ${network}${resumed ? " (resumed)" : ""}`);
317
+ lines.push("");
318
+ lines.push(formatCard(card));
319
+ lines.push("");
320
+ lines.push(` files ${dir} — cell.config.json, cell.ops.sqlite (the store), world.json, skills/, documents/, keys/ (0600), mcp/*.json, connect.md`);
321
+ lines.push(` wallet ${wallet.wallet.dir} — the identity; back it up, it is the cell`);
322
+ if (nodeUrl)
323
+ lines.push(` node ${nodeUrl} (${o.run ? `running with model ${o.run}: it takes the tenders it is eligible for` : "served: every entrance and the watchers, no model"}) — /mcp, /api/<capability>, /a2a, /.well-known/foga-cell.json, /.well-known/agent-card.json, /healthz`);
324
+ lines.push("");
325
+ lines.push("Connect an MCP client over stdio (the client starts the server on this machine; with the operator key it acts for the cell):");
326
+ lines.push(configs["claude_desktop_config.json"].trimEnd().split("\n").map((l) => ` ${l}`).join("\n"));
327
+ if (nodeUrl) {
328
+ lines.push("");
329
+ lines.push(`Or over HTTP: MCP ${nodeUrl}/mcp and ${nodeUrl}/api/<capability> with Authorization: Bearer ${apiKey} (${join(mcpDir, "http-mcp.json")}); A2A at ${nodeUrl}/a2a.`);
330
+ lines.push(`Reachable from other machines only if ${listen.split(":")[0]} is not the loopback (--listen 0.0.0.0:<port>) and the port is open; then --public-url https://<host> puts the address in the documents and the card.`);
331
+ }
332
+ lines.push("");
333
+ lines.push("Work comes over the chain: a demander mints an ERC-8414 tender for an offered skill; the watchers find it; an agent at the entrance (or the model, with --run) pre-checks, runs the skill and submits; the hashlock verifier accepts a right answer; the watchers settle and withdraw.");
334
+ if (next)
335
+ lines.push("", next);
336
+ lines.push("");
337
+ lines.push(o.exit ? "Started with --exit: stopping the node." : `Ctrl-C stops the node; the cell stays. Start it again with the same command, or: ${launcher.command} ${launcher.args.join(" ")} node serve --config ${configPath} --wallet ${wallet.wallet.dir}`);
338
+ const summary = lines.join("\n");
339
+ writeFileSync(join(dir, "connect.md"), `# foga join\n\n\`\`\`\n${summary}\n\`\`\`\n`);
340
+ writeFileSync(join(dir, JOIN_MARKER), JSON.stringify({ schema: "foga.join/1", joinedAt: new Date().toISOString(), package: PACKAGE_NAME, version, network, chainId: worldFile.chainId, worldId: worldFile.worldId, rpc: rpcResolved.source === "public" ? null : rpc, cell: name, account: born, agentId, wallet: wallet.wallet.dir, listen, publicUrl: o.publicUrl ?? null, run: o.run ?? null }, null, 2) + "\n");
341
+ out(summary);
342
+ return { dir, configPath, worldPath, rpc, chainId: worldFile.chainId, network, account: born, agentId, resumed, nodeUrl, card, stop, ended };
343
+ }
@@ -117,6 +117,8 @@ Three checks, three vocabularies. A call to the node passes **call permission**
117
117
 
118
118
  **3d-2, the cell among other agents (0.6.0-alpha.7).** Six pieces, one principle (section 10). *The wallet.* `foga wallet new` makes an agent's keys on its own machine — by default one key, its identity, which fills every role a cell needs (the separated owner/operator/guardian set is the opt-in `--cell` hardening) — in 0600 files, with no network and no chain; it never overwrites, and whatever needs the keys goes on with keys made in memory when the disk refuses (`foga quickstart` makes the default wallet on its first run and births every later cell with it; `--wallet <dir>` on every `foga cell` and `foga node` command takes the role's key from there). *Signed callers.* `Authorization: FOGA-EIP712 <envelope>` is EIP-712 typed data in the cell's own domain (name FOGA, version 1, the chain, the cell's account): a `Request` envelope bound to method, path and body hash, good once within 120 s, or a `Session` envelope, reusable within 600 s for a client with static headers; the caller is the signing address and `access.signers` says what it is granted — the cell's own operator or owner remotely, an allow-list by address or by ERC-8004 agent id (checked against the registry's owner and agent wallet), a KYA standing threshold, a default of build, read or none; an act wants a request-bound envelope unless the policy says otherwise. `/api`, `/mcp` and `/a2a` take the same header; `GET /api/capabilities` tells a caller what it is. *The documents.* `foga cell docs build | publish | status` generates from one configuration the ERC-8004 registration (`registration-v1`, with `registrations`), the ERC-8434 AID document (identity, standing, skills and endpoints facets), the ERC-8444 descriptor, the ERC-8454 unit definition, the A2A agent card, the registry's `server.json` and a manifest of content hashes; the node serves them under `/documents` and `/.well-known`; `docs_publish` (the 31st capability, the operator's act) commits only what differs from the chain — `setAgentURI`, `setDocumentURI`, `updateDescriptor`, `publishVersion`, as one keyed job — and reads every commitment back; a cell without a public URL commits `data:` URIs and is complete on chain all the same. *Preflight.* `foga node preflight` reconciles a cell against the chain and the machine before it is served — the world, the account's revision, roles and allow-list, the budget, the operator key alone and its gas, the standing, the skills' packages and their executors actually run, the documents, the public URL, the model — as rows that are advisory by default and fatal with `--strict`. *A2A.* `POST /a2a` is the 1.0 JSON-RPC binding with the card at `/.well-known/agent-card.json`: a quote for anyone (the offered skills and the terms a tender needs to meet), a tender taken for an act-level caller as one task over the catalog's jobs (pre-check, run, submit; settlement stays the watchers'), `GetTask`, `ListTasks` scoped to the caller, `CancelTask` that undoes nothing broadcast, duplicates by (caller, messageId), the specification's error codes; no road does work first and talks about payment after. *Sourcify.* `foga world verify --sourcify` submits every FOGA-deployed contract of a world with the standard JSON input of its own compiler run — `forge build` leaves the build-info beside the artifacts — matched by runtime code and identified by the path solc saw; a local chain is a dry run and CI dry-runs all 47; the Sepolia run was 3d-3's (below: fifteen contracts, 2 exact_match and 13 match). The gate: `test/wallet.test.ts`, `test/signed.test.ts`, `test/documents.test.ts`, `test/a2a.test.ts`, `test/preflight.test.ts`, `test/sourcify.test.ts`, and `foga quickstart` ending with a cell whose documents are on chain, whose card answers and whose A2A entrance quotes. docs/wallet.md, docs/documents.md, docs/a2a.md, docs/node.md ("Preflight"), docs/world.md ("Verifying the sources").
119
119
 
120
+ **3e-1, the cell that lives, in one command (0.7.0).** Two things the Phase 3 close left open were said plainly by the first outside reader: the one-command path led to a local chain that dies with its process, and the public-chain path was four commands with three things to prepare first — a world file to find, an RPC, a funded key. `foga join --chain sepolia` is the answer to both, and it adds nothing to the kernel: the wallet made or reused, the chain's address book from the package, a funding gate that prints the address and waits, then the birth, the skills, the documents and the node, each the same keyed operation `cell init`, `skill_mint`, `docs_publish` and `node serve` already were, so the command is resumable and idempotent by construction. The cell's directory carries the world and the skill packages with it — a cell must not depend on where this version of the package happens to be installed. `--run stub` makes the cell autonomous without a model: the deterministic decision table takes what the policy allows. `foga cell card` is the self-description the covenant was always implying — a cell knows its chain, account, identity, skills and where work for it is minted, and says so to any agent without a chain call. The social fact underneath: the handshake between cells is the chain, not the network; a tender minted for a cell's skill is found by its watchers wherever the demander is, and the hashlock verifier accepts a right answer with no one's say-so. `test/join.test.ts` runs that loop between a stranger and a joined cell on anvil. Other chains follow by the same key: a world deployed once (templates for three testnets ship), its address book handed to `join --world`. docs/join.md.
121
+
120
122
  **3d-3, the OG live and the public-chain evidence (0.6.0).** The reference cell is served: born fresh on Sepolia by the same path any cell takes (`wallet new`, `cell new`, `cell init`, `docs publish`, `node serve` — one key, no migration, no key handoff), agent 11261, account `0xD7155E48…`, its skill `finch-extract-verify` offered, its documents on chain, its fifteen FOGA-deployed contracts Sourcify-verified; served in host mode under systemd behind Caddy at `foga.finchip.ai`, so `/healthz`, `/.well-known/foga-cell.json`, `/.well-known/agent-card.json` and `/mcp` answer from outside and survive a reboot; listed in the MCP registry as `io.github.garyyang-finchip/foga` with the npm package as the stdio server and the public URL as the remote. The last acceptance ran on the public chain: Loop D with an outside agent (`--model external`, a person driving the HTTP entrance with the agent key), a second cell born for it (agent 11264), the demander's cases minted on Sepolia — and every invariant row Pass (every operation under an allowed decision; eleven refusals across policy, verifier, skill, conflict, budget, safety and execution with nothing broadcast; the four budget books; sixteen operator transactions at the account, allow-listed, none reverted; the recovery drill across a kill and restart with nothing sent twice; idempotency; conflict; concurrency), cases a, c, d, d2 and e as expected. The run also returned four findings a local chain cannot produce, each closed in 0.6.0 (docs/loop-d.md, "On a public chain"): a working directory reused across runs replayed the previous run's keyed operations (the orchestrator now clears its own files); the roles' gas reserve, priced at a near-zero gas price, left the operator under the policy's gas floor (the reserve is floored at 1 gwei); the settlement figure read as the account's balance delta took in a concurrent mint's escrow (it is now the tender's own: escrow released less credit left); a 9000-block log scan overran the worker's minute on a shared endpoint (the scan is windowed, the public-chain budget three minutes). docs/og-deployment.md, docs/loop-d.md.
121
123
 
122
124
  ## 10. Five defaults settled in 3d