pi-kage 0.3.8 โ†’ 0.5.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
@@ -15,14 +15,20 @@ can't collide.
15
15
 
16
16
  ```bash
17
17
  npm install -g pi-kage
18
+ eval "$(kage shell-init)" # so kage can cd your shell in & out (recommended)
19
+
18
20
  cd my-app
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
21
+ kage --agent pi # ๐Ÿฅท copy โ†’ ../my-app--<name>, cd in, open a fresh pi
22
+ # ...edit ยท commit ยท push ยท open a PR ยท quit pi...
23
+ kage finish # ๐Ÿ’จ merge pi's session memory back, delete the clone, cd you home
22
24
  ```
23
25
 
26
+ Launching an agent is optional: bare `kage` just makes the clone and cd's you in โ€” `--agent` (or
27
+ `kage config agent`, or `$KAGE_AGENT`) is what tells kage to start one for you.
28
+
24
29
  Code comes back through git (a PR, or a branch fetch). The agent's session memory comes back through
25
- `~/.pi`. kage never copies a working tree back onto the origin โ€” that's the whole point.
30
+ its own session store (`~/.pi`, `~/.claude`, or `~/.codex`). kage never copies a working tree back onto
31
+ the origin โ€” that's the whole point.
26
32
 
27
33
  ## Why a full copy, not `git worktree`?
28
34
 
@@ -46,8 +52,9 @@ npx pi-kage # run without installing
46
52
  curl -fsSL https://raw.githubusercontent.com/kid7st/kage/main/install.sh | sh
47
53
  ```
48
54
 
49
- Requires **git**, [**pi**](https://github.com/earendil-works), and **Node โ‰ฅ 18** on your `PATH`.
50
- kage has no runtime dependencies.
55
+ Requires **git** and **Node โ‰ฅ 18** on your `PATH`, plus at least one coding-agent CLI to drive โ€”
56
+ [**pi**](https://github.com/earendil-works) (the default), [**Claude Code**](https://github.com/anthropics/claude-code),
57
+ or [**Codex**](https://github.com/openai/codex). kage itself has no runtime dependencies.
51
58
 
52
59
  From source:
53
60
 
@@ -56,31 +63,60 @@ git clone https://github.com/kid7st/kage
56
63
  cd kage && npm install && npm link # npm install builds bin/kage.mjs from src/
57
64
  ```
58
65
 
59
- ## Commands
66
+ ## Usage
60
67
 
61
68
  | Command | Run from | What it does |
62
69
  |---|---|---|
63
- | `kage [path] [--name x]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), copy in the origin's 5 most recent pi sessions (resumable, never replayed), and launch a **fresh** pi. `--name` only names the folder โ€” kage never creates a branch. No args + existing clones โ†’ interactive menu. |
64
- | `kage status [--pr]` | origin repo | Dashboard: branch, dirty/clean, ahead/behind, "safe to clean". `--pr` adds PR state via `gh`. (`kage list` is an alias.) |
65
- | `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. |
70
+ | `kage [path] [--name x] [--agent <id>]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), import the origin's recent sessions (resumable, never replayed), **cd you into the clone**, and launch an agent only if you named one (`--agent`/`$KAGE_AGENT`/`kage config agent`) โ€” else just drop you in. `--name` only names the folder; kage never creates a branch. No args + existing clones โ†’ interactive menu. |
71
+ | `kage status [--pr]` | origin repo | Dashboard: branch, dirty/clean, ahead/behind, "safe to clean". `--pr` adds PR state via `gh`. |
72
+ | `kage finish [name] [--force] [--push] [--pr]` | origin / inside clone | Preserve the clone's commits (push, or with no remote a local `kage/<name>` branch), merge its **new** sessions back, delete it, cd you home. Refuses uncommitted changes โ€” and, with a remote, unpushed commits โ€” unless `--force`. `--push`/`--pr` push first (and open a PR via `gh`). |
66
73
  | `kage rm [name] [--force]` | origin / inside clone | Discard a clone **without** merging memory. Refuses local-only work unless `--force`. |
67
74
  | `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored, e.g. a generated `.env`) back to the origin. |
