Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ artifacts/
dist-web/
apps/desktop/native/.build*/
apps/desktop/native/.swiftpm/
celld.json
.celld/
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5FHS8DZHENAVBR7RPWT8Q1A
anchor: function failure
created: 2026-10-21T15:37:54Z
norm: '1'
sig: f1231fd34544d478
body_hash: f0ddb33d9820c29c
raw_hash: 691df1159bc16114
lines: 15-20
---

the poisoned-session error hides its real failure in cause, which JSON drops
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
id: 01M5FHS8DGB54T3DSJSMJQCYEQ
anchor: '@file'
created: 2026-10-21T15:37:54Z
norm: '1'
sig: 37297e6494f690e4
body_hash: 342828b49bb29be7
raw_hash: 4de2c420dc4c4f31
lines: 1-47
---

celld refuses wrangler keys it doesn't implement (observability), so the generated config must drop them
6 changes: 3 additions & 3 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@ access to it; see [the team](#the-team).
| `packages/channel` | inside a channel worker | The channel: chats, agents, lanes, and the client protocol types |
| `apps/host` | on every machine (Bun) | The `ace` CLI, channel workers, the channel catalog, local transport |
| `apps/desktop` | on macOS | The desktop client and packaging for Ace Helper |
| `services/channel` | Cloudflare Workers | Hosted channels: one Durable Object per channel |
| `services/directory` | Cloudflare Workers | The team's list of hosts and where each channel lives, and lobbies |
| `services/channel` | Cloudflare or celld | Hosted channels: one Durable Object per channel |
| `services/directory` | Cloudflare or celld | The team's list of hosts and where each channel lives, and lobbies |

Clients (the CLI and desktop app) attach to channels; a channel is not a client.

Expand Down Expand Up @@ -317,7 +317,7 @@ its outcome, and `task` and `call` on model responses and tool calls. Channels l
responses with usage and provider errors, and tool calls with durations; message text is not
logged, but tool arguments are, clipped to 2,000 characters. `debug` lines (listings, watches,
directory syncs, workspace calls) are written only with `ACE_DEBUG=1`. Hosted services log the same
records to Workers Logs.
records to Workers Logs on Cloudflare, or to the node's output on celld.

## Desktop

Expand Down
36 changes: 34 additions & 2 deletions docs/setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,9 @@ Whoever sets up the team deploys the services once from a checkout; everyone els
privately. `Troubleshooting → Refresh status` syncs at once. From a checkout,
`printf %s "$SECRET" | bun ace key set ACE_SECRET` then `bun ace directory <url>` does the same.

To deploy the channel and directory services to your Cloudflare account, give the Workers the same
secret and your tailnet's MagicDNS suffix. The services then serve only that tailnet; see
The services are cells, so they run on Cloudflare Workers or on [celld](#on-celld) on machines the
team runs. To deploy them to your Cloudflare account, give the Workers the same secret and your
tailnet's MagicDNS suffix. The services then serve only that tailnet; see
[the team](architecture.md#the-team) for what that scope does and doesn't protect.

```sh
Expand All @@ -81,6 +82,34 @@ picks them up:
printf %s https://ace-channel.<your-subdomain>.workers.dev | bunx wrangler secret put HOSTED
```

### On celld

celld runs the same services on any machine with an S3, R2, GCS, Tigris, or Azure Blob bucket.
Until [denoland/celld#277](https://github.com/denoland/celld/issues/277) lands, install the
[githubnext/celld](https://github.com/githubnext/celld) build, which fixes runs failing with
"Session is poisoned":

```sh
curl -fsSL https://celld.dev/install.sh | CELLD_RELEASES_URL=https://github.com/githubnext/celld/releases sh
```

Put each service's vars in a file (`ACE_SECRET`, `TAILNET`, model keys for `channel`, and `HOSTED`,
`APP`, or `COMPUTE` for `directory`), deploy each under its own prefix, and run a node per prefix.
celld has no secret store: the vars sit in the bucket as plain text, so restrict who can read it.

```sh
bun services/celld.ts channel deploy channel.env --bucket s3://my-bucket/channel
bun services/celld.ts directory deploy directory.env --bucket s3://my-bucket/directory
celld --bucket s3://my-bucket/channel --listen 127.0.0.1:8788 # one node per service
celld --bucket s3://my-bucket/directory --listen 127.0.0.1:8787
tailscale serve --bg --https 8443 http://127.0.0.1:8788 # celld serves no TLS
tailscale serve --bg --https 443 http://127.0.0.1:8787
```

Hosts then use `https://<machine>.<tailnet>.ts.net` as the directory and
`https://<machine>.<tailnet>.ts.net:8443` as `HOSTED`. Redeploying updates running nodes without a
restart. Compute still needs Cloudflare.

To try the services locally, put `ACE_SECRET`, `TAILNET`, and a model key in
`services/channel/.dev.vars`, then:

Expand All @@ -90,3 +119,6 @@ bun --filter @ace/directory dev --port 8788 # point hosts at http:
printf %s "$SECRET" | bun ace key set ACE_SECRET # the value in .dev.vars
bun ace new --hosted http://localhost:8787 --project ~/code/my-repo
```

`bun services/celld.ts <channel|directory> dev --port <port>` runs a service on celld instead, reading
its `.dev.vars`.
9 changes: 5 additions & 4 deletions packages/channel/src/log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,12 @@ export function scoped(log: Log, base: Fields): Log {
return (level, event, fields) => log(level, event, { ...base, ...fields });
}

/** An error's message and stack, which JSON drops. */
/** An error's message, stack, and cause, which JSON drops. */
export function failure(error: unknown): Fields {
return error instanceof Error
? { error: error.message, stack: error.stack }
: { error: String(error) };
if (!(error instanceof Error)) return { error: String(error) };
const fields: Fields = { error: error.message, stack: error.stack };
if (error.cause !== undefined) fields.cause = failure(error.cause);
return fields;
}

const ARGS = 2000;
Expand Down
47 changes: 47 additions & 0 deletions services/celld.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { rm } from "node:fs/promises";
import { dirname, join } from "node:path";

const USAGE = `bun services/celld.ts <channel|directory> dev [celld dev options]
bun services/celld.ts <channel|directory> deploy <vars-file> --bucket <bucket> [celld deploy options]`;

const [service, command, ...rest] = process.argv.slice(2);
if (!service || !["channel", "directory"].includes(service)) throw new Error(USAGE);
if (command !== "dev" && command !== "deploy") throw new Error(USAGE);

const root = join(import.meta.dir, service);
const config: Record<string, unknown> = (await import(join(root, "wrangler.jsonc"))).default;
// celld refuses keys it doesn't implement, and Workers Logs is Cloudflare's.
delete config.observability;

let args = rest;
if (command === "deploy") {
const [file, ...options] = rest;
if (!file) throw new Error(USAGE);
// celld has no secret store: these become plain vars in the fleet bucket's deployment.
config.vars = parse(await Bun.file(file).text());
args = options;
}

// Wrangler's own esbuild keeps the celld and Cloudflare bundles alike.
const wrangler = dirname(Bun.resolveSync("wrangler/package.json", root));
const generated = join(root, "celld.json");
await Bun.write(generated, JSON.stringify(config, null, "\t"));
try {
const child = Bun.spawn(["celld", command, generated, ...args], {
cwd: root,
env: { ...process.env, CELLD_ESBUILD: Bun.resolveSync("esbuild/bin/esbuild", wrangler) },
stdio: ["inherit", "inherit", "inherit"],
});
process.exitCode = await child.exited;
} finally {
await rm(generated, { force: true });
}

function parse(text: string): Record<string, string> {
const vars: Record<string, string> = {};
for (const line of text.split("\n")) {
const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/.exec(line);
if (match) vars[match[1]!] = match[2]!.trim().replace(/^(["'])(.*)\1$/, "$2");
}
return vars;
}