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
4 changes: 3 additions & 1 deletion .github/workflows/publish-release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,9 @@ jobs:
- name: Cache cargo
uses: Swatinem/rust-cache@v2
with:
workspaces: packages/agenty-bootstrap
workspaces: |
packages/agenty-bootstrap
packages/patch-applier

- name: Install dependencies
run: pnpm install --frozen-lockfile
Expand Down
15 changes: 9 additions & 6 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,11 @@
## Project overview

Agenty is a local-first AI agent application organized as a pnpm + Turborepo monorepo.
The active product path has three workspaces:
The active product path has four workspaces:

- `packages/agenty-core`: Go 1.26 core process and stdio JSON-RPC 2.0 server.
- `packages/agenty-cli`: Bun/TypeScript/React OpenTUI client.
- `packages/patch-applier`: Rust V4A parser and transactional filesystem patch helper.
- `packages/agenty-bootstrap`: Rust self-extracting launcher.

The CLI starts core as a child process and communicates only through NDJSON messages on
Expand Down Expand Up @@ -45,7 +46,9 @@ Core data is local-first:
- Config: `~/.agenty/config.json`
- Sessions: append-only JSONL under `~/.agenty/sessions/`
- Session projection: `~/.agenty/agenty.sqlite`
- Providers/models: `~/.agenty/providers/<provider-code>.json` (models embedded)
- Built-in providers/models: embedded in the core binary; custom providers use
`~/.agenty/providers/<provider-code>.json`, while built-in provider files contain
only the API key.
- Agents: `~/.agenty/agents/`
- Logs: `~/.agenty/logs/<yyyy>/<mm>/<dd>/core.log`

Expand Down Expand Up @@ -79,17 +82,17 @@ and breaks terminal capability handshakes.

The bootstrap artifact layout is:

`[bootstrap stub][xz CLI][xz core][108-byte footer]`
`[bootstrap stub][xz CLI][xz core][xz apply_patch][156-byte footer]`

The footer stores offsets, lengths, and SHA3-256 digests of decompressed payloads.
The footer stores offsets, lengths, and SHA3-256 digests of the three decompressed payloads.
`src/lib.rs` and `scripts/footer.ts` are one wire contract; changing the layout requires
updating both golden tests and incrementing `FORMAT_VERSION`. Compression uses
`@napi-rs/lzma`; Rust decompression uses statically linked vendored liblzma. Code signing
must happen after payload packing.

pnpm owns workspace resolution, Turborepo owns build ordering/caching, Bun builds the
CLI and packs payloads, Go builds core, and Cargo builds the launcher. Do not add an npm
`workspaces` field. The dependency graph builds core, then CLI, then bootstrap.
CLI and packs payloads, Go builds core, and Cargo builds the patch helper and launcher. Do not add an npm
`workspaces` field. The dependency graph builds the patch helper before core, then CLI and bootstrap.

Root `.env` is the single `AGENTY_VERSION` source and stays ignored; only
`.env.example` is committed. Release CI passes target-specific `GOOS`, `GOARCH`, `CC`,
Expand Down
18 changes: 12 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
[简体中文](./README.zh-CN.md)

Agenty is a local-first AI agent application. The current product path consists of
`agenty-cli`, `agenty-core`, and the self-extracting `agenty-bootstrap` launcher.
`agenty-cli`, `agenty-core`, the Rust `patch-applier` helper, and the self-extracting
`agenty-bootstrap` launcher.
The CLI communicates with core exclusively through line-delimited JSON-RPC 2.0 over
the child process's stdin/stdout; it does not start an HTTP server.