68
- | `kage shell-init` | shell rc | Shell wrapper (cd back to origin after `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
75
+ | `kage config [<key> [value]] [--unset]` | anywhere | Get/set persisted defaults. One key today: `agent` (your default launch agent), validated against known agents. |
76
+ | `kage shell-init` | shell rc | Shell wrapper (cd into a clone on create, back to origin on `finish`/`rm`) + tab completion. Use `eval "$(kage shell-init)"`. |
69
77
  | `kage --help` / `--version` | anywhere | Usage / version. |
70
78
 
71
79
  Run bare `kage` inside a repo that already has clones to get an interactive picker: create a new clone,
72
80
  or **enter** / **finish** / **remove** an existing one. `finish` and `rm` show the same picker when you
73
81
  have several clones and don't name one.
74
82
 
75
- ### Shell integration (optional)
83
+ ## Agents
84
+
85
+ kage is agent-agnostic โ€” and launching an agent is **opt-in**. By default `kage` just creates the clone and
86
+ cd's your shell into it; you work however you like. To have kage launch one for you, name it with `--agent`,
87
+ or set a default with `kage config agent` (or `$KAGE_AGENT`) โ€” precedence `--agent` > `$KAGE_AGENT` >
88
+ `kage config agent`; with none set, nothing is launched. Whichever way you work, every agent gets the same
89
+ isolated clone and git flow-back โ€” what differs is **memory flow-back**, i.e. whether the sessions you
90
+ create in the clone come home to the origin:
91
+
92
+ | Agent | Launch it with | Memory flow-back |
93
+ |---|---|---|
94
+ | **pi** | `kage --agent pi` | โœ… full round-trip โ€” recent sessions copied in on create, new ones merged back on `finish` |
95
+ | **Claude Code** | `kage --agent claude` | โœ… full round-trip (same as pi) |
96
+ | **Codex** | `kage --agent codex` | โš ๏ธ git only โ€” history stays global, reach it with `codex resume --all` |
97
+
98
+ **Why Codex is git-only.** pi and Claude Code key their session stores by working directory, so kage can
99
+ copy a clone's sessions over to the origin's directory and back. Codex keeps one global, sqlite-indexed
100
+ store that kage can't cleanly re-home โ€” so `--agent codex` gives you the isolated clone + git flow-back,
101
+ and your Codex history stays globally reachable. Details in
102
+ [`docs/multi-agent-design.md`](./docs/multi-agent-design.md).
103
+
104
+ You can run several agents at once โ€” a clone each, or different agents in one clone. Each agent uses its
105
+ own store, so they never collide, and `finish` merges back whichever stores gained new sessions.
106
+
107
+ **Driving a GUI/IDE agent kage can't spawn?** Use `--open <cmd>` to create the clone and open it yourself
108
+ (e.g. `kage --open code` runs `code <clone>`), or `--no-launch` to just make the clone and print its path.
109
+ Memory still flows for any agent whose store kage manages, no matter how you opened the clone.
110
+
111
+ ## Shell integration (recommended)
76
112
 
77
113
  ```bash
78
114
  eval "$(kage shell-init)" # add to ~/.zshrc or ~/.bashrc
79
115
  ```
80
116
 
81
- Running `finish`/`rm` from inside a clone deletes the directory your shell is sitting in. The wrapper
82
- cd's you back to the origin automatically (a CLI can't change its parent shell otherwise) and adds tab
83
- completion for subcommands and clone names.
117
+ `kage` cd's your shell **into** the new clone, and `finish`/`rm` cd it **back** to the origin afterward
118
+ (a CLI can't move its parent shell otherwise, so this needs the wrapper โ€” without it, kage just prints the
119
+ `cd` for you to run). It also adds tab completion for subcommands and clone names.
84
120
 
85
121
  ## How it works
86
122
 
@@ -91,10 +127,11 @@ completion for subcommands and clone names.
91
127
  Without a remote: `finish` fetches the clone's branch into the origin's git as a local
92
128
  `kage/<name>-<sha>` branch (origin working tree untouched โ€” `git merge` it when you like). Because a
93
129
  fetch can't preserve uncommitted work, `finish` refuses to delete a dirty clone unless `--force`.
94
- - **Memory flows via `~/.pi`, never replayed.** On create, the origin's 5 most recent sessions are
95
- copied in โ€” pi's resume picker surfaces them, but the clone opens a **fresh** session. On `finish`,
96
- sessions the clone created come back whole; a copied-in session you resumed comes back as a separate
97
- new session, so the origin's original is never mutated.
130
+ - **Memory flows via the agent's own session store, never replayed.** On create, the origin's 5 most
131
+ recent sessions for that agent are copied in โ€” the agent's resume picker surfaces them when you launch it, but you
132
+ start a **fresh** session. On `finish`, sessions the clone created come back whole; a copied-in session
133
+ you resumed comes back as a separate new session, so the origin's original is never mutated. (Codex is
134
+ the exception โ€” git-only flow-back; see [Agents](#agents).)
98
135
  - **The origin is read-only to kage.** It only copies out and writes session memory โ€” it never touches
99
136
  the origin's working tree, even while another session is live there.
100
137
 
@@ -103,7 +140,9 @@ completion for subcommands and clone names.
103
140
  - The copy snapshots the origin's **current** state, including uncommitted changes.
104
141
  - **Submodules**: a submodule's `.git` is an absolute path and breaks on copy โ€” run
105
142
  `git submodule update --init` in the clone.
106
- - Session storage defaults to `~/.pi/agent/sessions`; override with `KAGE_SESSIONS_DIR`.
143
+ - kage reads each agent's sessions from where that agent itself stores them, honoring the agent's own
144
+ config var โ€” `PI_CODING_AGENT_DIR` (pi), `CLAUDE_CONFIG_DIR` (Claude Code), `CODEX_HOME` (Codex) โ€” so
145
+ kage and the agent always agree on the location.
107
146
 
108
147
  ## Development
109
148
 
package/README.zh-CN.md CHANGED
@@ -14,14 +14,19 @@
14
14
 
15
15
  ```bash
16
16
  npm install -g pi-kage
17
+ eval "$(kage shell-init)" # ่ฎฉ kage ่ƒฝ cd ไฝ ็š„ shell ่ฟ› / ๅ‡บ๏ผˆๆŽจ่๏ผ‰
18
+
17
19
  cd my-app
18
- kage # ๐Ÿฅท ๅคๅˆถ โ†’ ../my-app--kage-<ts>๏ผŒๆ‰“ๅผ€ไธ€ไธชๅ…จๆ–ฐ็š„ pi
19
- # ...ๆไบคใ€pushใ€ๅผ€ PRใ€้€€ๅ‡บ pi...
20
- kage finish # ๐Ÿ’จ ๆŠŠๅˆ†่บซ็š„ session ๅˆๅนถๅ›žๆฅ๏ผŒๅˆ ๆމๅˆ†่บซ
20
+ kage --agent pi # ๐Ÿฅท ๅคๅˆถ โ†’ ../my-app--<name>๏ผŒcd ่ฟ›ๅŽป๏ผŒๅผ€ไธ€ไธชๅ…จๆ–ฐ็š„ pi
21
+ # ...็ผ–่พ‘ ยท ๆไบค ยท push ยท ๅผ€ PR ยท ้€€ๅ‡บ pi...
22
+ kage finish # ๐Ÿ’จ ๆŠŠ pi ็š„ session ่ฎฐๅฟ†ๅˆๅนถๅ›žๆฅ๏ผŒๅˆ ๆމๅˆ†่บซ๏ผŒๅ†ๆŠŠไฝ  cd ๅ›žๅŽŸไป“ๅบ“
21
23
  ```
22
24
 
23
- ไปฃ็ ้€š่ฟ‡ git ๅ›žๆต๏ผˆไธ€ไธช PR๏ผŒๆˆ–่€… fetch ๅˆ†ๆ”ฏ๏ผ‰๏ผ›agent ็š„ session ่ฎฐๅฟ†้€š่ฟ‡ `~/.pi` ๅ›žๆตใ€‚kage ไปŽไธๆŠŠๅทฅไฝœๅŒบ
24
- ๅคๅˆถๅ›žๅŽŸไป“ๅบ“ โ€”โ€” ่ฟ™ๆญฃๆ˜ฏๅฎƒๅญ˜ๅœจ็š„ๆ„ไน‰ใ€‚
25
+ ๅฏๅŠจ agent ๆ˜ฏๅฏ้€‰็š„๏ผš็บฏ่ท‘ `kage` ๅชๆ˜ฏๅปบๅฅฝๅˆ†่บซๅนถๆŠŠไฝ  cd ่ฟ›ๅŽป โ€”โ€” `--agent`๏ผˆๆˆ– `kage config agent`ใ€ๆˆ–
26
+ `$KAGE_AGENT`๏ผ‰ๆ‰ๆ˜ฏๅ‘Š่ฏ‰ kage ๆ›ฟไฝ ๅฏๅŠจไธ€ไธช็š„ๅผ€ๅ…ณใ€‚
27
+
28
+ ไปฃ็ ้€š่ฟ‡ git ๅ›žๆต๏ผˆไธ€ไธช PR๏ผŒๆˆ–่€… fetch ๅˆ†ๆ”ฏ๏ผ‰๏ผ›agent ็š„ session ่ฎฐๅฟ†้€š่ฟ‡ๅฎƒ่‡ชๅทฑ็š„ session ๅญ˜ๅ‚จ๏ผˆ`~/.pi`ใ€
29
+ `~/.claude` ๆˆ– `~/.codex`๏ผ‰ๅ›žๆตใ€‚kage ไปŽไธๆŠŠๅทฅไฝœๅŒบๅคๅˆถๅ›žๅŽŸไป“ๅบ“ โ€”โ€” ่ฟ™ๆญฃๆ˜ฏๅฎƒๅญ˜ๅœจ็š„ๆ„ไน‰ใ€‚
25
30
 
26
31
  ## ไธบไป€ไนˆ็”จๅฎŒๆ•ดๅ‰ฏๆœฌ๏ผŒ่€Œไธๆ˜ฏ `git worktree`๏ผŸ
27
32
 
@@ -43,8 +48,9 @@ npx pi-kage # ไธๅฎ‰่ฃ…็›ดๆŽฅ่ฟ่กŒ
43
48
  curl -fsSL https://raw.githubusercontent.com/kid7st/kage/main/install.sh | sh
44
49
  ```
45
50
 
46
- ้œ€่ฆ `PATH` ไธŠๆœ‰ **git**ใ€[**pi**](https://github.com/earendil-works) ๅ’Œ **Node โ‰ฅ 18**ใ€‚
47
- kage ๆฒกๆœ‰ไปปไฝ•่ฟ่กŒๆ—ถไพ่ต–ใ€‚
51
+ ้œ€่ฆ `PATH` ไธŠๆœ‰ **git** ๅ’Œ **Node โ‰ฅ 18**๏ผŒๅค–ๅŠ ่‡ณๅฐ‘ไธ€ไธช coding-agent CLI ๆฅ้ฉฑๅŠจ โ€”โ€”
52
+ [**pi**](https://github.com/earendil-works)๏ผˆ้ป˜่ฎค๏ผ‰ใ€[**Claude Code**](https://github.com/anthropics/claude-code)
53
+ ๆˆ– [**Codex**](https://github.com/openai/codex)ใ€‚kage ๆœฌ่บซๆฒกๆœ‰ไปปไฝ•่ฟ่กŒๆ—ถไพ่ต–ใ€‚
48
54
 
49
55
  ไปŽๆบ็ ๅฎ‰่ฃ…๏ผš
50
56
 
@@ -53,29 +59,56 @@ git clone https://github.com/kid7st/kage
53
59
  cd kage && npm install && npm link # npm install ไผšไปŽ src/ ็ผ–่ฏ‘ๅ‡บ bin/kage.mjs
54
60
  ```
55
61
 
56
- ## ๅ‘ฝไปค
62
+ ## ไฝฟ็”จ
57
63
 
58
64
  | ๅ‘ฝไปค | ๅœจๅ“ช่ฟ่กŒ | ไฝœ็”จ |
59
65
  |---|---|---|
60
- | `kage [path] [--name x]` | ๅŽŸไป“ๅบ“ | ๆŠŠไป“ๅบ“ๅคๅˆถๅˆฐ `../<repo>--<name>`๏ผˆ้ป˜่ฎค `kage-<ts>`๏ผ‰๏ผŒๆ‹ทๅ…ฅๅŽŸไป“ๅบ“ๆœ€่ฟ‘ 5 ไธช pi session๏ผˆๅฏ resume๏ผŒ็ปไธ้‡ๆ”พ๏ผ‰๏ผŒๅนถๅฏๅŠจไธ€ไธช**ๅ…จๆ–ฐ**็š„ piใ€‚`--name` ๅชๅ‘ฝๅๆ–‡ไปถๅคน โ€”โ€” kage ไปŽไธๅปบๅˆ†ๆ”ฏใ€‚ๆ— ๅ‚ๆ•ฐ + ๅทฒๆœ‰ๅˆ†่บซ โ†’ ่ฟ›ๅ…ฅไบคไบ’่œๅ•ใ€‚ |
61
- | `kage status [--pr]` | ๅŽŸไป“ๅบ“ | ไปช่กจ็›˜๏ผšๅˆ†ๆ”ฏใ€ๆ˜ฏๅฆๆœ‰ๆ”นๅŠจใ€ahead/behindใ€ๆ˜ฏๅฆใ€Œๅฏๅฎ‰ๅ…จๆธ…็†ใ€ใ€‚`--pr` ้€š่ฟ‡ `gh` ้™„ๅธฆ PR ็Šถๆ€ใ€‚๏ผˆ`kage list` ๆ˜ฏๅˆซๅใ€‚๏ผ‰ |
62
- | `kage finish [name] [--force] [--push] [--pr]` | ๅŽŸไป“ๅบ“ / ๅˆ†่บซๅ†… | ่‹ฅๅˆ†่บซๆœ‰ๆœชๆไบคๆˆ–ๆœช push ็š„ๆ”นๅŠจๅˆ™ๆ‹’็ป๏ผŒๆŠŠๅฎƒ**ๆ–ฐไบง็”Ÿ**็š„ session ๅˆๅนถๅ›žๆฅ๏ผŒๅ†ๅˆ ้™คๅฎƒใ€‚`--push` ๅ…ˆ push ๅˆ†ๆ”ฏ๏ผ›`--pr` push ๅนถ้€š่ฟ‡ `gh` ๅผ€ PR๏ผ›`--force` ่ทณ่ฟ‡ๆฃ€ๆŸฅใ€‚ |
66
+ | `kage [path] [--name x] [--agent <id>]` | ๅŽŸไป“ๅบ“ | ๆŠŠไป“ๅบ“ๅคๅˆถๅˆฐ `../<repo>--<name>`๏ผˆ้ป˜่ฎค `kage-<ts>`๏ผ‰๏ผŒๆ‹ทๅ…ฅๅŽŸไป“ๅบ“ๆœ€่ฟ‘็š„ session๏ผˆๅฏ resume๏ผŒ็ปไธ้‡ๆ”พ๏ผ‰๏ผŒ**ๆŠŠไฝ  cd ่ฟ›ๅˆ†่บซ**๏ผŒไป…ๅฝ“ไฝ ๆŒ‡ๅฎšไบ† agent๏ผˆ`--agent`/`$KAGE_AGENT`/`kage config agent`๏ผ‰ๆ‰ๅฏๅŠจๅฎƒ โ€”โ€” ๅฆๅˆ™ๅชๆŠŠไฝ ๆ”พ่ฟ›ๅŽปใ€‚`--name` ๅชๅ‘ฝๅๆ–‡ไปถๅคน๏ผ›kage ไปŽไธๅปบๅˆ†ๆ”ฏใ€‚ๆ— ๅ‚ๆ•ฐ + ๅทฒๆœ‰ๅˆ†่บซ โ†’ ่ฟ›ๅ…ฅไบคไบ’่œๅ•ใ€‚ |
67
+ | `kage status [--pr]` | ๅŽŸไป“ๅบ“ | ไปช่กจ็›˜๏ผšๅˆ†ๆ”ฏใ€ๆ˜ฏๅฆๆœ‰ๆ”นๅŠจใ€ahead/behindใ€ๆ˜ฏๅฆใ€Œๅฏๅฎ‰ๅ…จๆธ…็†ใ€ใ€‚`--pr` ้€š่ฟ‡ `gh` ้™„ๅธฆ PR ็Šถๆ€ใ€‚ |
68
+ | `kage finish [name] [--force] [--push] [--pr]` | ๅŽŸไป“ๅบ“ / ๅˆ†่บซๅ†… | ๅ…ˆไฟ็•™ๅˆ†่บซ็š„ commit๏ผˆpush๏ผŒๆˆ–ๆ—  remote ๆ—ถๅญ˜ๆˆๅŽŸไป“ๅบ“ๆœฌๅœฐๅˆ†ๆ”ฏ `kage/<name>`๏ผ‰๏ผŒๆŠŠๅฎƒ**ๆ–ฐไบง็”Ÿ**็š„ session ๅˆๅนถๅ›žๆฅ๏ผŒๅˆ ๆމๅˆ†่บซ๏ผŒๅ†ๆŠŠไฝ  cd ๅ›žๅŽŸไป“ๅบ“ใ€‚ๆœ‰ๆœชๆไบคๆ”นๅŠจ โ€”โ€” ไปฅๅŠๆœ‰ remote ๆ—ถ่ฟ˜ๆœ‰ๆœช push ็š„ commit โ€”โ€” ๅˆ™ๆ‹’็ป๏ผŒ้™ค้žๅŠ  `--force`ใ€‚`--push`/`--pr` ๅ…ˆ push๏ผˆๅนถ้€š่ฟ‡ `gh` ๅผ€ PR๏ผ‰ใ€‚ |
63
69
  | `kage rm [name] [--force]` | ๅŽŸไป“ๅบ“ / ๅˆ†่บซๅ†… | **ไธ**ๅˆๅนถ่ฎฐๅฟ†ๅœฐไธขๅผƒไธ€ไธชๅˆ†่บซใ€‚่‹ฅๆœ‰ไป…ๅญ˜ๅœจไบŽๆœฌๅœฐ็š„ๅทฅไฝœๅˆ™ๆ‹’็ป๏ผŒ้™ค้žๅŠ  `--force`ใ€‚ |
64
70
  | `kage pull <path...>` | ๅˆ†่บซๅ†… | ๆŠŠๆŒ‡ๅฎšๆ–‡ไปถ/็›ฎๅฝ•๏ผˆๅŒ…ๆ‹ฌ่ขซ gitignore ็š„๏ผŒๆฏ”ๅฆ‚็”Ÿๆˆ็š„ `.env`๏ผ‰ๆŒ‰็›ธๅŒ็›ธๅฏน่ทฏๅพ„ๆ‹ทๅ›žๅŽŸไป“ๅบ“ใ€‚ |
65
- | `kage shell-init` | shell ้…็ฝฎ | shell ๅŒ…่ฃ…ๅ‡ฝๆ•ฐ๏ผˆ`finish`/`rm` ๅŽ่‡ชๅŠจ cd ๅ›žๅŽŸไป“ๅบ“๏ผ‰+ tab ่กฅๅ…จใ€‚็”จ `eval "$(kage shell-init)"`ใ€‚ |
71
+ | `kage config [<key> [value]] [--unset]` | ไปปๆ„ไฝ็ฝฎ | ่ฏปๅ–/่ฎพ็ฝฎๆŒไน…ๅŒ–้ป˜่ฎคๅ€ผใ€‚็›ฎๅ‰ๅชๆœ‰ไธ€ไธช key๏ผš`agent`๏ผˆ้ป˜่ฎคๅฏๅŠจ็š„ agent๏ผ‰๏ผŒไผšๆ ก้ชŒๆ˜ฏๅฆไธบๅทฒ็Ÿฅ agentใ€‚ |
72
+ | `kage shell-init` | shell ้…็ฝฎ | shell ๅŒ…่ฃ…ๅ‡ฝๆ•ฐ๏ผˆๅˆ›ๅปบๆ—ถ cd ่ฟ›ๅˆ†่บซ๏ผŒ`finish`/`rm` ๅŽ cd ๅ›žๅŽŸไป“ๅบ“๏ผ‰+ tab ่กฅๅ…จใ€‚็”จ `eval "$(kage shell-init)"`ใ€‚ |
66
73
  | `kage --help` / `--version` | ไปปๆ„ไฝ็ฝฎ | ็”จๆณ• / ็‰ˆๆœฌใ€‚ |
67
74
 
68
75
  ๅœจๅทฒๆœ‰ๅˆ†่บซ็š„ไป“ๅบ“้‡Œ็›ดๆŽฅ่ฟ่กŒ `kage`๏ผŒไผšๅผนๅ‡บไบคไบ’้€‰ๆ‹ฉๅ™จ๏ผšๆ–ฐๅปบไธ€ไธชๅˆ†่บซ๏ผŒๆˆ–ๅฏนๅทฒๆœ‰ๅˆ†่บซๆ‰ง่กŒ **่ฟ›ๅ…ฅ** / **finish** /
69
76
  **ๅˆ ้™ค**ใ€‚ๅฝ“ๆœ‰ๅคšไธชๅˆ†่บซๅˆๆฒกๆŒ‡ๅฎšๅๅญ—ๆ—ถ๏ผŒ`finish` ๅ’Œ `rm` ไนŸไผšๅผนๅ‡บๅŒๆ ท็š„้€‰ๆ‹ฉๅ™จใ€‚
70
77
 
71
- ### Shell ้›†ๆˆ๏ผˆๅฏ้€‰๏ผ‰
78
+ ## Agents
79
+
80
+ kage ไธŽๅ…ทไฝ“ agent ๆ— ๅ…ณ โ€”โ€” ่€Œไธ”ๅฏๅŠจ agent ๆ˜ฏ**ๅฏ้€‰็š„**ใ€‚้ป˜่ฎคไธ‹ `kage` ๅชๅˆ›ๅปบๅˆ†่บซๅนถๆŠŠไฝ ็š„ shell cd ่ฟ›ๅŽป๏ผŒ
81
+ ๆŽฅไธ‹ๆฅไฝ ็ˆฑๆ€Žไนˆๅนฒๅฐฑๆ€Žไนˆๅนฒใ€‚ๆƒณ่ฎฉ kage ๆ›ฟไฝ ๅฏๅŠจไธ€ไธช agent๏ผŒๅฐฑ็”จ `--agent` ๆŒ‡ๅฎš๏ผŒๆˆ–็”จ `kage config agent`
82
+ ๏ผˆๆˆ– `$KAGE_AGENT`๏ผ‰่ฎพไธช้ป˜่ฎคๅ€ผ โ€”โ€” ไผ˜ๅ…ˆ็บง `--agent` > `$KAGE_AGENT` > `kage config agent`๏ผ›ไธ€ไธช้ƒฝๆฒก่ฎพๅฐฑ
83
+ ไป€ไนˆ้ƒฝไธๅฏๅŠจใ€‚ไธ็ฎกๆ€Žไนˆๅนฒ๏ผŒๆฏไธช agent ้ƒฝๅพ—ๅˆฐๅŒๆ ท็š„้š”็ฆปๅˆ†่บซๅ’Œ git ๅ›žๆต โ€”โ€” ๅŒบๅˆซๅœจไบŽ**่ฎฐๅฟ†ๅ›žๆต**๏ผŒไนŸๅฐฑๆ˜ฏ
84
+ ไฝ ๅœจๅˆ†่บซ้‡Œไบง็”Ÿ็š„ session ๆ˜ฏๅฆไผšๅ›žๅˆฐๅŽŸไป“ๅบ“๏ผš
85
+
86
+ | Agent | ๆ€ŽไนˆๅฏๅŠจ | ่ฎฐๅฟ†ๅ›žๆต |
87
+ |---|---|---|
88
+ | **pi** | `kage --agent pi` | โœ… ๅฎŒๆ•ดๅพ€่ฟ” โ€”โ€” ๅˆ›ๅปบๆ—ถๆ‹ทๅ…ฅๆœ€่ฟ‘็š„ session๏ผŒ`finish` ๆ—ถๆŠŠๆ–ฐไบง็”Ÿ็š„ๅˆๅนถๅ›žๆฅ |
89
+ | **Claude Code** | `kage --agent claude` | โœ… ๅฎŒๆ•ดๅพ€่ฟ”๏ผˆๅŒ pi๏ผ‰ |
90
+ | **Codex** | `kage --agent codex` | โš ๏ธ ไป… git โ€”โ€” ๅކๅฒไฟๆŒๅ…จๅฑ€๏ผŒ็”จ `codex resume --all` ๆ‰พๅ›ž |
91
+
92
+ **ไธบไป€ไนˆ Codex ๅชๆœ‰ git ๅ›žๆตใ€‚** pi ๅ’Œ Claude Code ็š„ session ๅญ˜ๅ‚จๆŒ‰ๅทฅไฝœ็›ฎๅฝ•็ดขๅผ•๏ผŒๆ‰€ไปฅ kage ่ƒฝๆŠŠๅˆ†่บซ็š„
93
+ session ๆ‹ทๅˆฐๅŽŸไป“ๅบ“็š„็›ฎๅฝ•ใ€ๅ†ๆ‹ทๅ›žๆฅใ€‚Codex ็”จ็š„ๆ˜ฏไธ€ไธช็”ฑ sqlite ็ดขๅผ•็š„ๅ…จๅฑ€ store๏ผŒkage ๆ— ๆณ•ๅนฒๅ‡€ๅœฐๆฌ่ฟ โ€”โ€”
94
+ ๆ‰€ไปฅ `--agent codex` ็ป™ไฝ ้š”็ฆปๅˆ†่บซ + git ๅ›žๆต๏ผŒ่€Œ Codex ๅކๅฒไฟๆŒๅ…จๅฑ€ๅฏ่พพใ€‚่ฏฆ่ง
95
+ [`docs/multi-agent-design.md`](./docs/multi-agent-design.md)ใ€‚
96
+
97
+ ไฝ ๅฏไปฅๅŒๆ—ถ่ท‘ๅคšไธช agent โ€”โ€” ๅ„ๅผ€ไธ€ไธชๅˆ†่บซ๏ผŒๆˆ–ๅœจๅŒไธ€ไธชๅˆ†่บซ้‡Œๆข็€็”จใ€‚ๆฏไธช agent ็”จ่‡ชๅทฑ็š„ store๏ผŒไบ’ไธๅ†ฒ็ช๏ผŒ
98
+ `finish` ไผšๆŠŠๆœ‰ๆ–ฐ session ็š„้‚ฃไบ› store ๅ„่‡ชๅˆๅนถๅ›žๆฅใ€‚
99
+
100
+ **่ฆ็”จ kage ๆ‹‰ไธ่ตทๆฅ็š„ GUI/IDE agent๏ผŸ** ็”จ `--open <cmd>` ๅปบๅฅฝๅˆ†่บซๅŽ่‡ชๅทฑๆ‰“ๅผ€ๅฎƒ๏ผˆๆฏ”ๅฆ‚ `kage --open code`
101
+ ไผšๆ‰ง่กŒ `code <clone>`๏ผ‰๏ผŒๆˆ–็”จ `--no-launch` ๅชๅปบๅˆ†่บซๅนถๆ‰“ๅฐ่ทฏๅพ„ใ€‚ๆ— ่ฎบไฝ ๆ€Žไนˆๆ‰“ๅผ€ๅˆ†่บซ๏ผŒๅช่ฆๆ˜ฏ kage ็ฎก็†ๅ…ถ
102
+ store ็š„ agent๏ผŒ่ฎฐๅฟ†็…งๆ ทๅ›žๆตใ€‚
103
+
104
+ ## Shell ้›†ๆˆ๏ผˆๆŽจ่๏ผ‰
72
105
 
73
106
  ```bash
74
107
  eval "$(kage shell-init)" # ๅŠ ๅˆฐ ~/.zshrc ๆˆ– ~/.bashrc
75
108
  ```
76
109
 
77
- ๅœจๅˆ†่บซๅ†…่ฟ่กŒ `finish`/`rm` ไผšๅˆ ๆމไฝ  shell ๅฝ“ๅ‰ๆ‰€ๅœจ็š„็›ฎๅฝ•ใ€‚่ฟ™ไธชๅŒ…่ฃ…ๅ‡ฝๆ•ฐไผš่‡ชๅŠจๆŠŠไฝ  cd ๅ›žๅŽŸไป“ๅบ“๏ผˆๅฆๅˆ™ CLI
78
- ๆฒกๆณ•ๆ”นๅ˜็ˆถ shell ็š„็›ฎๅฝ•๏ผ‰๏ผŒๅนถไธบๅญๅ‘ฝไปคๅ’Œๅˆ†่บซๅๅŠ ไธŠ tab ่กฅๅ…จใ€‚
110
+ `kage` ไผšๆŠŠไฝ ็š„ shell cd **่ฟ›**ๆ–ฐๅˆ†่บซ๏ผŒ`finish`/`rm` ๅ†ๆŠŠๅฎƒ cd **ๅ›ž**ๅŽŸไป“ๅบ“๏ผˆCLI ๆฒกๆณ•ๆ”นๅ˜็ˆถ shell ็š„็›ฎๅฝ•๏ผŒ
111
+ ๆ‰€ไปฅ่ฟ™้œ€่ฆๅŒ…่ฃ…ๅ‡ฝๆ•ฐ โ€”โ€” ๆฒก่ฃ…็š„่ฏ๏ผŒkage ๅฐฑๅชๆŠŠ `cd` ๅ‘ฝไปคๆ‰“ๅฐๅ‡บๆฅ่ฎฉไฝ ่‡ชๅทฑ่ท‘๏ผ‰ใ€‚ๅฎƒ่ฟ˜ไธบๅญๅ‘ฝไปคๅ’Œๅˆ†่บซๅๅŠ ไธŠ tab ่กฅๅ…จใ€‚
79
112
 
80
113
  ## ๅทฅไฝœๅŽŸ็†
81
114
 
@@ -84,9 +117,10 @@ eval "$(kage shell-init)" # ๅŠ ๅˆฐ ~/.zshrc ๆˆ– ~/.bashrc
84
117
  - **ไปฃ็ ๅช็ป็”ฑ git ๅ›žๆต๏ผŒ็ปไธ็ป่ฟ‡ๅทฅไฝœๅŒบใ€‚** ๆœ‰ remote ๆ—ถ๏ผšpush ๅˆ†ๆ”ฏใ€ๅˆๅนถ PRใ€‚ๆฒกๆœ‰ remote ๆ—ถ๏ผš`finish` ไผšๆŠŠ
85
118
  ๅˆ†่บซ็š„ๅˆ†ๆ”ฏ fetch ่ฟ›ๅŽŸไป“ๅบ“็š„ git๏ผŒๅญ˜ๆˆๆœฌๅœฐๅˆ†ๆ”ฏ `kage/<name>-<sha>`๏ผˆๅŽŸไป“ๅบ“ๅทฅไฝœๅŒบไธๅŠจ โ€”โ€” ไฝ ๆƒณๅˆๅนถๆ—ถๅ†
86
119
  `git merge`๏ผ‰ใ€‚็”ฑไบŽ fetch ๆ— ๆณ•ไฟ็•™ๆœชๆไบค็š„ๆ”นๅŠจ๏ผŒ`finish` ๆ‹’็ปๅˆ ้™คๆœ‰ๆ”นๅŠจ็š„ๅˆ†่บซ๏ผŒ้™ค้žๅŠ  `--force`ใ€‚
87
- - **่ฎฐๅฟ†ๅช็ป็”ฑ `~/.pi` ๅ›žๆต๏ผŒ็ปไธ้‡ๆ”พใ€‚** ๅˆ›ๅปบๆ—ถๆ‹ทๅ…ฅๅŽŸไป“ๅบ“ๆœ€่ฟ‘ 5 ไธช session โ€”โ€” pi ็š„ resume ้€‰ๆ‹ฉๅ™จ่ƒฝ็œ‹ๅˆฐๅฎƒไปฌ๏ผŒ
88
- ไฝ†ๅˆ†่บซๆœฌ่บซๆ‰“ๅผ€็š„ๆ˜ฏ**ๅ…จๆ–ฐ** sessionใ€‚`finish` ๆ—ถ๏ผŒๅˆ†่บซ่‡ชๅทฑไบง็”Ÿ็š„ session ๆ•ดไปฝๆ‹ทๅ›ž๏ผ›ไฝ  resume ่ฟ‡็š„ๆ‹ทๅ…ฅ session
89
- ไผšไฝœไธบไธ€ไธช็‹ฌ็ซ‹็š„ๆ–ฐ session ๅ›žๆฅ๏ผŒๅŽŸไป“ๅบ“็š„ๅŽŸๅง‹ session ็ปไธ่ขซๆ”นๅŠจใ€‚
120
+ - **่ฎฐๅฟ†็ป็”ฑ่ฏฅ agent ่‡ชๅทฑ็š„ session ๅญ˜ๅ‚จๅ›žๆต๏ผŒ็ปไธ้‡ๆ”พใ€‚** ๅˆ›ๅปบๆ—ถๆ‹ทๅ…ฅๅŽŸไป“ๅบ“ๆœ€่ฟ‘ 5 ไธช่ฏฅ agent ็š„ session โ€”โ€” ่ฏฅ
121
+ agent ็š„ resume ้€‰ๆ‹ฉๅ™จๅœจไฝ ๅฏๅŠจๅฎƒๆ—ถ่ƒฝ็œ‹ๅˆฐๅฎƒไปฌ๏ผŒไฝ†ไฝ ไปŽไธ€ไธช**ๅ…จๆ–ฐ** session ๅผ€ๅง‹ใ€‚`finish` ๆ—ถ๏ผŒๅˆ†่บซ่‡ชๅทฑไบง็”Ÿ็š„ session ๆ•ดไปฝ
122
+ ๆ‹ทๅ›ž๏ผ›ไฝ  resume ่ฟ‡็š„ๆ‹ทๅ…ฅ session ไผšไฝœไธบไธ€ไธช็‹ฌ็ซ‹็š„ๆ–ฐ session ๅ›žๆฅ๏ผŒๅŽŸไป“ๅบ“็š„ๅŽŸๅง‹ session ็ปไธ่ขซๆ”นๅŠจใ€‚๏ผˆCodex ๆ˜ฏ
123
+ ไพ‹ๅค– โ€”โ€” ไป… git ๅ›žๆต๏ผ›่ง [Agents](#agents)ใ€‚๏ผ‰
90
124
  - **ๅฏน kage ่€Œ่จ€ๅŽŸไป“ๅบ“ๆ˜ฏๅช่ฏป็š„ใ€‚** ๅฎƒๅชๅพ€ๅค–ๅคๅˆถใ€ๅชๅ†™ session ่ฎฐๅฟ† โ€”โ€” ๅณไฝฟๅŽŸไป“ๅบ“้‡Œๅฆๆœ‰ไธ€ไธช session ๆญฃๆดป่ทƒ๏ผŒ
91
125
  ๅฎƒไนŸ็ปไธ็ขฐๅŽŸไป“ๅบ“็š„ๅทฅไฝœๅŒบใ€‚
92
126
 
@@ -95,7 +129,9 @@ eval "$(kage shell-init)" # ๅŠ ๅˆฐ ~/.zshrc ๆˆ– ~/.bashrc
95
129
  - ๅ‰ฏๆœฌๆ˜ฏๅŽŸไป“ๅบ“**ๅฝ“ๅ‰**็Šถๆ€็š„ๅฟซ็…ง๏ผŒๅŒ…ๅซๆœชๆไบค็š„ๆ”นๅŠจใ€‚
96
130
  - **Submodule**๏ผšsubmodule ็š„ `.git` ๆ˜ฏ็ปๅฏน่ทฏๅพ„๏ผŒๅคๅˆถๅŽไผšๅคฑๆ•ˆ โ€”โ€” ๅœจๅˆ†่บซ้‡Œ่ท‘ไธ€ๆฌก
97
131
  `git submodule update --init`ใ€‚
98
- - Session ๅญ˜ๅ‚จ้ป˜่ฎคๅœจ `~/.pi/agent/sessions`๏ผŒๅฏ็”จ `KAGE_SESSIONS_DIR` ่ฆ†็›–ใ€‚
132
+ - kage ไปŽๆฏไธช agent **่‡ชๅทฑ**ๅญ˜ session ็š„ๅœฐๆ–นๅŽป่ฏป๏ผŒๅฐŠ้‡่ฏฅ agent ่‡ชๅทฑ็š„้…็ฝฎๅ˜้‡ โ€”โ€”
133
+ `PI_CODING_AGENT_DIR`๏ผˆpi๏ผ‰ใ€`CLAUDE_CONFIG_DIR`๏ผˆClaude Code๏ผ‰ใ€`CODEX_HOME`๏ผˆCodex๏ผ‰โ€”โ€” ่ฟ™ๆ ท
134
+ kage ๅ’Œ agent ๆฐธ่ฟœๆŒ‡ๅ‘ๅŒไธ€ไธชไฝ็ฝฎใ€‚
99
135
 
100
136
  ## ๅผ€ๅ‘
101
137
 
package/bin/kage.mjs CHANGED
@@ -2,35 +2,32 @@
2
2
  /**
3
3
  * kage ๐Ÿฅท โ€” cast the Shadow Clone Jutsu on a git repo.
4
4
  *
5
- * Copy the current repo into an isolated sibling folder (its own working tree and .git),
6
- * drop straight into `pi` to work in parallel, then `kage finish` merges the session memory
7
- * back into the original and deletes the clone.
5
+ * Copy the current repo into an isolated sibling folder (its own working tree and .git) and cd
6
+ * into it; optionally launch a coding agent there. `kage finish` merges the agent's session memory
7
+ * back into the origin and deletes the clone. Run `kage --help` for the command surface.
8
8
  *
9
9
  * Design invariants:
10
10
  * 1. Isolation โ€” a clone is a full independent copy (its own .git).
11
11
  * 2. Code flows back via git only โ€” a remote PR, or (no remote) a fetch of the clone's branch
12
12
  * into the origin's git on finish; kage never copies the working tree back onto the origin.
13
- * 3. Memory flows via ~/.pi โ€” the origin's session history is copied into the clone on create
13
+ * 3. Memory flows via each agent's own session store (pi/Claude Code keyed by cwd; Codex is
14
+ * launch-only, not kage-managed) โ€” the origin's history is copied into the clone on create
14
15
  * (resumable, never replayed) and the clone's new sessions are merged back on finish. These
15
- * are session .jsonl files, not the working tree, so there's no collision.
16
- * 4. The origin is read-only to kage โ€” it only copies out and writes session memory.
16
+ * are session files, not the working tree, so there's no collision.
17
+ * 4. The origin's working tree is read-only to kage โ€” it only copies out, writes the kage/<name>
18
+ * branch into the origin's git (no-remote finish), and writes session memory.
17
19
  *
18
- * Commands:
19
- * kage [path] [--name x] clone repo + launch a fresh pi (no args: interactive)
20
- * kage status [--pr] dashboard of clones (+ PR status via gh)
21
- * kage finish [name] [--force] check -> merge memory back -> delete clone
22
- * kage rm [name] [--force] discard a clone (no merge)
23
- * kage pull <path...> (inside a clone) copy files back to the origin
20
+ * Launch is opt-in and detachable (see docs/multi-agent-design.md): with no agent named
21
+ * (--agent / $KAGE_AGENT / `kage config agent`), kage just creates the clone and cd's you in.
24
22
  */
25
23
  import { spawn, spawnSync } from "node:child_process";
26
24
  import { randomUUID } from "node:crypto";
27
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
25
+ import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
28
26
  import { homedir } from "node:os";
29
27
  import { basename, dirname, join, resolve, sep } from "node:path";
30
28
  import readline from "node:readline";
31
- const VERSION = "0.3.8"; // keep in sync with package.json (enforced by test)
29
+ const VERSION = "0.5.0"; // keep in sync with package.json (enforced by test)
32
30
  const MARKER = ".kage.json";
33
- const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
34
31
  const RECENT_SESSIONS = 5; // how many of the origin's most-recent sessions to copy into a clone
35
32
  /** Typed accessors for the loose flags bag, so consumers don't re-derive the shape inline. */
36
33
  const boolFlag = (flags, name) => Boolean(flags[name]);
@@ -64,16 +61,16 @@ function sh(cmd, args, opts = {}) {
64
61
  return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
65
62
  }
66
63
  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
64
  function repoTopLevel(cwd) {
71
65
  const r = git(cwd, ["rev-parse", "--show-toplevel"]);
72
66
  return r.ok ? r.out : undefined;
73
67
  }
74
- /** Validate parsed JSON is a kage marker (only originRepo is required; name/createdAt are best-effort). */
68
+ /** Validate parsed JSON has the shape kage writes (all three fields are strings). */
75
69
  function isMarker(v) {
76
- return typeof v === "object" && v !== null && typeof v.originRepo === "string";
70
+ if (typeof v !== "object" || v === null)
71
+ return false;
72
+ const m = v;
73
+ return typeof m.originRepo === "string" && typeof m.name === "string" && typeof m.createdAt === "string";
77
74
  }
78
75
  function readMarker(dir) {
79
76
  const p = join(dir, MARKER);
@@ -87,6 +84,34 @@ function readMarker(dir) {
87
84
  return undefined;
88
85
  }
89
86
  }
87
+ const CONFIG_KEYS = ["agent"];
88
+ function configPath() {
89
+ const base = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
90
+ return join(base, "kage", "config.json");
91
+ }
92
+ function readConfig() {
93
+ const p = configPath();
94
+ if (!existsSync(p))
95
+ return {};
96
+ try {
97
+ const v = JSON.parse(readFileSync(p, "utf8"));
98
+ if (typeof v !== "object" || v === null)
99
+ return {};
100
+ const o = v;
101
+ const cfg = {};
102
+ if (typeof o.agent === "string")
103
+ cfg.agent = o.agent;
104
+ return cfg;
105
+ }
106
+ catch {
107
+ return {};
108
+ }
109
+ }
110
+ function writeConfig(cfg) {
111
+ const p = configPath();
112
+ mkdirSync(dirname(p), { recursive: true });
113
+ writeFileSync(p, `${JSON.stringify(cfg, null, 2)}\n`);
114
+ }
90
115
  /** Copy a whole directory: clonefile on macOS, reflink on Linux, plain copy as fallback. */
91
116
  function copyTree(src, dst) {
92
117
  const isMac = process.platform === "darwin";
@@ -185,7 +210,7 @@ function listClones(originRepo) {
185
210
  const dir = join(parent, name);
186
211
  const m = readMarker(dir);
187
212
  if (m && m.originRepo === originRepo)
188
- out.push({ dir, name: m.name || basename(dir), marker: m });
213
+ out.push({ dir, name: m.name, marker: m });
189
214
  }
190
215
  return out;
191
216
  }
@@ -247,6 +272,14 @@ function prInfo(dir, branch) {
247
272
  }
248
273
  /** True when the clone has no local-only work (clean + pushed) -> safe to remove. */
249
274
  const isSafeToClean = (s) => !s.dirty && s.hasUpstream && s.ahead === 0;
275
+ /** Single-glyph clone state, shared by the dashboard and the menu/picker so a clone always reads the
276
+ * same: โ— uncommitted ยท โ†‘ clean but unpushed/local-only ยท โœ“ clean & pushed (safe to remove). */
277
+ const statusTag = (s) => (s.dirty ? paint.yellow("โ—") : isSafeToClean(s) ? paint.green("โœ“") : paint.yellow("โ†‘"));
278
+ /** One-line clone label for the menu and the finish/rm picker: name, branch, state tag. */
279
+ function cloneLabel(c) {
280
+ const s = cloneStatus(c.dir);
281
+ return `${c.name} ${paint.cyan(s.branch)} ${statusTag(s)}`;
282
+ }
250
283
  /** True if the clone has committed work that lives only in the clone (not on a remote, not yet in the origin). */
251
284
  function hasUnpreservedCommits(originRepo, cloneDir, s) {
252
285
  if (s.hasUpstream && s.ahead === 0)
@@ -322,14 +355,14 @@ async function pickClone(action, name) {
322
355
  const here = repoTopLevel(process.cwd());
323
356
  const hm = here ? readMarker(here) : undefined;
324
357
  if (here && hm && !name)
325
- return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name || basename(here), marker: hm } };
358
+ return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name, marker: hm } };
326
359
  // If `name` resolves to a clone directory, use its marker directly โ€” works from anywhere,
327
360
  // even outside a repo (e.g. `kage rm ../app--fix` from the parent dir).
328
361
  if (name) {
329
362
  const asPath = resolve(name);
330
363
  const pm = readMarker(asPath);
331
364
  if (pm)
332
- return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name || basename(asPath), marker: pm } };
365
+ return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name, marker: pm } };
333
366
  }
334
367
  const originRepo = hm ? hm.originRepo : here;
335
368
  if (!originRepo)
@@ -346,159 +379,355 @@ async function pickClone(action, name) {
346
379
  const first = clones[0];
347
380
  if (first && clones.length === 1)
348
381
  return { originRepo, clone: first };
349
- const idx = await select(`Multiple clones โ€” pick one to ${action}:`, clones.map((c) => `${c.name} ${paint.dim(cloneStatus(c.dir).branch)}`));
382
+ const idx = await select(`Multiple clones โ€” pick one to ${action}:`, clones.map(cloneLabel));
350
383
  const chosen = idx < 0 ? undefined : clones[idx];
351
384
  if (!chosen)
352
385
  return null;
353
386
  return { originRepo, clone: chosen };
354
387
  }
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
388
  /**
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.
389
+ * The cwdโ†’directory algorithm, shared by pi and (later) Claude Code:
390
+ * - importHistory: copy the origin dir's most-recent sessions into the clone dir (cwd rewritten).
391
+ * - mergeBack: a session the clone created comes back whole; a copied-in origin session left
392
+ * unchanged is skipped; a copied-in session the clone resumed and extended comes back as a
393
+ * NEW self-contained file, so the origin's original (and the leaf it resumes) is untouched.
398
394
  */
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;
395
+ function dirStore(cfg) {
396
+ const dirFor = (cwd) => join(cfg.baseDir(), cfg.encodeCwd(cwd));
397
+ return {
398
+ id: cfg.id,
399
+ importHistory(originRepo, cloneDir) {
400
+ const srcDir = dirFor(originRepo);
401
+ if (!existsSync(srcDir))
402
+ return 0;
403
+ const destDir = dirFor(cloneDir);
404
+ mkdirSync(destDir, { recursive: true });
405
+ const recent = readdirSync(srcDir)
406
+ .filter((f) => f.endsWith(".jsonl"))
407
+ .map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
408
+ .sort((a, b) => b.m - a.m)
409
+ .slice(0, RECENT_SESSIONS);
410
+ let n = 0;
411
+ for (const { f } of recent) {
412
+ const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
413
+ writeFileSync(join(destDir, f), cfg.setCwd(lines, cloneDir).join("\n"));
414
+ n++;
415
+ }
416
+ return n;
417
+ },
418
+ mergeBack(cloneDir, originRepo) {
419
+ const srcDir = dirFor(cloneDir);
420
+ if (!existsSync(srcDir))
421
+ return 0;
422
+ const destDir = dirFor(originRepo);
423
+ mkdirSync(destDir, { recursive: true });
424
+ let n = 0;
425
+ for (const f of readdirSync(srcDir)) {
426
+ if (!f.endsWith(".jsonl"))
427
+ continue;
428
+ const src = readFileSync(join(srcDir, f), "utf8")
429
+ .split("\n")
430
+ .filter((l) => l.trim());
431
+ if (src.length === 0)
432
+ continue;
433
+ const dest = join(destDir, f);
434
+ // A session the clone created (not present in the origin) -> copy it back whole.
435
+ if (!existsSync(dest)) {
436
+ writeFileSync(dest, `${cfg.setCwd(src, originRepo).join("\n")}\n`);
437
+ n++;
438
+ continue;
439
+ }
440
+ // A copied-in origin session: write back only if the clone added records, and then as
441
+ // a NEW, self-contained file so the origin's original (and the leaf it resumes) is never
442
+ // mutated. An unchanged copy adds nothing.
443
+ const have = new Set();
444
+ for (const l of readFileSync(dest, "utf8").split("\n")) {
445
+ if (!l.trim())
446
+ continue;
447
+ try {
448
+ have.add(cfg.recordId(l));
449
+ }
450
+ catch {
451
+ /* ignore */
452
+ }
453
+ }
454
+ const hasNew = src.slice(1).some((l) => {
455
+ try {
456
+ return !have.has(cfg.recordId(l));
457
+ }
458
+ catch {
459
+ return false;
460
+ }
461
+ });
462
+ if (!hasNew)
463
+ continue;
464
+ const fresh = cfg.reidentify(src, originRepo);
465
+ writeFileSync(join(destDir, fresh.filename), `${fresh.lines.join("\n")}\n`);
466
+ n++;
467
+ }
417
468
  try {
418
- header = JSON.parse(src[0] ?? "");
469
+ rmSync(srcDir, { recursive: true, force: true });
419
470
  }
420
- catch {
421
- continue;
471
+ catch (e) {
472
+ info(paint.dim(` (couldn't clear the clone's ${cfg.id} sessions at ${srcDir}: ${e.message})`));
422
473
  }
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;
474
+ return n;
475
+ },
476
+ discard(cloneDir) {
477
+ const d = dirFor(cloneDir);
435
478
  try {
436
- have.add(JSON.parse(l).id);
479
+ rmSync(d, { recursive: true, force: true });
437
480
  }
438
- catch {
439
- /* ignore */
481
+ catch (e) {
482
+ info(paint.dim(` (couldn't discard the clone's ${cfg.id} sessions at ${d}: ${e.message})`));
440
483
  }
441
- }
442
- const hasNew = src.slice(1).some((l) => {
484
+ },
485
+ hasActivity(cwd) {
486
+ const d = dirFor(cwd);
443
487
  try {
444
- return !have.has(JSON.parse(l).id);
488
+ return existsSync(d) && readdirSync(d).some((f) => f.endsWith(".jsonl"));
445
489
  }
446
490
  catch {
447
491
  return false;
448
492
  }
449
- });
450
- if (!hasNew)
451
- continue;
452
- let header;
493
+ },
494
+ };
495
+ }
496
+ /** pi: one session per .jsonl, cwd in a first-line header, per-line `id` for dedup. */
497
+ const piStore = dirStore({
498
+ id: "pi",
499
+ baseDir: () => join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "sessions"),
500
+ encodeCwd: (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`,
501
+ setCwd(lines, cwd) {
502
+ const out = [...lines];
453
503
  try {
454
- header = JSON.parse(src[0] ?? "");
504
+ const header = JSON.parse(out[0] ?? "");
505
+ header.cwd = cwd;
506
+ out[0] = JSON.stringify(header);
455
507
  }
456
508
  catch {
457
- continue;
509
+ /* leave a malformed header as-is rather than drop the session */
510
+ }
511
+ return out;
512
+ },
513
+ recordId: (line) => JSON.parse(line).id,
514
+ reidentify(lines, cwd) {
515
+ let header = {};
516
+ try {
517
+ header = JSON.parse(lines[0] ?? "");
518
+ }
519
+ catch {
520
+ /* synthesize a minimal header below */
458
521
  }
459
522
  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
- }
523
+ const filename = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
524
+ return { filename, lines: [JSON.stringify({ ...header, id, cwd }), ...lines.slice(1)] };
525
+ },
526
+ });
527
+ /** Apply a JSON transform to every parseable line, leaving blank/malformed lines untouched. */
528
+ function mapJsonLines(lines, fn) {
529
+ return lines.map((l) => {
530
+ if (!l.trim())
531
+ return l;
532
+ try {
533
+ const rec = JSON.parse(l);
534
+ fn(rec);
535
+ return JSON.stringify(rec);
536
+ }
537
+ catch {
538
+ return l;
539
+ }
540
+ });
541
+ }
542
+ /** Claude Code: one session per <sessionId>.jsonl, with cwd + sessionId repeated on every line. */
543
+ const claudeStore = dirStore({
544
+ id: "claude",
545
+ baseDir: () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "projects"),
546
+ encodeCwd: (abs) => abs.replace(/[^A-Za-z0-9]/g, "-"),
547
+ setCwd: (lines, cwd) => mapJsonLines(lines, (r) => {
548
+ if ("cwd" in r)
549
+ r.cwd = cwd;
550
+ }),
551
+ recordId: (line) => JSON.parse(line).uuid,
552
+ reidentify(lines, cwd) {
553
+ const id = randomUUID();
554
+ const out = mapJsonLines(lines, (r) => {
555
+ if ("sessionId" in r)
556
+ r.sessionId = id;
557
+ if ("cwd" in r)
558
+ r.cwd = cwd;
559
+ });
560
+ return { filename: `${id}.jsonl`, lines: out };
561
+ },
562
+ });
563
+ const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions");
564
+ /**
565
+ * Read just the first line of a (possibly large) file, without loading the whole thing.
566
+ * Accumulates raw bytes and decodes once at the end โ€” decoding each read as UTF-8 would corrupt
567
+ * any multibyte char split across an 8 KiB read boundary (real Codex session_meta lines are tens
568
+ * of KiB of mixed-script text).
569
+ */
570
+ function readFirstLine(file) {
571
+ const fd = openSync(file, "r");
464
572
  try {
465
- rmSync(srcDir, { recursive: true, force: true });
573
+ const buf = Buffer.alloc(8192);
574
+ const chunks = [];
575
+ for (;;) {
576
+ const bytes = readSync(fd, buf, 0, buf.length, null);
577
+ if (bytes <= 0)
578
+ break;
579
+ const nl = buf.subarray(0, bytes).indexOf(0x0a); // newline byte
580
+ if (nl >= 0) {
581
+ chunks.push(Buffer.from(buf.subarray(0, nl)));
582
+ break;
583
+ }
584
+ chunks.push(Buffer.from(buf.subarray(0, bytes)));
585
+ }
586
+ return Buffer.concat(chunks).toString("utf8");
466
587
  }
467
- catch {
468
- /* ignore */
588
+ finally {
589
+ closeSync(fd);
469
590
  }
470
- return n;
591
+ }
592
+ /** Every rollout-*.jsonl under the Codex sessions tree, paired with its session_meta cwd. */
593
+ function codexRollouts() {
594
+ const base = codexSessionsDir();
595
+ if (!existsSync(base))
596
+ return [];
597
+ const out = [];
598
+ const walk = (dir) => {
599
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
600
+ const p = join(dir, e.name);
601
+ if (e.isDirectory())
602
+ walk(p);
603
+ else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) {
604
+ let cwd;
605
+ try {
606
+ cwd = JSON.parse(readFirstLine(p)).payload?.cwd;
607
+ }
608
+ catch {
609
+ /* not a session_meta we understand */
610
+ }
611
+ out.push({ file: p, cwd });
612
+ }
613
+ }
614
+ };
615
+ walk(base);
616
+ return out;
471
617
  }
