pi-kage 0.3.8 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -8
- package/README.zh-CN.md +22 -8
- package/bin/kage.mjs +324 -127
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -22,7 +22,8 @@ kage finish # 💨 merge the clone's sessions back, delete the clone
|
|
|
22
22
|
```
|
|
23
23
|
|
|
24
24
|
Code comes back through git (a PR, or a branch fetch). The agent's session memory comes back through
|
|
25
|
-
`~/.pi
|
|
25
|
+
its own session store (`~/.pi`, `~/.claude`, or `~/.codex`). kage never copies a working tree back onto
|
|
26
|
+
the origin — that's the whole point.
|
|
26
27
|
|
|
27
28
|
## Why a full copy, not `git worktree`?
|
|
28
29
|
|
|
@@ -60,8 +61,8 @@ cd kage && npm install && npm link # npm install builds bin/kage.mjs from src/
|
|
|
60
61
|
|
|
61
62
|
| Command | Run from | What it does |
|
|
62
63
|
|---|---|---|
|
|
63
|
-
| `kage [path] [--name x]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), copy in the origin's 5 most recent
|
|
64
|
-
| `kage status [--pr]` | origin repo | Dashboard: branch, dirty/clean, ahead/behind, "safe to clean". `--pr` adds PR state via `gh`.
|
|
64
|
+
| `kage [path] [--name x] [--agent pi\|claude\|codex]` | origin repo | Copy the repo to `../<repo>--<name>` (default `kage-<ts>`), copy in the origin's 5 most recent sessions for that agent (resumable, never replayed), and launch a **fresh** agent (pi by default). `--name` only names the folder — kage never creates a branch. No args + existing clones → interactive menu. |
|
|
65
|
+
| `kage status [--pr]` | origin repo | Dashboard: branch, dirty/clean, ahead/behind, "safe to clean". `--pr` adds PR state via `gh`. |
|
|
65
66
|
| `kage finish [name] [--force] [--push] [--pr]` | origin / inside clone | Refuse if the clone has uncommitted or unpushed work, merge its **new** sessions back, delete it. `--push` pushes the branch first; `--pr` pushes + opens a PR via `gh`; `--force` skips the guard. |
|
|
66
67
|
| `kage rm [name] [--force]` | origin / inside clone | Discard a clone **without** merging memory. Refuses local-only work unless `--force`. |
|
|
67
68
|
| `kage pull <path...>` | inside a clone | Copy specific files/dirs (even gitignored, e.g. a generated `.env`) back to the origin. |
|
|
@@ -72,6 +73,18 @@ Run bare `kage` inside a repo that already has clones to get an interactive pick
|
|
|
72
73
|
or **enter** / **finish** / **remove** an existing one. `finish` and `rm` show the same picker when you
|
|
73
74
|
have several clones and don't name one.
|
|
74
75
|
|
|
76
|
+
### Other agents (Claude Code, Codex)
|
|
77
|
+
|
|
78
|
+
kage isn't pi-only. `--agent claude` or `--agent codex` launches that agent instead. For **pi and Claude
|
|
79
|
+
Code**, memory flows the same way — through that agent's own session store (whether you drive it from the
|
|
80
|
+
CLI, the IDE extension, or the desktop app), and two agents can even work one repo in parallel (a clone
|
|
81
|
+
each, or different agents in one clone); `finish` merges back whichever stores have new sessions.
|
|
82
|
+
**Codex is launch-only**: its sessions live in one global, sqlite-indexed store kage can't cleanly
|
|
83
|
+
re-home, so `--agent codex` gives you the isolated clone + git flow-back while your Codex history stays
|
|
84
|
+
globally available via `codex resume --all`. For a GUI/desktop app kage can't spawn, use `--open <cmd>`
|
|
85
|
+
(e.g. `kage --open code`) or `--no-launch` to just make the clone and open it yourself. `KAGE_AGENT` sets
|
|
86
|
+
your default agent.
|
|
87
|
+
|
|
75
88
|
### Shell integration (optional)
|
|
76
89
|
|
|
77
90
|
```bash
|
|
@@ -91,10 +104,12 @@ completion for subcommands and clone names.
|
|
|
91
104
|
Without a remote: `finish` fetches the clone's branch into the origin's git as a local
|
|
92
105
|
`kage/<name>-<sha>` branch (origin working tree untouched — `git merge` it when you like). Because a
|
|
93
106
|
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.
|
|
107
|
+
- **Memory flows via the agent's own session store, never replayed.** On create, the origin's 5 most
|
|
108
|
+
recent sessions for that agent are copied in — the agent's resume picker surfaces them, but the clone
|
|
109
|
+
opens a **fresh** session. On `finish`, sessions the clone created come back whole; a copied-in session
|
|
110
|
+
you resumed comes back as a separate new session, so the origin's original is never mutated. (Codex is the
|
|
111
|
+
exception — its sessions live in one global, sqlite-indexed store kage can't cleanly re-home, so kage
|
|
112
|
+
doesn't manage Codex memory; find Codex history with `codex resume --all`. See `docs/multi-agent-design.md`.)
|
|
98
113
|
- **The origin is read-only to kage.** It only copies out and writes session memory — it never touches
|
|
99
114
|
the origin's working tree, even while another session is live there.
|
|
100
115
|
|
|
@@ -103,7 +118,9 @@ completion for subcommands and clone names.
|
|
|
103
118
|
- The copy snapshots the origin's **current** state, including uncommitted changes.
|
|
104
119
|
- **Submodules**: a submodule's `.git` is an absolute path and breaks on copy — run
|
|
105
120
|
`git submodule update --init` in the clone.
|
|
106
|
-
-
|
|
121
|
+
- kage reads each agent's sessions from where that agent itself stores them, honoring the agent's own
|
|
122
|
+
config var — `PI_CODING_AGENT_DIR` (pi), `CLAUDE_CONFIG_DIR` (Claude Code), `CODEX_HOME` (Codex) — so
|
|
123
|
+
kage and the agent always agree on the location.
|
|
107
124
|
|
|
108
125
|
## Development
|
|
109
126
|
|
package/README.zh-CN.md
CHANGED
|
@@ -20,8 +20,8 @@ kage # 🥷 复制 → ../my-app--kage-<ts>,打开一个全新的 p
|
|
|
20
20
|
kage finish # 💨 把分身的 session 合并回来,删掉分身
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
代码通过 git 回流(一个 PR,或者 fetch 分支);agent 的 session
|
|
24
|
-
|
|
23
|
+
代码通过 git 回流(一个 PR,或者 fetch 分支);agent 的 session 记忆通过它自己的 session 存储(`~/.pi`、
|
|
24
|
+
`~/.claude` 或 `~/.codex`)回流。kage 从不把工作区复制回原仓库 —— 这正是它存在的意义。
|
|
25
25
|
|
|
26
26
|
## 为什么用完整副本,而不是 `git worktree`?
|
|
27
27
|
|
|
@@ -57,8 +57,8 @@ cd kage && npm install && npm link # npm install 会从 src/ 编译出 bin/kag
|
|
|
57
57
|
|
|
58
58
|
| 命令 | 在哪运行 | 作用 |
|
|
59
59
|
|---|---|---|
|
|
60
|
-
| `kage [path] [--name x]` | 原仓库 | 把仓库复制到 `../<repo>--<name>`(默认 `kage-<ts>`),拷入原仓库最近 5
|
|
61
|
-
| `kage status [--pr]` | 原仓库 | 仪表盘:分支、是否有改动、ahead/behind、是否「可安全清理」。`--pr` 通过 `gh` 附带 PR
|
|
60
|
+
| `kage [path] [--name x] [--agent pi\|claude\|codex]` | 原仓库 | 把仓库复制到 `../<repo>--<name>`(默认 `kage-<ts>`),拷入原仓库最近 5 个该 agent 的 session(可 resume,绝不重放),并启动一个**全新**的 agent(默认 pi)。`--name` 只命名文件夹 —— kage 从不建分支。无参数 + 已有分身 → 进入交互菜单。 |
|
|
61
|
+
| `kage status [--pr]` | 原仓库 | 仪表盘:分支、是否有改动、ahead/behind、是否「可安全清理」。`--pr` 通过 `gh` 附带 PR 状态。 |
|
|
62
62
|
| `kage finish [name] [--force] [--push] [--pr]` | 原仓库 / 分身内 | 若分身有未提交或未 push 的改动则拒绝,把它**新产生**的 session 合并回来,再删除它。`--push` 先 push 分支;`--pr` push 并通过 `gh` 开 PR;`--force` 跳过检查。 |
|
|
63
63
|
| `kage rm [name] [--force]` | 原仓库 / 分身内 | **不**合并记忆地丢弃一个分身。若有仅存在于本地的工作则拒绝,除非加 `--force`。 |
|
|
64
64
|
| `kage pull <path...>` | 分身内 | 把指定文件/目录(包括被 gitignore 的,比如生成的 `.env`)按相同相对路径拷回原仓库。 |
|
|
@@ -68,6 +68,16 @@ cd kage && npm install && npm link # npm install 会从 src/ 编译出 bin/kag
|
|
|
68
68
|
在已有分身的仓库里直接运行 `kage`,会弹出交互选择器:新建一个分身,或对已有分身执行 **进入** / **finish** /
|
|
69
69
|
**删除**。当有多个分身又没指定名字时,`finish` 和 `rm` 也会弹出同样的选择器。
|
|
70
70
|
|
|
71
|
+
### 其它 agent(Claude Code、Codex)
|
|
72
|
+
|
|
73
|
+
kage 不只支持 pi。`--agent claude` 或 `--agent codex` 会改为启动对应 agent。对 **pi 和 Claude Code**,记忆照样
|
|
74
|
+
流动 —— 通过该 agent **自己的** session 存储(无论用 CLI、IDE 扩展还是桌面 App),两个 agent 甚至能并行
|
|
75
|
+
处理同一个仓库(各开一个分身,或同一个分身里换着用);`finish` 会把有新 session 的那些 store 各自合并回来。
|
|
76
|
+
**Codex 是 launch-only**:它的 session 存在一个由 sqlite 索引的全局 store 里,kage 无法干净地搬迁,所以
|
|
77
|
+
`--agent codex` 给你隔离分身 + git 回流,而 Codex 历史通过 `codex resume --all` 依然全局可见。对于 kage 没法
|
|
78
|
+
直接拉起的 GUI / 桌面 App,用 `--open <cmd>`(比如 `kage --open code`)或 `--no-launch` 只建分身、你自己去打开。
|
|
79
|
+
`KAGE_AGENT` 设置默认 agent。
|
|
80
|
+
|
|
71
81
|
### Shell 集成(可选)
|
|
72
82
|
|
|
73
83
|
```bash
|
|
@@ -84,9 +94,11 @@ eval "$(kage shell-init)" # 加到 ~/.zshrc 或 ~/.bashrc
|
|
|
84
94
|
- **代码只经由 git 回流,绝不经过工作区。** 有 remote 时:push 分支、合并 PR。没有 remote 时:`finish` 会把
|
|
85
95
|
分身的分支 fetch 进原仓库的 git,存成本地分支 `kage/<name>-<sha>`(原仓库工作区不动 —— 你想合并时再
|
|
86
96
|
`git merge`)。由于 fetch 无法保留未提交的改动,`finish` 拒绝删除有改动的分身,除非加 `--force`。
|
|
87
|
-
-
|
|
88
|
-
|
|
89
|
-
会作为一个独立的新 session 回来,原仓库的原始 session
|
|
97
|
+
- **记忆经由该 agent 自己的 session 存储回流,绝不重放。** 创建时拷入原仓库最近 5 个该 agent 的 session —— 该
|
|
98
|
+
agent 的 resume 选择器能看到它们,但分身本身打开的是**全新** session。`finish` 时,分身自己产生的 session 整份
|
|
99
|
+
拷回;你 resume 过的拷入 session 会作为一个独立的新 session 回来,原仓库的原始 session 绝不被改动。(Codex 是
|
|
100
|
+
例外 —— 它的 session 在一个 sqlite 索引的全局 store 里,kage 无法干净搬迁,所以不管理 Codex 记忆;Codex
|
|
101
|
+
历史用 `codex resume --all` 查。详见 `docs/multi-agent-design.md`。)
|
|
90
102
|
- **对 kage 而言原仓库是只读的。** 它只往外复制、只写 session 记忆 —— 即使原仓库里另有一个 session 正活跃,
|
|
91
103
|
它也绝不碰原仓库的工作区。
|
|
92
104
|
|
|
@@ -95,7 +107,9 @@ eval "$(kage shell-init)" # 加到 ~/.zshrc 或 ~/.bashrc
|
|
|
95
107
|
- 副本是原仓库**当前**状态的快照,包含未提交的改动。
|
|
96
108
|
- **Submodule**:submodule 的 `.git` 是绝对路径,复制后会失效 —— 在分身里跑一次
|
|
97
109
|
`git submodule update --init`。
|
|
98
|
-
-
|
|
110
|
+
- kage 从每个 agent **自己**存 session 的地方去读,尊重该 agent 自己的配置变量 ——
|
|
111
|
+
`PI_CODING_AGENT_DIR`(pi)、`CLAUDE_CONFIG_DIR`(Claude Code)、`CODEX_HOME`(Codex)—— 这样
|
|
112
|
+
kage 和 agent 永远指向同一个位置。
|
|
99
113
|
|
|
100
114
|
## 开发
|
|
101
115
|
|
package/bin/kage.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* 4. The origin is read-only to kage — it only copies out and writes session memory.
|
|
17
17
|
*
|
|
18
18
|
* Commands:
|
|
19
|
-
* kage [path] [--name x]
|
|
19
|
+
* kage [path] [--name x] [--agent id] clone repo + launch an agent (no args: interactive)
|
|
20
20
|
* kage status [--pr] dashboard of clones (+ PR status via gh)
|
|
21
21
|
* kage finish [name] [--force] check -> merge memory back -> delete clone
|
|
22
22
|
* kage rm [name] [--force] discard a clone (no merge)
|
|
@@ -24,13 +24,12 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { spawn, spawnSync } from "node:child_process";
|
|
26
26
|
import { randomUUID } from "node:crypto";
|
|
27
|
-
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
27
|
+
import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
28
28
|
import { homedir } from "node:os";
|
|
29
29
|
import { basename, dirname, join, resolve, sep } from "node:path";
|
|
30
30
|
import readline from "node:readline";
|
|
31
|
-
const VERSION = "0.
|
|
31
|
+
const VERSION = "0.4.0"; // keep in sync with package.json (enforced by test)
|
|
32
32
|
const MARKER = ".kage.json";
|
|
33
|
-
const SESSIONS = process.env.KAGE_SESSIONS_DIR || join(homedir(), ".pi", "agent", "sessions");
|
|
34
33
|
const RECENT_SESSIONS = 5; // how many of the origin's most-recent sessions to copy into a clone
|
|
35
34
|
/** Typed accessors for the loose flags bag, so consumers don't re-derive the shape inline. */
|
|
36
35
|
const boolFlag = (flags, name) => Boolean(flags[name]);
|
|
@@ -64,16 +63,16 @@ function sh(cmd, args, opts = {}) {
|
|
|
64
63
|
return { ok: r.status === 0, out: (r.stdout || "").trim(), err: (r.stderr || "").trim() };
|
|
65
64
|
}
|
|
66
65
|
const git = (cwd, args) => sh("git", args, { cwd });
|
|
67
|
-
/** Absolute path -> pi's session dir name: /a/b -> --a-b-- */
|
|
68
|
-
const encodeCwd = (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`;
|
|
69
|
-
const sessionDirFor = (repoAbs) => join(SESSIONS, encodeCwd(repoAbs));
|
|
70
66
|
function repoTopLevel(cwd) {
|
|
71
67
|
const r = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
72
68
|
return r.ok ? r.out : undefined;
|
|
73
69
|
}
|
|
74
|
-
/** Validate parsed JSON
|
|
70
|
+
/** Validate parsed JSON has the shape kage writes (all three fields are strings). */
|
|
75
71
|
function isMarker(v) {
|
|
76
|
-
|
|
72
|
+
if (typeof v !== "object" || v === null)
|
|
73
|
+
return false;
|
|
74
|
+
const m = v;
|
|
75
|
+
return typeof m.originRepo === "string" && typeof m.name === "string" && typeof m.createdAt === "string";
|
|
77
76
|
}
|
|
78
77
|
function readMarker(dir) {
|
|
79
78
|
const p = join(dir, MARKER);
|
|
@@ -185,7 +184,7 @@ function listClones(originRepo) {
|
|
|
185
184
|
const dir = join(parent, name);
|
|
186
185
|
const m = readMarker(dir);
|
|
187
186
|
if (m && m.originRepo === originRepo)
|
|
188
|
-
out.push({ dir, name: m.name
|
|
187
|
+
out.push({ dir, name: m.name, marker: m });
|
|
189
188
|
}
|
|
190
189
|
return out;
|
|
191
190
|
}
|
|
@@ -322,14 +321,14 @@ async function pickClone(action, name) {
|
|
|
322
321
|
const here = repoTopLevel(process.cwd());
|
|
323
322
|
const hm = here ? readMarker(here) : undefined;
|
|
324
323
|
if (here && hm && !name)
|
|
325
|
-
return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name
|
|
324
|
+
return { originRepo: hm.originRepo, clone: { dir: here, name: hm.name, marker: hm } };
|
|
326
325
|
// If `name` resolves to a clone directory, use its marker directly — works from anywhere,
|
|
327
326
|
// even outside a repo (e.g. `kage rm ../app--fix` from the parent dir).
|
|
328
327
|
if (name) {
|
|
329
328
|
const asPath = resolve(name);
|
|
330
329
|
const pm = readMarker(asPath);
|
|
331
330
|
if (pm)
|
|
332
|
-
return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name
|
|
331
|
+
return { originRepo: pm.originRepo, clone: { dir: asPath, name: pm.name, marker: pm } };
|
|
333
332
|
}
|
|
334
333
|
const originRepo = hm ? hm.originRepo : here;
|
|
335
334
|
if (!originRepo)
|
|
@@ -352,123 +351,254 @@ async function pickClone(action, name) {
|
|
|
352
351
|
return null;
|
|
353
352
|
return { originRepo, clone: chosen };
|
|
354
353
|
}
|
|
355
|
-
// ── copy the origin's session history into the clone ─────────────────────────
|
|
356
|
-
/**
|
|
357
|
-
* Copies the origin's most recent session files (up to RECENT_SESSIONS, by mtime) into the
|
|
358
|
-
* clone's session dir, so `pi` resume inside the clone surfaces them (you decide whether to
|
|
359
|
-
* resume any of it). The clone itself opens a fresh session — kage never replays turns or
|
|
360
|
-
* fabricates a "resumed" conversation. On merge-back an unchanged copy adds nothing; if you
|
|
361
|
-
* resumed one and added turns, it comes back as a separate session (see mergeBack).
|
|
362
|
-
*/
|
|
363
|
-
function copyOriginHistory(originRepo, cloneDir) {
|
|
364
|
-
const srcDir = sessionDirFor(originRepo);
|
|
365
|
-
if (!existsSync(srcDir))
|
|
366
|
-
return 0;
|
|
367
|
-
const destDir = sessionDirFor(cloneDir);
|
|
368
|
-
mkdirSync(destDir, { recursive: true });
|
|
369
|
-
const recent = readdirSync(srcDir)
|
|
370
|
-
.filter((f) => f.endsWith(".jsonl"))
|
|
371
|
-
.map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
|
|
372
|
-
.sort((a, b) => b.m - a.m)
|
|
373
|
-
.slice(0, RECENT_SESSIONS);
|
|
374
|
-
let n = 0;
|
|
375
|
-
for (const { f } of recent) {
|
|
376
|
-
const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
|
|
377
|
-
try {
|
|
378
|
-
const header = JSON.parse(lines[0] ?? "");
|
|
379
|
-
header.cwd = cloneDir;
|
|
380
|
-
lines[0] = JSON.stringify(header);
|
|
381
|
-
}
|
|
382
|
-
catch {
|
|
383
|
-
/* leave malformed header as-is */
|
|
384
|
-
}
|
|
385
|
-
writeFileSync(join(destDir, f), lines.join("\n"));
|
|
386
|
-
n++;
|
|
387
|
-
}
|
|
388
|
-
return n;
|
|
389
|
-
}
|
|
390
|
-
// ── merge the clone's new sessions back into the origin ──────────────────────
|
|
391
354
|
/**
|
|
392
|
-
*
|
|
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.
|
|
355
|
+
* The cwd→directory algorithm, shared by pi and (later) Claude Code:
|
|
356
|
+
* - importHistory: copy the origin dir's most-recent sessions into the clone dir (cwd rewritten).
|
|
357
|
+
* - mergeBack: a session the clone created comes back whole; a copied-in origin session left
|
|
358
|
+
* unchanged is skipped; a copied-in session the clone resumed and extended comes back as a
|
|
359
|
+
* NEW self-contained file, so the origin's original (and the leaf it resumes) is untouched.
|
|
398
360
|
*/
|
|
399
|
-
function
|
|
400
|
-
const
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
361
|
+
function dirStore(cfg) {
|
|
362
|
+
const dirFor = (cwd) => join(cfg.baseDir(), cfg.encodeCwd(cwd));
|
|
363
|
+
return {
|
|
364
|
+
id: cfg.id,
|
|
365
|
+
importHistory(originRepo, cloneDir) {
|
|
366
|
+
const srcDir = dirFor(originRepo);
|
|
367
|
+
if (!existsSync(srcDir))
|
|
368
|
+
return 0;
|
|
369
|
+
const destDir = dirFor(cloneDir);
|
|
370
|
+
mkdirSync(destDir, { recursive: true });
|
|
371
|
+
const recent = readdirSync(srcDir)
|
|
372
|
+
.filter((f) => f.endsWith(".jsonl"))
|
|
373
|
+
.map((f) => ({ f, m: statSync(join(srcDir, f)).mtimeMs }))
|
|
374
|
+
.sort((a, b) => b.m - a.m)
|
|
375
|
+
.slice(0, RECENT_SESSIONS);
|
|
376
|
+
let n = 0;
|
|
377
|
+
for (const { f } of recent) {
|
|
378
|
+
const lines = readFileSync(join(srcDir, f), "utf8").split("\n");
|
|
379
|
+
writeFileSync(join(destDir, f), cfg.setCwd(lines, cloneDir).join("\n"));
|
|
380
|
+
n++;
|
|
381
|
+
}
|
|
382
|
+
return n;
|
|
383
|
+
},
|
|
384
|
+
mergeBack(cloneDir, originRepo) {
|
|
385
|
+
const srcDir = dirFor(cloneDir);
|
|
386
|
+
if (!existsSync(srcDir))
|
|
387
|
+
return 0;
|
|
388
|
+
const destDir = dirFor(originRepo);
|
|
389
|
+
mkdirSync(destDir, { recursive: true });
|
|
390
|
+
let n = 0;
|
|
391
|
+
for (const f of readdirSync(srcDir)) {
|
|
392
|
+
if (!f.endsWith(".jsonl"))
|
|
393
|
+
continue;
|
|
394
|
+
const src = readFileSync(join(srcDir, f), "utf8")
|
|
395
|
+
.split("\n")
|
|
396
|
+
.filter((l) => l.trim());
|
|
397
|
+
if (src.length === 0)
|
|
398
|
+
continue;
|
|
399
|
+
const dest = join(destDir, f);
|
|
400
|
+
// A session the clone created (not present in the origin) -> copy it back whole.
|
|
401
|
+
if (!existsSync(dest)) {
|
|
402
|
+
writeFileSync(dest, `${cfg.setCwd(src, originRepo).join("\n")}\n`);
|
|
403
|
+
n++;
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
// A copied-in origin session: write back only if the clone added records, and then as
|
|
407
|
+
// a NEW, self-contained file so the origin's original (and the leaf it resumes) is never
|
|
408
|
+
// mutated. An unchanged copy adds nothing.
|
|
409
|
+
const have = new Set();
|
|
410
|
+
for (const l of readFileSync(dest, "utf8").split("\n")) {
|
|
411
|
+
if (!l.trim())
|
|
412
|
+
continue;
|
|
413
|
+
try {
|
|
414
|
+
have.add(cfg.recordId(l));
|
|
415
|
+
}
|
|
416
|
+
catch {
|
|
417
|
+
/* ignore */
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
const hasNew = src.slice(1).some((l) => {
|
|
421
|
+
try {
|
|
422
|
+
return !have.has(cfg.recordId(l));
|
|
423
|
+
}
|
|
424
|
+
catch {
|
|
425
|
+
return false;
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
if (!hasNew)
|
|
429
|
+
continue;
|
|
430
|
+
const fresh = cfg.reidentify(src, originRepo);
|
|
431
|
+
writeFileSync(join(destDir, fresh.filename), `${fresh.lines.join("\n")}\n`);
|
|
432
|
+
n++;
|
|
433
|
+
}
|
|
417
434
|
try {
|
|
418
|
-
|
|
435
|
+
rmSync(srcDir, { recursive: true, force: true });
|
|
419
436
|
}
|
|
420
437
|
catch {
|
|
421
|
-
|
|
438
|
+
/* ignore */
|
|
422
439
|
}
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
continue;
|
|
427
|
-
}
|
|
428
|
-
// A copied-in origin session. If the clone added records (e.g. you resumed it there),
|
|
429
|
-
// write the clone's full session back as a NEW, self-contained file — leaving the origin's
|
|
430
|
-
// original file (and the leaf pi resumes) untouched. Unchanged copies add nothing.
|
|
431
|
-
const have = new Set();
|
|
432
|
-
for (const l of readFileSync(dest, "utf8").split("\n")) {
|
|
433
|
-
if (!l.trim())
|
|
434
|
-
continue;
|
|
440
|
+
return n;
|
|
441
|
+
},
|
|
442
|
+
discard(cloneDir) {
|
|
435
443
|
try {
|
|
436
|
-
|
|
444
|
+
rmSync(dirFor(cloneDir), { recursive: true, force: true });
|
|
437
445
|
}
|
|
438
446
|
catch {
|
|
439
447
|
/* ignore */
|
|
440
448
|
}
|
|
441
|
-
}
|
|
442
|
-
|
|
449
|
+
},
|
|
450
|
+
hasActivity(cwd) {
|
|
451
|
+
const d = dirFor(cwd);
|
|
443
452
|
try {
|
|
444
|
-
return
|
|
453
|
+
return existsSync(d) && readdirSync(d).some((f) => f.endsWith(".jsonl"));
|
|
445
454
|
}
|
|
446
455
|
catch {
|
|
447
456
|
return false;
|
|
448
457
|
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
458
|
+
},
|
|
459
|
+
};
|
|
460
|
+
}
|
|
461
|
+
/** pi: one session per .jsonl, cwd in a first-line header, per-line `id` for dedup. */
|
|
462
|
+
const piStore = dirStore({
|
|
463
|
+
id: "pi",
|
|
464
|
+
baseDir: () => join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "sessions"),
|
|
465
|
+
encodeCwd: (abs) => `--${abs.replace(/^\//, "").replace(/\//g, "-")}--`,
|
|
466
|
+
setCwd(lines, cwd) {
|
|
467
|
+
const out = [...lines];
|
|
453
468
|
try {
|
|
454
|
-
header = JSON.parse(
|
|
469
|
+
const header = JSON.parse(out[0] ?? "");
|
|
470
|
+
header.cwd = cwd;
|
|
471
|
+
out[0] = JSON.stringify(header);
|
|
455
472
|
}
|
|
456
473
|
catch {
|
|
457
|
-
|
|
474
|
+
/* leave a malformed header as-is rather than drop the session */
|
|
475
|
+
}
|
|
476
|
+
return out;
|
|
477
|
+
},
|
|
478
|
+
recordId: (line) => JSON.parse(line).id,
|
|
479
|
+
reidentify(lines, cwd) {
|
|
480
|
+
let header = {};
|
|
481
|
+
try {
|
|
482
|
+
header = JSON.parse(lines[0] ?? "");
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
/* synthesize a minimal header below */
|
|
458
486
|
}
|
|
459
487
|
const id = randomUUID();
|
|
460
|
-
const
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
488
|
+
const filename = `${new Date().toISOString().replace(/[:.]/g, "-")}_${id}.jsonl`;
|
|
489
|
+
return { filename, lines: [JSON.stringify({ ...header, id, cwd }), ...lines.slice(1)] };
|
|
490
|
+
},
|
|
491
|
+
});
|
|
492
|
+
/** Apply a JSON transform to every parseable line, leaving blank/malformed lines untouched. */
|
|
493
|
+
function mapJsonLines(lines, fn) {
|
|
494
|
+
return lines.map((l) => {
|
|
495
|
+
if (!l.trim())
|
|
496
|
+
return l;
|
|
497
|
+
try {
|
|
498
|
+
const rec = JSON.parse(l);
|
|
499
|
+
fn(rec);
|
|
500
|
+
return JSON.stringify(rec);
|
|
501
|
+
}
|
|
502
|
+
catch {
|
|
503
|
+
return l;
|
|
504
|
+
}
|
|
505
|
+
});
|
|
506
|
+
}
|
|
507
|
+
/** Claude Code: one session per <sessionId>.jsonl, with cwd + sessionId repeated on every line. */
|
|
508
|
+
const claudeStore = dirStore({
|
|
509
|
+
id: "claude",
|
|
510
|
+
baseDir: () => join(process.env.CLAUDE_CONFIG_DIR || join(homedir(), ".claude"), "projects"),
|
|
511
|
+
encodeCwd: (abs) => abs.replace(/[^A-Za-z0-9]/g, "-"),
|
|
512
|
+
setCwd: (lines, cwd) => mapJsonLines(lines, (r) => {
|
|
513
|
+
if ("cwd" in r)
|
|
514
|
+
r.cwd = cwd;
|
|
515
|
+
}),
|
|
516
|
+
recordId: (line) => JSON.parse(line).uuid,
|
|
517
|
+
reidentify(lines, cwd) {
|
|
518
|
+
const id = randomUUID();
|
|
519
|
+
const out = mapJsonLines(lines, (r) => {
|
|
520
|
+
if ("sessionId" in r)
|
|
521
|
+
r.sessionId = id;
|
|
522
|
+
if ("cwd" in r)
|
|
523
|
+
r.cwd = cwd;
|
|
524
|
+
});
|
|
525
|
+
return { filename: `${id}.jsonl`, lines: out };
|
|
526
|
+
},
|
|
527
|
+
});
|
|
528
|
+
const codexSessionsDir = () => join(process.env.CODEX_HOME || join(homedir(), ".codex"), "sessions");
|
|
529
|
+
/**
|
|
530
|
+
* Read just the first line of a (possibly large) file, without loading the whole thing.
|
|
531
|
+
* Accumulates raw bytes and decodes once at the end — decoding each read as UTF-8 would corrupt
|
|
532
|
+
* any multibyte char split across an 8 KiB read boundary (real Codex session_meta lines are tens
|
|
533
|
+
* of KiB of mixed-script text).
|
|
534
|
+
*/
|
|
535
|
+
function readFirstLine(file) {
|
|
536
|
+
const fd = openSync(file, "r");
|
|
464
537
|
try {
|
|
465
|
-
|
|
538
|
+
const buf = Buffer.alloc(8192);
|
|
539
|
+
const chunks = [];
|
|
540
|
+
for (;;) {
|
|
541
|
+
const bytes = readSync(fd, buf, 0, buf.length, null);
|
|
542
|
+
if (bytes <= 0)
|
|
543
|
+
break;
|
|
544
|
+
const nl = buf.subarray(0, bytes).indexOf(0x0a); // newline byte
|
|
545
|
+
if (nl >= 0) {
|
|
546
|
+
chunks.push(Buffer.from(buf.subarray(0, nl)));
|
|
547
|
+
break;
|
|
548
|
+
}
|
|
549
|
+
chunks.push(Buffer.from(buf.subarray(0, bytes)));
|
|
550
|
+
}
|
|
551
|
+
return Buffer.concat(chunks).toString("utf8");
|
|
466
552
|
}
|
|
467
|
-
|
|
468
|
-
|
|
553
|
+
finally {
|
|
554
|
+
closeSync(fd);
|
|
469
555
|
}
|
|
470
|
-
return n;
|
|
471
556
|
}
|
|
557
|
+
/** Every rollout-*.jsonl under the Codex sessions tree, paired with its session_meta cwd. */
|
|
558
|
+
function codexRollouts() {
|
|
559
|
+
const base = codexSessionsDir();
|
|
560
|
+
if (!existsSync(base))
|
|
561
|
+
return [];
|
|
562
|
+
const out = [];
|
|
563
|
+
const walk = (dir) => {
|
|
564
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
565
|
+
const p = join(dir, e.name);
|
|
566
|
+
if (e.isDirectory())
|
|
567
|
+
walk(p);
|
|
568
|
+
else if (e.isFile() && e.name.startsWith("rollout-") && e.name.endsWith(".jsonl")) {
|
|
569
|
+
let cwd;
|
|
570
|
+
try {
|
|
571
|
+
cwd = JSON.parse(readFirstLine(p)).payload?.cwd;
|
|
572
|
+
}
|
|
573
|
+
catch {
|
|
574
|
+
/* not a session_meta we understand */
|
|
575
|
+
}
|
|
576
|
+
out.push({ file: p, cwd });
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
walk(base);
|
|
581
|
+
return out;
|
|
582
|
+
}
|
|
583
|
+
/**
|
|
584
|
+
* Codex keeps every session in one global tree shared by all cwds, and (since 0.13x) keys its
|
|
585
|
+
* cwd-filtered resume picker off a *versioned internal sqlite index* (e.g. state_5.sqlite's
|
|
586
|
+
* `threads.cwd`), not the rollout. kage can't re-home a clone's sessions to the origin without
|
|
587
|
+
* rewriting that sqlite — fragile (the schema/db version moves between Codex releases) and a
|
|
588
|
+
* runtime dependency kage avoids. So kage does NOT manage Codex memory: `--agent codex` gives the
|
|
589
|
+
* isolated clone + git flow-back, and Codex history stays globally available via
|
|
590
|
+
* `codex resume --all`. import/mergeBack/discard are intentional no-ops; hasActivity (a rollout
|
|
591
|
+
* scan, never the sqlite) only powers the re-enter menu while a clone still exists.
|
|
592
|
+
*/
|
|
593
|
+
const codexStore = {
|
|
594
|
+
id: "codex",
|
|
595
|
+
importHistory: () => 0,
|
|
596
|
+
mergeBack: () => 0,
|
|
597
|
+
discard: () => { },
|
|
598
|
+
hasActivity(cwd) {
|
|
599
|
+
return codexRollouts().some((r) => r.cwd === cwd);
|
|
600
|
+
},
|
|
601
|
+
};
|
|
472
602
|
/**
|
|
473
603
|
* We just deleted the clone we were running inside, so the parent shell is now in a
|
|
474
604
|
* deleted directory. A CLI can't cd its parent shell, so: if the shell wrapper is active
|
|
@@ -490,15 +620,57 @@ function leaveClone(originRepo) {
|
|
|
490
620
|
info(paint.yellow(` ↩ your shell is still in the deleted clone — run: ${paint.bold(`cd ${originRepo}`)}`));
|
|
491
621
|
info(paint.dim(` enable auto cd-back: add eval "$(kage shell-init)" to your ~/.zshrc`));
|
|
492
622
|
}
|
|
493
|
-
|
|
494
|
-
|
|
623
|
+
// Registered agents. Memory sync iterates this list, so each new agent is one entry (+ its
|
|
624
|
+
// SessionStore) with no change to any call site. Codex (a flat, cwd-in-content store) lands
|
|
625
|
+
// here in Phase 3.
|
|
626
|
+
const AGENTS = [
|
|
627
|
+
{ id: "pi", store: piStore, cli: { bin: "pi", freshArgs: [], resumeArgs: ["-c"] } },
|
|
628
|
+
{ id: "claude", store: claudeStore, cli: { bin: "claude", freshArgs: [], resumeArgs: ["--continue"] } },
|
|
629
|
+
{ id: "codex", store: codexStore, cli: { bin: "codex", freshArgs: [], resumeArgs: ["resume", "--last"] } },
|
|
630
|
+
];
|
|
631
|
+
const agentById = (id) => AGENTS.find((a) => a.id === id);
|
|
632
|
+
function launchCli(cli, cwd, args) {
|
|
633
|
+
const r = spawnSync(cli.bin, args, { cwd, stdio: "inherit" });
|
|
495
634
|
if (r.error) {
|
|
496
635
|
const err = r.error;
|
|
497
636
|
if (err.code === "ENOENT")
|
|
498
|
-
die(
|
|
499
|
-
die(`failed to launch
|
|
637
|
+
die(`${cli.bin} not found (make sure it is installed and on your PATH)`);
|
|
638
|
+
die(`failed to launch ${cli.bin}: ${err.message}`);
|
|
500
639
|
}
|
|
501
640
|
}
|
|
641
|
+
/** Open the clone with an external command (e.g. `code <clone>`) and return immediately. */
|
|
642
|
+
function launchOpen(cmd, cwd) {
|
|
643
|
+
const parts = cmd.split(/\s+/).filter(Boolean);
|
|
644
|
+
const bin = parts[0];
|
|
645
|
+
if (!bin)
|
|
646
|
+
die("--open needs a command, e.g. --open code");
|
|
647
|
+
const r = spawnSync(bin, [...parts.slice(1), cwd], { stdio: "inherit" });
|
|
648
|
+
if (r.error) {
|
|
649
|
+
const err = r.error;
|
|
650
|
+
if (err.code === "ENOENT")
|
|
651
|
+
die(`${bin} not found (make sure it is installed and on your PATH)`);
|
|
652
|
+
die(`failed to run --open ${cmd}: ${err.message}`);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
/** Re-enter a clone: resume whichever agent has activity here (ask if several), else start fresh. */
|
|
656
|
+
async function enterClone(cloneDir) {
|
|
657
|
+
const active = AGENTS.filter((a) => a.cli && a.store.hasActivity(cloneDir));
|
|
658
|
+
if (active.length === 0) {
|
|
659
|
+
const def = agentById(process.env.KAGE_AGENT ?? "pi") ?? AGENTS[0];
|
|
660
|
+
if (def?.cli)
|
|
661
|
+
launchCli(def.cli, cloneDir, def.cli.freshArgs);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
664
|
+
let chosen = active[0];
|
|
665
|
+
if (active.length > 1) {
|
|
666
|
+
const idx = await select("Resume which agent?", active.map((a) => a.id));
|
|
667
|
+
if (idx < 0)
|
|
668
|
+
return info("cancelled");
|
|
669
|
+
chosen = active[idx];
|
|
670
|
+
}
|
|
671
|
+
if (chosen?.cli)
|
|
672
|
+
launchCli(chosen.cli, cloneDir, chosen.cli.resumeArgs);
|
|
673
|
+
}
|
|
502
674
|
// ── subcommands ───────────────────────────────────────────────────────────────
|
|
503
675
|
async function cmdNew(argv) {
|
|
504
676
|
const { positional, flags } = parseArgs(argv);
|
|
@@ -523,9 +695,9 @@ async function cmdNew(argv) {
|
|
|
523
695
|
const clone = clones[idx - 1];
|
|
524
696
|
if (!clone)
|
|
525
697
|
return info("cancelled");
|
|
526
|
-
const act = await select(`${clone.name}:`, ["Enter (resume
|
|
698
|
+
const act = await select(`${clone.name}:`, ["Enter (resume)", "Finish (merge memory & remove)", "Remove (discard)", "Cancel"]);
|
|
527
699
|
if (act === 0)
|
|
528
|
-
return
|
|
700
|
+
return enterClone(clone.dir);
|
|
529
701
|
if (act === 1)
|
|
530
702
|
return cmdFinish([clone.name]);
|
|
531
703
|
if (act === 2)
|
|
@@ -554,11 +726,19 @@ async function cmdNew(argv) {
|
|
|
554
726
|
const cloneDir = join(dirname(repoRoot), `${basename(repoRoot)}--${safe}`);
|
|
555
727
|
if (existsSync(cloneDir))
|
|
556
728
|
die(`directory already exists: ${cloneDir}`);
|
|
729
|
+
// Resolve the agent to launch up front, so a typo fails before we copy anything.
|
|
730
|
+
const agentId = strFlag(flags, "agent") ?? process.env.KAGE_AGENT ?? "pi";
|
|
731
|
+
const agent = agentById(agentId);
|
|
732
|
+
if (!agent)
|
|
733
|
+
die(`unknown agent: ${agentId} (supported: ${AGENTS.map((a) => a.id).join(", ")})`);
|
|
557
734
|
const cp = await copyRepo(repoRoot, cloneDir);
|
|
558
735
|
if (!cp.ok)
|
|
559
736
|
die(`copy failed: ${cp.err}`);
|
|
560
737
|
// kage does NOT create a branch — the clone stays on the origin's current branch.
|
|
561
|
-
|
|
738
|
+
// Set-based memory: each agent's store imports its own origin history (no-op when empty).
|
|
739
|
+
let histN = 0;
|
|
740
|
+
for (const a of AGENTS)
|
|
741
|
+
histN += a.store.importHistory(repoRoot, cloneDir);
|
|
562
742
|
const marker = {
|
|
563
743
|
originRepo: repoRoot,
|
|
564
744
|
name: safe,
|
|
@@ -573,9 +753,23 @@ async function cmdNew(argv) {
|
|
|
573
753
|
info(paint.dim(` origin's ${histN} session(s) are available via resume (pi: pick from the list)`));
|
|
574
754
|
info(paint.dim(` when done: kage finish ${safe}`));
|
|
575
755
|
info("");
|
|
576
|
-
|
|
756
|
+
// Launch mode: --no-launch (just build), --open <cmd> (open + return), else spawn the CLI.
|
|
757
|
+
if (boolFlag(flags, "no-launch"))
|
|
758
|
+
return;
|
|
759
|
+
const openCmd = strFlag(flags, "open");
|
|
760
|
+
if (openCmd || boolFlag(flags, "open")) {
|
|
761
|
+
if (!openCmd)
|
|
762
|
+
die("--open needs a command, e.g. --open code");
|
|
763
|
+
launchOpen(openCmd, cloneDir);
|
|
764
|
+
info("");
|
|
765
|
+
info(`↩︎ opened ${cloneDir} with ${openCmd}. To finish: ${paint.bold(`kage finish ${safe}`)}`);
|
|
766
|
+
return;
|
|
767
|
+
}
|
|
768
|
+
if (!agent.cli)
|
|
769
|
+
die(`agent ${agent.id} has no CLI to launch — use --open <cmd> or --no-launch`);
|
|
770
|
+
launchCli(agent.cli, cloneDir, agent.cli.freshArgs);
|
|
577
771
|
info("");
|
|
578
|
-
info(`↩︎ left the clone's
|
|
772
|
+
info(`↩︎ left the clone's ${agent.cli.bin}. To finish: ${paint.bold(`kage finish ${safe}`)}`);
|
|
579
773
|
}
|
|
580
774
|
async function cmdFinish(argv) {
|
|
581
775
|
const { positional, flags } = parseArgs(argv);
|
|
@@ -641,7 +835,9 @@ async function cmdFinish(argv) {
|
|
|
641
835
|
die(`failed to preserve the clone's branch into the origin: ${r.err}`);
|
|
642
836
|
info(`🌿 preserved the clone's commits in the origin as ${paint.cyan(target)} (merge with: git merge ${target})`);
|
|
643
837
|
}
|
|
644
|
-
|
|
838
|
+
let n = 0;
|
|
839
|
+
for (const a of AGENTS)
|
|
840
|
+
n += a.store.mergeBack(clone.dir, originRepo);
|
|
645
841
|
try {
|
|
646
842
|
process.chdir(originRepo);
|
|
647
843
|
}
|
|
@@ -675,12 +871,8 @@ async function cmdRm(argv) {
|
|
|
675
871
|
catch {
|
|
676
872
|
/* ignore */
|
|
677
873
|
}
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
}
|
|
681
|
-
catch {
|
|
682
|
-
/* ignore */
|
|
683
|
-
}
|
|
874
|
+
for (const a of AGENTS)
|
|
875
|
+
a.store.discard(clone.dir);
|
|
684
876
|
rmSync(clone.dir, { recursive: true, force: true });
|
|
685
877
|
info(`🗑 Removed clone ${clone.name} (${clone.dir})`);
|
|
686
878
|
if (insideClone)
|
|
@@ -705,7 +897,7 @@ function cmdList(argv) {
|
|
|
705
897
|
const pr = boolFlag(flags, "pr") ? prInfo(c.dir, s.branch) : undefined;
|
|
706
898
|
// header: status glyph · name · branch · age
|
|
707
899
|
const glyph = s.dirty ? paint.yellow("●") : isSafeToClean(s) ? paint.green("✓") : paint.cyan("·");
|
|
708
|
-
const age =
|
|
900
|
+
const age = paint.dim(`created ${ago(c.marker.createdAt)}`);
|
|
709
901
|
info(` ${glyph} ${paint.bold(c.name)} ${paint.cyan(s.branch)} ${age}`);
|
|
710
902
|
// detail: working-tree state · sync · PR · safe-to-clean
|
|
711
903
|
const parts = [];
|
|
@@ -817,7 +1009,8 @@ function cmdClones() {
|
|
|
817
1009
|
const HELP = `kage 🥷 — Shadow Clone Jutsu for your git repo
|
|
818
1010
|
|
|
819
1011
|
Usage:
|
|
820
|
-
kage [path] [--name <x>]
|
|
1012
|
+
kage [path] [--name <x>] [--agent <id>] clone repo + launch a fresh agent (default: pi)
|
|
1013
|
+
--open <cmd> | --no-launch: open it yourself instead
|
|
821
1014
|
(no args inside a repo with clones: interactive menu)
|
|
822
1015
|
kage status [--pr] dashboard of clones (--pr adds PR status via gh)
|
|
823
1016
|
kage finish [name] [--force] [--push] [--pr] preserve work -> merge memory back -> delete clone
|
|
@@ -834,6 +1027,9 @@ With no args inside a repo that already has clones, kage opens an interactive me
|
|
|
834
1027
|
Options:
|
|
835
1028
|
--name <x> name the clone folder /<repo>--<x> (default: kage-<timestamp>); skips the name prompt
|
|
836
1029
|
(sanitized to a git-ref-safe slug, since the name is also used as a branch name)
|
|
1030
|
+
--agent <id> which agent CLI to launch (default: pi, or $KAGE_AGENT); memory still syncs for all agents
|
|
1031
|
+
--open <cmd> after cloning, run '<cmd> <clone>' (e.g. --open code) and return, instead of spawning a CLI
|
|
1032
|
+
--no-launch just create the clone (and import memory), print its path; you open it yourself
|
|
837
1033
|
--pr (finish) push the branch and open a GitHub PR via gh, then finish
|
|
838
1034
|
--push (finish) push the branch before finishing (implied by --pr)
|
|
839
1035
|
--force skip the safety checks: uncommitted/unpushed guard (finish) or local-only guard (rm)
|
|
@@ -854,8 +1050,11 @@ Examples:
|
|
|
854
1050
|
|
|
855
1051
|
kage pull .env # inside a clone: copy a gitignored file back to the origin
|
|
856
1052
|
|
|
857
|
-
Env:
|
|
858
|
-
|
|
1053
|
+
Env (each agent's own native var — kage just honors it, so kage and the agent always agree):
|
|
1054
|
+
KAGE_AGENT default agent to launch when --agent is omitted (default: pi)
|
|
1055
|
+
PI_CODING_AGENT_DIR pi's agent dir; sessions read from <it>/sessions (default: ~/.pi/agent)
|
|
1056
|
+
CLAUDE_CONFIG_DIR Claude Code's config dir; sessions read from <it>/projects (default: ~/.claude)
|
|
1057
|
+
CODEX_HOME Codex's home dir; sessions read from <it>/sessions (default: ~/.codex)`;
|
|
859
1058
|
async function main() {
|
|
860
1059
|
const [sub, ...rest] = process.argv.slice(2);
|
|
861
1060
|
switch (sub) {
|
|
@@ -863,7 +1062,6 @@ async function main() {
|
|
|
863
1062
|
case "new":
|
|
864
1063
|
return cmdNew(sub === "new" ? rest : process.argv.slice(2));
|
|
865
1064
|
case "status":
|
|
866
|
-
case "list": // alias
|
|
867
1065
|
return cmdList(rest);
|
|
868
1066
|
case "finish":
|
|
869
1067
|
return cmdFinish(rest);
|
|
@@ -871,8 +1069,7 @@ async function main() {
|
|
|
871
1069
|
return cmdRm(rest);
|
|
872
1070
|
case "pull":
|
|
873
1071
|
return cmdPull(rest);
|
|
874
|
-
case "shell-init":
|
|
875
|
-
case "completion": {
|
|
1072
|
+
case "shell-init": {
|
|
876
1073
|
process.stdout.write(`${SHELL_INIT}\n`);
|
|
877
1074
|
// When a human runs this directly (stdout is a TTY, not captured by `$(...)`),
|
|
878
1075
|
// the script just scrolled past unused — show how to actually activate it.
|
package/package.json
CHANGED