@ellieyujin/pi-reopen 0.0.0-stage → 0.2.0-dev.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/LICENSE +21 -0
- package/README.md +121 -2
- package/bin/pi-workspace.js +4 -0
- package/docs/PUBLISHING.md +97 -0
- package/docs/REQUIREMENTS.md +110 -0
- package/docs/STATUS.md +134 -0
- package/extensions/open-all.ts +232 -0
- package/package.json +50 -4
- package/src/binfmt.js +51 -0
- package/src/claims.js +67 -0
- package/src/cli.js +69 -0
- package/src/discover.js +65 -0
- package/src/launch.js +15 -0
- package/src/liveness.js +37 -0
- package/src/members.js +84 -0
- package/src/paths.js +21 -0
- package/src/plan.js +79 -0
- package/src/preflight.js +91 -0
- package/src/restore.js +132 -0
- package/src/spawn.js +53 -0
- package/src/verify.js +24 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pi-workspace 扩展:登记存活 Pi、区分主动退出与被动死亡、提供 Pi 内恢复入口。
|
|
3
|
+
* 核心逻辑在项目 src/*.js(CLI 共用);本文件只做 Pi 侧适配。
|
|
4
|
+
*/
|
|
5
|
+
import { realpathSync } from "node:fs";
|
|
6
|
+
import { dirname, join } from "node:path";
|
|
7
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
9
|
+
import { Key } from "@earendil-works/pi-tui";
|
|
10
|
+
|
|
11
|
+
function resolveSrcDir(): string {
|
|
12
|
+
const candidates: string[] = [];
|
|
13
|
+
if (process.env.PI_WORKSPACE_SRC) candidates.push(process.env.PI_WORKSPACE_SRC);
|
|
14
|
+
try {
|
|
15
|
+
candidates.push(join(dirname(realpathSync(fileURLToPath(import.meta.url))), "..", "src"));
|
|
16
|
+
} catch {
|
|
17
|
+
/* import.meta.url 不可用时忽略 */
|
|
18
|
+
}
|
|
19
|
+
for (const dir of candidates) {
|
|
20
|
+
try {
|
|
21
|
+
realpathSync(join(dir, "restore.js"));
|
|
22
|
+
return dir;
|
|
23
|
+
} catch {
|
|
24
|
+
/* 尝试下一个候选 */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw new Error("无法定位 pi-workspace 源码目录;可设置 PI_WORKSPACE_SRC");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const ENTRY_WINDOW_MS = 24 * 3_600_000;
|
|
31
|
+
|
|
32
|
+
export default async function (pi: ExtensionAPI) {
|
|
33
|
+
const srcDir = resolveSrcDir();
|
|
34
|
+
const load = async (name: string) => await import(pathToFileURL(join(srcDir, name)).href);
|
|
35
|
+
const { workspacePaths } = await load("paths.js");
|
|
36
|
+
const members = await load("members.js");
|
|
37
|
+
const liveness = await load("liveness.js");
|
|
38
|
+
const claims = await load("claims.js");
|
|
39
|
+
const restore = await load("restore.js");
|
|
40
|
+
const spawn = await load("spawn.js");
|
|
41
|
+
const preflightMod = await load("preflight.js");
|
|
42
|
+
const fs = await import("node:fs");
|
|
43
|
+
const paths = workspacePaths();
|
|
44
|
+
|
|
45
|
+
let currentSessionId: string | undefined;
|
|
46
|
+
let signalExit = false;
|
|
47
|
+
const signals = process.platform === "win32" ? ["SIGTERM"] as const : ["SIGHUP", "SIGTERM"] as const;
|
|
48
|
+
const markSignalExit = () => { signalExit = true; };
|
|
49
|
+
function detachSignals(): void {
|
|
50
|
+
for (const signal of signals) process.off(signal, markSignalExit);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 测试钩子:仅在设置 PI_WORKSPACE_HOOKS 时生效,返回 runRestore 的注入参数
|
|
54
|
+
async function loadHooks(): Promise<any> {
|
|
55
|
+
const file = process.env.PI_WORKSPACE_HOOKS;
|
|
56
|
+
if (!file) return {};
|
|
57
|
+
try {
|
|
58
|
+
const mod = await import(pathToFileURL(file).href);
|
|
59
|
+
return typeof mod.default === "function" ? await mod.default() : mod;
|
|
60
|
+
} catch {
|
|
61
|
+
return {};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
pi.on("session_start", (_event: unknown, ctx: any) => {
|
|
66
|
+
detachSignals();
|
|
67
|
+
signalExit = false;
|
|
68
|
+
if (ctx.mode === "tui") {
|
|
69
|
+
for (const signal of signals) process.on(signal, markSignalExit);
|
|
70
|
+
}
|
|
71
|
+
register(ctx);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// 全新会话启动时文件尚未落盘;首条用户消息后补登记,此后每次发消息刷新时间戳
|
|
75
|
+
pi.on("message_end", (event: any, ctx: any) => {
|
|
76
|
+
if (event?.message?.role === "user") register(ctx);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
function register(ctx: any): void {
|
|
80
|
+
if (ctx.mode !== "tui") return;
|
|
81
|
+
const sessionFile = ctx.sessionManager.getSessionFile();
|
|
82
|
+
if (!sessionFile) return; // 仍未落盘的空窗口,不登记(恢复时跳过)
|
|
83
|
+
currentSessionId = ctx.sessionManager.getSessionId();
|
|
84
|
+
members.writeMember({
|
|
85
|
+
sessionId: currentSessionId,
|
|
86
|
+
cwd: ctx.cwd,
|
|
87
|
+
sessionFile,
|
|
88
|
+
name: ctx.sessionManager.getSessionName(),
|
|
89
|
+
ts: Date.now(),
|
|
90
|
+
pid: process.pid,
|
|
91
|
+
processStart: liveness.processStart(process.pid),
|
|
92
|
+
bootId: liveness.currentBootId(),
|
|
93
|
+
});
|
|
94
|
+
claims.releaseClaim(currentSessionId, paths);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
pi.on("session_shutdown", async (event: any) => {
|
|
98
|
+
// Pi also emits quit for SIGHUP/SIGTERM. Let all signal listeners run first.
|
|
99
|
+
if (event.reason === "quit") await new Promise<void>((resolve) => setImmediate(resolve));
|
|
100
|
+
try {
|
|
101
|
+
if (!currentSessionId || event.reason === "reload") return;
|
|
102
|
+
if (event.reason === "quit" && signalExit) return;
|
|
103
|
+
if (["quit", "new", "resume", "fork"].includes(event.reason)) {
|
|
104
|
+
members.deleteMember(currentSessionId, paths);
|
|
105
|
+
}
|
|
106
|
+
} finally {
|
|
107
|
+
detachSignals();
|
|
108
|
+
currentSessionId = undefined;
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
async function restoreAndReport(ctx: any, dryRun = false): Promise<void> {
|
|
113
|
+
const result = await restore.runRestore({ dryRun, ...(await loadHooks()) });
|
|
114
|
+
for (const line of restore.summarize(result)) ctx.ui.notify(line, result.ok ? "info" : "warning");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async function openRecent(ctx: any): Promise<void> {
|
|
118
|
+
const now = Date.now();
|
|
119
|
+
const candidates: any[] = [];
|
|
120
|
+
try {
|
|
121
|
+
for (const dir of fs.readdirSync(paths.sessions)) {
|
|
122
|
+
const p = join(paths.sessions, dir);
|
|
123
|
+
let files: string[];
|
|
124
|
+
try {
|
|
125
|
+
files = fs.readdirSync(p).filter((f: string) => f.endsWith(".jsonl"));
|
|
126
|
+
} catch {
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
for (const file of files) {
|
|
130
|
+
const full = join(p, file);
|
|
131
|
+
try {
|
|
132
|
+
const mtime = fs.statSync(full).mtimeMs;
|
|
133
|
+
if (now - mtime > ENTRY_WINDOW_MS) continue;
|
|
134
|
+
// 只读首行取会话头,避免加载整个会话文件(可达数 MB)
|
|
135
|
+
const fd = fs.openSync(full, "r");
|
|
136
|
+
let firstLine: string;
|
|
137
|
+
try {
|
|
138
|
+
const buf = Buffer.alloc(65536);
|
|
139
|
+
const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
|
|
140
|
+
const chunk = buf.subarray(0, bytes).toString("utf8");
|
|
141
|
+
const nl = chunk.indexOf("\n");
|
|
142
|
+
firstLine = nl === -1 ? chunk : chunk.slice(0, nl);
|
|
143
|
+
} finally {
|
|
144
|
+
fs.closeSync(fd);
|
|
145
|
+
}
|
|
146
|
+
const header = JSON.parse(firstLine);
|
|
147
|
+
if (header.id && header.cwd) {
|
|
148
|
+
candidates.push({ sessionId: header.id, cwd: header.cwd, sessionFile: full, ts: mtime });
|
|
149
|
+
}
|
|
150
|
+
} catch {
|
|
151
|
+
/* 跳过损坏文件 */
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
} catch {
|
|
156
|
+
/* 无会话目录 */
|
|
157
|
+
}
|
|
158
|
+
const live = new Set(members.listMembers(paths).filter((m: any) => liveness.isLive(m)).map((m: any) => m.sessionId));
|
|
159
|
+
const own = currentSessionId;
|
|
160
|
+
const todo = candidates
|
|
161
|
+
.filter((c) => c.sessionId !== own && !live.has(c.sessionId))
|
|
162
|
+
.sort((a, b) => b.ts - a.ts);
|
|
163
|
+
if (!todo.length) {
|
|
164
|
+
ctx.ui.notify("近 24 小时没有可补选的对话", "info");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const list = todo
|
|
168
|
+
.map((e, i) => `${i + 1}. ${new Date(e.ts).toLocaleString()} | ${e.cwd} | ${e.name ?? e.sessionId}`)
|
|
169
|
+
.join("\n");
|
|
170
|
+
const input = await ctx.ui.input(`近期候选(不代表原先打开的窗口)\n${list}\n输入序号,多个用逗号分隔;取消则不打开`);
|
|
171
|
+
if (!input?.trim()) return;
|
|
172
|
+
const parts = input.trim().split(/[\s,,]+/);
|
|
173
|
+
if (parts.some((p: string) => !/^\d+$/.test(p) || Number(p) < 1 || Number(p) > todo.length)) {
|
|
174
|
+
ctx.ui.notify("序号无效,未打开标签页", "warning");
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
const selected = [...new Set(parts.map(Number))].map((n: number) => todo[n - 1]);
|
|
178
|
+
if (!(await ctx.ui.confirm("恢复选中的 Pi 对话?", selected.map((e: any) => `${e.cwd} | ${e.name ?? e.sessionId}`).join("\n")))) return;
|
|
179
|
+
const pre = preflightMod.preflight();
|
|
180
|
+
if (!pre.ok) {
|
|
181
|
+
for (const line of restore.summarize({ ok: false, stage: "preflight", checks: pre.checks })) {
|
|
182
|
+
ctx.ui.notify(line, "warning");
|
|
183
|
+
}
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
let opened = 0;
|
|
187
|
+
for (const entry of selected) {
|
|
188
|
+
const claim = claims.claimSession(entry.sessionId, process.pid, paths);
|
|
189
|
+
if (!claim.claimed) continue;
|
|
190
|
+
const result = await spawn.openTab({
|
|
191
|
+
wezterm: pre.wezterm,
|
|
192
|
+
distro: pre.distro,
|
|
193
|
+
piBin: pre.piBin,
|
|
194
|
+
cwd: entry.cwd,
|
|
195
|
+
sessionFile: entry.sessionFile,
|
|
196
|
+
});
|
|
197
|
+
if (result.ok) {
|
|
198
|
+
members.writeMember({ ...entry, pid: undefined, processStart: undefined, bootId: undefined, ts: Date.now() });
|
|
199
|
+
opened++;
|
|
200
|
+
} else {
|
|
201
|
+
claims.releaseClaim(entry.sessionId, paths);
|
|
202
|
+
ctx.ui.notify(`${entry.cwd}:WezTerm 请求失败 — ${result.error}`, "warning");
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
ctx.ui.notify(opened ? `已请求打开 ${opened} 个 pi 终端` : "没有打开任何标签页", opened ? "info" : "warning");
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
pi.registerCommand("all", {
|
|
209
|
+
description: "恢复已登记的 pi 终端(/all recent 补选历史;/all clear 清空登记)",
|
|
210
|
+
handler: async (args: string, ctx: any) => {
|
|
211
|
+
const arg = (args ?? "").trim();
|
|
212
|
+
if (arg === "clear") {
|
|
213
|
+
const removed = members.clearWorkspace(paths);
|
|
214
|
+
ctx.ui.notify(`已清空 ${removed} 条登记;对话文件未删除`, "info");
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (arg === "recent") return openRecent(ctx);
|
|
218
|
+
if (arg) {
|
|
219
|
+
ctx.ui.notify("用法:/all | /all recent | /all clear", "warning");
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
await restoreAndReport(ctx);
|
|
223
|
+
},
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
pi.registerShortcut(Key.ctrlShift("o"), {
|
|
227
|
+
description: "一键恢复上次所有 pi 终端",
|
|
228
|
+
handler: async (ctx: any) => {
|
|
229
|
+
await restoreAndReport(ctx);
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ellieyujin/pi-reopen",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"description": "
|
|
6
|
-
|
|
3
|
+
"license": "MIT",
|
|
4
|
+
"version": "0.2.0-dev.0",
|
|
5
|
+
"description": "Restore recorded Pi sessions in Windows WezTerm tabs from WSL; includes a Pi extension and standalone CLI.",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/bridge-01/pi-workspace.git"
|
|
9
|
+
},
|
|
10
|
+
"homepage": "https://github.com/bridge-01/pi-workspace#readme",
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/bridge-01/pi-workspace/issues"
|
|
13
|
+
},
|
|
14
|
+
"publishConfig": {
|
|
15
|
+
"access": "public",
|
|
16
|
+
"registry": "https://registry.npmjs.org/",
|
|
17
|
+
"tag": "next"
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"keywords": [
|
|
21
|
+
"pi-package",
|
|
22
|
+
"workspace",
|
|
23
|
+
"wezterm",
|
|
24
|
+
"wsl"
|
|
25
|
+
],
|
|
26
|
+
"engines": {
|
|
27
|
+
"node": ">=22.19.0"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"extensions",
|
|
31
|
+
"src",
|
|
32
|
+
"bin",
|
|
33
|
+
"README.md",
|
|
34
|
+
"docs"
|
|
35
|
+
],
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./extensions/open-all.ts"
|
|
39
|
+
]
|
|
40
|
+
},
|
|
41
|
+
"peerDependencies": {
|
|
42
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
43
|
+
"@earendil-works/pi-tui": "*"
|
|
44
|
+
},
|
|
45
|
+
"bin": {
|
|
46
|
+
"pi-workspace": "bin/pi-workspace.js"
|
|
47
|
+
},
|
|
48
|
+
"scripts": {
|
|
49
|
+
"test": "node --test test/*.test.mjs",
|
|
50
|
+
"prepublishOnly": "npm test"
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/binfmt.js
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
const BINFMT_ENTRY = "/proc/sys/fs/binfmt_misc/WSLInterop";
|
|
4
|
+
const CONF_DIRS = ["/etc/binfmt.d", "/usr/lib/binfmt.d", "/run/binfmt.d", "/lib/binfmt.d"];
|
|
5
|
+
export const BINFMT_CONF_CONTENT = ":WSLInterop:M::MZ::/init:PF";
|
|
6
|
+
|
|
7
|
+
export const PERSIST_HINT =
|
|
8
|
+
"WSLInterop 注册可能在 WSL 重启后再次丢失(未发现 binfmt.d 持久化配置)。\n" +
|
|
9
|
+
"一次性持久化(需 sudo 密码,工具不会自动执行):\n" +
|
|
10
|
+
`printf '%s\\n' '${BINFMT_CONF_CONTENT}' | sudo tee /usr/lib/binfmt.d/WSLInterop.conf >/dev/null\n` +
|
|
11
|
+
"echo -1 | sudo tee /proc/sys/fs/binfmt_misc/WSLInterop >/dev/null\n" +
|
|
12
|
+
"sudo systemctl restart systemd-binfmt";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* WSLInterop 注册状态与持久化状态:
|
|
16
|
+
* - registered:当前内核是否已注册(恢复可用性的前提)
|
|
17
|
+
* - persistent:是否存在 binfmt.d 配置,使 systemd-binfmt 在启动时自动补注册
|
|
18
|
+
*/
|
|
19
|
+
export function binfmtStatus(deps = {}) {
|
|
20
|
+
const exists = deps.exists ?? existsSync;
|
|
21
|
+
const readFile = deps.readFile ?? ((p) => readFileSync(p, "utf8"));
|
|
22
|
+
const readdir = deps.readdir ?? readdirSync;
|
|
23
|
+
const registered = exists(BINFMT_ENTRY);
|
|
24
|
+
let persistent = false;
|
|
25
|
+
for (const dir of CONF_DIRS) {
|
|
26
|
+
if (!exists(dir)) continue;
|
|
27
|
+
let files;
|
|
28
|
+
try {
|
|
29
|
+
files = readdir(dir);
|
|
30
|
+
} catch {
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
for (const file of files) {
|
|
34
|
+
const path = `${dir}/${file}`;
|
|
35
|
+
if (file.toLowerCase().includes("wslinterop")) {
|
|
36
|
+
persistent = true;
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
if (readFile(path).includes(":WSLInterop:")) {
|
|
41
|
+
persistent = true;
|
|
42
|
+
break;
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
/* 不可读文件(目录等)忽略 */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
if (persistent) break;
|
|
49
|
+
}
|
|
50
|
+
return { registered, persistent };
|
|
51
|
+
}
|
package/src/claims.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { processStart } from "./liveness.js";
|
|
4
|
+
import { workspacePaths } from "./paths.js";
|
|
5
|
+
|
|
6
|
+
export const CLAIM_TTL_MS = 60_000;
|
|
7
|
+
|
|
8
|
+
function writeAtomic(file, data) {
|
|
9
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
10
|
+
writeFileSync(temp, data, { mode: 0o600 });
|
|
11
|
+
renameSync(temp, file);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function readClaim(sessionId, paths = workspacePaths()) {
|
|
15
|
+
try {
|
|
16
|
+
return JSON.parse(readFileSync(join(paths.claims, `${sessionId}.json`), "utf8"));
|
|
17
|
+
} catch {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 声明“本会话正在被启动”,防止并发/连续恢复重复开标签页。
|
|
24
|
+
* 已有声明在 TTL 内且持有者进程存活时视为占用;过期或持有者已死则接管。
|
|
25
|
+
*/
|
|
26
|
+
export function claimSession(sessionId, ownerPid, paths = workspacePaths(), now = Date.now()) {
|
|
27
|
+
mkdirSync(paths.claims, { recursive: true });
|
|
28
|
+
const existing = readClaim(sessionId, paths);
|
|
29
|
+
if (
|
|
30
|
+
existing &&
|
|
31
|
+
existing.pid !== ownerPid &&
|
|
32
|
+
now - existing.ts < CLAIM_TTL_MS &&
|
|
33
|
+
processStart(existing.pid) !== undefined
|
|
34
|
+
) {
|
|
35
|
+
return { claimed: false, holder: existing };
|
|
36
|
+
}
|
|
37
|
+
writeAtomic(join(paths.claims, `${sessionId}.json`), JSON.stringify({ pid: ownerPid, ts: now }));
|
|
38
|
+
return { claimed: true };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 新 Pi 完成登记时清除自己的启动声明。 */
|
|
42
|
+
export function releaseClaim(sessionId, paths = workspacePaths()) {
|
|
43
|
+
try {
|
|
44
|
+
unlinkSync(join(paths.claims, `${sessionId}.json`));
|
|
45
|
+
} catch {
|
|
46
|
+
/* 不存在即目标状态 */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 清理过期或持有者已死的声明(恢复前调用)。 */
|
|
51
|
+
export function sweepClaims(paths = workspacePaths(), now = Date.now()) {
|
|
52
|
+
let files;
|
|
53
|
+
try {
|
|
54
|
+
files = readdirSync(paths.claims);
|
|
55
|
+
} catch {
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
for (const file of files.filter((f) => f.endsWith(".json"))) {
|
|
59
|
+
try {
|
|
60
|
+
const claim = JSON.parse(readFileSync(join(paths.claims, file), "utf8"));
|
|
61
|
+
const dead = now - claim.ts >= CLAIM_TTL_MS || processStart(claim.pid) === undefined;
|
|
62
|
+
if (dead) unlinkSync(join(paths.claims, file));
|
|
63
|
+
} catch {
|
|
64
|
+
/* 损坏声明留待 TTL 后接管 */
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { binfmtStatus, PERSIST_HINT } from "./binfmt.js";
|
|
4
|
+
import { workspacePaths } from "./paths.js";
|
|
5
|
+
import { currentBootId, isLive } from "./liveness.js";
|
|
6
|
+
import { clearWorkspace } from "./members.js";
|
|
7
|
+
import { inspect, label, runRestore, summarize } from "./restore.js";
|
|
8
|
+
import { preflight } from "./preflight.js";
|
|
9
|
+
|
|
10
|
+
const USAGE = `pi-workspace — 恢复上次的 Pi 终端组合
|
|
11
|
+
|
|
12
|
+
用法:
|
|
13
|
+
pi-workspace restore [--dry-run] [--no-wait] 恢复关闭终端时仍打开的 Pi(补开缺失的)
|
|
14
|
+
pi-workspace list 查看登记成员与状态
|
|
15
|
+
pi-workspace doctor 环境检查
|
|
16
|
+
pi-workspace clear 清空登记(不删除对话文件)
|
|
17
|
+
`;
|
|
18
|
+
|
|
19
|
+
function listLines({ plan }) {
|
|
20
|
+
if (!plan.items.length) return ["没有登记成员。关闭 WezTerm 时仍打开的 Pi 会在下次启动后自动登记。"];
|
|
21
|
+
const lines = [];
|
|
22
|
+
for (const item of plan.items) {
|
|
23
|
+
const title = item.entry.name || item.entry.sessionId;
|
|
24
|
+
const when = new Date(item.entry.ts).toLocaleString();
|
|
25
|
+
lines.push(`${label(item.state).padEnd(8)} ${title} | ${item.entry.cwd} | ${when}`);
|
|
26
|
+
}
|
|
27
|
+
return lines;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function run(argv = [], { stdout = process.stdout, env = process.env } = {}) {
|
|
31
|
+
const [command, ...flags] = argv;
|
|
32
|
+
const hasFlag = (name) => flags.includes(name);
|
|
33
|
+
const write = (line) => stdout.write(`${line}\n`);
|
|
34
|
+
|
|
35
|
+
switch (command) {
|
|
36
|
+
case "restore": {
|
|
37
|
+
const result = await runRestore({ env, dryRun: hasFlag("--dry-run"), noWait: hasFlag("--no-wait") });
|
|
38
|
+
for (const line of summarize(result)) write(line);
|
|
39
|
+
if (!result.ok) return result.stage === "preflight" ? 2 : 1;
|
|
40
|
+
return 0;
|
|
41
|
+
}
|
|
42
|
+
case "list": {
|
|
43
|
+
for (const line of listLines(inspect({ paths: workspacePaths(env) }))) write(line);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
case "doctor": {
|
|
47
|
+
const pre = preflight({ env });
|
|
48
|
+
for (const check of pre.checks) {
|
|
49
|
+
write(`${check.ok ? "✔" : "✖"} ${check.name}:${check.detail}`);
|
|
50
|
+
if (check.hint) write(` → ${check.hint}`);
|
|
51
|
+
}
|
|
52
|
+
const binfmt = binfmtStatus();
|
|
53
|
+
if (binfmt.registered && !binfmt.persistent) {
|
|
54
|
+
write("⚠ WSLInterop 已注册但未持久化:");
|
|
55
|
+
for (const line of PERSIST_HINT.split("\n")) write(` ${line}`);
|
|
56
|
+
}
|
|
57
|
+
write(pre.ok ? "环境就绪。" : "存在未通过项,恢复前需先解决。");
|
|
58
|
+
return pre.ok ? 0 : 2;
|
|
59
|
+
}
|
|
60
|
+
case "clear": {
|
|
61
|
+
const removed = clearWorkspace(workspacePaths(env));
|
|
62
|
+
write(`已清空 ${removed} 条登记与启动占用;对话文件与旧格式记录未删除。`);
|
|
63
|
+
return 0;
|
|
64
|
+
}
|
|
65
|
+
default:
|
|
66
|
+
write(USAGE);
|
|
67
|
+
return command ? 1 : 0;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/discover.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readdirSync, statSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/** WezTerm 发现:显式路径 → PATH → 常见安装位置 → where.exe + wslpath。 */
|
|
6
|
+
export function discoverWezterm(env = process.env, deps = {}) {
|
|
7
|
+
const fileExists = deps.isFile ?? isFile;
|
|
8
|
+
const readdir = deps.readdir ?? readdirSync;
|
|
9
|
+
const execFile = deps.execFile ?? execFileSync;
|
|
10
|
+
if (env.PI_WORKSPACE_WEZTERM) {
|
|
11
|
+
return fileExists(env.PI_WORKSPACE_WEZTERM) ? env.PI_WORKSPACE_WEZTERM : undefined;
|
|
12
|
+
}
|
|
13
|
+
const candidates = (env.PATH ?? "").split(":").filter((p) => p.startsWith("/"))
|
|
14
|
+
.map((dir) => join(dir, "wezterm.exe"));
|
|
15
|
+
candidates.push("/mnt/c/Program Files/WezTerm/wezterm.exe");
|
|
16
|
+
try {
|
|
17
|
+
for (const drive of readdir("/mnt")) {
|
|
18
|
+
candidates.push(join("/mnt", drive, "Program Files", "WezTerm", "wezterm.exe"));
|
|
19
|
+
const users = join("/mnt", drive, "Users");
|
|
20
|
+
let names;
|
|
21
|
+
try {
|
|
22
|
+
names = readdir(users);
|
|
23
|
+
} catch {
|
|
24
|
+
continue;
|
|
25
|
+
}
|
|
26
|
+
for (const user of names) {
|
|
27
|
+
candidates.push(
|
|
28
|
+
join(users, user, "AppData", "Local", "Programs", "WezTerm", "wezterm.exe"),
|
|
29
|
+
join(users, user, "scoop", "shims", "wezterm.exe"),
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
} catch {
|
|
34
|
+
/* /mnt 不可枚举时忽略 */
|
|
35
|
+
}
|
|
36
|
+
for (const candidate of candidates) {
|
|
37
|
+
if (fileExists(candidate)) return candidate;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
const options = { env, encoding: "utf8", timeout: 5000 };
|
|
41
|
+
const where = (env.PATH ?? "").split(":").filter((p) => p.startsWith("/"))
|
|
42
|
+
.map((dir) => join(dir, "where.exe")).find(fileExists)
|
|
43
|
+
?? "/mnt/c/Windows/System32/where.exe";
|
|
44
|
+
const out = execFile(where, ["wezterm.exe"], options);
|
|
45
|
+
for (const line of out.split(/\r?\n/).map((s) => s.trim()).filter(Boolean)) {
|
|
46
|
+
try {
|
|
47
|
+
const path = execFile("wslpath", ["-u", line], options).trim();
|
|
48
|
+
if (fileExists(path)) return path;
|
|
49
|
+
} catch {
|
|
50
|
+
/* 某个 Windows 路径不可转换时继续下一项 */
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
/* interop 不可用时静默 */
|
|
55
|
+
}
|
|
56
|
+
return undefined;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isFile(path) {
|
|
60
|
+
try {
|
|
61
|
+
return statSync(path).isFile();
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/launch.js
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { dirname } from "node:path";
|
|
2
|
+
import { agentDir } from "./paths.js";
|
|
3
|
+
|
|
4
|
+
export function shellQuote(value) {
|
|
5
|
+
return "'" + value.replaceAll("'", "'\\''") + "'";
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
/** 仅传递启动所需配置,不把认证等整个环境写进命令行。 */
|
|
9
|
+
export function launchSetup(env = process.env, piBin) {
|
|
10
|
+
const path = [piBin?.startsWith("/") ? dirname(piBin) : undefined, env.PATH].filter(Boolean).join(":");
|
|
11
|
+
return [
|
|
12
|
+
...(path ? [`export PATH=${shellQuote(path)}:"$PATH"`] : []),
|
|
13
|
+
`export PI_CODING_AGENT_DIR=${shellQuote(agentDir(env))}`,
|
|
14
|
+
].join("; ");
|
|
15
|
+
}
|
package/src/liveness.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
/** 当前 Linux/WSL 启动 ID;重启后变化。 */
|
|
4
|
+
export function currentBootId(readFile = (p) => readFileSync(p, "utf8")) {
|
|
5
|
+
try {
|
|
6
|
+
return readFile("/proc/sys/kernel/random/boot_id").trim();
|
|
7
|
+
} catch {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** 进程启动时刻(/proc stat 字段 22);进程不存在或为僵尸时 undefined。 */
|
|
13
|
+
export function processStart(pid, readFile = (p) => readFileSync(p, "utf8")) {
|
|
14
|
+
try {
|
|
15
|
+
const stat = readFile(`/proc/${pid}/stat`);
|
|
16
|
+
const after = stat.slice(stat.lastIndexOf(")") + 2).trim().split(/\s+/);
|
|
17
|
+
if (after[0] === "Z") return undefined;
|
|
18
|
+
return after[19];
|
|
19
|
+
} catch {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 记录所指向的 Pi 进程是否仍在同一启动周期内存活。
|
|
26
|
+
* bootId 不一致(重启过)一律视为不存活——满足“重启后失效 PID 不误判”。
|
|
27
|
+
*/
|
|
28
|
+
export function isLive(entry, deps = {}) {
|
|
29
|
+
if (!entry?.pid || !entry?.processStart || !entry?.bootId) return false;
|
|
30
|
+
// bootId 允许传值或函数,便于调用方复用已读取的值
|
|
31
|
+
const boot = typeof deps.bootId === "function" ? deps.bootId() : (deps.bootId ?? currentBootId());
|
|
32
|
+
if (boot !== entry.bootId) return false;
|
|
33
|
+
const start = deps.processStart
|
|
34
|
+
? deps.processStart(entry.pid)
|
|
35
|
+
: processStart(entry.pid);
|
|
36
|
+
return start === entry.processStart;
|
|
37
|
+
}
|
package/src/members.js
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import {
|
|
2
|
+
mkdirSync,
|
|
3
|
+
readdirSync,
|
|
4
|
+
readFileSync,
|
|
5
|
+
renameSync,
|
|
6
|
+
unlinkSync,
|
|
7
|
+
writeFileSync,
|
|
8
|
+
} from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { workspacePaths } from "./paths.js";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* 成员记录语义(已确认规则):
|
|
14
|
+
* - Pi 打开(session_start)→ 写入/刷新记录(含 pid、processStart、bootId)
|
|
15
|
+
* - Pi 内主动退出 / 切换会话(session_shutdown: quit|new|resume|fork)→ 删除记录(不恢复)
|
|
16
|
+
* - 被动死亡(关窗、崩溃、WSL 关机)→ 无清理,记录自然残留 → 恢复
|
|
17
|
+
*/
|
|
18
|
+
function writeAtomic(file, data) {
|
|
19
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
20
|
+
writeFileSync(temp, data, { mode: 0o600 });
|
|
21
|
+
renameSync(temp, file);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writeMember(entry, paths = workspacePaths()) {
|
|
25
|
+
mkdirSync(paths.members, { recursive: true });
|
|
26
|
+
writeAtomic(join(paths.members, `${entry.sessionId}.json`), JSON.stringify(entry, null, 2));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function readMember(sessionId, paths = workspacePaths()) {
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(join(paths.members, `${sessionId}.json`), "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function deleteMember(sessionId, paths = workspacePaths()) {
|
|
38
|
+
try {
|
|
39
|
+
unlinkSync(join(paths.members, `${sessionId}.json`));
|
|
40
|
+
} catch {
|
|
41
|
+
/* 不存在即目标状态 */
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function listMembers(paths = workspacePaths()) {
|
|
46
|
+
const out = [];
|
|
47
|
+
let files;
|
|
48
|
+
try {
|
|
49
|
+
files = readdirSync(paths.members);
|
|
50
|
+
} catch {
|
|
51
|
+
return out;
|
|
52
|
+
}
|
|
53
|
+
for (const file of files.filter((f) => f.endsWith(".json"))) {
|
|
54
|
+
try {
|
|
55
|
+
const entry = JSON.parse(readFileSync(join(paths.members, file), "utf8"));
|
|
56
|
+
if (entry?.sessionId) out.push(entry);
|
|
57
|
+
} catch {
|
|
58
|
+
/* 跳过损坏记录,恢复时宁可少报不可错报 */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return out.sort((a, b) => a.ts - b.ts);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 清空登记与启动占用;不触碰旧格式文件与对话文件。 */
|
|
65
|
+
export function clearWorkspace(paths = workspacePaths()) {
|
|
66
|
+
let removed = 0;
|
|
67
|
+
for (const dir of [paths.members, paths.claims]) {
|
|
68
|
+
let files;
|
|
69
|
+
try {
|
|
70
|
+
files = readdirSync(dir);
|
|
71
|
+
} catch {
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
for (const file of files.filter((f) => f.endsWith(".json"))) {
|
|
75
|
+
try {
|
|
76
|
+
unlinkSync(join(dir, file));
|
|
77
|
+
removed++;
|
|
78
|
+
} catch {
|
|
79
|
+
/* 忽略 */
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return removed;
|
|
84
|
+
}
|