472
618
  /**
473
- * We just deleted the clone we were running inside, so the parent shell is now in a
474
- * deleted directory. A CLI can't cd its parent shell, so: if the shell wrapper is active
475
- * (KAGE_CD_FILE set by `eval "$(kage shell-init)"`), hand it the origin path to cd into;
476
- * otherwise print a copy-pasteable `cd` and how to enable the auto version.
619
+ * Codex keeps every session in one global tree shared by all cwds, and (since 0.13x) keys its
620
+ * cwd-filtered resume picker off a *versioned internal sqlite index* (e.g. state_5.sqlite's
621
+ * `threads.cwd`), not the rollout. kage can't re-home a clone's sessions to the origin without
622
+ * rewriting that sqlite โ€” fragile (the schema/db version moves between Codex releases) and a
623
+ * runtime dependency kage avoids. So kage does NOT manage Codex memory: `--agent codex` gives the
624
+ * isolated clone + git flow-back, and Codex history stays globally available via
625
+ * `codex resume --all`. import/mergeBack/discard are intentional no-ops; hasActivity (a rollout
626
+ * scan, never the sqlite) only powers the re-enter menu while a clone still exists.
477
627
  */
478
- function leaveClone(originRepo) {
628
+ const codexStore = {
629
+ id: "codex",
630
+ importHistory: () => 0,
631
+ mergeBack: () => 0,
632
+ discard: () => { },
633
+ hasActivity(cwd) {
634
+ return codexRollouts().some((r) => r.cwd === cwd);
635
+ },
636
+ };
637
+ // A CLI can't cd its parent shell. If the shell wrapper is active (KAGE_CD_FILE, set by
638
+ // `eval "$(kage shell-init)"`), hand it a path to cd into once kage exits; otherwise we can only
639
+ // print a copy-pasteable `cd`. armCd does the handoff; leaveClone/cdIntoClone do the messaging.
640
+ function armCd(dir) {
479
641
  const f = process.env.KAGE_CD_FILE;
480
- if (f) {
481
- try {
482
- writeFileSync(f, originRepo);
483
- info(paint.dim(` โ†ฉ back to ${originRepo}`));
484
- return;
485
- }
486
- catch {
487
- /* fall through to the manual hint */
488
- }
642
+ if (!f)
643
+ return false;
644
+ try {
645
+ writeFileSync(f, dir);
646
+ return true;
647
+ }
648
+ catch {
649
+ return false;
650
+ }
651
+ }
652
+ const SHELL_INIT_HINT = `add eval "$(kage shell-init)" to your ~/.zshrc`;
653
+ /** After deleting the clone we were inside, the parent shell sits in a now-deleted dir โ€” send it
654
+ * back to the origin (or print how, without the wrapper). */
655
+ function leaveClone(originRepo) {
656
+ if (armCd(originRepo)) {
657
+ info(paint.dim(` โ†ฉ back to ${originRepo}`));
658
+ return;
489
659
  }
490
660
  info(paint.yellow(` โ†ฉ your shell is still in the deleted clone โ€” run: ${paint.bold(`cd ${originRepo}`)}`));
491
- info(paint.dim(` enable auto cd-back: add eval "$(kage shell-init)" to your ~/.zshrc`));
661
+ info(paint.dim(` enable auto cd-back: ${SHELL_INIT_HINT}`));
662
+ }
663
+ /** Settle the shell inside a clone we just created/entered: cd there if the wrapper is active,
664
+ * else print a copy-pasteable `cd` + the finish hint. */
665
+ function cdIntoClone(cloneDir, name) {
666
+ info("");
667
+ if (armCd(cloneDir)) {
668
+ info(paint.dim(` โ†ช now in the clone โ€” when done: ${paint.bold("kage finish")}`));
669
+ return;
670
+ }
671
+ info(` โ–ธ enter it: ${paint.bold(`cd ${cloneDir}`)}`);
672
+ info(paint.dim(` when done: ${paint.bold(`kage finish ${name}`)}`));
673
+ info(paint.dim(` tip: ${SHELL_INIT_HINT} โ€” then kage cd's you in & out automatically`));
674
+ }
675
+ /** chdir the kage process out of a clone it's about to delete (rmSync of the cwd fails otherwise).
676
+ * Surfaced rather than swallowed โ€” a silent failure here is why a later rmSync would fail. */
677
+ function chdirToOrigin(originRepo) {
678
+ try {
679
+ process.chdir(originRepo);
680
+ }
681
+ catch (e) {
682
+ info(paint.dim(` (couldn't chdir to ${originRepo}: ${e.message})`));
683
+ }
684
+ }
685
+ // Registered agents. Memory sync iterates this list, so each new agent is one entry (+ its
686
+ // SessionStore) with no change to any call site. Codex is the odd one โ€” a flat, cwd-in-content
687
+ // store that kage doesn't memory-manage (see codexStore).
688
+ const AGENTS = [
689
+ { id: "pi", store: piStore, cli: { bin: "pi", freshArgs: [], resumeArgs: ["-c"] } },
690
+ { id: "claude", store: claudeStore, cli: { bin: "claude", freshArgs: [], resumeArgs: ["--continue"] } },
691
+ { id: "codex", store: codexStore, cli: { bin: "codex", freshArgs: [], resumeArgs: ["resume", "--last"] } },
692
+ ];
693
+ const agentById = (id) => AGENTS.find((a) => a.id === id);
694
+ function launchCli(cli, cwd, args) {
695
+ const r = spawnSync(cli.bin, args, { cwd, stdio: "inherit" });
696
+ if (r.error) {
697
+ const err = r.error;
698
+ if (err.code === "ENOENT")
699
+ die(`${cli.bin} not found (make sure it is installed and on your PATH)`);
700
+ die(`failed to launch ${cli.bin}: ${err.message}`);
701
+ }
492
702
  }
493
- function launchPi(cwd, args) {
494
- const r = spawnSync("pi", args, { cwd, stdio: "inherit" });
703
+ /** Open the clone with an external command (e.g. `code <clone>`) and return immediately. */
704
+ function launchOpen(cmd, cwd) {
705
+ const parts = cmd.split(/\s+/).filter(Boolean);
706
+ const bin = parts[0];
707
+ if (!bin)
708
+ die("--open needs a command, e.g. --open code");
709
+ const r = spawnSync(bin, [...parts.slice(1), cwd], { stdio: "inherit" });
495
710
  if (r.error) {
496
711
  const err = r.error;
497
712
  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}`);
713
+ die(`${bin} not found (make sure it is installed and on your PATH)`);
714
+ die(`failed to run --open ${cmd}: ${err.message}`);
500
715
  }
501
716
  }
717
+ /** Re-enter a clone from the menu: resume an agent that has activity here (ask if several), then
718
+ * leave the shell in the clone. With no activity โ€” or if you cancel the resume picker โ€” just cd in;
719
+ * kage never auto-launches an agent, and Enter always lands you in the clone. */
720
+ async function enterClone(cloneDir, name) {
721
+ const active = AGENTS.filter((a) => a.cli && a.store.hasActivity(cloneDir));
722
+ let chosen = active[0]; // undefined when nothing has activity here
723
+ if (active.length > 1) {
724
+ const idx = await select("Resume which agent? (Esc = just cd in)", active.map((a) => a.id));
725
+ chosen = idx < 0 ? undefined : active[idx]; // cancel โ†’ don't resume, but still cd in below
726
+ }
727
+ if (chosen?.cli)
728
+ launchCli(chosen.cli, cloneDir, chosen.cli.resumeArgs);
729
+ cdIntoClone(cloneDir, name);
730
+ }
502
731
  // โ”€โ”€ subcommands โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
503
732
  async function cmdNew(argv) {
504
733
  const { positional, flags } = parseArgs(argv);
@@ -508,24 +737,22 @@ async function cmdNew(argv) {
508
737
  const repoRoot = repoTopLevel(process.cwd());
509
738
  const clones = repoRoot ? listClones(repoRoot) : [];
510
739
  if (repoRoot && clones.length > 0) {
511
- const labels = [
512
- "๏ผ‹ Create a new shadow clone",
513
- ...clones.map((c) => {
514
- const s = cloneStatus(c.dir);
515
- const tag = s.dirty ? paint.yellow(" โ—") : isSafeToClean(s) ? paint.green(" โœ“") : "";
516
- return `โ†’ Enter ${c.name} ${paint.cyan(s.branch)}${tag}`;
517
- }),
518
- ];
519
- const idx = await select(`Shadow clones of ${basename(repoRoot)} โ€” pick one, or create:`, labels);
740
+ const labels = ["๏ผ‹ Create a new clone", ...clones.map(cloneLabel)];
741
+ const idx = await select(`Shadow clones of ${basename(repoRoot)} โ€” pick one to act on, or create:`, labels);
520
742
  if (idx < 0)
521
743
  return info("cancelled");
522
744
  if (idx > 0) {
523
745
  const clone = clones[idx - 1];
524
746
  if (!clone)
525
747
  return info("cancelled");
526
- const act = await select(`${clone.name}:`, ["Enter (resume pi)", "Finish (merge memory & remove)", "Remove (discard)", "Cancel"]);
748
+ const act = await select(`${clone.name}:`, [
749
+ "Enter (resume agent + cd in)",
750
+ "Finish (merge memory & remove)",
751
+ "Remove (discard, no merge)",
752
+ "Cancel",
753
+ ]);
527
754
  if (act === 0)
528
- return launchPi(clone.dir, ["-c"]);
755
+ return enterClone(clone.dir, clone.name);
529
756
  if (act === 1)
530
757
  return cmdFinish([clone.name]);
531
758
  if (act === 2)
@@ -547,18 +774,27 @@ async function cmdNew(argv) {
547
774
  let name = strFlag(flags, "name") ?? "";
548
775
  if (!name) {
549
776
  const def = tsName();
550
- const prompt = `Kage name: ${basename(repoRoot)}--`;
777
+ const prompt = `Clone name: ${basename(repoRoot)}--`;
551
778
  name = (process.stdin.isTTY ? await ask(prompt, def) : "") || def;
552
779
  }
553
780
  const safe = slug(name);
554
781
  const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
555
782
  if (existsSync(cloneDir))
556
783
  die(`directory already exists: ${cloneDir}`);
784
+ // Launch is opt-in: a CLI launches only if you named one (--agent / $KAGE_AGENT / `kage config agent`).
785
+ // Resolve it up front so a typo fails before we copy; with none set, kage just cd's you in (below).
786
+ const agentId = strFlag(flags, "agent") ?? process.env.KAGE_AGENT ?? readConfig().agent;
787
+ const agent = agentId ? agentById(agentId) : undefined;
788
+ if (agentId && !agent)
789
+ die(`unknown agent: ${agentId} (supported: ${AGENTS.map((a) => a.id).join(", ")})`);
557
790
  const cp = await copyRepo(repoRoot, cloneDir);
558
791
  if (!cp.ok)
559
792
  die(`copy failed: ${cp.err}`);
560
793
  // kage does NOT create a branch โ€” the clone stays on the origin's current branch.
561
- const histN = copyOriginHistory(repoRoot, cloneDir);
794
+ // Set-based memory: each agent's store imports its own origin history (no-op when empty).
795
+ let histN = 0;
796
+ for (const a of AGENTS)
797
+ histN += a.store.importHistory(repoRoot, cloneDir);
562
798
  const marker = {
563
799
  originRepo: repoRoot,
564
800
  name: safe,
@@ -570,12 +806,22 @@ async function cmdNew(argv) {
570
806
  info(`๐Ÿฅท ${paint.bold("Shadow clone ready")}: ${cloneDir}`);
571
807
  info(` origin: ${repoRoot} branch: ${paint.cyan(curBranch)}`);
572
808
  if (histN > 0)
573
- info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
574
- info(paint.dim(` when done: kage finish ${safe}`));
575
- info("");
576
- launchPi(cloneDir, []);
577
- info("");
578
- info(`โ†ฉ๏ธŽ left the clone's pi. To finish: ${paint.bold(`kage finish ${safe}`)}`);
809
+ info(paint.dim(` origin's ${histN} session(s) imported โ€” resume them from inside the clone`));
810
+ // Optionally hand off to an agent, then settle the shell in the clone (always):
811
+ // --open <cmd> โ†’ run it (e.g. an editor); an agent named โ†’ spawn it (blocking);
812
+ // else (--no-launch, or no agent set) โ†’ launch nothing.
813
+ const openCmd = strFlag(flags, "open");
814
+ if (openCmd || boolFlag(flags, "open")) {
815
+ if (!openCmd)
816
+ die("--open needs a command, e.g. --open code");
817
+ launchOpen(openCmd, cloneDir);
818
+ }
819
+ else if (agent && !boolFlag(flags, "no-launch")) {
820
+ if (!agent.cli)
821
+ die(`agent ${agent.id} has no CLI to launch โ€” use --open <cmd> or --no-launch`);
822
+ launchCli(agent.cli, cloneDir, agent.cli.freshArgs);
823
+ }
824
+ cdIntoClone(cloneDir, safe);
579
825
  }
580
826
  async function cmdFinish(argv) {
581
827
  const { positional, flags } = parseArgs(argv);
@@ -641,13 +887,10 @@ async function cmdFinish(argv) {
641
887
  die(`failed to preserve the clone's branch into the origin: ${r.err}`);
642
888
  info(`๐ŸŒฟ preserved the clone's commits in the origin as ${paint.cyan(target)} (merge with: git merge ${target})`);
643
889
  }
644
- const n = mergeBack(clone.dir, originRepo);
645
- try {
646
- process.chdir(originRepo);
647
- }
648
- catch {
649
- /* ignore */
650
- }
890
+ let n = 0;
891
+ for (const a of AGENTS)
892
+ n += a.store.mergeBack(clone.dir, originRepo);
893
+ chdirToOrigin(originRepo);
651
894
  rmSync(clone.dir, { recursive: true, force: true });
652
895
  info(`๐Ÿ’จ Clone dispelled: merged ${n} session(s) back, removed ${clone.dir}`);
653
896
  if (insideClone)
@@ -669,18 +912,9 @@ async function cmdRm(argv) {
669
912
  if (!(await confirm(`Discard clone ${clone.name} without merging its memory?`)))
670
913
  return info("aborted");
671
914
  }
672
- try {
673
- process.chdir(originRepo);
674
- }
675
- catch {
676
- /* ignore */
677
- }
678
- try {
679
- rmSync(sessionDirFor(clone.dir), { recursive: true, force: true });
680
- }
681
- catch {
682
- /* ignore */
683
- }
915
+ chdirToOrigin(originRepo);
916
+ for (const a of AGENTS)
917
+ a.store.discard(clone.dir);
684
918
  rmSync(clone.dir, { recursive: true, force: true });
685
919
  info(`๐Ÿ—‘ Removed clone ${clone.name} (${clone.dir})`);
686
920
  if (insideClone)
@@ -704,8 +938,8 @@ function cmdList(argv) {
704
938
  const s = cloneStatus(c.dir);
705
939
  const pr = boolFlag(flags, "pr") ? prInfo(c.dir, s.branch) : undefined;
706
940
  // header: status glyph ยท name ยท branch ยท age
707
- 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)}`) : "";
941
+ const glyph = statusTag(s);
942
+ const age = paint.dim(`created ${ago(c.marker.createdAt)}`);
709
943
  info(` ${glyph} ${paint.bold(c.name)} ${paint.cyan(s.branch)} ${age}`);
710
944
  // detail: working-tree state ยท sync ยท PR ยท safe-to-clean
711
945
  const parts = [];
@@ -781,6 +1015,41 @@ function cmdPull(argv) {
781
1015
  }
782
1016
  info(`๐Ÿ“ค Pulled ${done}/${positional.length} path(s) from the clone back to the origin (${originRepo})`);
783
1017
  }
1018
+ /** get/set kage's small persisted config (~/.config/kage/config.json). Keys are validated so a
1019
+ * typo can't silently become a "default". `kage config` (no args) prints the file path + every key. */
1020
+ function cmdConfig(argv) {
1021
+ const { positional, flags } = parseArgs(argv);
1022
+ const cfg = readConfig();
1023
+ const key = positional[0] ?? (typeof flags.unset === "string" ? flags.unset : undefined);
1024
+ const value = positional[1];
1025
+ if (!key) {
1026
+ info(paint.dim(`# ${configPath()}`));
1027
+ for (const k of CONFIG_KEYS)
1028
+ info(`${k} = ${cfg[k] ?? paint.dim("(unset)")}`);
1029
+ return;
1030
+ }
1031
+ if (!CONFIG_KEYS.includes(key)) {
1032
+ die(`unknown config key: ${key} (known: ${CONFIG_KEYS.join(", ")})`);
1033
+ }
1034
+ const k = key;
1035
+ if (boolFlag(flags, "unset")) {
1036
+ delete cfg[k];
1037
+ writeConfig(cfg);
1038
+ info(`unset ${k}`);
1039
+ return;
1040
+ }
1041
+ if (value === undefined) {
1042
+ // a bare read goes to stdout so `X=$(kage config <key>)` works; other output stays on stderr
1043
+ process.stdout.write(`${cfg[k] ?? ""}\n`);
1044
+ return;
1045
+ }
1046
+ if (k === "agent" && !agentById(value)) {
1047
+ die(`unknown agent: ${value} (supported: ${AGENTS.map((a) => a.id).join(", ")})`);
1048
+ }
1049
+ cfg[k] = value;
1050
+ writeConfig(cfg);
1051
+ info(`${k} = ${value}`);
1052
+ }
784
1053
  const SHELL_INIT = `# kage shell integration โ€” add to ~/.zshrc or ~/.bashrc: eval "$(kage shell-init)"
785
1054
  kage() {
786
1055
  local f; f="$(mktemp "\${TMPDIR:-/tmp}/kage-cd.XXXXXX")"
@@ -791,18 +1060,20 @@ kage() {
791
1060
  }
792
1061
  if [ -n "$ZSH_VERSION" ]; then
793
1062
  _kage() {
794
- if (( CURRENT == 2 )); then compadd new status finish rm pull; return; fi
1063
+ if (( CURRENT == 2 )); then compadd new status finish rm pull config; return; fi
795
1064
  case "\${words[2]}" in
796
1065
  finish|rm) compadd $(command kage __clones 2>/dev/null);;
1066
+ config) compadd agent;;
797
1067
  esac
798
1068
  }
799
1069
  compdef _kage kage
800
1070
  elif [ -n "$BASH_VERSION" ]; then
801
1071
  _kage() {
802
1072
  local cur="\${COMP_WORDS[COMP_CWORD]}"
803
- if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "new status finish rm pull" -- "$cur") ); return; fi
1073
+ if [ "$COMP_CWORD" -eq 1 ]; then COMPREPLY=( $(compgen -W "new status finish rm pull config" -- "$cur") ); return; fi
804
1074
  case "\${COMP_WORDS[1]}" in
805
1075
  finish|rm) COMPREPLY=( $(compgen -W "$(command kage __clones 2>/dev/null)" -- "$cur") );;
1076
+ config) COMPREPLY=( $(compgen -W "agent" -- "$cur") );;
806
1077
  esac
807
1078
  }
808
1079
  complete -F _kage kage
@@ -817,7 +1088,8 @@ function cmdClones() {
817
1088
  const HELP = `kage ๐Ÿฅท โ€” Shadow Clone Jutsu for your git repo
818
1089
 
819
1090
  Usage:
820
- kage [path] [--name <x>] clone repo + launch a fresh pi
1091
+ kage [path] [--name <x>] [--agent <id>] clone the repo and cd into it (launch an agent only if one is set)
1092
+ --open <cmd> opens it elsewhere; --no-launch skips the agent
821
1093
  (no args inside a repo with clones: interactive menu)
822
1094
  kage status [--pr] dashboard of clones (--pr adds PR status via gh)
823
1095
  kage finish [name] [--force] [--push] [--pr] preserve work -> merge memory back -> delete clone
@@ -825,7 +1097,8 @@ Usage:
825
1097
  kept in the origin as a local 'kage/<name>-<sha>' branch)
