agent-lyceum 0.4.9
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/LICENSE +21 -0
- package/README.md +132 -0
- package/README.zh-TW.md +133 -0
- package/dist/adapters/claude.d.ts +14 -0
- package/dist/adapters/claude.js +155 -0
- package/dist/adapters/codex.d.ts +5 -0
- package/dist/adapters/codex.js +109 -0
- package/dist/adapters/index.d.ts +6 -0
- package/dist/adapters/index.js +20 -0
- package/dist/adapters/types.d.ts +61 -0
- package/dist/adapters/types.js +1 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +471 -0
- package/dist/config.d.ts +115 -0
- package/dist/config.js +253 -0
- package/dist/dispatcher.d.ts +37 -0
- package/dist/dispatcher.js +467 -0
- package/dist/doctor.d.ts +46 -0
- package/dist/doctor.js +151 -0
- package/dist/format.d.ts +33 -0
- package/dist/format.js +100 -0
- package/dist/fs-util.d.ts +2 -0
- package/dist/fs-util.js +9 -0
- package/dist/guard.d.ts +20 -0
- package/dist/guard.js +72 -0
- package/dist/mailbox.d.ts +97 -0
- package/dist/mailbox.js +259 -0
- package/dist/message-store.d.ts +91 -0
- package/dist/message-store.js +225 -0
- package/dist/paths.d.ts +30 -0
- package/dist/paths.js +62 -0
- package/dist/policy.d.ts +38 -0
- package/dist/policy.js +143 -0
- package/dist/process-runner.d.ts +21 -0
- package/dist/process-runner.js +165 -0
- package/dist/project-lock.d.ts +30 -0
- package/dist/project-lock.js +186 -0
- package/dist/prompt.d.ts +11 -0
- package/dist/prompt.js +125 -0
- package/dist/run-cleanup.d.ts +51 -0
- package/dist/run-cleanup.js +120 -0
- package/dist/run-store.d.ts +111 -0
- package/dist/run-store.js +119 -0
- package/dist/scaffold.d.ts +11 -0
- package/dist/scaffold.js +126 -0
- package/dist/schema.d.ts +303 -0
- package/dist/schema.js +84 -0
- package/dist/status.d.ts +142 -0
- package/dist/status.js +399 -0
- package/dist/task.d.ts +24 -0
- package/dist/task.js +71 -0
- package/dist/validate.d.ts +13 -0
- package/dist/validate.js +116 -0
- package/dist/worktree.d.ts +103 -0
- package/dist/worktree.js +395 -0
- package/package.json +58 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gogogohuang
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# agent-lyceum
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/agent-lyceum)
|
|
4
|
+
|
|
5
|
+
**English** | [繁體中文](README.zh-TW.md)
|
|
6
|
+
|
|
7
|
+
Configure and run a **team of agents** (Claude Code and/or Codex) that talk to each other through file mailboxes, each with its own persona (`AGENT.md`) and long-term memory folder. Writes are scoped: an agent can only change its own memory and outbox, never other agents' context or its own persona.
|
|
8
|
+
|
|
9
|
+
The tool keeps **its own data out of your repo**: configuration, mailboxes, logs, task memory and agent worktrees all live in one visible folder, `~/agent-lyceum-config/` (override with `AGENT_LYCEUM_HOME` or `--home`). What agents *produce* is a different matter: they edit your repo (directly when they run one at a time, or in git worktrees whose changes are brought into your working tree when they run in parallel). The only things agent-lyceum itself adds inside your repository are git bookkeeping: while a parallel run exists, `refs/agent-lyceum/<run-id>/*` snapshot refs, `agent-lyceum/<run-id>/<agent>` branches and worktree registrations under `.git`; `agent-lyceum clear` removes them. See [docs/upgrading-run-v2.md](docs/upgrading-run-v2.md) if you are coming from an older version.
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx agent-lyceum init # create the home + global agent library
|
|
13
|
+
npx agent-lyceum project add web --dir ~/code/web-app
|
|
14
|
+
npx agent-lyceum validate --project web # check config + show enforcement levels
|
|
15
|
+
npx agent-lyceum run --task-file spec.md # inside the repo, the project is auto-detected
|
|
16
|
+
npx agent-lyceum status
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The installed binary is `agent-lyceum`. To run the latest unreleased code straight from GitHub, use `npx github:gogogohuang/agent-lyceum <command>` instead.
|
|
20
|
+
|
|
21
|
+
Requires Node 20+, plus `claude` and/or `codex` on your `PATH` (already logged in). macOS and Linux (Linux needs `bwrap` for OS sandboxing); Windows only gets warnings.
|
|
22
|
+
|
|
23
|
+
## Commands
|
|
24
|
+
|
|
25
|
+
| Command | What it does |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `init [-y]` | Create the home and a global agent library (`lead`, `fe-member`, `qa-member`). Asks before creating the default path. |
|
|
28
|
+
| `project add <name> --dir <repo>` | Register a project: creates `projects/<name>/project.yaml`, shared folders and `COMMON.md`. |
|
|
29
|
+
| `project list` / `project remove <name> [--purge]` | List projects / unregister (keeps context unless `--purge`). |
|
|
30
|
+
| `validate [-p name] [--task-file f]` | Validate the merged config and print each agent's enforcement level. Exit 1 on errors. |
|
|
31
|
+
| `run ["task"] [--task-file f] [-p name]` | Give the task to the lead and run the dispatcher until done. Give *either* text or `--task-file`. |
|
|
32
|
+
| `resume [run-id] [-p name]` | Continue an interrupted or failed run (each `run` is its own task; without an id, the newest run that is neither done nor running): same run dir, sessions and round count; the task is not re-sent and unread mail is picked up again. Refuses if the run is still alive. For a run that is already done it continues nothing: it prints the recorded outcome and result and exits with the code for that outcome (`0` completed, `2` partial/blocked, `1` failed; a run from before outcomes were recorded counts as an unverified `partial`, exit `2`). A run that ended idle or at `max_rounds` has no unread mail left unless you add some, so resuming it ends idle again (exit `2`). |
|
|
33
|
+
| `status [-p name] [--monitor]` | Agents, unread mail, what is running right now (agent, elapsed time, message being handled), last run (task source, rounds, end reason, output tokens). Without `--monitor` it also prints the full wake-by-wake record of the latest run; `--monitor` keeps a live page open showing only the latest three wakes of each run. `--task-id <id>` prints everything about one task (every wake, its result, log directory). `--task-list [project]` lists every run of the project with its id, state, rounds and task (feed the id to `resume`). `--json` prints the same report as JSON (`schema_version: 1`, no colour, nothing else on stdout; errors go to stderr with a non-zero exit); it works with `--task-list` and `--task-id`. Text and JSON are made from the same report. |
|
|
34
|
+
| `clear <run-id> [-p name] [--dry-run] [--keep-worktrees]` | Delete a task (run) by id: its run directory (state, log, result, mailboxes), its **task memory**, and its agents' git worktrees, branches and snapshot refs. Global and project (long-term) memory is never touched. It refuses, and deletes nothing, while the run is still running or while a worktree holds work that is not in your repo (uncommitted, or committed but not brought in); it lists that work. `--keep-worktrees` keeps the worktrees and their branches and still clears the rest. `--dry-run` only prints what would be deleted and kept. Symlinks are never followed out of the project home. Progress is journaled in `projects/<name>/cleanup/`, so an interrupted `clear` is finished by repeating the same command. List ids with `status --task-list`. |
|
|
35
|
+
| `config show --resolved [-p name] [--json]` | Print every effective setting (global library, project file and defaults merged) and where it came from: the file and key, `default`, or `inferred from <model key>` when the runtime was recognised from `model`. Project values replace global ones; lists (`can_message`, `owns`) are replaced, not merged; relative paths are resolved against the file they were written in. |
|
|
36
|
+
| `doctor [-p name] [--json]` | Check the setup without running any agent: configuration, git (for parallel runs), the project lock, and what each runtime CLI supports, read from its `--version` and `--help` (JSON output, resume, sandbox settings, effort; each is `yes`, `no` or `unknown`). Exit 1 when something the team needs is explicitly unsupported. It does not read credentials: login status is reported as unknown. `run` and `resume` do the same capability check first: they refuse to start on an explicit `no` and warn on `unknown`. A real end-to-end check against the live CLIs (a tiny task) spends tokens and is deliberately manual. |
|
|
37
|
+
| `unlock [-p name] --force` | Remove the project's run lock left behind by a crashed run (only one run per project at a time). Without `--force` it only shows who holds the lock. |
|
|
38
|
+
|
|
39
|
+
**Stopping a run:** Ctrl-C (or SIGTERM) cancels it cleanly: running agents get SIGTERM, then SIGKILL after 5 s (their whole process tree), unread mail is kept, the lock is released and the exit code is `130`; continue later with `agent-lyceum resume`. A second Ctrl-C quits at once. A wake-up that exceeds `wake_timeout_sec` is stopped the same way and counts as a failed attempt. Each attempt's complete stdout/stderr is written to `runs/<run-id>/mail/attempts/<attempt>/log/`; only the last 64 KiB of each stream is kept in memory.
|
|
40
|
+
|
|
41
|
+
**Exit codes of `run` / `resume`:** `0` only when the lead reported `outcome: completed`; `2` for `partial` or `blocked` (also when the run went idle or hit `max_rounds` without a done); `1` for `failed` (also when the lead itself failed); `130` for `cancelled`. *Upgrading:* before this version an `idle` run exited `0` and a failed lead exited `2`; scripts that treated `0` as "the run ended" must now check for `0` as "the work was completed". Runs that ended before outcomes were recorded show as unverified (`partial`) and are not reported as a success.
|
|
42
|
+
|
|
43
|
+
Without `--project`, the project is the registered one whose `dir` is the longest prefix of the current directory; if none matches the command lists the registered projects and stops.
|
|
44
|
+
|
|
45
|
+
## Monitor plugin (Claude Code)
|
|
46
|
+
|
|
47
|
+
`plugins/agent-lyceum-monitor` is a Claude Code plugin that shows live run status for one or several projects.
|
|
48
|
+
|
|
49
|
+
**Load it.** Build first (`npm run build`; the plugin runs `dist/cli.js`), then start a session with:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
claude --plugin-dir /path/to/agent-lyceum/plugins/agent-lyceum-monitor
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--plugin-dir` only applies when a session starts; an already-open session must be restarted (`claude --resume --plugin-dir ...` keeps the conversation). To load it every time, add `alias claude='claude --plugin-dir /path/to/agent-lyceum/plugins/agent-lyceum-monitor'` to your shell rc.
|
|
56
|
+
|
|
57
|
+
**Use it.**
|
|
58
|
+
- **Status line:** always on once loaded; shows the run state, round `n/max`, output tokens, the current step and unread mail. With several projects it shows one overview line (`team 1/3 running · a ... | b ...`).
|
|
59
|
+
- **Toasts:** a message pops up when a run finishes or fails (prefixed `[project]` when several are watched). Nothing to do.
|
|
60
|
+
- **`/team-monitor`:** type it in the prompt to open the pane. It shows, per project: the run id and state, task, progress and checklist, the agents working now and the queued mail, blocked integrations, notes and result summary, output tokens per runtime, per-agent wake statistics, the last 8 wakes and the last 5 runs. It refreshes with the polling interval while open. Click **關閉** (Close) to dismiss it.
|
|
61
|
+
|
|
62
|
+
**Configure it.** In `~/.claude/settings.json` under `pluginConfigs."agent-lyceum-monitor@inline"` (`options`):
|
|
63
|
+
|
|
64
|
+
| Option | Meaning | Default |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `command` | CLI to run, split on spaces, e.g. `node /path/to/agent-lyceum/dist/cli.js` | `agent-lyceum` |
|
|
67
|
+
| `project` | Registered project name(s) (`-p`); comma-separate to watch several; empty infers from the session's directory | empty |
|
|
68
|
+
| `intervalSeconds` | How often status is polled | `3` |
|
|
69
|
+
|
|
70
|
+
Changes take effect on the next session start. If the pane shows `無法取得狀態:...`, the `command` could not run (usually `dist/cli.js` was not built) or the project name is not registered.
|
|
71
|
+
|
|
72
|
+
## Layout
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
~/agent-lyceum-config/
|
|
76
|
+
├── team.yaml # global agent library
|
|
77
|
+
├── agents/<agent>/{AGENT.md, memory/} # global persona + cross-project memory
|
|
78
|
+
└── projects/<project>/
|
|
79
|
+
├── project.yaml # team, repo dir, overrides
|
|
80
|
+
├── agents/<agent>/{AGENT.md?, memory/} # project-level persona (optional) + project memory
|
|
81
|
+
├── shared/common/COMMON.md
|
|
82
|
+
├── lock.json # held while a run is active: one run per project
|
|
83
|
+
└── runs/<run-id>/{task.md, log.jsonl, state.json, snapshots/, agents/, mail/{inbox,outbox}/<agent>/}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
`team.yaml` defines reusable agents; `project.yaml` picks the team and overrides fields. Objects merge field by field, arrays (`can_message`, `owns`) are replaced. Relative paths resolve against the folder of the file they are written in; `~` is allowed.
|
|
87
|
+
|
|
88
|
+
```yaml
|
|
89
|
+
# project.yaml
|
|
90
|
+
dir: ~/code/web-app
|
|
91
|
+
team: { lead: lead }
|
|
92
|
+
dispatcher: { max_rounds: 30, max_parallel: 1, wake_timeout_sec: 600, retry: 1, strict: false, workspace_mode: auto }
|
|
93
|
+
agents:
|
|
94
|
+
lead: { resume: true, can_message: all, can_edit_agent_md: true }
|
|
95
|
+
fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
|
|
96
|
+
qa-member: { can_message: [lead], owns: ["tests/**"] }
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Agent fields: `runtime` (`claude-code`|`codex`; optional when `model` is recognizable: `opus`/`sonnet`/`haiku`/`claude-*` → Claude Code, `gpt-*`/`o3`/`*codex*` → Codex; precedence: project runtime > project model > global runtime > global model), `model`, `effort` (Claude Code: `low`|`medium`|`high`|`xhigh`|`max` via `--effort`; Codex: `minimal`|`low`|`medium`|`high`|`xhigh` via `model_reasoning_effort`; unset = CLI default), `agent_md`, `memory.global` / `memory.project`, `resume`, `can_message` (`all` or list; default `[lead]`, lead default `all`), `can_edit_agent_md` (default only the lead), `owns` (repo globs).
|
|
100
|
+
|
|
101
|
+
Rules checked by `validate`: ≥ 2 agents (the lead and one member; the template has three), the lead is a listed agent, every agent has a runtime (explicit or inferred from `model`) and an existing `AGENT.md`, `effort` is valid for the agent's runtime (warning if `runtime` contradicts a recognizable `model`), `can_message` targets exist, memory dirs don't overlap, and with `max_parallel > 1` every non-lead agent needs non-overlapping `owns` and the repo must be a git repository.
|
|
102
|
+
|
|
103
|
+
## How a run works
|
|
104
|
+
|
|
105
|
+
1. The task (text, or a copy of `--task-file` kept read-only as `runs/<id>/task.md`) becomes the first mail to the lead. Tasks ≤ 16 KB are inlined; larger ones are passed by reference. Files over 1 MB are rejected.
|
|
106
|
+
2. The dispatcher wakes whichever agent has unread mail with `claude -p` or `codex exec` (headless). Each wake-up is a fresh session unless `resume: true`.
|
|
107
|
+
3. Every wake-up prompt contains the agent's `AGENT.md`, the team protocol, `COMMON.md` (read-only, ≤ 8 KB), the `MEMORY.md` index of each memory dir, the **oldest** unread message in full (one message per wake-up; the lead instead takes a run of up to 5 `reply`/`failure` messages at once and decides on them together), and titles of the queued ones, which stay unread until their own wake-up.
|
|
108
|
+
4. Agents send mail by writing a Markdown file with frontmatter (`to`, `type`, `subject`) into **their own** `outbox/`. The dispatcher checks `can_message`, stamps the real sender, and moves it to the recipient's `inbox/`. Handled mail goes to `inbox/<agent>/read/`. Mailboxes belong to one run (`runs/<run-id>/mail/`), so mail never crosses runs. Runs started by older versions keep using the shared `shared/{inbox,outbox}/` mailboxes (their mail is never moved); resuming such a run shows that legacy layout.
|
|
109
|
+
The body of every `task` and `reply` should carry fixed `##` headings (injected into each agent's prompt): `task` → `Goal`, `Acceptance criteria`, `Scope`, `Upstream`; `reply` → `Changes`, `Verification`, `Open items`, `Risks` (write `None` when empty; `done` is exempt). Mail missing a heading is still delivered, with a warning note prepended and a `format-warning` entry in the run log.
|
|
110
|
+
5. The run ends when the lead sends `type: done`, when all mailboxes are empty, or after `max_rounds` wake-ups. A `done` must declare `outcome: completed|partial|blocked|failed` in its frontmatter and carry `## Result`, `## Files`, `## Verification`, `## Not done`; `completed` also needs every `## Steps` item ticked. A `done` that breaks this is sent back to the lead (twice at most, then it is kept as `partial`). Agent-lyceum does not verify what the lead reports. A failed wake-up is retried once, then reported to the lead as a failure message (if the lead itself fails, the run aborts).
|
|
111
|
+
|
|
112
|
+
Parallelism (`max_parallel > 1`) only runs agents with disjoint `owns`, and never alongside the lead. Each non-lead agent then works in its **own git worktree** (`projects/<name>/worktrees/<run-id>/<agent>`, branch `agent-lyceum/<run-id>/<agent>`) cut from a snapshot of the repo as it is when the agent is woken (`refs/agent-lyceum/<run-id>/base-<n>`; it includes the lead's uncommitted and new, non-ignored files; your HEAD, branches and working tree are not touched). `dispatcher.workspace_mode` is `auto` (worktrees when `max_parallel > 1`), `worktree` or `shared`; `shared` cannot be combined with parallel agents. A parallel run refuses to start while the repo has uncommitted changes of yours, and when the repo is not a git repository (run sequentially instead). When a member finishes, the dispatcher commits its worktree and brings the changes into your working tree (not your index or HEAD) before the lead reads the member's mail. Every changed path is checked first: it must be inside the member's `owns` (renames and deletions included), must not be `CLAUDE.md`/`AGENTS.md`, and symlinks may not point out of the project or the member's `owns`. If a file also changed in your repo since the member's snapshot, if a check fails, or if `git apply` fails, nothing is applied: the work stays on its branch and worktree, a report and patch are written to `runs/<run-id>/integration/`, the lead is told, and the run cannot end as `completed` (it ends `blocked`). A worktree isolates files only: it cannot isolate side effects on external services, databases or the network.
|
|
113
|
+
|
|
114
|
+
## Write scope
|
|
115
|
+
|
|
116
|
+
Default rules (everything else in the repo is unrestricted):
|
|
117
|
+
|
|
118
|
+
| Target | Rule |
|
|
119
|
+
|---|---|
|
|
120
|
+
| own memory dirs, own `outbox/` | writable |
|
|
121
|
+
| any `AGENT.md`, `COMMON.md`, repo `CLAUDE.md`/`AGENTS.md` | read-only; the lead (`can_edit_agent_md`) may edit them |
|
|
122
|
+
| other agents' memory/inbox/outbox, `runs/`, config files | not writable |
|
|
123
|
+
|
|
124
|
+
Enforcement is layered because no single mechanism is complete:
|
|
125
|
+
|
|
126
|
+
- **Claude Code**: per-run `--settings` with `Edit(...)` rules **plus** the OS sandbox (`allowWrite`/`denyWrite`, `allowUnsandboxedCommands: false`). Without the sandbox, `python -c` and similar bypass Edit rules, so the sandbox is required for the `os` level.
|
|
127
|
+
- **Codex**: `-s workspace-write` with `-c sandbox_workspace_write.writable_roots=[...]` (OS-enforced). Codex cannot forbid a single file inside the repo, so repo `CLAUDE.md`/`AGENTS.md` are protected by **post-hoc detection** only.
|
|
128
|
+
- **All runtimes**: protected files are hashed before the run; after each wake-up unauthorized changes are reverted, logged, and reported to the lead.
|
|
129
|
+
|
|
130
|
+
`validate` and `run` print every agent's level per category (`os`, `tool-rules`, `post-hoc`, `prompt-only`) and the paths it can write outside the repo. With `dispatcher.strict: true`, a run is refused unless memory, `AGENT.md` and other agents' context are OS-enforced.
|
|
131
|
+
|
|
132
|
+
Known gaps: Claude Code's built-in Edit/Write tools are not sandboxed (covered by the `Edit` rules); Codex MCP tools and hooks run outside its sandbox; `--dangerously-bypass-approvals-and-sandbox` / `danger-full-access` disable everything. With `can_edit_agent_md`, a Codex lead's writable roots widen to whole agent directories.
|
package/README.zh-TW.md
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# agent-lyceum
|
|
2
|
+
|
|
3
|
+
[](https://www.npmjs.com/package/agent-lyceum)
|
|
4
|
+
|
|
5
|
+
[English](README.md) | **繁體中文**
|
|
6
|
+
|
|
7
|
+
設定並執行一個由多個 **agent**(Claude Code 和/或 Codex)組成的團隊。agent 之間透過檔案信箱互相溝通,每個 agent 都有自己的人設(`AGENT.md`)與長期記憶資料夾。寫入權限有範圍限制:agent 只能修改自己的記憶與寄件匣(outbox),不能改動其他 agent 的 context,也不能改自己的人設。
|
|
8
|
+
|
|
9
|
+
此工具**不會把自己的資料放進你的 repo**:設定、信箱、log、任務記憶與 agent 的 worktree 都放在一個看得到的資料夾 `~/agent-lyceum-config/`(可用 `AGENT_LYCEUM_HOME` 或 `--home` 覆寫)。但 agent *產出的成果*是另一回事:agent 會修改你的 repo(序列執行時直接修改;平行執行時在各自的 git worktree 修改,再把變更整合進你的工作目錄)。agent-lyceum 自己在你的 repository 內唯一新增的是 git 的管理資料:平行 run 存在期間,`.git` 下會有 `refs/agent-lyceum/<run-id>/*` 快照 ref、`agent-lyceum/<run-id>/<agent>` branch 與 worktree 登記,`agent-lyceum clear` 會把它們移除。從舊版升級請見 [docs/upgrading-run-v2.md](docs/upgrading-run-v2.md)。
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx agent-lyceum init # 建立 home 與全域 agent 庫
|
|
13
|
+
npx agent-lyceum project add web --dir ~/code/web-app
|
|
14
|
+
npx agent-lyceum validate --project web # 檢查設定並顯示各項防護等級
|
|
15
|
+
npx agent-lyceum run --task-file spec.md # 在 repo 內執行時會自動偵測專案
|
|
16
|
+
npx agent-lyceum status
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
安裝後的執行檔名稱為 `agent-lyceum`。若要直接跑 GitHub 上尚未發佈的最新程式碼,可改用 `npx github:gogogohuang/agent-lyceum <command>`。
|
|
20
|
+
|
|
21
|
+
需求:Node 20+,以及 `PATH` 中有 `claude` 和/或 `codex`(且已登入)。支援 macOS 與 Linux(Linux 需要 `bwrap` 才有 OS 沙箱);Windows 只會顯示警告。
|
|
22
|
+
|
|
23
|
+
## 指令
|
|
24
|
+
|
|
25
|
+
| 指令 | 功能 |
|
|
26
|
+
|---|---|
|
|
27
|
+
| `init [-y]` | 建立 home 與全域 agent 庫(`lead`、`fe-member`、`qa-member`)。建立預設路徑前會先詢問。 |
|
|
28
|
+
| `project add <name> --dir <repo>` | 註冊專案:建立 `projects/<name>/project.yaml`、共用資料夾與 `COMMON.md`。 |
|
|
29
|
+
| `project list` / `project remove <name> [--purge]` | 列出專案/取消註冊(除非加 `--purge`,否則保留 context)。 |
|
|
30
|
+
| `validate [-p name] [--task-file f]` | 驗證合併後的設定,並印出每個 agent 的防護等級。有錯誤時 exit 1。 |
|
|
31
|
+
| `run ["task"] [--task-file f] [-p name]` | 把任務交給 lead,並執行 dispatcher 直到完成。任務文字與 `--task-file` 擇一提供。 |
|
|
32
|
+
| `resume [run-id] [-p name]` | 接續被中斷或失敗的 run(每次 `run` 都是獨立任務;不指定 id 時接續最新一個尚未結束且未在執行的 run,指定 id 則接續該任務):沿用同一個 run 目錄、session 與輪數,不會重送任務,未讀信件會重新處理。run 仍在執行時會拒絕。對已完成(done)的 run 不會繼續任何工作:只印出已記錄的結果狀態與內容,並以該結果對應的碼結束(`0` completed、`2` partial/blocked、`1` failed;在記錄結果狀態之前就結束的舊 run 視為未驗證的 `partial`,回傳 `2`)。因閒置或達 `max_rounds` 而結束的 run,除非補上新信件,否則已沒有未讀信件,接續後會再次以閒置結束(回傳 `2`)。 |
|
|
33
|
+
| `status [-p name] [--monitor]` | 顯示 agent、未讀信件、目前正在執行的 agent(耗時、處理中的信件)、上次執行(任務來源、輪數、結束原因、output token 數)。不帶 `--monitor` 時會一併印出最新 run 的完整 wake 紀錄;`--monitor` 會常駐並持續更新,每個 run 只顯示最新三筆 wake。`--task-id <id>` 印出單一任務的完整內容(每次 wake、結果、log 目錄)。`--task-list [project]` 列出專案所有任務(run)的 id、狀態、輪數與任務內容,id 可直接給 `resume` 使用。加上 `--json` 會把同一份報告以 JSON 輸出(`schema_version: 1`、無顏色,stdout 只有 JSON;錯誤走 stderr 並以非零 exit code 結束),可搭配 `--task-list`、`--task-id`。文字與 JSON 由同一份報告產生。 |
|
|
34
|
+
| `clear <run-id> [-p name] [--dry-run] [--keep-worktrees]` | 依 id 刪除一個任務(run):它的 run 目錄(狀態、log、結果、信箱)、**任務記憶**,以及各 agent 的 git worktree、branch 與快照 ref。全域與專案層(長期)記憶一律不會動。run 仍在執行,或有 worktree 內的成果不在你的 repo 裡(未提交,或已提交但尚未整合進來)時會拒絕且什麼都不刪,並列出那些成果。`--keep-worktrees` 會保留 worktree 與 branch,其餘照樣清除。`--dry-run` 只列出會刪除與會保留的項目。絕不會順著 symlink 刪到專案 home 之外。進度會記在 `projects/<name>/cleanup/` 的 journal,所以中斷後重跑同一個指令即可完成。id 可用 `status --task-list` 查。 |
|
|
35
|
+
| `config show --resolved [-p name] [--json]` | 印出每一項生效的設定(全域 agent 庫、專案檔與預設值合併後的結果)與它的來源:檔案與 key、`default`,或在 runtime 是由 `model` 辨識出來時顯示 `inferred from <model 的 key>`。專案的值覆蓋全域;清單(`can_message`、`owns`)是整個取代而非合併;相對路徑以寫它的那個檔案為基準解析。 |
|
|
36
|
+
| `doctor [-p name] [--json]` | 在不執行任何 agent 的前提下檢查環境:設定、git(平行 run 需要)、專案鎖,以及各 runtime CLI 支援什麼(從 `--version` 與 `--help` 讀取:JSON 輸出、resume、sandbox 設定、effort,每項都是 `yes`、`no` 或 `unknown`)。只要團隊需要的功能被明確判定不支援就 exit 1。它不讀取憑證,登入狀態會回報為 unknown。`run` 與 `resume` 啟動前會先做同樣的能力檢查:明確 `no` 就拒絕啟動,`unknown` 則警告。要對真實 CLI 做端到端驗證(跑一個很小的任務)會花 token,刻意保留為手動。 |
|
|
37
|
+
| `unlock [-p name] --force` | 移除當機的 run 留下的專案鎖(同一專案同時只能有一個 run)。不加 `--force` 只會顯示鎖的持有者。 |
|
|
38
|
+
|
|
39
|
+
**中止 run:** Ctrl-C(或 SIGTERM)會乾淨地取消 run:執行中的 agent 先收到 SIGTERM,5 秒後 SIGKILL(連同整個子程序樹),未讀信件保留、專案鎖釋放,exit code 為 `130`;之後可用 `agent-lyceum resume` 接續。再按一次 Ctrl-C 會立刻結束。超過 `wake_timeout_sec` 的喚醒也以同樣方式停止,並算作一次失敗的嘗試。每次嘗試完整的 stdout/stderr 都寫在 `runs/<run-id>/mail/attempts/<attempt>/log/`,記憶體中每個串流只保留最後 64 KiB。
|
|
40
|
+
|
|
41
|
+
**`run`/`resume` 的 exit code:** `0` 只代表 lead 回報 `outcome: completed`;`2` 代表 `partial` 或 `blocked`(run 閒置或達到 `max_rounds` 而沒有 done 也算);`1` 代表 `failed`(lead 本身失敗也算);`130` 代表 `cancelled`。*升級注意:* 舊版 `idle` 結束會回傳 `0`、lead 失敗回傳 `2`;原本把 `0` 當成「run 結束了」的腳本,現在 `0` 的意思是「工作確實完成」。在記錄結果狀態之前就結束的舊 run 會顯示為「未驗證」(`partial`),不會被當成成功。
|
|
42
|
+
|
|
43
|
+
未指定 `--project` 時,會選用 `dir` 為目前目錄最長前綴的已註冊專案;若沒有符合的專案,指令會列出已註冊專案後停止。
|
|
44
|
+
|
|
45
|
+
## 監控 plugin(Claude Code)
|
|
46
|
+
|
|
47
|
+
`plugins/agent-lyceum-monitor` 是 Claude Code plugin,會顯示一個或多個專案的即時執行狀態。
|
|
48
|
+
|
|
49
|
+
**載入。** 先 build(`npm run build`;plugin 會執行 `dist/cli.js`),再用下面的指令啟動 session:
|
|
50
|
+
|
|
51
|
+
```
|
|
52
|
+
claude --plugin-dir /path/to/agent-lyceum/plugins/agent-lyceum-monitor
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
`--plugin-dir` 只在 session 啟動時生效,已開著的 session 要重開(`claude --resume --plugin-dir ...` 可保留對話)。想每次都載入,就在 shell rc 加 `alias claude='claude --plugin-dir /path/to/agent-lyceum/plugins/agent-lyceum-monitor'`。
|
|
56
|
+
|
|
57
|
+
**操作。**
|
|
58
|
+
- **狀態列:** 載入後常駐,顯示執行狀態、輪數 `n/max`、output tokens、目前步驟與未讀信件。監看多個專案時顯示一行總覽(`team 1/3 執行中 · a ... | b ...`)。
|
|
59
|
+
- **通知:** 執行完成或失敗時會跳出訊息(監看多個專案時前面加 `[專案名]`),不需要任何操作。
|
|
60
|
+
- **`/team-monitor`:** 在輸入框輸入即可開啟面板。每個專案顯示:執行 id 與狀態、任務、進度與清單、正在工作的 agent 與排隊中的信件、受阻的整合、備註與結果摘要、各 runtime 的 output tokens、各 agent 的喚醒統計、最近 8 次喚醒與最近 5 次執行。面板開著時會依輪詢間隔自動更新,按 **關閉** 即可收起。
|
|
61
|
+
|
|
62
|
+
**設定。** 在 `~/.claude/settings.json` 的 `pluginConfigs."agent-lyceum-monitor@inline"`(`options`):
|
|
63
|
+
|
|
64
|
+
| 選項 | 說明 | 預設 |
|
|
65
|
+
|---|---|---|
|
|
66
|
+
| `command` | 要執行的 CLI,以空白分隔,例如 `node /path/to/agent-lyceum/dist/cli.js` | `agent-lyceum` |
|
|
67
|
+
| `project` | 已註冊的專案名稱(`-p`);用逗號分隔可同時監看多個;留空則依 session 所在目錄推斷 | 空 |
|
|
68
|
+
| `intervalSeconds` | 輪詢狀態的間隔(秒) | `3` |
|
|
69
|
+
|
|
70
|
+
設定變更要等下次啟動 session 才生效。面板若顯示 `無法取得狀態:...`,通常是 `command` 跑不起來(多半是還沒 build `dist/cli.js`),或專案名稱沒有註冊。
|
|
71
|
+
|
|
72
|
+
## 目錄結構
|
|
73
|
+
|
|
74
|
+
```
|
|
75
|
+
~/agent-lyceum-config/
|
|
76
|
+
├── team.yaml # 全域 agent 庫
|
|
77
|
+
├── agents/<agent>/{AGENT.md, memory/} # 全域人設 + 跨專案記憶
|
|
78
|
+
└── projects/<project>/
|
|
79
|
+
├── project.yaml # 團隊、repo 路徑、覆寫設定
|
|
80
|
+
├── agents/<agent>/{AGENT.md?, memory/} # 專案層人設(選用)+ 專案記憶
|
|
81
|
+
├── shared/common/COMMON.md
|
|
82
|
+
├── lock.json # run 執行期間持有:同一專案同時只能有一個 run
|
|
83
|
+
├── task-memory/<run-id>/<agent>/ # 任務記憶:每次 run 獨立,不與其他任務共用
|
|
84
|
+
└── runs/<run-id>/{task.md, log.jsonl, state.json, snapshots/, agents/, mail/{inbox,outbox}/<agent>/}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`team.yaml` 定義可重複使用的 agent;`project.yaml` 選擇團隊並覆寫欄位。物件會逐欄位合併,陣列(`can_message`、`owns`)則整個取代。相對路徑以寫入該路徑的檔案所在資料夾為基準;允許使用 `~`。
|
|
88
|
+
|
|
89
|
+
```yaml
|
|
90
|
+
# project.yaml
|
|
91
|
+
dir: ~/code/web-app
|
|
92
|
+
team: { lead: lead }
|
|
93
|
+
dispatcher: { max_rounds: 30, max_parallel: 1, wake_timeout_sec: 600, retry: 1, strict: false, workspace_mode: auto }
|
|
94
|
+
agents:
|
|
95
|
+
lead: { resume: true, can_message: all, can_edit_agent_md: true }
|
|
96
|
+
fe-member: { runtime: codex, can_message: [lead], owns: ["src/web/**"] }
|
|
97
|
+
qa-member: { can_message: [lead], owns: ["tests/**"] }
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
Agent 欄位:`runtime`(`claude-code`|`codex`;若 `model` 可辨識則可省略:`opus`/`sonnet`/`haiku`/`claude-*` → Claude Code,`gpt-*`/`o3`/`*codex*` → Codex;優先順序:專案 runtime > 專案 model > 全域 runtime > 全域 model)、`model`、`effort`(Claude Code:`low`|`medium`|`high`|`xhigh`|`max`,經 `--effort`;Codex:`minimal`|`low`|`medium`|`high`|`xhigh`,經 `model_reasoning_effort`;未設則用 CLI 預設)、`agent_md`、`memory.global` / `memory.project`、`resume`、`can_message`(`all` 或清單;預設 `[lead]`,lead 預設 `all`)、`can_edit_agent_md`(預設只有 lead)、`owns`(repo glob)。
|
|
101
|
+
|
|
102
|
+
`validate` 檢查的規則:至少 2 個 agent(lead 加一位成員;範本預設是三位)、lead 在 agent 清單內、每個 agent 都有 runtime(明設或由 `model` 推斷)且 `AGENT.md` 存在、`effort` 對該 runtime 合法(若 `runtime` 與可辨識的 `model` 矛盾則警告)、`can_message` 的目標存在、記憶資料夾互不重疊,且當 `max_parallel > 1` 時,每個非 lead 的 agent 都必須有互不重疊的 `owns`,repo 也必須是 git repository。
|
|
103
|
+
|
|
104
|
+
## 一次執行如何運作
|
|
105
|
+
|
|
106
|
+
1. 任務(文字,或 `--task-file` 的唯讀副本 `runs/<id>/task.md`)會成為寄給 lead 的第一封信。≤ 16 KB 的任務直接內嵌,更大的改以引用方式傳遞。超過 1 MB 的檔案會被拒絕。
|
|
107
|
+
2. dispatcher 用 `claude -p` 或 `codex exec`(headless)喚醒有未讀信件的 agent。除非設定 `resume: true`,否則每次喚醒都是全新的 session。
|
|
108
|
+
3. 每次喚醒的 prompt 都包含:該 agent 的 `AGENT.md`、團隊協定、`COMMON.md`(唯讀,≤ 8 KB)、每個記憶資料夾的 `MEMORY.md` 索引、**最舊**一封未讀信件全文(每次喚醒只處理一封;lead 例外,會一次拿到連續最多 5 封 `reply`/`failure` 並一併決策),以及排隊中信件的標題;排隊的信維持未讀,等各自的喚醒再處理。
|
|
109
|
+
4. agent 寄信的方式,是在**自己的** `outbox/` 寫一個含 frontmatter(`to`、`type`、`subject`)的 Markdown 檔。dispatcher 會檢查 `can_message`、蓋上真正的寄件者,並移到收件者的 `inbox/`。處理完的信件移至 `inbox/<agent>/read/`。信箱屬於單一 run(`runs/<run-id>/mail/`),信件不會跨 run。舊版本啟動的 run 繼續使用共用的 `shared/{inbox,outbox}/` 信箱(信件不會被搬移),接續這類 run 時會沿用舊版配置。
|
|
110
|
+
每封 `task` 和 `reply` 的內文應包含固定的 `##` 標題(會注入每個 agent 的 prompt):`task` → `Goal`、`Acceptance criteria`、`Scope`、`Upstream`;`reply` → `Changes`、`Verification`、`Open items`、`Risks`(沒內容就寫 `None`;`done` 免檢)。缺標題的信仍會送達,但開頭會加上警告說明,run log 也會記一筆 `format-warning`。
|
|
111
|
+
5. 結束條件:lead 寄出 `type: done`、所有信箱都空了,或喚醒次數達到 `max_rounds`。`done` 的 frontmatter 必須有 `outcome: completed|partial|blocked|failed`,內文要有 `## Result`、`## Files`、`## Verification`、`## Not done`;`completed` 還要求 `## Steps` 全部勾選。不符合的 `done` 會退回給 lead(最多兩次,之後以 `partial` 收下)。agent-lyceum 不會驗證 lead 回報的內容是否屬實。喚醒失敗會重試一次,之後以失敗訊息通知 lead(若 lead 本身失敗,則整個 run 中止)。
|
|
112
|
+
|
|
113
|
+
平行執行(`max_parallel > 1`)只會同時跑 `owns` 互不相交的 agent,且絕不與 lead 同時執行。此時每個非 lead 的 agent 都在**自己的 git worktree**(`projects/<name>/worktrees/<run-id>/<agent>`,branch `agent-lyceum/<run-id>/<agent>`)工作;worktree 是從喚醒當下 repo 的快照(`refs/agent-lyceum/<run-id>/base-<n>`,包含 lead 尚未提交的修改與新增、未被 ignore 的檔案)切出來的,你的 HEAD、branch 與工作目錄都不會被動到。`dispatcher.workspace_mode` 可設為 `auto`(`max_parallel > 1` 時用 worktree)、`worktree` 或 `shared`;`shared` 不能與平行 agent 並用。平行 run 在 repo 有你自己未提交的修改時,或 repo 不是 git repository 時,會拒絕啟動(請改為序列執行)。成員完成後,dispatcher 會提交它 worktree 中的修改,並在 lead 讀到該成員的信之前,把變更套用到你的工作目錄(不動你的 index 與 HEAD)。每個被改動的路徑都會先檢查:必須在該成員的 `owns` 內(rename 與刪除也算)、不得是 `CLAUDE.md`/`AGENTS.md`,symlink 不得指向專案或該成員 `owns` 之外。若同一檔案在成員的快照之後又在你的 repo 被改過、檢查未通過,或 `git apply` 失敗,就一個檔案都不套用:成果保留在它的 branch 與 worktree,報告與 patch 寫到 `runs/<run-id>/integration/`,並通知 lead,且這個 run 不能以 `completed` 結束(會以 `blocked` 結束)。worktree 只隔離檔案,無法隔離對外部服務、資料庫或網路的副作用。
|
|
114
|
+
|
|
115
|
+
## 寫入範圍
|
|
116
|
+
|
|
117
|
+
預設規則(repo 內其餘部分不受限制):
|
|
118
|
+
|
|
119
|
+
| 目標 | 規則 |
|
|
120
|
+
|---|---|
|
|
121
|
+
| 自己的記憶資料夾、自己的 `outbox/` | 可寫入 |
|
|
122
|
+
| 任何 `AGENT.md`、`COMMON.md`、repo 的 `CLAUDE.md`/`AGENTS.md` | 唯讀;lead(`can_edit_agent_md`)可編輯 |
|
|
123
|
+
| 其他 agent 的記憶/inbox/outbox、`runs/`、設定檔 | 不可寫入 |
|
|
124
|
+
|
|
125
|
+
由於沒有單一機制是完整的,防護採分層設計:
|
|
126
|
+
|
|
127
|
+
- **Claude Code**:每次執行帶入 `--settings`,內含 `Edit(...)` 規則,**加上** OS 沙箱(`allowWrite`/`denyWrite`、`allowUnsandboxedCommands: false`)。沒有沙箱時,`python -c` 之類的做法能繞過 Edit 規則,因此 `os` 等級必須啟用沙箱。
|
|
128
|
+
- **Codex**:`-s workspace-write` 搭配 `-c sandbox_workspace_write.writable_roots=[...]`(由 OS 強制)。Codex 無法禁止 repo 內的單一檔案,所以 repo 的 `CLAUDE.md`/`AGENTS.md` 只能靠**事後偵測**保護。
|
|
129
|
+
- **所有 runtime**:執行前會對受保護檔案做雜湊;每次喚醒後,未經授權的變更會被還原、記錄,並通報 lead。
|
|
130
|
+
|
|
131
|
+
`validate` 與 `run` 會依類別(`os`、`tool-rules`、`post-hoc`、`prompt-only`)印出每個 agent 的防護等級,以及它能寫入 repo 之外的哪些路徑。設定 `dispatcher.strict: true` 時,除非記憶、`AGENT.md` 與其他 agent 的 context 都由 OS 強制保護,否則拒絕執行。
|
|
132
|
+
|
|
133
|
+
已知缺口:Claude Code 內建的 Edit/Write 工具不在沙箱內(由 `Edit` 規則涵蓋);Codex 的 MCP 工具與 hooks 在其沙箱之外執行;`--dangerously-bypass-approvals-and-sandbox` / `danger-full-access` 會停用所有防護。啟用 `can_edit_agent_md` 時,Codex lead 的可寫根目錄會擴大到整個 agent 目錄。
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { ResolvedAgent, ResolvedProject } from "../config.js";
|
|
2
|
+
import type { Invocation, WakeInput } from "./types.js";
|
|
3
|
+
export declare function buildClaudeSettings(project: ResolvedProject, agent: ResolvedAgent): Record<string, unknown>;
|
|
4
|
+
export declare function buildClaudeInvocation(input: WakeInput): Invocation;
|
|
5
|
+
interface ClaudeResult {
|
|
6
|
+
result?: string;
|
|
7
|
+
session_id?: string;
|
|
8
|
+
usage?: {
|
|
9
|
+
output_tokens?: number;
|
|
10
|
+
};
|
|
11
|
+
is_error?: boolean;
|
|
12
|
+
}
|
|
13
|
+
export declare function extractClaudeResult(stdout: string): ClaudeResult | undefined;
|
|
14
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { repoDirFor, writePolicy } from "../policy.js";
|
|
4
|
+
/** Claude Code path rules: `//abs/path` — a leading "/" plus the absolute path. */
|
|
5
|
+
const rule = (tool, p) => `${tool}(/${p})`;
|
|
6
|
+
function denyRules(p) {
|
|
7
|
+
// Unknown whether it's a file or dir (may not exist yet): cover both.
|
|
8
|
+
const out = [rule("Edit", p)];
|
|
9
|
+
let isFile = false;
|
|
10
|
+
try {
|
|
11
|
+
isFile = fs.statSync(p).isFile();
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
/* missing */
|
|
15
|
+
}
|
|
16
|
+
if (!isFile)
|
|
17
|
+
out.push(rule("Edit", `${p}/**`));
|
|
18
|
+
return out;
|
|
19
|
+
}
|
|
20
|
+
export function buildClaudeSettings(project, agent) {
|
|
21
|
+
const pol = writePolicy(project, agent);
|
|
22
|
+
const ws = project.workspaces?.[agent.name];
|
|
23
|
+
const repo = repoDirFor(project, agent.name);
|
|
24
|
+
const allow = ["Bash", "Read", "Glob", "Grep"];
|
|
25
|
+
if (pol.restrictRepoToOwns) {
|
|
26
|
+
for (const g of pol.owns)
|
|
27
|
+
allow.push(rule("Edit", path.join(repo, g)));
|
|
28
|
+
}
|
|
29
|
+
else {
|
|
30
|
+
allow.push(rule("Edit", `${repo}/**`));
|
|
31
|
+
}
|
|
32
|
+
for (const d of pol.allowDirs)
|
|
33
|
+
allow.push(rule("Edit", `${d}/**`));
|
|
34
|
+
for (const f of pol.allowFiles)
|
|
35
|
+
allow.push(rule("Edit", f));
|
|
36
|
+
const deny = pol.deny.flatMap(denyRules);
|
|
37
|
+
return {
|
|
38
|
+
permissions: { allow, deny },
|
|
39
|
+
sandbox: {
|
|
40
|
+
enabled: true,
|
|
41
|
+
allowUnsandboxedCommands: false,
|
|
42
|
+
filesystem: {
|
|
43
|
+
allowWrite: [ws?.root ?? project.dir, ...pol.allowDirs, ...pol.allowFiles],
|
|
44
|
+
denyWrite: pol.deny,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
export function buildClaudeInvocation(input) {
|
|
50
|
+
const { project, agent, workDir } = input;
|
|
51
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
52
|
+
const settingsFile = path.join(workDir, "claude-settings.json");
|
|
53
|
+
const systemFile = path.join(workDir, "system-prompt.md");
|
|
54
|
+
fs.writeFileSync(settingsFile, JSON.stringify(buildClaudeSettings(project, agent), null, 2));
|
|
55
|
+
fs.writeFileSync(systemFile, input.systemPrompt);
|
|
56
|
+
const pol = writePolicy(project, agent);
|
|
57
|
+
const repo = repoDirFor(project, agent.name);
|
|
58
|
+
const addDirs = [...pol.allowDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === repo || d.startsWith(repo + path.sep)));
|
|
59
|
+
const args = [
|
|
60
|
+
"-p",
|
|
61
|
+
"--output-format",
|
|
62
|
+
"json",
|
|
63
|
+
"--permission-mode",
|
|
64
|
+
"dontAsk",
|
|
65
|
+
"--settings",
|
|
66
|
+
settingsFile,
|
|
67
|
+
"--append-system-prompt-file",
|
|
68
|
+
systemFile,
|
|
69
|
+
];
|
|
70
|
+
if (agent.model)
|
|
71
|
+
args.push("--model", agent.model);
|
|
72
|
+
if (agent.effort)
|
|
73
|
+
args.push("--effort", agent.effort);
|
|
74
|
+
if (input.sessionId)
|
|
75
|
+
args.push("--resume", input.sessionId);
|
|
76
|
+
for (const d of [...new Set(addDirs)])
|
|
77
|
+
args.push("--add-dir", d);
|
|
78
|
+
return {
|
|
79
|
+
cmd: "claude",
|
|
80
|
+
args,
|
|
81
|
+
stdin: input.userPrompt, // prompt via stdin: --add-dir is variadic and would swallow a positional prompt
|
|
82
|
+
cwd: repo,
|
|
83
|
+
env: { ...process.env, AGENT_LYCEUM_AGENT: agent.name },
|
|
84
|
+
parse({ stdout, stderr, code }) {
|
|
85
|
+
return interpretClaude(extractClaudeResult(stdout), stdout, stderr, code);
|
|
86
|
+
},
|
|
87
|
+
stream: () => claudeStream(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
function interpretClaude(j, stdout, stderr, code) {
|
|
91
|
+
if (!j)
|
|
92
|
+
return { ok: false, text: stdout, error: (stderr || stdout || `exit code ${code}`).slice(0, 500) };
|
|
93
|
+
const ok = code === 0 && !j.is_error;
|
|
94
|
+
return {
|
|
95
|
+
ok,
|
|
96
|
+
text: String(j.result ?? ""),
|
|
97
|
+
sessionId: j.session_id,
|
|
98
|
+
outputTokens: j.usage?.output_tokens,
|
|
99
|
+
error: ok ? undefined : String(j.result ?? stderr).slice(0, 500),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
/** Reads `--output-format json` output line by line: only JSON lines are kept, and only the last few, so a flood of other output costs nothing. */
|
|
103
|
+
function claudeStream() {
|
|
104
|
+
const candidates = [];
|
|
105
|
+
return {
|
|
106
|
+
line(text) {
|
|
107
|
+
if (text[0] !== "{" && text[0] !== "[")
|
|
108
|
+
return;
|
|
109
|
+
try {
|
|
110
|
+
candidates.push(JSON.parse(text));
|
|
111
|
+
if (candidates.length > 8)
|
|
112
|
+
candidates.shift();
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
/* not JSON: ignore the line */
|
|
116
|
+
}
|
|
117
|
+
},
|
|
118
|
+
finish({ code, stdoutTail, stderrTail }) {
|
|
119
|
+
for (const parsed of [...candidates].reverse()) {
|
|
120
|
+
const j = pickClaudeResult(parsed);
|
|
121
|
+
if (j)
|
|
122
|
+
return interpretClaude(j, stdoutTail, stderrTail, code);
|
|
123
|
+
}
|
|
124
|
+
return interpretClaude(undefined, stdoutTail, stderrTail, code);
|
|
125
|
+
},
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
/** `--output-format json` prints either one result object or (newer versions) an array of events. */
|
|
129
|
+
function pickClaudeResult(parsed) {
|
|
130
|
+
if (Array.isArray(parsed)) {
|
|
131
|
+
return [...parsed].reverse().find((e) => e && typeof e === "object" && e.type === "result");
|
|
132
|
+
}
|
|
133
|
+
if (parsed && typeof parsed === "object")
|
|
134
|
+
return parsed;
|
|
135
|
+
return undefined;
|
|
136
|
+
}
|
|
137
|
+
export function extractClaudeResult(stdout) {
|
|
138
|
+
let parsed;
|
|
139
|
+
try {
|
|
140
|
+
parsed = JSON.parse(stdout.trim());
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// maybe JSON lines: take the last parseable line
|
|
144
|
+
for (const line of stdout.trim().split("\n").reverse()) {
|
|
145
|
+
try {
|
|
146
|
+
parsed = JSON.parse(line);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
/* keep looking */
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return pickClaudeResult(parsed);
|
|
155
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Invocation, WakeInput } from "./types.js";
|
|
2
|
+
export declare function codexWritableRoots(input: WakeInput): string[];
|
|
3
|
+
/** Sum `output_tokens` over `turn.completed` events in `codex exec --json` output. */
|
|
4
|
+
export declare function codexOutputTokens(jsonl: string): number | undefined;
|
|
5
|
+
export declare function buildCodexInvocation(input: WakeInput): Invocation;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { repoDirFor, writePolicy } from "../policy.js";
|
|
4
|
+
export function codexWritableRoots(input) {
|
|
5
|
+
const pol = writePolicy(input.project, input.agent);
|
|
6
|
+
const work = repoDirFor(input.project, input.agent.name);
|
|
7
|
+
const roots = [...pol.allowDirs, ...pol.allowFiles.map((f) => path.dirname(f))].filter((d) => !(d === work || d.startsWith(work + path.sep)));
|
|
8
|
+
return [...new Set(roots)];
|
|
9
|
+
}
|
|
10
|
+
function sessionIdOf(line) {
|
|
11
|
+
if (!line.trim().startsWith("{"))
|
|
12
|
+
return undefined;
|
|
13
|
+
try {
|
|
14
|
+
const j = JSON.parse(line);
|
|
15
|
+
const id = j.thread_id ?? j.session_id ?? j.conversation_id ?? j.msg?.session_id ?? j.msg?.thread_id;
|
|
16
|
+
return typeof id === "string" ? id : undefined;
|
|
17
|
+
}
|
|
18
|
+
catch {
|
|
19
|
+
return undefined; // not json
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
function findSessionId(jsonl) {
|
|
23
|
+
for (const line of jsonl.split("\n")) {
|
|
24
|
+
const id = sessionIdOf(line);
|
|
25
|
+
if (id)
|
|
26
|
+
return id;
|
|
27
|
+
}
|
|
28
|
+
return undefined;
|
|
29
|
+
}
|
|
30
|
+
function tokensOf(line) {
|
|
31
|
+
if (!line.trim().startsWith("{"))
|
|
32
|
+
return undefined;
|
|
33
|
+
try {
|
|
34
|
+
const j = JSON.parse(line);
|
|
35
|
+
return j.type === "turn.completed" && typeof j.usage?.output_tokens === "number" ? j.usage.output_tokens : undefined;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return undefined; // not json
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Sum `output_tokens` over `turn.completed` events in `codex exec --json` output. */
|
|
42
|
+
export function codexOutputTokens(jsonl) {
|
|
43
|
+
let total;
|
|
44
|
+
for (const line of jsonl.split("\n")) {
|
|
45
|
+
const n = tokensOf(line);
|
|
46
|
+
if (n !== undefined)
|
|
47
|
+
total = (total ?? 0) + n;
|
|
48
|
+
}
|
|
49
|
+
return total;
|
|
50
|
+
}
|
|
51
|
+
export function buildCodexInvocation(input) {
|
|
52
|
+
const { project, agent, workDir } = input;
|
|
53
|
+
const repo = repoDirFor(project, agent.name);
|
|
54
|
+
fs.mkdirSync(workDir, { recursive: true });
|
|
55
|
+
const lastFile = path.join(workDir, "codex-last-message.txt");
|
|
56
|
+
const roots = codexWritableRoots(input);
|
|
57
|
+
const common = [
|
|
58
|
+
"-c",
|
|
59
|
+
'approval_policy="never"',
|
|
60
|
+
"-c",
|
|
61
|
+
`sandbox_workspace_write.writable_roots=${JSON.stringify(roots)}`,
|
|
62
|
+
"--json",
|
|
63
|
+
"-o",
|
|
64
|
+
lastFile,
|
|
65
|
+
];
|
|
66
|
+
if (agent.model)
|
|
67
|
+
common.push("-m", agent.model);
|
|
68
|
+
if (agent.effort)
|
|
69
|
+
common.push("-c", `model_reasoning_effort="${agent.effort}"`);
|
|
70
|
+
const args = input.sessionId
|
|
71
|
+
? ["exec", "resume", input.sessionId, "-c", 'sandbox_mode="workspace-write"', ...common, "-"]
|
|
72
|
+
: ["exec", "-C", repo, "-s", "workspace-write", "--skip-git-repo-check", ...common, "-"];
|
|
73
|
+
return {
|
|
74
|
+
cmd: "codex",
|
|
75
|
+
args,
|
|
76
|
+
// Codex has no flag for a custom instruction file, so the persona travels in the prompt.
|
|
77
|
+
stdin: `${input.systemPrompt}\n\n=====\n\n${input.userPrompt}`,
|
|
78
|
+
cwd: repo,
|
|
79
|
+
env: { ...process.env, AGENT_LYCEUM_AGENT: agent.name },
|
|
80
|
+
parse({ stdout, stderr, code }) {
|
|
81
|
+
return finishCodex(lastFile, findSessionId(stdout), codexOutputTokens(stdout), stdout, stderr, code);
|
|
82
|
+
},
|
|
83
|
+
stream() {
|
|
84
|
+
// one pass over the events as they arrive; only the session id and the token total are kept
|
|
85
|
+
let sessionId;
|
|
86
|
+
let tokens;
|
|
87
|
+
return {
|
|
88
|
+
line(l) {
|
|
89
|
+
sessionId ??= sessionIdOf(l);
|
|
90
|
+
const n = tokensOf(l);
|
|
91
|
+
if (n !== undefined)
|
|
92
|
+
tokens = (tokens ?? 0) + n;
|
|
93
|
+
},
|
|
94
|
+
finish: ({ code, stdoutTail, stderrTail }) => finishCodex(lastFile, sessionId, tokens, stdoutTail, stderrTail, code),
|
|
95
|
+
};
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
function finishCodex(lastFile, sessionId, outputTokens, stdout, stderr, code) {
|
|
100
|
+
let text = "";
|
|
101
|
+
try {
|
|
102
|
+
text = fs.readFileSync(lastFile, "utf8");
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
/* no last message */
|
|
106
|
+
}
|
|
107
|
+
const ok = code === 0;
|
|
108
|
+
return { ok, text, sessionId, outputTokens, error: ok ? undefined : (stderr || stdout || `exit code ${code}`).slice(-500) };
|
|
109
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { runInvocation } from "../process-runner.js";
|
|
2
|
+
import type { Invocation, Invoker, WakeInput } from "./types.js";
|
|
3
|
+
export declare function buildInvocation(input: WakeInput): Invocation;
|
|
4
|
+
export declare const realInvoker: Invoker;
|
|
5
|
+
export { runInvocation };
|
|
6
|
+
export type { Invoker, WakeInput, WakeResult } from "./types.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { runInvocation } from "../process-runner.js";
|
|
3
|
+
import { buildClaudeInvocation } from "./claude.js";
|
|
4
|
+
import { buildCodexInvocation } from "./codex.js";
|
|
5
|
+
export function buildInvocation(input) {
|
|
6
|
+
switch (input.agent.runtime) {
|
|
7
|
+
case "claude-code":
|
|
8
|
+
return buildClaudeInvocation(input);
|
|
9
|
+
case "codex":
|
|
10
|
+
return buildCodexInvocation(input);
|
|
11
|
+
default:
|
|
12
|
+
throw new Error(`Agent "${input.agent.name}" has no runtime`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export const realInvoker = (input) => runInvocation(buildInvocation(input), {
|
|
16
|
+
timeoutSec: input.timeoutSec,
|
|
17
|
+
signal: input.signal,
|
|
18
|
+
logDir: input.logDir ?? path.join(input.workDir, "logs"),
|
|
19
|
+
});
|
|
20
|
+
export { runInvocation };
|