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 +59 -20
- package/README.zh-CN.md +55 -19
- package/bin/kage.mjs +469 -188
- package/package.json +2 -2
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
|
|
20
|
-
# ...commit
|
|
21
|
-
kage finish
|
|
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
|
|
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
|
|
50
|
-
|
|
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
|
-
##
|
|
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>`),
|
|
64
|
-
| `kage status [--pr]` | origin repo | Dashboard: branch, dirty/clean, ahead/behind, "safe to clean". `--pr` adds PR state via `gh`.
|
|
65
|
-
| `kage finish [name] [--force] [--push] [--pr]` | origin / inside clone |
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
82
|
-
|
|
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
|
|
95
|
-
copied in โ
|
|
96
|
-
sessions the clone created come back whole; a copied-in session
|
|
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
|
-
-
|
|
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
|
|
19
|
-
#
|
|
20
|
-
kage finish
|
|
20
|
+
kage --agent pi # ๐ฅท ๅคๅถ โ ../my-app--<name>๏ผcd ่ฟๅป๏ผๅผไธไธชๅ
จๆฐ็ pi
|
|
21
|
+
# ...็ผ่พ ยท ๆไบค ยท push ยท ๅผ PR ยท ้ๅบ pi...
|
|
22
|
+
kage finish # ๐จ ๆ pi ็ session ่ฎฐๅฟๅๅนถๅๆฅ๏ผๅ ๆๅ่บซ๏ผๅๆไฝ cd ๅๅไปๅบ
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
|
|
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
|
|
47
|
-
|
|
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
|
|
61
|
-
| `kage status [--pr]` | ๅไปๅบ | ไปช่กจ็๏ผๅๆฏใๆฏๅฆๆๆนๅจใahead/behindใๆฏๅฆใๅฏๅฎๅ
จๆธ
็ใใ`--pr` ้่ฟ `gh` ้ๅธฆ PR
|
|
62
|
-
| `kage finish [name] [--force] [--push] [--pr]` | ๅไปๅบ / ๅ่บซๅ
|
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
-
|
|
88
|
-
|
|
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
|
-
-
|
|
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
|
-
*
|
|
7
|
-
* back into the
|
|
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
|
|
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
|
|
16
|
-
* 4. The origin is read-only to kage โ it only copies out
|
|
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
|
-
*
|
|
19
|
-
*
|
|
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.
|
|
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
|
|
68
|
+
/** Validate parsed JSON has the shape kage writes (all three fields are strings). */
|
|
75
69
|
function isMarker(v) {
|
|
76
|
-
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
-
*
|
|
393
|
-
* -
|
|
394
|
-
* - a copied-in origin session left
|
|
395
|
-
*
|
|
396
|
-
* self-contained
|
|
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
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
-
|
|
469
|
+
rmSync(srcDir, { recursive: true, force: true });
|
|
419
470
|
}
|
|
420
|
-
catch {
|
|
421
|
-
|
|
471
|
+
catch (e) {
|
|
472
|
+
info(paint.dim(` (couldn't clear the clone's ${cfg.id} sessions at ${srcDir}: ${e.message})`));
|
|
422
473
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
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
|
-
|
|
479
|
+
rmSync(d, { recursive: true, force: true });
|
|
437
480
|
}
|
|
438
|
-
catch {
|
|
439
|
-
|
|
481
|
+
catch (e) {
|
|
482
|
+
info(paint.dim(` (couldn't discard the clone's ${cfg.id} sessions at ${d}: ${e.message})`));
|
|
440
483
|
}
|
|
441
|
-
}
|
|
442
|
-
|
|
484
|
+
},
|
|
485
|
+
hasActivity(cwd) {
|
|
486
|
+
const d = dirFor(cwd);
|
|
443
487
|
try {
|
|
444
|
-
return
|
|
488
|
+
return existsSync(d) && readdirSync(d).some((f) => f.endsWith(".jsonl"));
|
|
445
489
|
}
|
|
446
490
|
catch {
|
|
447
491
|
return false;
|
|
448
492
|
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
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(
|
|
504
|
+
const header = JSON.parse(out[0] ?? "");
|
|
505
|
+
header.cwd = cwd;
|
|
506
|
+
out[0] = JSON.stringify(header);
|
|
455
507
|
}
|
|
456
508
|
catch {
|
|
457
|
-
|
|
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
|
|
461
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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
|
-
|
|
468
|
-
|
|
588
|
+
finally {
|
|
589
|
+
closeSync(fd);
|
|
469
590
|
}
|
|
470
|
-
|
|
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
|
-
*
|
|
474
|
-
*
|
|
475
|
-
*
|
|
476
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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:
|
|
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
|
-
|
|
494
|
-
|
|
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(
|
|
499
|
-
die(`failed to
|
|
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
|
-
|
|
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}:`, [
|
|
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
|
|
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 = `
|
|
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
|
-
|
|
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)
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
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
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
-
|
|
673
|
-
|
|
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 =
|
|
708
|
-
const age =
|
|
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>]
|
|
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
|
|
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,
|
|
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
|
-
|
|
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 "
|
|
875
|
-
|
|
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.
|
|
4
|
-
"description": "๐ฅท Shadow Clone Jutsu for your git repo: copy it into an isolated folder,
|
|
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",
|