826
1098
  kage rm [name] [--force] discard a clone without merging
827
1099
  kage pull <path...> (inside a clone) copy files back to the origin
828
- kage shell-init shell wrapper (cd-back) + tab completion
1100
+ kage config [<key> [value]] [--unset] get/set persisted defaults (e.g. the launch agent)
1101
+ kage shell-init shell wrapper (cd into clones / back) + tab completion
829
1102
  kage --help | --version show this help / print the version
830
1103
 
831
1104
  With no args inside a repo that already has clones, kage opens an interactive menu
@@ -834,12 +1107,16 @@ With no args inside a repo that already has clones, kage opens an interactive me
834
1107
  Options:
835
1108
  --name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>); skips the name prompt
836
1109
  (sanitized to a git-ref-safe slug, since the name is also used as a branch name)
1110
+ --agent <id> launch this agent CLI in the clone (one-off). with none set (--agent > $KAGE_AGENT >
1111
+ 'kage config agent') kage just cd's you into the clone. memory syncs for every agent
1112
+ --open <cmd> after cloning, run '<cmd> <clone>' (e.g. --open code), then cd into the clone
1113
+ --no-launch don't launch an agent even if one is configured โ€” just create the clone and cd in
837
1114
  --pr (finish) push the branch and open a GitHub PR via gh, then finish