Expand All @@ -24,29 +25,32 @@ sudo install -m 755 agenty /usr/local/bin/agenty
agenty
```

On first run, the launcher verifies and extracts the bundled CLI and core into
`~/.agenty/bin/{cli,core}`. The CLI starts core as a child process and opens a setup
On first run, the launcher verifies and extracts the bundled CLI, core, and patch helper into
`~/.agenty/bin/{cli,core,apply_patch}`. The CLI starts core as a child process and opens a setup
wizard. The wizard creates one provider, one chat model, and one default agent through
the existing `provider.*` and `agent.*` IPC methods, then calls `initialize.complete`.

## Runtime model

The launcher contains two XZ-compressed payloads and their decompressed SHA3-256
The launcher contains three XZ-compressed payloads and their decompressed SHA3-256
digests. Matching extracted files are reused; missing or mismatched files are verified
and atomically replaced. The CLI resolves core in this order:

1. `AGENTY_CORE_BIN`
2. `packages/agenty-core/bin/agenty-core` during repository development
3. `~/.agenty/bin/core` from the launcher

Before starting core, the CLI prepends core's directory to `PATH`, making the bundled
`apply_patch` command available to core and shell tool calls.

Core reads one compact JSON-RPC message per stdin line and writes responses and
notifications to stdout. After `session.start`, core sends ordered `session.event`
notifications for round lifecycle, persisted messages, model stream deltas, tool calls,
and the terminal round status. Notifications may arrive before the `session.start`
response, so clients must subscribe before sending the request. Core exits when stdin
reaches EOF.

The TUI currently exposes `/provider`, `/model`, `/agents`, `/cwd`, `/think`, `/status`,
The TUI currently exposes `/provider`, `/model`, `/agents`, `/cwd`, `/effort`, `/status`,
`/new`, `/resume`, `/help`, and `/exit`. Features not yet implemented by core are hidden.

## Configuration and storage
Expand All @@ -59,8 +63,10 @@ Core stores data under `~/.agenty` by default. Pass `--data-dir <path>` to the C
| Configuration | `~/.agenty/config.json` |
| Session transcripts | `~/.agenty/sessions/<yyyy>/<mm>/<dd>/<session-id>.jsonl` |
| Session index | `~/.agenty/agenty.sqlite` |
| Providers and models | `~/.agenty/providers/<provider-code>.json` (models embedded) |
| Providers and models | Built-in catalog is embedded in the core binary; custom providers use `~/.agenty/providers/<provider-code>.json`, while built-in provider files store only API keys |
| Model discovery cache | Kept only in the running core process for 8 hours; it is refreshed on demand and does not survive a core restart |
| Agents | `~/.agenty/agents/` |
| Patch transaction locks | `~/.agenty/locks/` |
| Logs | `~/.agenty/logs/<yyyy>/<mm>/<dd>/core.log` |

`AGENTY_LOG_LEVEL` accepts `debug`, `info`, `warn`, or `error`.
Expand Down
16 changes: 11 additions & 5 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
[English](./README.md)

Agenty 是一个本地优先的 AI agent 应用。当前产品链路由 `agenty-cli`、
`agenty-core` 和自解压 launcher `agenty-bootstrap` 组成。CLI 仅通过子进程
`agenty-core`、Rust `patch-applier` helper 和自解压 launcher `agenty-bootstrap` 组成。
CLI 仅通过子进程
stdin/stdout 上的逐行 JSON-RPC 2.0 与 core 通信,不再启动 HTTP server。

core 当前支持 provider/model/agent 管理、持久化会话、模型流式输出、agent 工具循环
Expand All @@ -22,25 +23,28 @@ agenty
```

首次运行时,launcher 会校验并释放内置的 CLI 和 core 到
`~/.agenty/bin/{cli,core}`。CLI 启动 core 子进程并打开初始化向导;向导通过
`~/.agenty/bin/{cli,core,apply_patch}`。CLI 启动 core 子进程并打开初始化向导;向导通过
已有的 `provider.*` 和 `agent.*` IPC methods 创建一个 provider、一个聊天 model 和一个默认 agent,
最后调用 `initialize.complete` 标记初始化完成。

## 运行模型

launcher 内含两个 XZ 压缩 payload 及其解压内容的 SHA3-256 摘要。已释放文件摘要一致时
launcher 内含三个 XZ 压缩 payload 及其解压内容的 SHA3-256 摘要。已释放文件摘要一致时
直接复用;缺失或不一致时会重新解压、校验并原子替换。CLI 按以下顺序查找 core:

1. `AGENTY_CORE_BIN`
2. 仓库开发环境中的 `packages/agenty-core/bin/agenty-core`
3. launcher 释放的 `~/.agenty/bin/core`

CLI 启动 core 前会把 core 所在目录放到 `PATH` 首位,使 core 和 shell 工具调用可以找到
同目录中的 `apply_patch`。

core 从 stdin 逐行读取紧凑 JSON-RPC message,并把 response 和 notification 写到 stdout。
调用 `session.start` 后,core 会持续发送有序的 `session.event` 通知,覆盖 round 生命周期、
已持久化消息、模型流式增量、工具调用和 round 终态。通知可能早于 `session.start` response
到达,因此 client 必须先订阅事件再发送请求。stdin EOF 时 core 退出。

TUI 当前开放 `/provider`、`/model`、`/agents`、`/cwd`、`/think`、`/status`、
TUI 当前开放 `/provider`、`/model`、`/agents`、`/cwd`、`/effort`、`/status`、
`/new`、`/resume`、`/help` 和 `/exit`。core 尚未实现的功能暂不展示。

