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
27 changes: 27 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,33 @@

All notable changes to Agent River will be documented in this file.

## v0.4.0 — 2026-07-06

### Added
- Exec envelope is versioned: it now leads with
`agent_river_contract: "exec-v1"` and `message_id`, so external adapters can
rely on a stable, evolvable stdin contract.
- Codex and exec runner replies now parse fenced `agent-dispatch` blocks and
create pending dispatch approvals, matching the opus lane — every lane can
propose cross-agent routing, and every proposal still waits for the owner.
- `telegram-codex-policy-set --direct-send-user-add / --direct-send-user-remove`
restores owner-list management that was accidentally dropped with the v1
direct-send retirement (fresh installs could not become owner from the CLI).

### Changed
- Dashboard gate approval buttons are bound to a hash of the task content and
fail closed: buttons whose task changed underneath, and legacy buttons
without a hash, are refused; approving twice answers idempotently.
- README (en/zh-Hant) rewritten for from-zero onboarding: prerequisites,
bot + owner bootstrap in an order that actually works, and a session
walkthrough. SECURITY.md documents the repository trust model (registering a
repo means executing its code) and state-directory retention responsibility.

### Breaking
- Gate approval buttons sent before this release stop working after upgrade
(fail-closed by design). Re-trigger the pending approval to get a fresh
button.

## v0.3.0 — 2026-07-06

### Added
Expand Down
71 changes: 65 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,18 @@ Status: early but usable for a single local operator. The v3 dashboard, v2
launcher, exchange/session ledgers, dispatch approvals, runners, safety gates,
and secret scanning are covered by the test suite.

## Quickstart
## Prerequisites