838
1115
  --push (finish) push the branch before finishing (implied by --pr)
839
1116
  --force skip the safety checks: uncommitted/unpushed guard (finish) or local-only guard (rm)
840
1117
 
841
1118
  Examples:
842
- kage # clone the current repo, pick a name, open a fresh pi to work in
1119
+ kage # clone the current repo, pick a name, and cd into the clone
843
1120
  kage --name fix-login # same, but name the clone ../<repo>--fix-login (no prompt)
844
1121
  kage ~/code/other-repo # clone a different repo instead of the current dir
845
1122
 
@@ -853,9 +1130,13 @@ Examples:
853
1130
  kage rm experiment # throw a clone away without merging its memory
854
1131
 
855
1132
  kage pull .env # inside a clone: copy a gitignored file back to the origin
1133
+ kage config agent claude # launch claude on create (instead of just cd-ing into the clone)
856
1134
 
857
- Env:
858
- KAGE_SESSIONS_DIR pi session storage (default: ~/.pi/agent/sessions)`;
1135
+ Env (each agent's own native var โ€” kage just honors it, so kage and the agent always agree):
1136
+ KAGE_AGENT agent to launch on create when --agent is omitted (none set = just cd into the clone)
1137
+ PI_CODING_AGENT_DIR pi's agent dir; sessions read from <it>/sessions (default: ~/.pi/agent)
1138
+ CLAUDE_CONFIG_DIR Claude Code's config dir; sessions read from <it>/projects (default: ~/.claude)
1139
+ CODEX_HOME Codex's home dir; sessions read from <it>/sessions (default: ~/.codex)`;
859
1140
  async function main() {
860
1141
  const [sub, ...rest] = process.argv.slice(2);
861
1142
  switch (sub) {
@@ -863,7 +1144,6 @@ async function main() {
863
1144
  case "new":
864
1145
  return cmdNew(sub === "new" ? rest : process.argv.slice(2));
865
1146
  case "status":
866
- case "list": // alias
867
1147
  return cmdList(rest);
868
1148
  case "finish":
869
1149
  return cmdFinish(rest);
@@ -871,8 +1151,9 @@ async function main() {
871
1151
  return cmdRm(rest);
872
1152
  case "pull":
873
1153
  return cmdPull(rest);
874
- case "shell-init":
875
- case "completion": {
1154
+ case "config":
1155
+ return cmdConfig(rest);
1156
+ case "shell-init": {
876
1157
  process.stdout.write(`${SHELL_INIT}\n`);
877
1158
  // When a human runs this directly (stdout is a TTY, not captured by `$(...)`),
878
1159
  // the script just scrolled past unused โ€” show how to actually activate it.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-kage",
3
- "version": "0.3.8",
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",
3
+ "version": "0.5.0",
4
+ "description": "๐Ÿฅท Shadow Clone Jutsu for your git repo: copy it into an isolated sibling folder, run your coding agent (pi, Claude Code, Codex) in parallel, then merge the session memory back",
5
5
  "keywords": [
6
6
  "pi",
7
7
  "coding-agent",