@co0ontty/wand 1.18.1 → 1.20.4
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/dist/cli.js +72 -5
- package/dist/ensure-node-pty-helper.d.ts +1 -0
- package/dist/ensure-node-pty-helper.js +51 -0
- package/dist/git-quick-commit.d.ts +18 -0
- package/dist/git-quick-commit.js +373 -0
- package/dist/process-manager.d.ts +6 -9
- package/dist/process-manager.js +26 -195
- package/dist/prompt-optimizer.d.ts +5 -0
- package/dist/prompt-optimizer.js +72 -0
- package/dist/pty-text-utils.d.ts +1 -3
- package/dist/pty-text-utils.js +1 -3
- package/dist/server-session-routes.d.ts +2 -2
- package/dist/server-session-routes.js +79 -13
- package/dist/server.d.ts +19 -1
- package/dist/server.js +90 -5
- package/dist/storage.js +4 -8
- package/dist/structured-session-manager.d.ts +6 -1
- package/dist/structured-session-manager.js +156 -13
- package/dist/tui/index.d.ts +24 -0
- package/dist/tui/index.js +138 -0
- package/dist/tui/layout.d.ts +25 -0
- package/dist/tui/layout.js +198 -0
- package/dist/tui/log-bus.d.ts +23 -0
- package/dist/tui/log-bus.js +111 -0
- package/dist/tui/relative-time.d.ts +4 -0
- package/dist/tui/relative-time.js +27 -0
- package/dist/tui/session-formatter.d.ts +17 -0
- package/dist/tui/session-formatter.js +111 -0
- package/dist/types.d.ts +42 -14
- package/dist/web-ui/content/scripts.js +1188 -209
- package/dist/web-ui/content/styles.css +536 -19
- package/dist/web-ui/content/vendor/wterm/wterm.bundle.js +1 -1
- package/package.json +3 -1
package/dist/cli.js
CHANGED
|
@@ -11,9 +11,38 @@ async function main() {
|
|
|
11
11
|
break;
|
|
12
12
|
}
|
|
13
13
|
case "web": {
|
|
14
|
-
const
|
|
14
|
+
const useTui = shouldUseTui();
|
|
15
|
+
// web 命令下"已存在"的 ready 信息由统一的启动 banner / TUI 展示,避免重复。
|
|
16
|
+
// "Created..." 这种首次创建事件仍会输出(ensureRequiredFiles 内部判断)。
|
|
17
|
+
const config = await ensureRequiredFiles(configPath, { silentReady: true });
|
|
18
|
+
const { ensureNodePtyHelperExecutable } = await import("./ensure-node-pty-helper.js");
|
|
19
|
+
ensureNodePtyHelperExecutable();
|
|
15
20
|
const { startServer } = await import("./server.js");
|
|
16
|
-
await startServer(config, configPath);
|
|
21
|
+
const handle = await startServer(config, configPath);
|
|
22
|
+
if (useTui) {
|
|
23
|
+
const { startTui } = await import("./tui/index.js");
|
|
24
|
+
const tui = startTui({
|
|
25
|
+
processManager: handle.processManager,
|
|
26
|
+
structuredSessions: handle.structuredSessions,
|
|
27
|
+
version: handle.version,
|
|
28
|
+
configPath: handle.configPath,
|
|
29
|
+
dbPath: handle.dbPath,
|
|
30
|
+
bindAddr: handle.bindAddr,
|
|
31
|
+
httpsEnabled: handle.httpsEnabled,
|
|
32
|
+
urls: handle.urls,
|
|
33
|
+
orphanRecoveredCount: handle.orphanRecoveredCount,
|
|
34
|
+
onExit: async () => {
|
|
35
|
+
await handle.close();
|
|
36
|
+
process.exit(0);
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
const onSignal = () => { void tui.stop("signal"); };
|
|
40
|
+
process.on("SIGINT", onSignal);
|
|
41
|
+
process.on("SIGTERM", onSignal);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
printStartupBanner(handle);
|
|
45
|
+
}
|
|
17
46
|
break;
|
|
18
47
|
}
|
|
19
48
|
case "config:path": {
|
|
@@ -65,26 +94,64 @@ Options:
|
|
|
65
94
|
-c, --config <path> Use a custom config file (default: ~/.wand/config.json)
|
|
66
95
|
`);
|
|
67
96
|
}
|
|
68
|
-
async function ensureRequiredFiles(configPath) {
|
|
97
|
+
async function ensureRequiredFiles(configPath, opts = {}) {
|
|
69
98
|
const { ensureDatabaseFile, resolveDatabasePath } = await import("./storage.js");
|
|
70
99
|
const dbPath = resolveDatabasePath(configPath);
|
|
71
100
|
const hadConfig = hasConfigFile(configPath);
|
|
72
101
|
const config = await ensureConfig(configPath);
|
|
73
102
|
const createdDb = ensureDatabaseFile(dbPath);
|
|
103
|
+
// 已存在的 ready 信息在 TUI 模式下由启动 banner 统一展示,此处静默;
|
|
104
|
+
// 但 created 是首次创建事件,无论 TUI 与否都值得提示。
|
|
74
105
|
if (!hadConfig) {
|
|
75
106
|
process.stdout.write(`[wand] Created default config at ${configPath}\n`);
|
|
76
107
|
}
|
|
77
|
-
else {
|
|
108
|
+
else if (!opts.silentReady) {
|
|
78
109
|
process.stdout.write(`[wand] Config ready at ${configPath}\n`);
|
|
79
110
|
}
|
|
80
111
|
if (createdDb) {
|
|
81
112
|
process.stdout.write(`[wand] Created SQLite database at ${dbPath}\n`);
|
|
82
113
|
}
|
|
83
|
-
else {
|
|
114
|
+
else if (!opts.silentReady) {
|
|
84
115
|
process.stdout.write(`[wand] SQLite database ready at ${dbPath}\n`);
|
|
85
116
|
}
|
|
86
117
|
return config;
|
|
87
118
|
}
|
|
119
|
+
function shouldUseTui() {
|
|
120
|
+
if (process.env.WAND_NO_TUI)
|
|
121
|
+
return false;
|
|
122
|
+
if (!process.stdout.isTTY || !process.stderr.isTTY)
|
|
123
|
+
return false;
|
|
124
|
+
// Windows conhost 旧版渲染 box-drawing 不可靠;仅 Windows Terminal (WT_SESSION) 启用
|
|
125
|
+
if (process.platform === "win32" && !process.env.WT_SESSION)
|
|
126
|
+
return false;
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
function printStartupBanner(handle) {
|
|
130
|
+
const all = [...handle.processManager.listSlim(), ...handle.structuredSessions.listSlim()];
|
|
131
|
+
let active = 0, archived = 0;
|
|
132
|
+
for (const s of all) {
|
|
133
|
+
if (s.archived)
|
|
134
|
+
archived += 1;
|
|
135
|
+
else if (s.status === "running")
|
|
136
|
+
active += 1;
|
|
137
|
+
}
|
|
138
|
+
const scheme = handle.httpsEnabled ? "HTTPS" : "HTTP";
|
|
139
|
+
const primary = handle.urls[0]?.url ?? `${handle.httpsEnabled ? "https" : "http"}://${handle.bindAddr}`;
|
|
140
|
+
const orphan = handle.orphanRecoveredCount > 0
|
|
141
|
+
? ` (${handle.orphanRecoveredCount} orphan PTYs cleaned)`
|
|
142
|
+
: "";
|
|
143
|
+
const lines = [
|
|
144
|
+
`[wand] wand v${handle.version} ready · ${primary} (${scheme})`,
|
|
145
|
+
` Bind ${handle.bindAddr}`,
|
|
146
|
+
` Config ${handle.configPath}`,
|
|
147
|
+
` Database ${handle.dbPath}`,
|
|
148
|
+
` Sessions ${active} active · ${archived} archived · ${all.length} total${orphan}`,
|
|
149
|
+
];
|
|
150
|
+
for (const extra of handle.urls.slice(1)) {
|
|
151
|
+
lines.splice(1, 0, ` URL ${extra.url} (${extra.scheme})`);
|
|
152
|
+
}
|
|
153
|
+
process.stdout.write(lines.join("\n") + "\n");
|
|
154
|
+
}
|
|
88
155
|
function setConfigValue(config, key, value) {
|
|
89
156
|
switch (key) {
|
|
90
157
|
case "host":
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function ensureNodePtyHelperExecutable(): void;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// node-pty's prebuilt `spawn-helper` ships in node_modules without the
|
|
2
|
+
// executable bit on some npm/tar combinations (npm/cli#8131). On macOS,
|
|
3
|
+
// posix_spawn against a non-executable file returns EACCES and node-pty
|
|
4
|
+
// throws "posix_spawnp failed." — every PTY session fails before it starts.
|
|
5
|
+
//
|
|
6
|
+
// Run once on server startup. Locates node-pty wherever it actually lives
|
|
7
|
+
// (dev repo, hoisted global, pnpm store …) via require.resolve, finds the
|
|
8
|
+
// per-arch prebuilds dir the loaded binary is in, and ensures the helper
|
|
9
|
+
// next to it is +x. Idempotent and best-effort: silent on the happy path,
|
|
10
|
+
// warns on failure but never blocks startup.
|
|
11
|
+
import { chmodSync, existsSync, statSync } from "node:fs";
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import process from "node:process";
|
|
15
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
16
|
+
export function ensureNodePtyHelperExecutable() {
|
|
17
|
+
// spawn-helper is only used on Unix-likes. Windows uses winpty / conpty.
|
|
18
|
+
if (process.platform === "win32")
|
|
19
|
+
return;
|
|
20
|
+
let nodePtyEntry;
|
|
21
|
+
try {
|
|
22
|
+
nodePtyEntry = requireFromHere.resolve("node-pty");
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
// node-pty's lib/index.js sits at <pkg>/lib/index.js; helper lives at
|
|
28
|
+
// <pkg>/prebuilds/<platform>-<arch>/spawn-helper.
|
|
29
|
+
const pkgRoot = path.resolve(path.dirname(nodePtyEntry), "..");
|
|
30
|
+
const arch = `${process.platform}-${process.arch}`;
|
|
31
|
+
const helper = path.join(pkgRoot, "prebuilds", arch, "spawn-helper");
|
|
32
|
+
if (!existsSync(helper))
|
|
33
|
+
return;
|
|
34
|
+
let mode;
|
|
35
|
+
try {
|
|
36
|
+
mode = statSync(helper).mode & 0o777;
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if ((mode & 0o111) === 0o111)
|
|
42
|
+
return;
|
|
43
|
+
try {
|
|
44
|
+
chmodSync(helper, mode | 0o755);
|
|
45
|
+
process.stderr.write(`[wand] Restored +x on ${helper} (npm dropped the bit on install)\n`);
|
|
46
|
+
}
|
|
47
|
+
catch (err) {
|
|
48
|
+
process.stderr.write(`[wand] Warning: could not chmod +x ${helper}: ${err instanceof Error ? err.message : String(err)}\n`
|
|
49
|
+
+ `[wand] PTY sessions may fail to start. Run: chmod +x ${JSON.stringify(helper)}\n`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { GitStatusResult, QuickCommitResult } from "./types.js";
|
|
2
|
+
export declare function getGitStatus(cwd: string): GitStatusResult;
|
|
3
|
+
interface QuickCommitOptions {
|
|
4
|
+
cwd: string;
|
|
5
|
+
language: string;
|
|
6
|
+
autoMessage: boolean;
|
|
7
|
+
customMessage?: string;
|
|
8
|
+
tag?: string;
|
|
9
|
+
autoTag?: boolean;
|
|
10
|
+
push?: boolean;
|
|
11
|
+
}
|
|
12
|
+
export declare class QuickCommitError extends Error {
|
|
13
|
+
readonly code: string;
|
|
14
|
+
constructor(message: string, code: string);
|
|
15
|
+
}
|
|
16
|
+
export declare function generateCommitMessageOnly(cwd: string, language: string): Promise<string>;
|
|
17
|
+
export declare function runQuickCommit(opts: QuickCommitOptions): Promise<QuickCommitResult>;
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
import { execFile, execFileSync } from "node:child_process";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
const GIT_TIMEOUT_MS = 1500;
|
|
4
|
+
const GIT_PUSH_TIMEOUT_MS = 30_000;
|
|
5
|
+
const MAX_FILE_ENTRIES = 200;
|
|
6
|
+
const CLAUDE_MESSAGE_TIMEOUT_MS = 30_000;
|
|
7
|
+
const MAX_DIFF_FOR_AI = 100_000;
|
|
8
|
+
function runGit(args, cwd, timeoutMs = GIT_TIMEOUT_MS) {
|
|
9
|
+
return execFileSync("git", args, {
|
|
10
|
+
cwd,
|
|
11
|
+
encoding: "utf8",
|
|
12
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
13
|
+
timeout: timeoutMs,
|
|
14
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
15
|
+
}).trim();
|
|
16
|
+
}
|
|
17
|
+
function runGitAllowEmpty(args, cwd, timeoutMs = GIT_TIMEOUT_MS) {
|
|
18
|
+
return execFileSync("git", args, {
|
|
19
|
+
cwd,
|
|
20
|
+
encoding: "utf8",
|
|
21
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
22
|
+
timeout: timeoutMs,
|
|
23
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
function getGitErrorMessage(error) {
|
|
27
|
+
const e = error;
|
|
28
|
+
if (e?.stderr && typeof e.stderr === "string")
|
|
29
|
+
return e.stderr.trim() || e.message || "git 命令失败";
|
|
30
|
+
if (e?.message)
|
|
31
|
+
return e.message;
|
|
32
|
+
return String(error);
|
|
33
|
+
}
|
|
34
|
+
function unquotePath(raw) {
|
|
35
|
+
if (raw.startsWith("\"") && raw.endsWith("\"")) {
|
|
36
|
+
return raw.slice(1, -1).replace(/\\"/g, "\"").replace(/\\\\/g, "\\");
|
|
37
|
+
}
|
|
38
|
+
return raw;
|
|
39
|
+
}
|
|
40
|
+
function makeEntry(path, status, sub) {
|
|
41
|
+
if (sub && sub.length === 4 && sub[0] === "S") {
|
|
42
|
+
return {
|
|
43
|
+
path,
|
|
44
|
+
status,
|
|
45
|
+
isSubmodule: true,
|
|
46
|
+
submoduleState: {
|
|
47
|
+
commitChanged: sub[1] === "C",
|
|
48
|
+
hasTrackedChanges: sub[2] === "M",
|
|
49
|
+
hasUntracked: sub[3] === "U",
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
return { path, status };
|
|
54
|
+
}
|
|
55
|
+
function parsePorcelainV2(raw) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const lines = raw.split(/\r?\n/);
|
|
58
|
+
for (const line of lines) {
|
|
59
|
+
if (!line)
|
|
60
|
+
continue;
|
|
61
|
+
const head = line[0];
|
|
62
|
+
if (head === "1") {
|
|
63
|
+
const parts = line.split(" ");
|
|
64
|
+
if (parts.length < 9)
|
|
65
|
+
continue;
|
|
66
|
+
const status = parts[1];
|
|
67
|
+
const sub = parts[2];
|
|
68
|
+
const path = unquotePath(parts.slice(8).join(" "));
|
|
69
|
+
out.push(makeEntry(path, status, sub));
|
|
70
|
+
}
|
|
71
|
+
else if (head === "2") {
|
|
72
|
+
const parts = line.split(" ");
|
|
73
|
+
if (parts.length < 10)
|
|
74
|
+
continue;
|
|
75
|
+
const status = parts[1];
|
|
76
|
+
const sub = parts[2];
|
|
77
|
+
const rest = parts.slice(9).join(" ");
|
|
78
|
+
const tabIdx = rest.indexOf("\t");
|
|
79
|
+
const newPath = unquotePath(tabIdx === -1 ? rest : rest.slice(0, tabIdx));
|
|
80
|
+
out.push(makeEntry(newPath, status, sub));
|
|
81
|
+
}
|
|
82
|
+
else if (head === "?") {
|
|
83
|
+
out.push({ path: unquotePath(line.slice(2)), status: "??" });
|
|
84
|
+
}
|
|
85
|
+
else if (head === "!") {
|
|
86
|
+
out.push({ path: unquotePath(line.slice(2)), status: "!!" });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return out;
|
|
90
|
+
}
|
|
91
|
+
export function getGitStatus(cwd) {
|
|
92
|
+
if (!cwd || !existsSync(cwd)) {
|
|
93
|
+
return { isGit: false, error: "工作目录不存在。" };
|
|
94
|
+
}
|
|
95
|
+
let isInside;
|
|
96
|
+
try {
|
|
97
|
+
isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
98
|
+
}
|
|
99
|
+
catch {
|
|
100
|
+
return { isGit: false };
|
|
101
|
+
}
|
|
102
|
+
if (isInside !== "true") {
|
|
103
|
+
return { isGit: false };
|
|
104
|
+
}
|
|
105
|
+
let repoRoot;
|
|
106
|
+
try {
|
|
107
|
+
repoRoot = runGit(["rev-parse", "--show-toplevel"], cwd);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
repoRoot = undefined;
|
|
111
|
+
}
|
|
112
|
+
let branch;
|
|
113
|
+
try {
|
|
114
|
+
branch = runGit(["branch", "--show-current"], cwd);
|
|
115
|
+
}
|
|
116
|
+
catch (error) {
|
|
117
|
+
return { isGit: true, repoRoot, error: getGitErrorMessage(error) };
|
|
118
|
+
}
|
|
119
|
+
if (!branch) {
|
|
120
|
+
try {
|
|
121
|
+
branch = `HEAD (${runGit(["rev-parse", "--short", "HEAD"], cwd)})`;
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
branch = "HEAD";
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
let head;
|
|
128
|
+
let initialCommit = false;
|
|
129
|
+
try {
|
|
130
|
+
head = runGit(["rev-parse", "HEAD"], cwd);
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
initialCommit = true;
|
|
134
|
+
}
|
|
135
|
+
let porcelain;
|
|
136
|
+
try {
|
|
137
|
+
porcelain = runGitAllowEmpty(["status", "--porcelain=v2", "--untracked-files=all"], cwd);
|
|
138
|
+
}
|
|
139
|
+
catch (error) {
|
|
140
|
+
return { isGit: true, branch, repoRoot, head, initialCommit, error: getGitErrorMessage(error) };
|
|
141
|
+
}
|
|
142
|
+
const allEntries = parsePorcelainV2(porcelain);
|
|
143
|
+
const files = allEntries.slice(0, MAX_FILE_ENTRIES);
|
|
144
|
+
let latestTag;
|
|
145
|
+
let suggestedNextTag;
|
|
146
|
+
try {
|
|
147
|
+
latestTag = runGit(["describe", "--tags", "--abbrev=0"], cwd);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
latestTag = undefined;
|
|
151
|
+
}
|
|
152
|
+
if (latestTag) {
|
|
153
|
+
suggestedNextTag = bumpPatchTag(latestTag);
|
|
154
|
+
}
|
|
155
|
+
return {
|
|
156
|
+
isGit: true,
|
|
157
|
+
branch,
|
|
158
|
+
modifiedCount: allEntries.length,
|
|
159
|
+
files,
|
|
160
|
+
head,
|
|
161
|
+
repoRoot,
|
|
162
|
+
initialCommit,
|
|
163
|
+
latestTag,
|
|
164
|
+
suggestedNextTag,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
function bumpPatchTag(tag) {
|
|
168
|
+
const m = tag.match(/^(v?)(\d+)\.(\d+)\.(\d+)(.*)/);
|
|
169
|
+
if (!m)
|
|
170
|
+
return "";
|
|
171
|
+
const prefix = m[1];
|
|
172
|
+
const major = m[2];
|
|
173
|
+
const minor = m[3];
|
|
174
|
+
const patch = parseInt(m[4], 10) + 1;
|
|
175
|
+
return `${prefix}${major}.${minor}.${patch}`;
|
|
176
|
+
}
|
|
177
|
+
export class QuickCommitError extends Error {
|
|
178
|
+
code;
|
|
179
|
+
constructor(message, code) {
|
|
180
|
+
super(message);
|
|
181
|
+
this.code = code;
|
|
182
|
+
this.name = "QuickCommitError";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
// ── AI commit message generation ──
|
|
186
|
+
function callClaudeText(prompt, cwd) {
|
|
187
|
+
return new Promise((resolve, reject) => {
|
|
188
|
+
const child = execFile("claude", ["-p", "--output-format", "text"], {
|
|
189
|
+
cwd,
|
|
190
|
+
encoding: "utf8",
|
|
191
|
+
maxBuffer: 4 * 1024 * 1024,
|
|
192
|
+
timeout: CLAUDE_MESSAGE_TIMEOUT_MS,
|
|
193
|
+
}, (error, stdout, stderr) => {
|
|
194
|
+
if (error) {
|
|
195
|
+
const e = error;
|
|
196
|
+
if (e.code === "ENOENT") {
|
|
197
|
+
reject(new QuickCommitError("未找到 claude CLI。", "CLAUDE_CLI_MISSING"));
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
if (e.code === "ETIMEDOUT") {
|
|
201
|
+
reject(new QuickCommitError("Claude 生成超时,请手动填写 commit message。", "CLAUDE_TIMEOUT"));
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
const msg = (stderr || "").trim() || e.message || "claude 调用失败";
|
|
205
|
+
reject(new QuickCommitError(`Claude CLI 失败:${msg}`, "CLAUDE_CLI_FAILED"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
resolve((stdout || "").trim());
|
|
209
|
+
});
|
|
210
|
+
child.stdin?.end(prompt);
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
async function generateCommitMessage(cwd, language) {
|
|
214
|
+
let diff;
|
|
215
|
+
try {
|
|
216
|
+
diff = runGit(["diff", "--cached", "--submodule=log"], cwd, 5000);
|
|
217
|
+
}
|
|
218
|
+
catch {
|
|
219
|
+
diff = "";
|
|
220
|
+
}
|
|
221
|
+
if (!diff) {
|
|
222
|
+
try {
|
|
223
|
+
diff = runGit(["diff", "--cached", "--name-only"], cwd, 3000);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
diff = "(no diff available)";
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (diff.length > MAX_DIFF_FOR_AI) {
|
|
230
|
+
diff = diff.slice(0, MAX_DIFF_FOR_AI) + "\n\n... (diff truncated) ...";
|
|
231
|
+
}
|
|
232
|
+
const lang = language.trim() || "中文";
|
|
233
|
+
const prompt = `阅读以下 git diff,用${lang}写一条简洁的 commit message。要求:祈使句,不超过 50 字,描述「做了什么」。只输出 message 本身,不要引号、不要 Markdown 格式、不要任何额外说明。\n\n${diff}`;
|
|
234
|
+
const raw = await callClaudeText(prompt, cwd);
|
|
235
|
+
const message = raw.replace(/^["'`]+|["'`]+$/g, "").trim();
|
|
236
|
+
if (!message) {
|
|
237
|
+
throw new QuickCommitError("Claude 返回了空的 commit message。", "EMPTY_AI_MESSAGE");
|
|
238
|
+
}
|
|
239
|
+
return message;
|
|
240
|
+
}
|
|
241
|
+
export async function generateCommitMessageOnly(cwd, language) {
|
|
242
|
+
if (!cwd || !existsSync(cwd)) {
|
|
243
|
+
throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
runGit(["add", "-A"], cwd, 5000);
|
|
247
|
+
}
|
|
248
|
+
catch {
|
|
249
|
+
// best-effort staging so the diff is complete
|
|
250
|
+
}
|
|
251
|
+
return generateCommitMessage(cwd, language);
|
|
252
|
+
}
|
|
253
|
+
// ── Direct git operations ──
|
|
254
|
+
export async function runQuickCommit(opts) {
|
|
255
|
+
const { cwd, language, autoMessage, customMessage, tag, autoTag, push } = opts;
|
|
256
|
+
if (!cwd || !existsSync(cwd)) {
|
|
257
|
+
throw new QuickCommitError("工作目录不存在。", "CWD_MISSING");
|
|
258
|
+
}
|
|
259
|
+
let isInside;
|
|
260
|
+
try {
|
|
261
|
+
isInside = runGit(["rev-parse", "--is-inside-work-tree"], cwd);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
throw new QuickCommitError(getGitErrorMessage(error), "NOT_A_GIT_REPO");
|
|
265
|
+
}
|
|
266
|
+
if (isInside !== "true") {
|
|
267
|
+
throw new QuickCommitError("当前目录不在 git 仓库内。", "NOT_A_GIT_REPO");
|
|
268
|
+
}
|
|
269
|
+
// Step 1: stage all
|
|
270
|
+
try {
|
|
271
|
+
runGit(["add", "-A"], cwd, 5000);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
throw new QuickCommitError(`git add 失败:${getGitErrorMessage(error)}`, "GIT_ADD_FAILED");
|
|
275
|
+
}
|
|
276
|
+
// Step 2: check if anything to commit
|
|
277
|
+
let stagedFiles;
|
|
278
|
+
try {
|
|
279
|
+
stagedFiles = runGitAllowEmpty(["diff", "--cached", "--name-only"], cwd).trim();
|
|
280
|
+
}
|
|
281
|
+
catch (error) {
|
|
282
|
+
throw new QuickCommitError(getGitErrorMessage(error), "GIT_DIFF_FAILED");
|
|
283
|
+
}
|
|
284
|
+
if (!stagedFiles) {
|
|
285
|
+
throw new QuickCommitError("没有任何改动可以提交。", "NOTHING_TO_COMMIT");
|
|
286
|
+
}
|
|
287
|
+
// Step 3: get commit message
|
|
288
|
+
let message;
|
|
289
|
+
if (autoMessage) {
|
|
290
|
+
message = await generateCommitMessage(cwd, language);
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
message = (customMessage || "").trim();
|
|
294
|
+
if (!message) {
|
|
295
|
+
throw new QuickCommitError("commit message 不能为空。", "EMPTY_MESSAGE");
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
// Step 4: commit
|
|
299
|
+
try {
|
|
300
|
+
runGit(["commit", "-m", message], cwd, 10_000);
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
throw new QuickCommitError(`git commit 失败:${getGitErrorMessage(error)}`, "GIT_COMMIT_FAILED");
|
|
304
|
+
}
|
|
305
|
+
let commitHash;
|
|
306
|
+
try {
|
|
307
|
+
commitHash = runGit(["rev-parse", "--short", "HEAD"], cwd);
|
|
308
|
+
}
|
|
309
|
+
catch {
|
|
310
|
+
commitHash = "";
|
|
311
|
+
}
|
|
312
|
+
// Step 5: tag
|
|
313
|
+
const makeTag = !!(autoTag || (tag && tag.trim()));
|
|
314
|
+
let tagName = "";
|
|
315
|
+
if (makeTag) {
|
|
316
|
+
if (tag && tag.trim()) {
|
|
317
|
+
tagName = tag.trim();
|
|
318
|
+
}
|
|
319
|
+
else {
|
|
320
|
+
let latestTag;
|
|
321
|
+
try {
|
|
322
|
+
latestTag = runGit(["describe", "--tags", "--abbrev=0"], cwd);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
latestTag = undefined;
|
|
326
|
+
}
|
|
327
|
+
tagName = bumpPatchTag(latestTag || "v0.0.0");
|
|
328
|
+
}
|
|
329
|
+
if (tagName) {
|
|
330
|
+
try {
|
|
331
|
+
runGit(["tag", tagName], cwd);
|
|
332
|
+
}
|
|
333
|
+
catch (error) {
|
|
334
|
+
throw new QuickCommitError(`git tag 失败:${getGitErrorMessage(error)}`, "GIT_TAG_FAILED");
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// Step 6: push
|
|
339
|
+
let pushed = false;
|
|
340
|
+
let pushError;
|
|
341
|
+
if (push) {
|
|
342
|
+
try {
|
|
343
|
+
let hasUpstream = false;
|
|
344
|
+
try {
|
|
345
|
+
runGit(["rev-parse", "--abbrev-ref", "@{upstream}"], cwd);
|
|
346
|
+
hasUpstream = true;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
hasUpstream = false;
|
|
350
|
+
}
|
|
351
|
+
if (hasUpstream) {
|
|
352
|
+
runGit(["push", "--recurse-submodules=on-demand"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
353
|
+
}
|
|
354
|
+
else {
|
|
355
|
+
runGit(["push", "-u", "--recurse-submodules=on-demand", "origin", "HEAD"], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
356
|
+
}
|
|
357
|
+
if (tagName) {
|
|
358
|
+
runGit(["push", "origin", `refs/tags/${tagName}`], cwd, GIT_PUSH_TIMEOUT_MS);
|
|
359
|
+
}
|
|
360
|
+
pushed = true;
|
|
361
|
+
}
|
|
362
|
+
catch (error) {
|
|
363
|
+
pushError = getGitErrorMessage(error);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
return {
|
|
367
|
+
ok: true,
|
|
368
|
+
commit: { hash: commitHash, message },
|
|
369
|
+
tag: tagName ? { name: tagName } : undefined,
|
|
370
|
+
pushed,
|
|
371
|
+
pushError,
|
|
372
|
+
};
|
|
373
|
+
}
|
|
@@ -29,13 +29,18 @@ export declare class ProcessManager extends EventEmitter {
|
|
|
29
29
|
private readonly storage;
|
|
30
30
|
private readonly sessions;
|
|
31
31
|
private readonly logger;
|
|
32
|
-
|
|
32
|
+
/** 24h archive scan timer */
|
|
33
|
+
private archiveTimer;
|
|
33
34
|
/** Per-session debounce timers for throttled persist calls */
|
|
34
35
|
private readonly persistDebounceTimers;
|
|
35
36
|
/** Last persisted message state per session — used to skip redundant message writes */
|
|
36
37
|
private readonly lastPersistedMessageState;
|
|
38
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数(旧服务器进程已死) */
|
|
39
|
+
private orphanRecoveredCount;
|
|
37
40
|
constructor(config: WandConfig, storage: WandStorage, configDir?: string);
|
|
38
41
|
on(event: "process", listener: ProcessEventHandler): this;
|
|
42
|
+
/** 启动时被识别为孤儿 PTY 并标记为 exited 的旧会话数量(仅用于启动摘要展示)。 */
|
|
43
|
+
getOrphanRecoveredCount(): number;
|
|
39
44
|
private emitEvent;
|
|
40
45
|
private cleanupOldSessions;
|
|
41
46
|
start(command: string, cwd: string | undefined, mode: ExecutionMode, initialInput?: string, opts?: {
|
|
@@ -102,14 +107,6 @@ export declare class ProcessManager extends EventEmitter {
|
|
|
102
107
|
* Use this at critical points (exit, stop, delete) to ensure no data loss.
|
|
103
108
|
*/
|
|
104
109
|
private flushPersist;
|
|
105
|
-
private backfillExitedClaudeSessionIds;
|
|
106
|
-
/**
|
|
107
|
-
* Auto-recover the most recent exited session that has a Claude session ID.
|
|
108
|
-
* Only resumes one session per server start, using the most recent eligible
|
|
109
|
-
* session. Reuses the original session ID (in-place resume) and sets
|
|
110
|
-
* `autoRecovered: true`.
|
|
111
|
-
*/
|
|
112
|
-
private autoRecoverExitedSessions;
|
|
113
110
|
private archiveExpiredSessions;
|
|
114
111
|
private assertCommandAllowed;
|
|
115
112
|
/**
|