## 配置与存储
Expand All @@ -53,8 +57,10 @@ core 默认把数据保存在 `~/.agenty`。可向 CLI 传入 `--data-dir <path>
| 配置 | `~/.agenty/config.json` |
| 会话 transcript | `~/.agenty/sessions/<yyyy>/<mm>/<dd>/<session-id>.jsonl` |
| 会话索引 | `~/.agenty/agenty.sqlite` |
| Providers 和 models | `~/.agenty/providers/<provider-code>.json`(模型内嵌) |
| Providers 和 models | 内置 catalog 固化在 core 二进制中;自定义 provider 使用 `~/.agenty/providers/<provider-code>.json`,内置 provider 文件仅保存 API key |
| 模型发现缓存 | 仅保存在运行中的 core 进程内,有效期 8 小时;按需刷新,core 重启后不会保留 |
| Agents | `~/.agenty/agents/` |
| Patch 事务锁 | `~/.agenty/locks/` |
| 日志 | `~/.agenty/logs/<yyyy>/<mm>/<dd>/core.log` |

`AGENTY_LOG_LEVEL` 接受 `debug`、`info`、`warn` 或 `error`;
Expand Down
11 changes: 8 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@
"private": true,
"scripts": {
"build": "turbo run build && mkdir -p dist && (cp packages/agenty-bootstrap/bin/* dist/ 2>/dev/null || true)",
"test": "turbo run test",
"test": "node --test scripts/dev.test.mjs && turbo run test",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"clean": "rm -rf dist && turbo run clean",
"dev": "node scripts/dev.mjs",
"deepclean": "pnpm clean && rm -rf node_modules packages/*/node_modules",
"core:build": "turbo run build --filter=agenty-core",
"core:test": "turbo run test --filter=agenty-core",
Expand All @@ -18,6 +19,10 @@
"core:test:repeat": "pnpm --filter agenty-core test:repeat",
"core:tidyup": "cd packages/agenty-core && go fmt ./... && go vet ./... && go mod tidy",
"core:clean": "pnpm --filter agenty-core clean",
"patch-applier:build": "turbo run build --filter=patch-applier",
"patch-applier:test": "turbo run test --filter=patch-applier",
"patch-applier:lint": "pnpm --filter patch-applier lint",
"patch-applier:clean": "pnpm --filter patch-applier clean",
"cli:build": "turbo run build --filter=agenty-cli",
"cli:dev": "turbo run build --filter=agenty-core && pnpm --filter agenty-cli dev",
"cli:typecheck": "pnpm --filter agenty-cli typecheck",
Expand All @@ -33,7 +38,7 @@
"eslint-plugin-import-newlines": "^2.0.0",
"eslint-plugin-jsonc": "^3.4.1",
"eslint-plugin-simple-import-sort": "^14.0.0",
"turbo": "^2.10.10",
"turbo": "^2.10.11",
"typescript-eslint": "^8.67.0"
}
}
}
3 changes: 2 additions & 1 deletion packages/agenty-bootstrap/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
},
"devDependencies": {
"agenty-cli": "workspace:*",
"agenty-core": "workspace:*"
"agenty-core": "workspace:*",
"patch-applier": "workspace:*"
}
}
10 changes: 8 additions & 2 deletions packages/agenty-bootstrap/scripts/footer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { encodeFooter, FOOTER_SIZE } from "./footer";
const GOLDEN_FOOTER_HEX =
"88776655443322110807060504030201000102030405060708090a0b0c0d0e0f" +
"101112131415161718191a1b1c1d1e1f1122334455667788010203040506070820212223242526" +
"2728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f01000000cafebabe10136666";
"2728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f08090a0b0c0d0e0f1011121314151617" +
"404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f02000000cafebabe10136666";

function toHex(bytes: Uint8Array): string {
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
Expand All @@ -29,6 +30,11 @@ describe("encodeFooter", () => {
len: 0x0807060504030201n,
sha3_256: Uint8Array.from({ length: 32 }, (_, i) => 0x20 + i),
},
{
offset: 0x0f0e0d0c0b0a0908n,
len: 0x1716151413121110n,
sha3_256: Uint8Array.from({ length: 32 }, (_, i) => 0x40 + i),
},
);

expect(footer.length).toBe(FOOTER_SIZE);
Expand All @@ -37,6 +43,6 @@ describe("encodeFooter", () => {

test("rejects non-32-byte digests", () => {
const spec = { offset: 0n, len: 0n, sha3_256: new Uint8Array(31) };
expect(() => encodeFooter(spec, spec)).toThrow("32 bytes");
expect(() => encodeFooter(spec, spec, spec)).toThrow("32 bytes");
});
});
15 changes: 9 additions & 6 deletions packages/agenty-bootstrap/scripts/footer.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
export const MAGIC = [0xca, 0xfe, 0xba, 0xbe, 0x10, 0x13, 0x66, 0x66] as const;
export const FORMAT_VERSION = 1;
export const FOOTER_SIZE = 108;
export const FORMAT_VERSION = 2;
export const FOOTER_SIZE = 156;

export interface PayloadSpec {
offset: bigint;
len: bigint;
sha3_256: Uint8Array;
}

export function encodeFooter(cli: PayloadSpec, core: PayloadSpec): Uint8Array {
if (cli.sha3_256.length !== 32 || core.sha3_256.length !== 32) {
export function encodeFooter(cli: PayloadSpec, core: PayloadSpec, patchApplier: PayloadSpec): Uint8Array {
if (cli.sha3_256.length !== 32 || core.sha3_256.length !== 32 || patchApplier.sha3_256.length !== 32) {
throw new Error("payload SHA3-256 digests must be 32 bytes");
}

Expand All @@ -21,7 +21,10 @@ export function encodeFooter(cli: PayloadSpec, core: PayloadSpec): Uint8Array {
view.setBigUint64(48, core.offset, true);
view.setBigUint64(56, core.len, true);
out.set(core.sha3_256, 64);
view.setUint32(96, FORMAT_VERSION, true);
out.set(MAGIC, 100);
view.setBigUint64(96, patchApplier.offset, true);
view.setBigUint64(104, patchApplier.len, true);
out.set(patchApplier.sha3_256, 112);
view.setUint32(144, FORMAT_VERSION, true);
out.set(MAGIC, 148);
return out;
}
24 changes: 19 additions & 5 deletions packages/agenty-bootstrap/scripts/pack.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Packs the final self-extracting `agenty-<os>-<arch>` binary:
*
* [ agenty-bootstrap stub ][ compressed CLI ][ compressed core ][ footer ]
* [ bootstrap stub ][ compressed CLI ][ compressed core ][ compressed apply_patch ][ footer ]
*
* Both payloads are compressed in memory with xz and appended directly, so no
* intermediate archives are written to disk. The footer records each payload's
Expand All @@ -28,6 +28,7 @@ const PKG = resolve(import.meta.dir, "..");
const REPO = resolve(PKG, "../..");
const CORE_BIN_DIR = join(REPO, "packages/agenty-core/bin");
const CLI_DIST_DIR = join(REPO, "packages/agenty-cli/bin");
const PATCH_APPLIER_TARGET_DIR = join(REPO, "packages/patch-applier/target/release");
const DIST = join(PKG, "bin");

function findAgentyBinary(dir: string): string | null {
Expand Down Expand Up @@ -96,18 +97,25 @@ if (!existsSync(stubPath)) {
}
const cliPath = resolveCliBinary(os, arch, ext);
const corePath = resolveCoreBinary(os, arch);
const patchApplierPath = join(PATCH_APPLIER_TARGET_DIR, `apply_patch${ext}`);
if (!existsSync(patchApplierPath)) {
throw new Error(`patch-applier binary not found at ${patchApplierPath}; run its build first`);
}

const stub = readFileSync(stubPath);
const cli = readFileSync(cliPath);
const core = readFileSync(corePath);
if (stub.length === 0 || cli.length === 0 || core.length === 0) {
throw new Error("stub, CLI or core binary is empty");
const patchApplier = readFileSync(patchApplierPath);
if (stub.length === 0 || cli.length === 0 || core.length === 0 || patchApplier.length === 0) {
throw new Error("stub, CLI, core or patch-applier binary is empty");
}

const cliSha3 = sha3_256(cli);
const coreSha3 = sha3_256(core);
const patchApplierSha3 = sha3_256(patchApplier);
const cliPayload = await compress(cli);
const corePayload = await compress(core);
const patchApplierPayload = await compress(patchApplier);

const footer = encodeFooter(
{
Expand All @@ -120,11 +128,16 @@ const footer = encodeFooter(
len: BigInt(corePayload.length),
sha3_256: coreSha3,
},
{
offset: BigInt(stub.length + cliPayload.length + corePayload.length),
len: BigInt(patchApplierPayload.length),
sha3_256: patchApplierSha3,
},
);

mkdirSync(DIST, { recursive: true });
const out = join(DIST, `agenty-${os}-${arch}${ext}`);
writeFileSync(out, Buffer.concat([stub, cliPayload, corePayload, footer]));
writeFileSync(out, Buffer.concat([stub, cliPayload, corePayload, patchApplierPayload, footer]));
if (os !== "windows") {
chmodSync(out, 0o755);
}
Expand All @@ -134,5 +147,6 @@ console.log(
` stub ${stub.length} bytes (${stubPath})\n` +
` cli ${cli.length} -> ${cliPayload.length} bytes (${cliPath})\n` +
` core ${core.length} -> ${corePayload.length} bytes (${corePath})\n` +
` total ${stub.length + cliPayload.length + corePayload.length + footer.length} bytes`,
` patch ${patchApplier.length} -> ${patchApplierPayload.length} bytes (${patchApplierPath})\n` +
` total ${stub.length + cliPayload.length + corePayload.length + patchApplierPayload.length + footer.length} bytes`,
);
Loading
Loading