pi-kage 0.3.7 → 0.4.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/README.md CHANGED
@@ -4,115 +4,86 @@
4
4
  [![npm](https://img.shields.io/npm/v/pi-kage)](https://www.npmjs.com/package/pi-kage)
5
5
  [![license](https://img.shields.io/npm/l/pi-kage)](./LICENSE)
6
6
 
7
- > **影分身の術**cast the **Shadow Clone Jutsu** on your git repo.
7
+ > 影分身の術 — Shadow Clone Jutsu for your git repo. · [中文](./README.zh-CN.md)
8
8
 
9
9
  <p align="center"><img src="./assets/demo.svg" alt="kage demo" width="100%"></p>
10
10
 
11
- `kage` runs multiple AI coding-agent sessions against one repo in parallel. The problem it solves:
12
- point two agents at the same checkout and they fight over one working tree — editing the same files,
13
- colliding on branches, tripping over each other's uncommitted changes.
14
-
15
- Instead of a shared [`git worktree`](https://git-scm.com/docs/git-worktree), kage gives each session
16
- its own **full copy** of the repo in a sibling folder — its own working tree, its own `.git`. Code
17
- comes back through git (a PR, or a branch fetch); the agent's session memory comes back through
18
- `~/.pi`. kage never copies a working tree back onto the origin, so concurrent sessions can't collide.
19
- And like a real Naruto shadow clone, the clone carries the agent's memory out and returns it on
20
- dispel (see [How it works](#how-it-works)).
11
+ Run several AI coding-agent sessions on one repo at the same time. Point two agents at the same
12
+ checkout and they fight over one working tree — same files, same branches, each other's uncommitted
13
+ changes. kage gives each session its **own full copy** of the repo in a sibling folder, so they
14
+ can't collide.
21
15
 
22
16
  ```bash
23
17
  npm install -g pi-kage
24
18
  cd my-app
25
- kage # 🥷 clone -> ../my-app--kage-<ts>, open a fresh pi (origin history resumable)
26
- # ...work in the clone: commit, push, open a PR, quit pi...
27
- kage finish # 💨 merge the clone's new sessions back, delete the clone
19
+ kage # 🥷 copy ../my-app--kage-<ts>, open a fresh pi
20
+ # ...commit, push, open a PR, quit pi...
21
+ kage finish # 💨 merge the clone's sessions back, delete the clone
28
22
  ```
29
23
 
30
- ---
31
-
32
- ## Why a full copy instead of `git worktree`?
24
+ Code comes back through git (a PR, or a branch fetch). The agent's session memory comes back through
25
+ its own session store (`~/.pi`, `~/.claude`, or `~/.codex`). kage never copies a working tree back onto
26
+ the origin that's the whole point.
33
27
 
34
- A worktree is the obvious way to get a second working directory, but every worktree shares the
35
- repo's single `.git`. For parallel agents that shared `.git` is the problem:
28
+ ## Why a full copy, not `git worktree`?
36
29
 
37
- - you can't check out the same branch in two worktrees;
38
- - stash, refs, config, and the index are shared;
39
- - a worktree is a clean checkout no `node_modules`, `.env`, `.venv`, or build cache so each one
40
- needs a full setup pass before the agent can build or run anything.
30
+ A [`git worktree`](https://git-scm.com/docs/git-worktree) gives you a second working directory, but
31
+ every worktree shares one `.git` — so two agents can't check out the same branch, and stash/refs/index
32
+ are shared. A worktree is also a clean checkout: no `node_modules`, `.env`, build cache, so each one
33
+ needs a setup pass first.
41
34
 
42
- A full copy has none of those constraints: independent `.git`, independent branches, and every
43
- untracked or gitignored file is already in place. The price is disk and copy time — which on macOS
44
- APFS kage avoids with a `cp -c` clonefile (copy-on-write): the clone is near-instant and uses no
45
- extra space until files actually diverge. Non-reflink filesystems fall back to a full recursive copy.
35
+ A full copy has independent `.git`, independent branches, and every gitignored/untracked file already
36
+ in place. The cost is disk and copy time — which on APFS (macOS) and reflink filesystems (Linux) kage
37
+ sidesteps with a copy-on-write clone: near-instant, no extra space until files actually change. Other
38
+ filesystems fall back to a plain recursive copy.
46
39
 
47
40
  ## Install
48
41
 
49
42
  ```bash
50
- # npm
51
- npm install -g pi-kage # then use `kage` anywhere
52
- npx pi-kage # or run without installing
53
-
54
- # pnpm
55
- pnpm add -g pi-kage
56
- pnpm dlx pi-kage # or run without installing
43
+ npm install -g pi-kage # or: pnpm add -g pi-kage
44
+ npx pi-kage # run without installing
57
45
 
58
- # or install script (no npm needed — kage is a single, zero-dependency Node script)
46
+ # install script (single zero-dependency Node script → ~/.local/bin)
59
47
  curl -fsSL https://raw.githubusercontent.com/kid7st/kage/main/install.sh | sh
60
48
  ```
61
49
 
62
- The install script drops the single `kage` file into `~/.local/bin` (override with `KAGE_BIN_DIR`,
63
- pin a version with `KAGE_VERSION`). kage has **no dependencies** — it only needs Node, git, and pi.
50
+ Requires **git**, [**pi**](https://github.com/earendil-works), and **Node 18** on your `PATH`.
51
+ kage has no runtime dependencies.
64
52
 
65
53
  From source:
66
54
 
67
55
  ```bash
68
56
  git clone https://github.com/kid7st/kage
69
- cd kage && npm install && npm link # `npm install` builds bin/kage.mjs from src/ (TypeScript)
57
+ cd kage && npm install && npm link # npm install builds bin/kage.mjs from src/
70
58
  ```
71
59
 
72
- Requires **git**, [**pi**](https://github.com/earendil-works), and **Node ≥ 18** on your `PATH`.
60
+ ## Commands
73
61
 
74
- ## Lifecycle
62
+ | Command | Run from | What it does |
63
+ |---|---|---|
64
+ | `kage [path] [--name x] [--agent pi\|claude\|codex]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), copy in the origin's 5 most recent sessions for that agent (resumable, never replayed), and launch a **fresh** agent (pi by default). `--name` only names the folder — kage never creates a branch. No args + existing clones → interactive menu. |
65
+ | `kage status [--pr]` | origin repo | Dashboard: branch, dirty/clean, ahead/behind, "safe to clean". `--pr` adds PR state via `gh`. |
66
+ | `kage finish [name] [--force] [--push] [--pr]` | origin / inside clone | Refuse if the clone has uncommitted or unpushed work, merge its **new** sessions back, delete it. `--push` pushes the branch first; `--pr` pushes + opens a PR via `gh`; `--force` skips the guard. |
67
+ | `kage rm [name] [--force]` | origin / inside clone | Discard a clone **without** merging memory. Refuses local-only work unless `--force`. |
68
+ | `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored, e.g. a generated `.env`) back to the origin. |
69
+ | `kage shell-init` | shell rc | Shell wrapper (cd back to origin after `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
70
+ | `kage --help` / `--version` | anywhere | Usage / version. |
75
71
 
76
- ```
77
- origin repo (you) shadow clone (independent copy)
78
- ───────────────── ──────────────────────────────
79
- $ kage --name fix-login ─copy + history─► ../my-app--fix-login
80
- $ pi (fresh session; origin history resumable)
81
- · git switch -c fix-login
82
- · edit / commit / push / open PR
83
- · quit pi
84
- $ kage finish fix-login ◄─new sessions── (the clone's .jsonl, copied back)
85
- · safety check (committed? pushed?)
86
- · merge the clone's new sessions into ~/.pi
87
- · delete the clone folder
88
- code arrives via the merged GitHub PR ✓
89
- ```
72
+ Run bare `kage` inside a repo that already has clones to get an interactive picker: create a new clone,
73
+ or **enter** / **finish** / **remove** an existing one. `finish` and `rm` show the same picker when you
74
+ have several clones and don't name one.
90
75
 
91
- ## Usage
76
+ ### Other agents (Claude Code, Codex)
92
77
 
93
- ```bash
94
- cd ~/code/my-app
95
-
96
- kage # clone . ../my-app--kage-<ts>, open a fresh pi (origin history resumable)
97
- kage --name fix-login # name the clone folder: ../my-app--fix-login
98
- kage /path/to/other-repo # clone a different repo (path defaults to cwd)
99
-
100
- # back in the origin after you quit the clone's pi:
101
- kage # no args inside a repo with clones -> interactive menu
102
- kage status # status dashboard: branch · dirty · ahead/behind · safe-to-clean
103
- kage status --pr # also show PR state (via gh)
104
- kage finish fix-login # check → merge the clone's new sessions back → delete the clone
105
- kage finish fix-login --pr # push the branch + open a PR (via gh), then finish
106
- kage finish --force # skip the uncommitted/unpushed guard
107
- kage rm old-experiment # discard a clone without merging (refuses if it has local-only work)
108
-
109
- # inside a clone, to retrieve a non-git file (e.g. a generated .env):
110
- kage pull .env config/local.json
111
- ```
112
-
113
- With no arguments inside a repo that already has clones, `kage` shows an interactive picker: create a
114
- new clone, or select an existing one to **enter** (`pi -c`), **finish**, or **remove**. `finish` and `rm`
115
- show the same picker when you have multiple clones and don't name one.
78
+ kage isn't pi-only. `--agent claude` or `--agent codex` launches that agent instead. For **pi and Claude
79
+ Code**, memory flows the same way — through that agent's own session store (whether you drive it from the
80
+ CLI, the IDE extension, or the desktop app), and two agents can even work one repo in parallel (a clone
81
+ each, or different agents in one clone); `finish` merges back whichever stores have new sessions.
82
+ **Codex is launch-only**: its sessions live in one global, sqlite-indexed store kage can't cleanly
83
+ re-home, so `--agent codex` gives you the isolated clone + git flow-back while your Codex history stays
84
+ globally available via `codex resume --all`. For a GUI/desktop app kage can't spawn, use `--open <cmd>`
85
+ (e.g. `kage --open code`) or `--no-launch` to just make the clone and open it yourself. `KAGE_AGENT` sets
86
+ your default agent.
116
87
 
117
88
  ### Shell integration (optional)
118
89
 
@@ -120,84 +91,54 @@ show the same picker when you have multiple clones and don't name one.
120
91
  eval "$(kage shell-init)" # add to ~/.zshrc or ~/.bashrc
121
92
  ```
122
93
 
123
- This wraps `kage` so that `finish`/`rm` run from inside a clone **cd you back to the origin**
124
- automatically (a CLI can't change its parent shell's directory otherwise), and adds tab completion
125
- for subcommands and clone names.
126
-
127
- ### Commands
128
-
129
- | Command | Run from | What it does |
130
- |---|---|---|
131
- | `kage [path] [--name x]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), copy the origin's 5 most recent pi sessions into the clone (resumable there, never replayed), and launch a **fresh** `pi` session. `--name` only names the folder — kage never creates a branch. With no args (and existing clones) it opens an interactive picker. |
132
- | `kage status [--pr]` | origin repo | Status dashboard of clones: branch, dirty/clean, ahead/behind upstream, and a “safe to clean” flag. `--pr` adds PR state via `gh`. (`kage list` is a kept alias.) |
133
- | `kage finish [name] [--force] [--push] [--pr]` | origin, inside the clone, or anywhere with a clone path | Refuse if the clone has uncommitted or unpushed work (`--force` overrides), merge the clone's **new** sessions back (copied-in origin history is skipped), then delete the clone. `--push` pushes the branch first; `--pr` pushes and opens a PR via `gh`. Auto-selects / prompts when there are several. |
134
- | `kage rm [name] [--force]` | origin, inside the clone, or anywhere with a clone path | Discard a clone **without** merging memory. Refuses if it has local-only work unless `--force`. For abandoned experiments. `name` may be a clone name (run from the repo) or a path to the clone folder (works from anywhere). |
135
- | `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored ones) back to the origin at the same relative path. |
136
- | `kage shell-init` | shell rc | Print a shell wrapper (cd-back after `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
137
- | `kage --help` / `--version` | anywhere | Usage / version. |
94
+ Running `finish`/`rm` from inside a clone deletes the directory your shell is sitting in. The wrapper
95
+ cd's you back to the origin automatically (a CLI can't change its parent shell otherwise) and adds tab
96
+ completion for subcommands and clone names.
138
97
 
139
98
  ## How it works
140
99
 
141
- Four invariants keep parallel work safe and lossless:
142
-
143
- 1. **Isolation** a clone is a full independent copy with its own `.git`. kage **doesn't create a
144
- branch**: the clone stays on the origin's current branch and kage stays out of git flow entirely,
145
- so you decide your own branching/PR workflow inside the clone (instruct the agent via your
146
- `AGENTS.md`).
147
- 2. **Code flows back via git, never the working tree.** With a remote you push the branch and merge a
148
- PR. With **no remote**, `finish` fetches the clone's branch into the origin's git as a local
149
- `kage/<name>-<sha>` branch (origin's working tree untouched`git merge` it when you like; the short
150
- sha keeps the ref unique so reusing a name never collides). kage never copies the clone's working
151
- tree onto the origin that would re-create the collisions it avoids so `finish` refuses to delete
152
- **uncommitted** work, which a fetch can't preserve.
153
- 3. **Memory flows through `~/.pi`, never replayed.** On create, the origin's 5 most recent session
154
- `.jsonl` files are copied into the clone `pi`'s resume picker surfaces them, but the clone opens a
155
- **fresh** session (kage never replays turns or fakes a resumed conversation). On `finish`, sessions
156
- the clone created are copied back whole; an unchanged copied-in session adds nothing; and a
157
- copied-in session you *resumed and added to* comes back as a **new, self-contained** session — so
158
- the origin's original session (the leaf pi would resume) is never mutated and your turns aren't lost.
159
- 4. **The origin is read-only to kage** it only copies out and writes session memory; it never
160
- touches the origin's working tree, even while another session is live there.
161
-
162
- With a remote configured, `finish` nudges you to push first (so PR-flow mistakes surface) unless you
163
- pass `--push` / `--pr` / `--force`.
164
-
165
- ## Notes & caveats
166
-
167
- - The copy is a snapshot of the origin's **current** state, **including uncommitted changes**.
168
- - **Submodules**: a submodule's `.git` pointer is an absolute path and breaks on copy — run
100
+ - **Isolation.** A clone is a full independent copy with its own `.git`. kage does **not** create a
101
+ branch — the clone stays on the origin's current branch and stays out of git flow, so you own the
102
+ branching/PR workflow inside the clone (tell the agent via your `AGENTS.md`).
103
+ - **Code flows back via git, never the working tree.** With a remote: push the branch, merge the PR.
104
+ Without a remote: `finish` fetches the clone's branch into the origin's git as a local
105
+ `kage/<name>-<sha>` branch (origin working tree untouched — `git merge` it when you like). Because a
106
+ fetch can't preserve uncommitted work, `finish` refuses to delete a dirty clone unless `--force`.
107
+ - **Memory flows via the agent's own session store, never replayed.** On create, the origin's 5 most
108
+ recent sessions for that agent are copied in the agent's resume picker surfaces them, but the clone
109
+ opens a **fresh** session. On `finish`, sessions the clone created come back whole; a copied-in session
110
+ you resumed comes back as a separate new session, so the origin's original is never mutated. (Codex is the
111
+ exception — its sessions live in one global, sqlite-indexed store kage can't cleanly re-home, so kage
112
+ doesn't manage Codex memory; find Codex history with `codex resume --all`. See `docs/multi-agent-design.md`.)
113
+ - **The origin is read-only to kage.** It only copies out and writes session memory it never touches
114
+ the origin's working tree, even while another session is live there.
115
+
116
+ ## Notes
117
+
118
+ - The copy snapshots the origin's **current** state, including uncommitted changes.
119
+ - **Submodules**: a submodule's `.git` is an absolute path and breaks on copy — run
169
120
  `git submodule update --init` in the clone.
170
- - Non-APFS / non-reflink filesystems fall back to a full (heavier) copy.
171
- - Session storage is assumed at `~/.pi/agent/sessions`; override with `KAGE_SESSIONS_DIR`.
121
+ - kage reads each agent's sessions from where that agent itself stores them, honoring the agent's own
122
+ config var `PI_CODING_AGENT_DIR` (pi), `CLAUDE_CONFIG_DIR` (Claude Code), `CODEX_HOME` (Codex) — so
123
+ kage and the agent always agree on the location.
172
124
 
173
125
  ## Development
174
126
 
175
- The CLI and its tests are written in TypeScript and compiled with `tsc`:
127
+ The CLI is TypeScript compiled to a single zero-dependency file:
176
128
 
177
- - `src/kage.mts` → `bin/kage.mjs` — a single, zero-runtime-dependency file (the only thing the
178
- install script + npm package ship).
179
- - `test/kage.test.mts` → `dist/kage.test.mjs` — black-box `node:test` smoke tests that spawn the
180
- built CLI (compiled to `dist/` so they run on every Node in CI, no TS loader needed).
181
-
182
- `bin/` and `dist/` are build artifacts — both gitignored. `bin/` is produced on `npm install`
183
- (via `prepare`), so `npm link` from a clone just works.
184
-
185
- Linting/formatting is handled by [Biome](https://biomejs.dev) — a dev-only dependency that never
186
- ships in the package, so the zero-runtime-dependency artifact is unaffected.
187
-
188
- ```bash
189
- npm run build # tsc: src/kage.mts -> bin/kage.mjs
190
- npm run format # biome: auto-format + safe lint fixes
191
- npm run lint # biome (lint + format check) + tsc type check (src + test)
192
- npm test # build CLI + tests, then run node:test smoke tests (temp repos, no network)
193
- ```
129
+ - `src/kage.mts` → `bin/kage.mjs` (the only thing shipped)
130
+ - `test/kage.test.mts` `dist/` black-box `node:test` smoke tests that spawn the built CLI
194
131
 
195
- Releases publish automatically: bump `version` in `package.json`, then
132
+ `bin/` and `dist/` are gitignored build artifacts; `bin/` is produced on `npm install` (via `prepare`),
133
+ so `npm link` from a clone just works. Linting/formatting is [Biome](https://biomejs.dev) (dev-only).
196
134
 
197
135
  ```bash
198
- git tag vX.Y.Z && git push origin main vX.Y.Z
136
+ npm run build # tsc: src/kage.mts bin/kage.mjs
137
+ npm run lint # biome + tsc type check
138
+ npm test # build + node:test smoke tests (temp repos, no network)
199
139
  ```
200
140
 
141
+ Releases: bump `version` in `package.json`, then `git tag vX.Y.Z && git push origin main vX.Y.Z`.
201
142
  CI runs lint + tests and `npm publish --provenance` on any `v*` tag.
202
143
 
203
144
  ## License
@@ -0,0 +1,135 @@
1
+ # kage 🥷
2
+
3
+ [![CI](https://github.com/kid7st/kage/actions/workflows/ci.yml/badge.svg)](https://github.com/kid7st/kage/actions/workflows/ci.yml)
4
+ [![npm](https://img.shields.io/npm/v/pi-kage)](https://www.npmjs.com/package/pi-kage)
5
+ [![license](https://img.shields.io/npm/l/pi-kage)](./LICENSE)
6
+
7
+ > 影分身の術 —— 给你的 git 仓库来一发影分身。· [English](./README.md)
8
+
9
+ <p align="center"><img src="./assets/demo.svg" alt="kage demo" width="100%"></p>
10
+
11
+ 在同一个仓库上同时跑多个 AI coding agent。让两个 agent 指向同一个 checkout,它们就会抢同一个工作区
12
+ ——改同样的文件、撞同样的分支、踩到彼此未提交的改动。kage 给每个 session 一份**独立的完整副本**,放在
13
+ 同级目录里,从根本上避免冲突。
14
+
15
+ ```bash
16
+ npm install -g pi-kage
17
+ cd my-app
18
+ kage # 🥷 复制 → ../my-app--kage-<ts>,打开一个全新的 pi
19
+ # ...提交、push、开 PR、退出 pi...
20
+ kage finish # 💨 把分身的 session 合并回来,删掉分身
21
+ ```
22
+
23
+ 代码通过 git 回流(一个 PR,或者 fetch 分支);agent 的 session 记忆通过它自己的 session 存储(`~/.pi`、
24
+ `~/.claude` 或 `~/.codex`)回流。kage 从不把工作区复制回原仓库 —— 这正是它存在的意义。
25
+
26
+ ## 为什么用完整副本,而不是 `git worktree`?
27
+
28
+ [`git worktree`](https://git-scm.com/docs/git-worktree) 能给你第二个工作目录,但所有 worktree 共享同一个
29
+ `.git`:两个 agent 没法 checkout 同一个分支,stash / refs / index 也都是共享的。而且 worktree 是干净的
30
+ checkout —— 没有 `node_modules`、`.env`、build 缓存,每个都得先跑一遍环境初始化。
31
+
32
+ 完整副本则有独立的 `.git`、独立的分支,所有被 gitignore 或未跟踪的文件也都已就位。代价是磁盘和复制时间
33
+ —— 而在 APFS(macOS)和支持 reflink 的文件系统(Linux)上,kage 用写时复制(copy-on-write)绕开了这个代价:
34
+ 几乎瞬间完成,文件真正发生改动前不占额外空间。其它文件系统则退化为普通的递归复制。
35
+
36
+ ## 安装
37
+
38
+ ```bash
39
+ npm install -g pi-kage # 或:pnpm add -g pi-kage
40
+ npx pi-kage # 不安装直接运行
41
+
42
+ # 安装脚本(单个零依赖的 Node 脚本 → ~/.local/bin)
43
+ curl -fsSL https://raw.githubusercontent.com/kid7st/kage/main/install.sh | sh
44
+ ```
45
+
46
+ 需要 `PATH` 上有 **git**、[**pi**](https://github.com/earendil-works) 和 **Node ≥ 18**。
47
+ kage 没有任何运行时依赖。
48
+
49
+ 从源码安装:
50
+
51
+ ```bash
52
+ git clone https://github.com/kid7st/kage
53
+ cd kage && npm install && npm link # npm install 会从 src/ 编译出 bin/kage.mjs
54
+ ```
55
+
56
+ ## 命令
57
+
58
+ | 命令 | 在哪运行 | 作用 |
59
+ |---|---|---|
60
+ | `kage [path] [--name x] [--agent pi\|claude\|codex]` | 原仓库 | 把仓库复制到 `../<repo>--<name>`(默认 `kage-<ts>`),拷入原仓库最近 5 个该 agent 的 session(可 resume,绝不重放),并启动一个**全新**的 agent(默认 pi)。`--name` 只命名文件夹 —— kage 从不建分支。无参数 + 已有分身 → 进入交互菜单。 |
61
+ | `kage status [--pr]` | 原仓库 | 仪表盘:分支、是否有改动、ahead/behind、是否「可安全清理」。`--pr` 通过 `gh` 附带 PR 状态。 |
62
+ | `kage finish [name] [--force] [--push] [--pr]` | 原仓库 / 分身内 | 若分身有未提交或未 push 的改动则拒绝,把它**新产生**的 session 合并回来,再删除它。`--push` 先 push 分支;`--pr` push 并通过 `gh` 开 PR;`--force` 跳过检查。 |
63
+ | `kage rm [name] [--force]` | 原仓库 / 分身内 | **不**合并记忆地丢弃一个分身。若有仅存在于本地的工作则拒绝,除非加 `--force`。 |
64
+ | `kage pull <path...>` | 分身内 | 把指定文件/目录(包括被 gitignore 的,比如生成的 `.env`)按相同相对路径拷回原仓库。 |
65
+ | `kage shell-init` | shell 配置 | shell 包装函数(`finish`/`rm` 后自动 cd 回原仓库)+ tab 补全。用 `eval "$(kage shell-init)"`。 |
66
+ | `kage --help` / `--version` | 任意位置 | 用法 / 版本。 |
67
+
68
+ 在已有分身的仓库里直接运行 `kage`,会弹出交互选择器:新建一个分身,或对已有分身执行 **进入** / **finish** /
69
+ **删除**。当有多个分身又没指定名字时,`finish` 和 `rm` 也会弹出同样的选择器。
70
+
71
+ ### 其它 agent(Claude Code、Codex)
72
+
73
+ kage 不只支持 pi。`--agent claude` 或 `--agent codex` 会改为启动对应 agent。对 **pi 和 Claude Code**,记忆照样
74
+ 流动 —— 通过该 agent **自己的** session 存储(无论用 CLI、IDE 扩展还是桌面 App),两个 agent 甚至能并行
75
+ 处理同一个仓库(各开一个分身,或同一个分身里换着用);`finish` 会把有新 session 的那些 store 各自合并回来。
76
+ **Codex 是 launch-only**:它的 session 存在一个由 sqlite 索引的全局 store 里,kage 无法干净地搬迁,所以
77
+ `--agent codex` 给你隔离分身 + git 回流,而 Codex 历史通过 `codex resume --all` 依然全局可见。对于 kage 没法
78
+ 直接拉起的 GUI / 桌面 App,用 `--open <cmd>`(比如 `kage --open code`)或 `--no-launch` 只建分身、你自己去打开。
79
+ `KAGE_AGENT` 设置默认 agent。
80
+
81
+ ### Shell 集成(可选)
82
+
83
+ ```bash
84
+ eval "$(kage shell-init)" # 加到 ~/.zshrc 或 ~/.bashrc
85
+ ```
86
+
87
+ 在分身内运行 `finish`/`rm` 会删掉你 shell 当前所在的目录。这个包装函数会自动把你 cd 回原仓库(否则 CLI
88
+ 没法改变父 shell 的目录),并为子命令和分身名加上 tab 补全。
89
+
90
+ ## 工作原理
91
+
92
+ - **隔离。** 一个分身是带独立 `.git` 的完整副本。kage **不**建分支 —— 分身停在原仓库当前分支上,且完全不
93
+ 介入 git 流程,所以分身内的分支 / PR 工作流由你自己决定(通过 `AGENTS.md` 告诉 agent)。
94
+ - **代码只经由 git 回流,绝不经过工作区。** 有 remote 时:push 分支、合并 PR。没有 remote 时:`finish` 会把
95
+ 分身的分支 fetch 进原仓库的 git,存成本地分支 `kage/<name>-<sha>`(原仓库工作区不动 —— 你想合并时再
96
+ `git merge`)。由于 fetch 无法保留未提交的改动,`finish` 拒绝删除有改动的分身,除非加 `--force`。
97
+ - **记忆经由该 agent 自己的 session 存储回流,绝不重放。** 创建时拷入原仓库最近 5 个该 agent 的 session —— 该
98
+ agent 的 resume 选择器能看到它们,但分身本身打开的是**全新** session。`finish` 时,分身自己产生的 session 整份
99
+ 拷回;你 resume 过的拷入 session 会作为一个独立的新 session 回来,原仓库的原始 session 绝不被改动。(Codex 是
100
+ 例外 —— 它的 session 在一个 sqlite 索引的全局 store 里,kage 无法干净搬迁,所以不管理 Codex 记忆;Codex
101
+ 历史用 `codex resume --all` 查。详见 `docs/multi-agent-design.md`。)
102
+ - **对 kage 而言原仓库是只读的。** 它只往外复制、只写 session 记忆 —— 即使原仓库里另有一个 session 正活跃,
103
+ 它也绝不碰原仓库的工作区。
104
+
105
+ ## 注意事项
106
+
107
+ - 副本是原仓库**当前**状态的快照,包含未提交的改动。
108
+ - **Submodule**:submodule 的 `.git` 是绝对路径,复制后会失效 —— 在分身里跑一次
109
+ `git submodule update --init`。
110
+ - kage 从每个 agent **自己**存 session 的地方去读,尊重该 agent 自己的配置变量 ——
111
+ `PI_CODING_AGENT_DIR`(pi)、`CLAUDE_CONFIG_DIR`(Claude Code)、`CODEX_HOME`(Codex)—— 这样
112
+ kage 和 agent 永远指向同一个位置。
113
+
114
+ ## 开发
115
+
116
+ CLI 用 TypeScript 写,编译成单个零依赖文件:
117
+
118
+ - `src/kage.mts` → `bin/kage.mjs`(唯一发布的文件)
119
+ - `test/kage.test.mts` → `dist/` —— 启动编译后 CLI 的黑盒 `node:test` 冒烟测试
120
+
121
+ `bin/` 和 `dist/` 是被 gitignore 的构建产物;`bin/` 在 `npm install` 时(通过 `prepare`)生成,所以从克隆出来
122
+ 的仓库直接 `npm link` 即可。代码检查 / 格式化用 [Biome](https://biomejs.dev)(仅开发依赖)。
123
+
124
+ ```bash
125
+ npm run build # tsc: src/kage.mts → bin/kage.mjs
126
+ npm run lint # biome + tsc 类型检查
127
+ npm test # 构建 + node:test 冒烟测试(临时仓库,不联网)
128
+ ```
129
+
130
+ 发布:在 `package.json` 里更新 `version`,然后 `git tag vX.Y.Z && git push origin main vX.Y.Z`。
131
+ 任何 `v*` tag 都会触发 CI 跑 lint + 测试并执行 `npm publish --provenance`。
132
+
133
+ ## License
134
+
135
+ [MIT](./LICENSE)
package/bin/kage.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  * 4. The origin is read-only to kage — it only copies out and writes session memory.
17
17
  *
18
18
  * Commands:
19
- * kage [path] [--name x] clone repo + launch a fresh pi (no args: interactive)
19
+ * kage [path] [--name x] [--agent id] clone repo + launch an agent (no args: interactive)
20
20
  * kage status [--pr] dashboard of clones (+ PR status via gh)
21
21
  * kage finish [name] [--force] check -> merge memory back -> delete clone
22
22
  * kage rm [name] [--force] discard a clone (no merge)
@@ -24,13 +24,12 @@
24
24
  */
25
25
  import { spawn, spawnSync } from "node:child_process";
26
26
  import { randomUUID } from "node:crypto";
27
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
27
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
28
28
  import { homedir } from "node:os";
29
29
  import { basename, dirname, join, resolve, sep } from "node:path";
30
30
  import readline from "node:readline";
31
- const VERSION = "0.3.7"; // keep in sync with package.json (enforced by test)
31
+ const VERSION = "0.4.0"; // keep in sync with package.json (enforced by test)
32
32
  const MARKER = ".kage.json";
33
- const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
34
33
  const RECENT_SESSIONS = 5; // how many of the origin's most-recent sessions to copy into a clone
35
34
  /** Typed accessors for the loose flags bag, so consumers don't re-derive the shape inline. */
36
35
  const boolFlag = (flags, name) => Boolean(flags[name]);
@@ -64,16 +63,16 @@ function sh(cmd, args, opts = {}) {
64
63
  return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
65
64
  }
66
65
  const git = (cwd, args) => sh("git", args, { cwd });
67
- /** Absolute path -> pi's session dir name: /a/b -> --a-b-- */
68
- const encodeCwd = (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`;
69
- const sessionDirFor = (repoAbs) => join(SESSIONS, encodeCwd(repoAbs));
70
66
  function repoTopLevel(cwd) {
71
67
  const r = git(cwd, ["rev-parse", "--show-toplevel"]);
72
68
  return r.ok ? r.out : undefined;
73
69
  }
74
- /** Validate parsed JSON is a kage marker (only originRepo is required; name/createdAt are best-effort). */
70
+ /** Validate parsed JSON has the shape kage writes (all three fields are strings). */
75
71
  function isMarker(v) {
76
- return typeof v === "object" && v !== null && typeof v.originRepo === "string";
72
+ if (typeof v !== "object" || v === null)
73
+ return false;
74
+ const m = v;
75
+ return typeof m.originRepo === "string" && typeof m.name === "string" && typeof m.createdAt === "string";
77
76
  }
78
77
  function readMarker(dir) {
79
78
  const p = join(dir, MARKER);
@@ -185,7 +184,7 @@ function listClones(originRepo) {
185
184
  const dir = join(parent, name);
186
185
  const m = readMarker(dir);
187
186
  if (m && m.originRepo === originRepo)
188
- out.push({ dir, name: m.name || basename(dir), marker: m });
187
+ out.push({ dir, name: m.name, marker: m });
189
188
  }
190
189
  return out;
191
190
  }
@@ -322,14 +321,14 @@ async function pickClone(action, name) {
322
321
  const here = repoTopLevel(process.cwd());
323
322
  const hm = here ? readMarker(here) : undefined;
324
323
  if (here && hm && !name)
325
- return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name || basename(here), marker: hm } };
324
+ return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name, marker: hm } };
326
325
  // If `name` resolves to a clone directory, use its marker directly — works from anywhere,
327
326
  // even outside a repo (e.g. `kage rm ../app--fix` from the parent dir).
328
327
  if (name) {
329
328
  const asPath = resolve(name);
330
329
  const pm = readMarker(asPath);
331
330
  if (pm)
332
- return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name || basename(asPath), marker: pm } };
331
+ return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name, marker: pm } };
333
332
  }
334
333
  const originRepo = hm ? hm.originRepo : here;
335
334
  if (!originRepo)
@@ -352,123 +351,254 @@ async function pickClone(action, name) {
352
351
  return null;
353
352
  return { originRepo, clone: chosen };
354
353
  }
355
- // ── copy the origin's session history into the clone ─────────────────────────
356
- /**
357
- * Copies the origin's most recent session files (up to RECENT_SESSIONS, by mtime) into the
358
- * clone's session dir, so `pi` resume inside the clone surfaces them (you decide whether to
359
- * resume any of it). The clone itself opens a fresh session — kage never replays turns or
360
- * fabricates a "resumed" conversation. On merge-back an unchanged copy adds nothing; if you
361
- * resumed one and added turns, it comes back as a separate session (see mergeBack).
362
- */
363
- function copyOriginHistory(originRepo, cloneDir) {
364
- const srcDir = sessionDirFor(originRepo);
365
- if (!existsSync(srcDir))
366
- return 0;
367
- const destDir = sessionDirFor(cloneDir);
368
- mkdirSync(destDir, { recursive: true });
369
- const recent = readdirSync(srcDir)
370
- .filter((f) => f.endsWith(".jsonl"))
371
- .map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
372
- .sort((a, b) => b.m - a.m)
373
- .slice(0, RECENT_SESSIONS);
374
- let n = 0;
375
- for (const { f } of recent) {
376
- const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
377
- try {
378
- const header = JSON.parse(lines[0] ?? "");
379
- header.cwd = cloneDir;
380
- lines[0] = JSON.stringify(header);
381
- }
382
- catch {
383
- /* leave malformed header as-is */
384
- }
385
- writeFileSync(join(destDir, f), lines.join("\n"));
386
- n++;
387
- }
388
- return n;
389
- }
390
- // ── merge the clone's new sessions back into the origin ──────────────────────
391
354
  /**
392
- * Copies the clone's sessions into the origin's session dir:
393
- * - a session the clone created (filename not in the origin) -> copied back whole.
394
- * - a copied-in origin session left unchanged -> skipped (nothing new).
395
- * - a copied-in origin session you resumed and added turns to -> written back as a NEW,
396
- * self-contained session file, so the origin's original session (and the active leaf pi
397
- * resumes) is never mutated. Costs a duplicated prefix; avoids hijacking the origin's leaf.
355
+ * The cwd→directory algorithm, shared by pi and (later) Claude Code:
356
+ * - importHistory: copy the origin dir's most-recent sessions into the clone dir (cwd rewritten).
357
+ * - mergeBack: a session the clone created comes back whole; a copied-in origin session left
358
+ * unchanged is skipped; a copied-in session the clone resumed and extended comes back as a
359
+ * NEW self-contained file, so the origin's original (and the leaf it resumes) is untouched.
398
360
  */
399
- function mergeBack(cloneDir, originRepo) {
400
- const srcDir = sessionDirFor(cloneDir);
401
- if (!existsSync(srcDir))
402
- return 0;
403
- const destDir = sessionDirFor(originRepo);
404
- mkdirSync(destDir, { recursive: true });
405
- let n = 0;
406
- for (const f of readdirSync(srcDir)) {
407
- if (!f.endsWith(".jsonl"))
408
- continue;
409
- const src = readFileSync(join(srcDir, f), "utf8")
410
- .split("\n")
411
- .filter((l) => l.trim());
412
- if (src.length === 0)
413
- continue;
414
- const dest = join(destDir, f);
415
- if (!existsSync(dest)) {
416
- let header;
361
+ function dirStore(cfg) {
362
+ const dirFor = (cwd) => join(cfg.baseDir(), cfg.encodeCwd(cwd));
363
+ return {
364
+ id: cfg.id,
365
+ importHistory(originRepo, cloneDir) {
366
+ const srcDir = dirFor(originRepo);
367
+ if (!existsSync(srcDir))
368
+ return 0;
369
+ const destDir = dirFor(cloneDir);
370
+ mkdirSync(destDir, { recursive: true });
371
+ const recent = readdirSync(srcDir)
372
+ .filter((f) => f.endsWith(".jsonl"))
373
+ .map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
374
+ .sort((a, b) => b.m - a.m)
375
+ .slice(0, RECENT_SESSIONS);
376
+ let n = 0;
377
+ for (const { f } of recent) {
378
+ const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
379
+ writeFileSync(join(destDir, f), cfg.setCwd(lines, cloneDir).join("\n"));
380
+ n++;
381
+ }
382
+ return n;
383
+ },
384
+ mergeBack(cloneDir, originRepo) {
385
+ const srcDir = dirFor(cloneDir);
386
+ if (!existsSync(srcDir))
387
+ return 0;
388
+ const destDir = dirFor(originRepo);
389
+ mkdirSync(destDir, { recursive: true });
390
+ let n = 0;
391
+ for (const f of readdirSync(srcDir)) {
392
+ if (!f.endsWith(".jsonl"))
393
+ continue;
394
+ const src = readFileSync(join(srcDir, f), "utf8")
395
+ .split("\n")
396
+ .filter((l) => l.trim());
397
+ if (src.length === 0)
398
+ continue;
399
+ const dest = join(destDir, f);
400
+ // A session the clone created (not present in the origin) -> copy it back whole.
401
+ if (!existsSync(dest)) {
402
+ writeFileSync(dest, `${cfg.setCwd(src, originRepo).join("\n")}\n`);
403
+ n++;
404
+ continue;
405
+ }
406
+ // A copied-in origin session: write back only if the clone added records, and then as
407
+ // a NEW, self-contained file so the origin's original (and the leaf it resumes) is never
408
+ // mutated. An unchanged copy adds nothing.
409
+ const have = new Set();
410
+ for (const l of readFileSync(dest, "utf8").split("\n")) {
411
+ if (!l.trim())
412
+ continue;
413
+ try {
414
+ have.add(cfg.recordId(l));
415
+ }
416
+ catch {
417
+ /* ignore */
418
+ }
419
+ }
420
+ const hasNew = src.slice(1).some((l) => {
421
+ try {
422
+ return !have.has(cfg.recordId(l));
423
+ }
424
+ catch {
425
+ return false;
426
+ }
427
+ });
428
+ if (!hasNew)
429
+ continue;
430
+ const fresh = cfg.reidentify(src, originRepo);
431
+ writeFileSync(join(destDir, fresh.filename), `${fresh.lines.join("\n")}\n`);
432
+ n++;
433
+ }
417
434
  try {
418
- header = JSON.parse(src[0] ?? "");
435
+ rmSync(srcDir, { recursive: true, force: true });
419
436
  }
420
437
  catch {
421
- continue;
438
+ /* ignore */
422
439
  }
423
- header.cwd = originRepo;
424
- writeFileSync(dest, `${[JSON.stringify(header), ...src.slice(1)].join("\n")}\n`);
425
- n++;
426
- continue;
427
- }
428
- // A copied-in origin session. If the clone added records (e.g. you resumed it there),
429
- // write the clone's full session back as a NEW, self-contained file — leaving the origin's
430
- // original file (and the leaf pi resumes) untouched. Unchanged copies add nothing.
431
- const have = new Set();
432
- for (const l of readFileSync(dest, "utf8").split("\n")) {
433
- if (!l.trim())
434
- continue;
440
+ return n;
441
+ },
442
+ discard(cloneDir) {
435
443
  try {
436
- have.add(JSON.parse(l).id);
444
+ rmSync(dirFor(cloneDir), { recursive: true, force: true });
437
445
  }
438
446
  catch {
439
447
  /* ignore */
440
448
  }
441
- }
442
- const hasNew = src.slice(1).some((l) => {
449
+ },
450
+ hasActivity(cwd) {
451
+ const d = dirFor(cwd);
443
452
  try {
444
- return !have.has(JSON.parse(l).id);
453
+ return existsSync(d) && readdirSync(d).some((f) => f.endsWith(".jsonl"));
445
454
  }
446
455
  catch {
447
456
  return false;
448
457
  }
449
- });
450
- if (!hasNew)
451
- continue;
452
- let header;
458
+ },
459
+ };
460
+ }
461
+ /** pi: one session per .jsonl, cwd in a first-line header, per-line `id` for dedup. */
462
+ const piStore = dirStore({
463
+ id: "pi",
464
+ baseDir: () => join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "sessions"),
465
+ encodeCwd: (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`,
466
+ setCwd(lines, cwd) {
467
+ const out = [...lines];
453
468
  try {
454
- header = JSON.parse(src[0] ?? "");
469
+ const header = JSON.parse(out[0] ?? "");
470
+ header.cwd = cwd;
471
+ out[0] = JSON.stringify(header);
455
472
  }
456
473
  catch {
457
- continue;
474
+ /* leave a malformed header as-is rather than drop the session */
475
+ }
476
+ return out;
477
+ },
478
+ recordId: (line) => JSON.parse(line).id,
479
+ reidentify(lines, cwd) {
480
+ let header = {};
481
+ try {
482
+ header = JSON.parse(lines[0] ?? "");
483
+ }
484
+ catch {
485
+ /* synthesize a minimal header below */
458
486
  }
459
487
  const id = randomUUID();
460
- const fname = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
461
- writeFileSync(join(destDir, fname), `${[JSON.stringify({ ...header, id, cwd: originRepo }), ...src.slice(1)].join("\n")}\n`);
462
- n++;
463
- }
488
+ const filename = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
489
+ return { filename, lines: [JSON.stringify({ ...header, id, cwd }), ...lines.slice(1)] };
490
+ },
491
+ });
492
+ /** Apply a JSON transform to every parseable line, leaving blank/malformed lines untouched. */
493
+ function mapJsonLines(lines, fn) {
494
+ return lines.map((l) => {
495
+ if (!l.trim())
496
+ return l;
497
+ try {
498
+ const rec = JSON.parse(l);
499
+ fn(rec);
500
+ return JSON.stringify(rec);
501
+ }
502
+ catch {
503
+ return l;
504
+ }
505
+ });
506
+ }
507
+ /** Claude Code: one session per <sessionId>.jsonl, with cwd + sessionId repeated on every line. */
508
+ const claudeStore = dirStore({
509
+ id: "claude",
510
+ baseDir: () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "projects"),
511
+ encodeCwd: (abs) => abs.replace(/[^A-Za-z0-9]/g, "-"),
512
+ setCwd: (lines, cwd) => mapJsonLines(lines, (r) => {
513
+ if ("cwd" in r)
514
+ r.cwd = cwd;
515
+ }),
516
+ recordId: (line) => JSON.parse(line).uuid,
517
+ reidentify(lines, cwd) {
518
+ const id = randomUUID();
519
+ const out = mapJsonLines(lines, (r) => {
520
+ if ("sessionId" in r)
521
+ r.sessionId = id;
522
+ if ("cwd" in r)
523
+ r.cwd = cwd;
524
+ });
525
+ return { filename: `${id}.jsonl`, lines: out };
526
+ },
527
+ });
528
+ const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions");
529
+ /**
530
+ * Read just the first line of a (possibly large) file, without loading the whole thing.
531
+ * Accumulates raw bytes and decodes once at the end — decoding each read as UTF-8 would corrupt
532
+ * any multibyte char split across an 8 KiB read boundary (real Codex session_meta lines are tens
533
+ * of KiB of mixed-script text).
534
+ */
535
+ function readFirstLine(file) {
536
+ const fd = openSync(file, "r");
464
537
  try {
465
- rmSync(srcDir, { recursive: true, force: true });
538
+ const buf = Buffer.alloc(8192);
539
+ const chunks = [];
540
+ for (;;) {
541
+ const bytes = readSync(fd, buf, 0, buf.length, null);
542
+ if (bytes <= 0)
543
+ break;
544
+ const nl = buf.subarray(0, bytes).indexOf(0x0a); // newline byte
545
+ if (nl >= 0) {
546
+ chunks.push(Buffer.from(buf.subarray(0, nl)));
547
+ break;
548
+ }
549
+ chunks.push(Buffer.from(buf.subarray(0, bytes)));
550
+ }
551
+ return Buffer.concat(chunks).toString("utf8");
466
552
  }
467
- catch {
468
- /* ignore */
553
+ finally {
554
+ closeSync(fd);
469
555
  }
470
- return n;
471
556
  }
557
+ /** Every rollout-*.jsonl under the Codex sessions tree, paired with its session_meta cwd. */
558
+ function codexRollouts() {
559
+ const base = codexSessionsDir();
560
+ if (!existsSync(base))
561
+ return [];
562
+ const out = [];
563
+ const walk = (dir) => {
564
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
565
+ const p = join(dir, e.name);
566
+ if (e.isDirectory())
567
+ walk(p);
568
+ else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) {
569
+ let cwd;
570
+ try {
571
+ cwd = JSON.parse(readFirstLine(p)).payload?.cwd;
572
+ }
573
+ catch {
574
+ /* not a session_meta we understand */
575
+ }
576
+ out.push({ file: p, cwd });
577
+ }
578
+ }
579
+ };
580
+ walk(base);
581
+ return out;
582
+ }
583
+ /**
584
+ * Codex keeps every session in one global tree shared by all cwds, and (since 0.13x) keys its
585
+ * cwd-filtered resume picker off a *versioned internal sqlite index* (e.g. state_5.sqlite's
586
+ * `threads.cwd`), not the rollout. kage can't re-home a clone's sessions to the origin without
587
+ * rewriting that sqlite — fragile (the schema/db version moves between Codex releases) and a
588
+ * runtime dependency kage avoids. So kage does NOT manage Codex memory: `--agent codex` gives the
589
+ * isolated clone + git flow-back, and Codex history stays globally available via
590
+ * `codex resume --all`. import/mergeBack/discard are intentional no-ops; hasActivity (a rollout
591
+ * scan, never the sqlite) only powers the re-enter menu while a clone still exists.
592
+ */
593
+ const codexStore = {
594
+ id: "codex",
595
+ importHistory: () => 0,
596
+ mergeBack: () => 0,
597
+ discard: () => { },
598
+ hasActivity(cwd) {
599
+ return codexRollouts().some((r) => r.cwd === cwd);
600
+ },
601
+ };
472
602
  /**
473
603
  * We just deleted the clone we were running inside, so the parent shell is now in a
474
604
  * deleted directory. A CLI can't cd its parent shell, so: if the shell wrapper is active
@@ -490,15 +620,57 @@ function leaveClone(originRepo) {
490
620
  info(paint.yellow(` ↩ your shell is still in the deleted clone — run: ${paint.bold(`cd ${originRepo}`)}`));
491
621
  info(paint.dim(` enable auto cd-back: add eval "$(kage shell-init)" to your ~/.zshrc`));
492
622
  }
493
- function launchPi(cwd, args) {
494
- const r = spawnSync("pi", args, { cwd, stdio: "inherit" });
623
+ // Registered agents. Memory sync iterates this list, so each new agent is one entry (+ its
624
+ // SessionStore) with no change to any call site. Codex (a flat, cwd-in-content store) lands
625
+ // here in Phase 3.
626
+ const AGENTS = [
627
+ { id: "pi", store: piStore, cli: { bin: "pi", freshArgs: [], resumeArgs: ["-c"] } },
628
+ { id: "claude", store: claudeStore, cli: { bin: "claude", freshArgs: [], resumeArgs: ["--continue"] } },
629
+ { id: "codex", store: codexStore, cli: { bin: "codex", freshArgs: [], resumeArgs: ["resume", "--last"] } },
630
+ ];
631
+ const agentById = (id) => AGENTS.find((a) => a.id === id);
632
+ function launchCli(cli, cwd, args) {
633
+ const r = spawnSync(cli.bin, args, { cwd, stdio: "inherit" });
495
634
  if (r.error) {
496
635
  const err = r.error;
497
636
  if (err.code === "ENOENT")
498
- die("pi not found (make sure it is installed and on your PATH)");
499
- die(`failed to launch pi: ${err.message}`);
637
+ die(`${cli.bin} not found (make sure it is installed and on your PATH)`);
638
+ die(`failed to launch ${cli.bin}: ${err.message}`);
500
639
  }
501
640
  }
641
+ /** Open the clone with an external command (e.g. `code <clone>`) and return immediately. */
642
+ function launchOpen(cmd, cwd) {
643
+ const parts = cmd.split(/\s+/).filter(Boolean);
644
+ const bin = parts[0];
645
+ if (!bin)
646
+ die("--open needs a command, e.g. --open code");
647
+ const r = spawnSync(bin, [...parts.slice(1), cwd], { stdio: "inherit" });
648
+ if (r.error) {
649
+ const err = r.error;
650
+ if (err.code === "ENOENT")
651
+ die(`${bin} not found (make sure it is installed and on your PATH)`);
652
+ die(`failed to run --open ${cmd}: ${err.message}`);
653
+ }
654
+ }
655
+ /** Re-enter a clone: resume whichever agent has activity here (ask if several), else start fresh. */
656
+ async function enterClone(cloneDir) {
657
+ const active = AGENTS.filter((a) => a.cli && a.store.hasActivity(cloneDir));
658
+ if (active.length === 0) {
659
+ const def = agentById(process.env.KAGE_AGENT ?? "pi") ?? AGENTS[0];
660
+ if (def?.cli)
661
+ launchCli(def.cli, cloneDir, def.cli.freshArgs);
662
+ return;
663
+ }
664
+ let chosen = active[0];
665
+ if (active.length > 1) {
666
+ const idx = await select("Resume which agent?", active.map((a) => a.id));
667
+ if (idx < 0)
668
+ return info("cancelled");
669
+ chosen = active[idx];
670
+ }
671
+ if (chosen?.cli)
672
+ launchCli(chosen.cli, cloneDir, chosen.cli.resumeArgs);
673
+ }
502
674
  // ── subcommands ───────────────────────────────────────────────────────────────
503
675
  async function cmdNew(argv) {
504
676
  const { positional, flags } = parseArgs(argv);
@@ -523,9 +695,9 @@ async function cmdNew(argv) {
523
695
  const clone = clones[idx - 1];
524
696
  if (!clone)
525
697
  return info("cancelled");
526
- const act = await select(`${clone.name}:`, ["Enter (resume pi)", "Finish (merge memory & remove)", "Remove (discard)", "Cancel"]);
698
+ const act = await select(`${clone.name}:`, ["Enter (resume)", "Finish (merge memory & remove)", "Remove (discard)", "Cancel"]);
527
699
  if (act === 0)
528
- return launchPi(clone.dir, ["-c"]);
700
+ return enterClone(clone.dir);
529
701
  if (act === 1)
530
702
  return cmdFinish([clone.name]);
531
703
  if (act === 2)
@@ -554,11 +726,19 @@ async function cmdNew(argv) {
554
726
  const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
555
727
  if (existsSync(cloneDir))
556
728
  die(`directory already exists: ${cloneDir}`);
729
+ // Resolve the agent to launch up front, so a typo fails before we copy anything.
730
+ const agentId = strFlag(flags, "agent") ?? process.env.KAGE_AGENT ?? "pi";
731
+ const agent = agentById(agentId);
732
+ if (!agent)
733
+ die(`unknown agent: ${agentId} (supported: ${AGENTS.map((a) => a.id).join(", ")})`);
557
734
  const cp = await copyRepo(repoRoot, cloneDir);
558
735
  if (!cp.ok)
559
736
  die(`copy failed: ${cp.err}`);
560
737
  // kage does NOT create a branch — the clone stays on the origin's current branch.
561
- const histN = copyOriginHistory(repoRoot, cloneDir);
738
+ // Set-based memory: each agent's store imports its own origin history (no-op when empty).
739
+ let histN = 0;
740
+ for (const a of AGENTS)
741
+ histN += a.store.importHistory(repoRoot, cloneDir);
562
742
  const marker = {
563
743
  originRepo: repoRoot,
564
744
  name: safe,
@@ -573,9 +753,23 @@ async function cmdNew(argv) {
573
753
  info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
574
754
  info(paint.dim(` when done: kage finish ${safe}`));
575
755
  info("");
576
- launchPi(cloneDir, []);
756
+ // Launch mode: --no-launch (just build), --open <cmd> (open + return), else spawn the CLI.
757
+ if (boolFlag(flags, "no-launch"))
758
+ return;
759
+ const openCmd = strFlag(flags, "open");
760
+ if (openCmd || boolFlag(flags, "open")) {
761
+ if (!openCmd)
762
+ die("--open needs a command, e.g. --open code");
763
+ launchOpen(openCmd, cloneDir);
764
+ info("");
765
+ info(`↩︎ opened ${cloneDir} with ${openCmd}. To finish: ${paint.bold(`kage finish ${safe}`)}`);
766
+ return;
767
+ }
768
+ if (!agent.cli)
769
+ die(`agent ${agent.id} has no CLI to launch — use --open <cmd> or --no-launch`);
770
+ launchCli(agent.cli, cloneDir, agent.cli.freshArgs);
577
771
  info("");
578
- info(`↩︎ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
772
+ info(`↩︎ left the clone's ${agent.cli.bin}. To finish: ${paint.bold(`kage finish ${safe}`)}`);
579
773
  }
580
774
  async function cmdFinish(argv) {
581
775
  const { positional, flags } = parseArgs(argv);
@@ -641,7 +835,9 @@ async function cmdFinish(argv) {
641
835
  die(`failed to preserve the clone's branch into the origin: ${r.err}`);
642
836
  info(`🌿 preserved the clone's commits in the origin as ${paint.cyan(target)} (merge with: git merge ${target})`);
643
837
  }
644
- const n = mergeBack(clone.dir, originRepo);
838
+ let n = 0;
839
+ for (const a of AGENTS)
840
+ n += a.store.mergeBack(clone.dir, originRepo);
645
841
  try {
646
842
  process.chdir(originRepo);
647
843
  }
@@ -675,12 +871,8 @@ async function cmdRm(argv) {
675
871
  catch {
676
872
  /* ignore */
677
873
  }
678
- try {
679
- rmSync(sessionDirFor(clone.dir), { recursive: true, force: true });
680
- }
681
- catch {
682
- /* ignore */
683
- }
874
+ for (const a of AGENTS)
875
+ a.store.discard(clone.dir);
684
876
  rmSync(clone.dir, { recursive: true, force: true });
685
877
  info(`🗑 Removed clone ${clone.name} (${clone.dir})`);
686
878
  if (insideClone)
@@ -705,7 +897,7 @@ function cmdList(argv) {
705
897
  const pr = boolFlag(flags, "pr") ? prInfo(c.dir, s.branch) : undefined;
706
898
  // header: status glyph · name · branch · age
707
899
  const glyph = s.dirty ? paint.yellow("●") : isSafeToClean(s) ? paint.green("✓") : paint.cyan("·");
708
- const age = c.marker?.createdAt ? paint.dim(`created ${ago(c.marker.createdAt)}`) : "";
900
+ const age = paint.dim(`created ${ago(c.marker.createdAt)}`);
709
901
  info(` ${glyph} ${paint.bold(c.name)} ${paint.cyan(s.branch)} ${age}`);
710
902
  // detail: working-tree state · sync · PR · safe-to-clean
711
903
  const parts = [];
@@ -817,7 +1009,8 @@ function cmdClones() {
817
1009
  const HELP = `kage 🥷 — Shadow Clone Jutsu for your git repo
818
1010
 
819
1011
  Usage:
820
- kage [path] [--name <x>] clone repo + launch a fresh pi
1012
+ kage [path] [--name <x>] [--agent <id>] clone repo + launch a fresh agent (default: pi)
1013
+ --open <cmd> | --no-launch: open it yourself instead
821
1014
  (no args inside a repo with clones: interactive menu)
822
1015
  kage status [--pr] dashboard of clones (--pr adds PR status via gh)
823
1016
  kage finish [name] [--force] [--push] [--pr] preserve work -> merge memory back -> delete clone
@@ -834,6 +1027,9 @@ With no args inside a repo that already has clones, kage opens an interactive me
834
1027
  Options:
835
1028
  --name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>); skips the name prompt
836
1029
  (sanitized to a git-ref-safe slug, since the name is also used as a branch name)
1030
+ --agent <id> which agent CLI to launch (default: pi, or $KAGE_AGENT); memory still syncs for all agents
1031
+ --open <cmd> after cloning, run '<cmd> <clone>' (e.g. --open code) and return, instead of spawning a CLI
1032
+ --no-launch just create the clone (and import memory), print its path; you open it yourself
837
1033
  --pr (finish) push the branch and open a GitHub PR via gh, then finish
838
1034
  --push (finish) push the branch before finishing (implied by --pr)
839
1035
  --force skip the safety checks: uncommitted/unpushed guard (finish) or local-only guard (rm)
@@ -854,8 +1050,11 @@ Examples:
854
1050
 
855
1051
  kage pull .env # inside a clone: copy a gitignored file back to the origin
856
1052
 
857
- Env:
858
- KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
1053
+ Env (each agent's own native var — kage just honors it, so kage and the agent always agree):
1054
+ KAGE_AGENT default agent to launch when --agent is omitted (default: pi)
1055
+ PI_CODING_AGENT_DIR pi's agent dir; sessions read from <it>/sessions (default: ~/.pi/agent)
1056
+ CLAUDE_CONFIG_DIR Claude Code's config dir; sessions read from <it>/projects (default: ~/.claude)
1057
+ CODEX_HOME Codex's home dir; sessions read from <it>/sessions (default: ~/.codex)`;
859
1058
  async function main() {
860
1059
  const [sub, ...rest] = process.argv.slice(2);
861
1060
  switch (sub) {
@@ -863,7 +1062,6 @@ async function main() {
863
1062
  case "new":
864
1063
  return cmdNew(sub === "new" ? rest : process.argv.slice(2));
865
1064
  case "status":
866
- case "list": // alias
867
1065
  return cmdList(rest);
868
1066
  case "finish":
869
1067
  return cmdFinish(rest);
@@ -871,8 +1069,7 @@ async function main() {
871
1069
  return cmdRm(rest);
872
1070
  case "pull":
873
1071
  return cmdPull(rest);
874
- case "shell-init":
875
- case "completion": {
1072
+ case "shell-init": {
876
1073
  process.stdout.write(`${SHELL_INIT}\n`);
877
1074
  // When a human runs this directly (stdout is a TTY, not captured by `$(...)`),
878
1075
  // the script just scrolled past unused — show how to actually activate it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-kage",
3
- "version": "0.3.7",
3
+ "version": "0.4.0",
4
4
  "description": "🥷 Shadow Clone Jutsu for your git repo: copy it into an isolated folder, work in parallel with pi, then merge the session memory back",
5
5
  "keywords": [
6
6
  "pi",