- Linux with a systemd user session; other platforms are not supported.
- Node.js >= 20.
- The agent CLIs you want to drive, installed and logged in on this machine:
`codex` (Codex CLI) and/or `claude` (Claude Code). Agent River starts them
locally; without at least one of them there is nothing to drive.
- A Telegram bot token: create a bot with [@BotFather](https://t.me/BotFather).
- Your own numeric Telegram user id (for example from
[@userinfobot](https://t.me/userinfobot)) — needed to make yourself the owner.

System requirement: **Linux + systemd (user session); other platforms are not supported.**
## Quickstart

```sh
git clone https://github.com/Hsi431/agent-river.git agent-river
Expand All @@ -24,8 +33,29 @@ npm install
node bin/codex-agent.js init
```

Then fill `~/.config/codex-agent/telegram.env`, reload and enable the generated
user units:
`init` seeds the agent registry, writes the systemd user units, and creates
`~/.config/codex-agent/telegram.env`. Then:

1. Put the bot token in `~/.config/codex-agent/telegram.env`.

2. Make yourself the owner and enable routing. Without this step the dashboard
answers every command read-only:

```sh
node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \
--direct-send-user-add <your_telegram_user_id> \
--v2-enabled true --workspace-root /home/you --default-repo "$PWD"
```

3. Enable the exchange agents that sessions and runners will use:

```sh
node bin/codex-agent.js agent-enable --state ~/.codex/agent --agent codex --kind coding
node bin/codex-agent.js agent-enable --state ~/.codex/agent --agent opus --kind review
node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent --exchange-runner-enabled true
```

4. Reload and enable the generated user units:

```sh
systemctl --user daemon-reload
Expand All @@ -35,12 +65,33 @@ systemctl --user enable --now codex-agent-codex-runner.timer
systemctl --user enable --now codex-agent-exec-runner.timer
```

Start a Telegram session:
5. Message your bot on Telegram.

### What using it looks like

A multi-agent session (output abridged):

```text
you > /session codex,opus -- should we cache the tokenizer? repo=myproj
bot > session opened (codex, opus), budget 20 messages / 20 minutes
bot > [codex] Loading the tokenizer costs ~1.2s per turn; a module-level cache ...
bot > [opus] Agree, but invalidate the cache when the model id changes ...
you > /say <session> settle on the simplest option
bot > [codex] Final: module-level cache keyed by model id.
bot > session closed (budget exhausted), transcript saved
you > /task <session> repo=myproj ← turn the conclusion into an edit task
```

A one-shot turn without a session:

```text
/session codex,opus -- your task
@claude repo=myproj -- review the current diff
@codex repo=myproj mode=write -- fix the failing test
```

Write turns always come back to you as an approval button before anything
runs.

Optional: after `npm link`, the CLI is also available as `agent-river <cmd>`.

## Core Flows
Expand Down Expand Up @@ -111,6 +162,14 @@ journalctl --user -u codex-agent-dashboard.service -n 20

Owners are listed in `telegram_codex_policy.direct_send_user_allowlist`; this
field is retained as the owner list after the v1 direct-send retirement.
Manage it with:

```sh
node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \
--direct-send-user-add <telegram_user_id>
node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \
--direct-send-user-remove <telegram_user_id>
```

Enable v2 routing and a workspace root:

Expand Down
68 changes: 62 additions & 6 deletions README.zh-Hant.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,17 @@ Codex/Claude worker。Telegram v3 看板負責 owner 操作、v2 agent 啟動、
狀態:早期但可用,適合單一本機 operator。v3 看板、v2 launcher、exchange/session 帳本、
dispatch 核准、runner、安全閘與機密掃描都有測試覆蓋。

## Quickstart
## 前置需求

- Linux + systemd(user session);其他平台未支援。
- Node.js >= 20。
- 想指揮的 agent CLI 要先在本機裝好並登入:`codex`(Codex CLI)和/或
`claude`(Claude Code)。Agent River 是在本機啟動它們,一個都沒有就沒東西可指揮。
- 一個 Telegram bot token:找 [@BotFather](https://t.me/BotFather) 建。
- 你自己的 Telegram 數字 user id(可問 [@userinfobot](https://t.me/userinfobot)),
設 owner 要用。

系統需求:**Linux + systemd(user session);其他平台未支援**。
## Quickstart

```sh
git clone https://github.com/Hsi431/agent-river.git agent-river
Expand All @@ -22,7 +30,28 @@ npm install
node bin/codex-agent.js init
```

接著填 `~/.config/codex-agent/telegram.env`,reload 並 enable 產生的 user units:
`init` 會種好 agent registry、寫出 systemd user units、建立
`~/.config/codex-agent/telegram.env`。接著:

1. 把 bot token 填進 `~/.config/codex-agent/telegram.env`。

2. 把自己設成 owner 並打開路由。跳過這步的話,看板對你的每個指令都只回唯讀:

```sh
node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \
--direct-send-user-add <你的 telegram user id> \
--v2-enabled true --workspace-root /home/you --default-repo "$PWD"
```

3. 啟用 session 和 runner 會用到的 exchange agent:

```sh
node bin/codex-agent.js agent-enable --state ~/.codex/agent --agent codex --kind coding
node bin/codex-agent.js agent-enable --state ~/.codex/agent --agent opus --kind review
node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent --exchange-runner-enabled true
```

4. reload 並 enable 產生的 user units:

```sh
systemctl --user daemon-reload
Expand All @@ -32,12 +61,32 @@ systemctl --user enable --now codex-agent-codex-runner.timer
systemctl --user enable --now codex-agent-exec-runner.timer
```

到 Telegram 開 session:
5. 到 Telegram 跟你的 bot 講話。

### 用起來長這樣

多 agent session(輸出節錄):

```text
你 > /session codex,opus -- tokenizer 要不要加快取? repo=myproj
bot > session 已開(codex, opus),預算 20 封 / 20 分鐘
bot > [codex] tokenizer 每個 turn 要載 ~1.2 秒,module 層級快取可以 ...
bot > [opus] 同意,但 model id 換了要失效 ...
你 > /say <session> 收斂到最簡單的方案
bot > [codex] 結論:以 model id 為 key 的 module 層級快取。
bot > session 結束(預算用完),逐字稿已落檔
你 > /task <session> repo=myproj ← 把結論一鍵轉成 edit task
```

不開 session 的單發任務:

```text
/session codex,opus -- 題目
@claude repo=myproj -- review 目前的 diff
@codex repo=myproj mode=write -- 修掉紅的測試
```

會寫檔的 turn 一律先跳核准按鈕給你,按了才跑。

可選:跑過 `npm link` 後,也可用 `agent-river <cmd>`。

## 核心流程
Expand Down Expand Up @@ -103,7 +152,14 @@ journalctl --user -u codex-agent-dashboard.service -n 20
## Owner 使用方式

Owner 名單存在 `telegram_codex_policy.direct_send_user_allowlist`;v1 direct-send 退役後,
這個欄位保留作 owner list。
這個欄位保留作 owner list。用這組指令管理:

```sh
node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \
--direct-send-user-add <telegram_user_id>
node bin/codex-agent.js telegram-codex-policy-set --state ~/.codex/agent \
--direct-send-user-remove <telegram_user_id>
```

啟用 v2 routing 與 workspace root:

Expand Down
18 changes: 16 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,22 @@ Agent River is a local, single-operator control plane. It assumes:
- one primary bridge/runner set owns an agent state directory.

Agent state under `~/.codex/agent` can contain raw task, chat, prompt, reply,
repository, and audit data. Keep it private, restrict filesystem access, and
never commit it.
repository, and audit data. Secret scanning redacts known patterns but does not
guarantee the ledgers are free of sensitive content. Keep the state directory
private, restrict filesystem access, never commit it, and treat backup,
encryption, and retention as the operator's responsibility.

## Repository Trust

Pointing Agent River at a repository means its code will be executed: edit
tasks run the repository's test command (e.g. `npm test`), and repository
config (project `CLAUDE.md`, settings, hooks) is inherited by the runners.
This is safe only for repositories you already trust to run locally.

Do not register untrusted or unreviewed repositories. For code you do not
trust, limit yourself to read-only review without automatic script execution;
Agent River does not sandbox repository-defined commands (OS-level sandboxing
is deferred to Phase 2).

## v2 Trust Model (local parity)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "agent-river",
"version": "0.3.0",
"version": "0.4.0",
"description": "Local approval-gated Codex/Telegram agent control plane.",
"type": "module",
"bin": {
Expand Down
6 changes: 4 additions & 2 deletions src/agent/cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ export async function runAgentCli(argv) {

const [command, ...rest] = argv;
const args = parseArgs(rest);
validateValueOptions(args, ["agent", "budget-messages", "budget-minutes", "capabilities", "channel", "chat-id", "codex-runner-model", "dashboard-chat-id", "days", "default-repo", "dir", "exec", "exec-cwd", "exec-timeout-seconds", "exchange-notify-chat-id", "exchange-notify-enabled", "exchange-notify-max-per-cycle", "exchange-runner-daily-max", "exchange-runner-enabled", "exchange-runner-max-attempts", "exchange-runner-model", "exchange-runner-timeout-seconds", "from", "from-file", "id", "initiator", "interval-seconds", "kind", "lease-seconds", "long-poll-seconds", "max-cycles", "max-runtime-seconds", "memory-enabled", "memory-state", "name", "participants", "repo", "request", "session", "settings", "sleep-seconds", "state", "style", "systemd-dir", "text", "thread", "to", "token-file", "tokens", "topic", "transport", "update-json", "user", "v2-enabled", "workspace-root", "write-access"]);
validateValueOptions(args, ["agent", "budget-messages", "budget-minutes", "capabilities", "channel", "chat-id", "codex-runner-model", "dashboard-chat-id", "days", "default-repo", "dir", "direct-send-user-add", "direct-send-user-remove", "exec", "exec-cwd", "exec-timeout-seconds", "exchange-notify-chat-id", "exchange-notify-enabled", "exchange-notify-max-per-cycle", "exchange-runner-daily-max", "exchange-runner-enabled", "exchange-runner-max-attempts", "exchange-runner-model", "exchange-runner-timeout-seconds", "from", "from-file", "id", "initiator", "interval-seconds", "kind", "lease-seconds", "long-poll-seconds", "max-cycles", "max-runtime-seconds", "memory-enabled", "memory-state", "name", "participants", "repo", "request", "session", "settings", "sleep-seconds", "state", "style", "systemd-dir", "text", "thread", "to", "token-file", "tokens", "topic", "transport", "update-json", "user", "v2-enabled", "workspace-root", "write-access"]);
if (args.help) {
return printHelp();
}
Expand Down Expand Up @@ -240,6 +240,8 @@ export async function runAgentCli(argv) {
return printResult({
policy: setTelegramCodexPolicy(agentHome, {
default_repo: args["default-repo"],
direct_send_user_add: args["direct-send-user-add"],
direct_send_user_remove: args["direct-send-user-remove"],
memory_enabled: args["memory-enabled"],
exchange_notify_enabled: args["exchange-notify-enabled"],
exchange_notify_chat_id: args["exchange-notify-chat-id"],
Expand Down Expand Up @@ -439,7 +441,7 @@ function printHelp() {
telegram-update --update-json '{"message":{"from":{"id":123},"chat":{"id":456},"text":"agent status"}}'
telegram-poll [--transport fetch|curl]
telegram-codex-policy
telegram-codex-policy-set [--default-repo /path] [--memory-enabled true|false] [--exchange-notify-enabled true|false] [--exchange-notify-chat-id ID] [--exchange-notify-max-per-cycle N] [--exchange-runner-enabled true|false] [--exchange-runner-model sonnet|opus] [--codex-runner-model MODEL] [--exchange-runner-max-attempts N] [--exchange-runner-timeout-seconds N] [--exchange-runner-daily-max N] [--v2-enabled true|false] [--workspace-root /path]
telegram-codex-policy-set [--default-repo /path] [--direct-send-user-add ID] [--direct-send-user-remove ID] [--memory-enabled true|false] [--exchange-notify-enabled true|false] [--exchange-notify-chat-id ID] [--exchange-notify-max-per-cycle N] [--exchange-runner-enabled true|false] [--exchange-runner-model sonnet|opus] [--codex-runner-model MODEL] [--exchange-runner-max-attempts N] [--exchange-runner-timeout-seconds N] [--exchange-runner-daily-max N] [--v2-enabled true|false] [--workspace-root /path]
dashboard-once [--transport fetch|curl] [--long-poll-seconds N] [--dashboard-chat-id ID]
dashboard-bridge [--transport fetch|curl] [--long-poll-seconds N] [--max-cycles N] [--sleep-seconds N] [--dashboard-chat-id ID]
dashboard-service-print [--repo /path] [--long-poll-seconds N]
Expand Down
16 changes: 15 additions & 1 deletion src/agent/codex-exchange-runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import {
replyExchangeMessage,
} from "./exchange.js";
import { isSessionExchangeEligible } from "./sessions.js";
import { DISPATCH_CHANNEL, dispatchTargetAllowlist } from "./dispatch.js";
import { createDispatchApproval, DISPATCH_CHANNEL, dispatchTargetAllowlist, parseDispatchProposal } from "./dispatch.js";
import { realCodexRunner } from "./codex-runner.js";
import { resolveMessageRepoBinding } from "./runner-repo.js";

Expand Down Expand Up @@ -118,6 +118,18 @@ export async function runCodexExchangeRunnerOnce({
const relay = reply.session_id
? relaySessionReply({ agentHome, message, reply })
: null;
const parsed = parseDispatchProposal(reply.text);
const proposed = parsed.valid
? createDispatchApproval({
agentHome,
proposedBy: RUNNER_AGENT,
proposal: parsed.proposal,
parentMsgId: message.id,
parentDispatch: message.dispatch || null,
chatId: message.chat_id || null,
now,
})
: null;
recordCodexDispatch(paths, { messageId: message.id, attempt, outcome: "replied", model, now, repoFallback: repoBinding.repoFallback });
return summary({
ran: true,
Expand All @@ -128,6 +140,8 @@ export async function runCodexExchangeRunnerOnce({
reply_error: runResult.replyError || null,
relay_message_id: relay?.message?.id || null,
relay_skipped: relay && !relay.relayed ? relay.reason : null,
dispatch_approval_id: proposed?.approval?.id || null,
dispatch_blocked_reason: proposed?.blocked ? proposed.reason : null,
});
}

Expand Down
30 changes: 22 additions & 8 deletions src/agent/dashboard/bot.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { redactSecrets } from "../../lib/secret-scan.js";
import { agentPaths } from "../paths.js";
import { approveAgentRegistration, rejectAgentRegistration } from "../registry.js";
import { getTelegramCodexPolicy } from "../safety.js";
import { readTask, taskApprovalHash } from "../tasks.js";
import { approveAgentTask, rejectAgentTask } from "../orchestrator.js";
import {
maybeHandleV2,
Expand Down Expand Up @@ -227,12 +228,25 @@ async function handleDashboardCallback({ agentHome, client, callback }) {
});
notice = result.text;
} else {
const task = parsed.action === "approve"
? approveAgentTask({ agentHome, id: parsed.taskId })
: rejectAgentTask({ agentHome, id: parsed.taskId });
notice = parsed.action === "approve"
? `已放行 ${task.id}`
: `已拒絕 ${task.id}`;
if (parsed.action === "approve") {
const task = readTask(agentHome, parsed.taskId);
if (!task) {
throw new Error(`Task not found: ${parsed.taskId}`);
}
if (parsed.hash === null) {
notice = "舊版按鈕已失效,請重新產生核准請求";
} else if (parsed.hash !== taskApprovalHash(task)) {
notice = "task 內容已變更,請重新檢視";
} else if (task.approval === "approved") {
notice = "已處理過";
} else {
const approved = approveAgentTask({ agentHome, id: parsed.taskId });
notice = `已放行 ${approved.id}`;
}
} else {
const rejected = rejectAgentTask({ agentHome, id: parsed.taskId });
notice = `已拒絕 ${rejected.id}`;
}
}
} catch {
if (parsed.kind === "join") {
Expand Down Expand Up @@ -272,9 +286,9 @@ async function answerSafe(client, { callbackQueryId, text }) {

function parseDashboardCallback(data) {
const raw = String(data || "");
const gate = raw.match(/^gate:(approve|reject):(task_[A-Za-z0-9_-]+)$/);
const gate = raw.match(/^gate:(approve|reject):(task_[A-Za-z0-9_-]+)(?::([a-f0-9]{8}))?$/);
if (gate) {
return { kind: "gate", action: gate[1], taskId: gate[2] };
return { kind: "gate", action: gate[1], taskId: gate[2], hash: gate[3] || null };
}
const join = raw.match(/^join:(approve|reject):([a-z][a-z0-9_-]*)$/);
if (join) {
Expand Down
Loading
Loading