@nurix/nustack 0.10.0-dev.18 → 0.10.0-dev.20
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/commands/logscope.js +55 -0
- package/dist/commands/review.js +34 -11
- package/dist/commands/sessions.js +155 -4
- package/dist/index.js +40 -7
- package/dist/lib/logs/answer.js +99 -0
- package/dist/lib/logs/library.js +19 -0
- package/dist/lib/logs/lifecycle.js +159 -0
- package/dist/lib/{lanes → session-engine}/claudeAdapter.js +3 -3
- package/dist/lib/{lanes → session-engine}/frames.js +31 -27
- package/dist/lib/session-engine/prune.js +233 -0
- package/dist/lib/session-engine/registry.js +83 -0
- package/dist/lib/session-engine/supervisor.js +315 -0
- package/dist/lib/session-engine/worktree.js +56 -0
- package/dist/lib/sessions/outsideSessions.js +3 -3
- package/package.json +5 -4
- package/dist/commands/lane.js +0 -12
- package/dist/lib/lanes/supervisor.js +0 -247
- package/dist/lib/lanes/worktree.js +0 -33
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { printJsonSuccess } from "../json_output.js";
|
|
2
|
+
import { loadLogscope } from "./library.js";
|
|
3
|
+
import { LogsNotFound, LogsRefused, progress, resolveRoot } from "./answer.js";
|
|
4
|
+
function reportPruneToStderr(library) {
|
|
5
|
+
for (const record of library.prune()) {
|
|
6
|
+
progress(`pruned ${record.id} (retention ${library.retentionOf(record)} days, indexed ${record.indexedAt ?? "unknown"})`);
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function indexLogs(targets, options, cwd) {
|
|
10
|
+
const library = await loadLogscope();
|
|
11
|
+
const root = resolveRoot(options.root, cwd);
|
|
12
|
+
const retentionDays = Number(options.retain ?? String(library.DEFAULT_RETENTION_DAYS));
|
|
13
|
+
if (!Number.isInteger(retentionDays) || retentionDays < 0) {
|
|
14
|
+
throw new LogsRefused("--retain takes a non-negative whole number of days");
|
|
15
|
+
}
|
|
16
|
+
reportPruneToStderr(library);
|
|
17
|
+
const outcome = await library.indexCorpus({
|
|
18
|
+
targets,
|
|
19
|
+
root,
|
|
20
|
+
since: options.since,
|
|
21
|
+
until: options.until,
|
|
22
|
+
revision: options.revision,
|
|
23
|
+
redact: options.redact !== false,
|
|
24
|
+
keepExamples: options.examples === true,
|
|
25
|
+
retentionDays,
|
|
26
|
+
});
|
|
27
|
+
if (!outcome.ok)
|
|
28
|
+
throw new LogsRefused(outcome.message);
|
|
29
|
+
if (options.json === true) {
|
|
30
|
+
printJsonSuccess("logscope index", {
|
|
31
|
+
id: outcome.id,
|
|
32
|
+
sources: outcome.sources,
|
|
33
|
+
events: outcome.events,
|
|
34
|
+
templates: outcome.templates,
|
|
35
|
+
ratio: outcome.ratio,
|
|
36
|
+
resumes: outcome.resumes,
|
|
37
|
+
retentionDays: outcome.retentionDays,
|
|
38
|
+
hasExamples: outcome.hasExamples,
|
|
39
|
+
expiresAt: outcome.expiresAt ?? null,
|
|
40
|
+
warning: outcome.warning ?? null,
|
|
41
|
+
});
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
console.log([
|
|
45
|
+
`indexed ${outcome.id}`,
|
|
46
|
+
` ${outcome.events} events -> ${outcome.templates} templates (ratio ${outcome.ratio.toFixed(4)})`,
|
|
47
|
+
` sources ${Object.entries(outcome.resumes).map(([path, resume]) => `${path} [${resume}]`).join(", ")}`,
|
|
48
|
+
` retention ${outcome.retentionDays === 0 ? "off (kept until forgotten)" : `${outcome.retentionDays} days (expires ${outcome.expiresAt ?? "unknown"})`}`,
|
|
49
|
+
` examples ${outcome.hasExamples ? "kept" : "not kept — drill and trace <id> cannot answer on this corpus"}`,
|
|
50
|
+
...(outcome.warning === undefined ? [] : [` WARNING: ${outcome.warning}`]),
|
|
51
|
+
].join("\n"));
|
|
52
|
+
}
|
|
53
|
+
export async function listCorpora(options) {
|
|
54
|
+
const library = await loadLogscope();
|
|
55
|
+
reportPruneToStderr(library);
|
|
56
|
+
const corpora = library.readRegistry().map((record) => ({
|
|
57
|
+
id: record.id,
|
|
58
|
+
sources: record.sources,
|
|
59
|
+
events: record.events ?? 0,
|
|
60
|
+
templates: record.templates ?? 0,
|
|
61
|
+
indexedAt: record.indexedAt ?? null,
|
|
62
|
+
retentionDays: library.retentionOf(record),
|
|
63
|
+
expiresAt: library.expiresAt(record) ?? null,
|
|
64
|
+
hasExamples: record.hasExamples === true,
|
|
65
|
+
}));
|
|
66
|
+
if (options.json === true) {
|
|
67
|
+
printJsonSuccess("logscope corpora", { corpora });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (corpora.length === 0) {
|
|
71
|
+
console.log("no corpus has been indexed on this machine — run: nustack logscope index <sources...>");
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
console.log(corpora
|
|
75
|
+
.map((corpus) => `${corpus.id} ${corpus.events} events, ${corpus.templates} templates (${corpus.sources.length} sources) ` +
|
|
76
|
+
(corpus.retentionDays === 0
|
|
77
|
+
? "retention off"
|
|
78
|
+
: `retention ${corpus.retentionDays} days, expires ${corpus.expiresAt ?? "unknown"}`))
|
|
79
|
+
.join("\n"));
|
|
80
|
+
}
|
|
81
|
+
export async function allowSources(targets, options, cwd) {
|
|
82
|
+
const library = await loadLogscope();
|
|
83
|
+
const root = resolveRoot(options.root, cwd);
|
|
84
|
+
const allowed = library.allow(root, targets, options.note).map((grant) => grant.path);
|
|
85
|
+
if (options.json === true) {
|
|
86
|
+
printJsonSuccess("logscope allow", { root, allowed });
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
console.log(allowed.map((path) => `allowed ${path}`).join("\n"));
|
|
90
|
+
}
|
|
91
|
+
export async function listAllowed(options, cwd) {
|
|
92
|
+
const library = await loadLogscope();
|
|
93
|
+
const root = resolveRoot(options.root, cwd);
|
|
94
|
+
const grants = library.allowedFor(root);
|
|
95
|
+
if (options.json === true) {
|
|
96
|
+
printJsonSuccess("logscope allowed", { root, grants });
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (grants.length === 0) {
|
|
100
|
+
console.log(`no source is allowed for ${root} — run: nustack logscope allow <path...>`);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
console.log(grants
|
|
104
|
+
.map((grant) => `${grant.path} granted ${grant.grantedAt}${grant.note === undefined ? "" : ` ${grant.note}`}`)
|
|
105
|
+
.join("\n"));
|
|
106
|
+
}
|
|
107
|
+
export async function revokeSources(targets, options, cwd) {
|
|
108
|
+
const library = await loadLogscope();
|
|
109
|
+
const root = resolveRoot(options.root, cwd);
|
|
110
|
+
const revoked = library.revoke(root, targets);
|
|
111
|
+
if (revoked === 0)
|
|
112
|
+
throw new LogsNotFound(`no grant under ${root} names ${targets.join(", ")}`);
|
|
113
|
+
if (options.json === true) {
|
|
114
|
+
printJsonSuccess("logscope revoke", { root, revoked });
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
console.log(`revoked ${revoked} grant(s)`);
|
|
118
|
+
}
|
|
119
|
+
export async function pruneCorpora(options, cwd) {
|
|
120
|
+
const library = await loadLogscope();
|
|
121
|
+
resolveRoot(options.root, cwd);
|
|
122
|
+
const dryRun = options.dryRun === true;
|
|
123
|
+
const now = new Date();
|
|
124
|
+
const candidates = dryRun
|
|
125
|
+
? library.readRegistry().filter((record) => {
|
|
126
|
+
const at = library.expiresAt(record);
|
|
127
|
+
return at !== null && at !== undefined && new Date(at).getTime() <= now.getTime();
|
|
128
|
+
})
|
|
129
|
+
: library.prune();
|
|
130
|
+
const pruned = candidates.map((record) => ({
|
|
131
|
+
id: record.id,
|
|
132
|
+
retentionDays: library.retentionOf(record),
|
|
133
|
+
indexedAt: record.indexedAt ?? null,
|
|
134
|
+
}));
|
|
135
|
+
if (options.json === true) {
|
|
136
|
+
printJsonSuccess("logscope prune", { dryRun, pruned });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
if (pruned.length === 0) {
|
|
140
|
+
console.log("nothing to prune");
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
console.log(pruned
|
|
144
|
+
.map((record) => `${dryRun ? "would prune" : "pruned"} ${record.id} (retention ${record.retentionDays} days, indexed ${record.indexedAt ?? "unknown"})`)
|
|
145
|
+
.join("\n"));
|
|
146
|
+
}
|
|
147
|
+
export async function forgetCorpusCommand(ref, options) {
|
|
148
|
+
const library = await loadLogscope();
|
|
149
|
+
if (ref === undefined)
|
|
150
|
+
throw new LogsNotFound("name the corpus to forget — run: nustack logscope corpora");
|
|
151
|
+
const forgot = library.forgetCorpus(ref);
|
|
152
|
+
if (forgot === undefined)
|
|
153
|
+
throw new LogsNotFound(`no corpus matches ${ref}`);
|
|
154
|
+
if (options.json === true) {
|
|
155
|
+
printJsonSuccess("logscope forget", { forgot });
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
console.log(`forgot ${forgot}`);
|
|
159
|
+
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { spawn } from "node:child_process";
|
|
2
2
|
import { createInterface } from "node:readline";
|
|
3
|
-
export function
|
|
3
|
+
export function claudeSessionArgs(permissionMode, resumeSessionId) {
|
|
4
4
|
const args = [
|
|
5
5
|
"-p",
|
|
6
6
|
"--input-format",
|
|
@@ -20,7 +20,7 @@ export function claudeLaneArgs(permissionMode, resumeSessionId) {
|
|
|
20
20
|
}
|
|
21
21
|
const DENY_MESSAGE = "Denied by the user in NuStack desktop.";
|
|
22
22
|
const STOP_KILL_GRACE_MS = 3_000;
|
|
23
|
-
export class
|
|
23
|
+
export class ClaudeSession {
|
|
24
24
|
child;
|
|
25
25
|
pendingApprovals = new Map();
|
|
26
26
|
interruptSeq = 0;
|
|
@@ -30,7 +30,7 @@ export class ClaudeLane {
|
|
|
30
30
|
constructor(options) {
|
|
31
31
|
this.options = options;
|
|
32
32
|
const spawnImpl = options.spawnImpl ?? spawn;
|
|
33
|
-
this.child = spawnImpl(options.claudeBin ?? "claude",
|
|
33
|
+
this.child = spawnImpl(options.claudeBin ?? "claude", claudeSessionArgs(options.permissionMode, options.resumeSessionId), {
|
|
34
34
|
cwd: options.cwd,
|
|
35
35
|
env: options.env ?? process.env,
|
|
36
36
|
stdio: ["pipe", "pipe", "pipe"],
|
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
export const FRAME_VERSION =
|
|
1
|
+
export const FRAME_VERSION = 2;
|
|
2
2
|
const INBOUND_TYPES = new Set([
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
3
|
+
"session.open",
|
|
4
|
+
"session.turn",
|
|
5
|
+
"session.approval",
|
|
6
|
+
"session.interrupt",
|
|
7
|
+
"session.close",
|
|
8
|
+
"session.list",
|
|
9
|
+
"session.reclaim",
|
|
8
10
|
"engine.shutdown",
|
|
9
11
|
]);
|
|
10
12
|
export function parseInboundFrame(line) {
|
|
@@ -31,38 +33,40 @@ export function parseInboundFrame(line) {
|
|
|
31
33
|
const requireString = (key) => (typeof frame[key] === "string" && frame[key] !== "" ? frame[key] : null);
|
|
32
34
|
switch (type) {
|
|
33
35
|
case "engine.shutdown":
|
|
36
|
+
case "session.list":
|
|
34
37
|
return { ok: true, frame: { v: FRAME_VERSION, type } };
|
|
35
|
-
case "
|
|
36
|
-
const
|
|
38
|
+
case "session.open": {
|
|
39
|
+
const sessionId = requireString("sessionId");
|
|
37
40
|
const projectPath = requireString("projectPath");
|
|
38
|
-
if (!
|
|
39
|
-
return { ok: false, error: { code: "BAD_FRAME", name: type, message: "
|
|
41
|
+
if (!sessionId || !projectPath)
|
|
42
|
+
return { ok: false, error: { code: "BAD_FRAME", name: type, message: "session.open requires sessionId and projectPath" } };
|
|
40
43
|
const permissionMode = typeof frame.permissionMode === "string" ? frame.permissionMode : undefined;
|
|
41
44
|
const isolated = frame.isolated === true ? true : undefined;
|
|
42
45
|
const resumeSessionId = typeof frame.resumeSessionId === "string" && frame.resumeSessionId !== "" ? frame.resumeSessionId : undefined;
|
|
43
|
-
return { ok: true, frame: { v: FRAME_VERSION, type,
|
|
46
|
+
return { ok: true, frame: { v: FRAME_VERSION, type, sessionId, projectPath, permissionMode, isolated, resumeSessionId } };
|
|
44
47
|
}
|
|
45
|
-
case "
|
|
46
|
-
const
|
|
48
|
+
case "session.turn": {
|
|
49
|
+
const sessionId = requireString("sessionId");
|
|
47
50
|
const text = typeof frame.text === "string" ? frame.text : null;
|
|
48
|
-
if (!
|
|
49
|
-
return { ok: false, error: { code: "BAD_FRAME", name: type, message: "
|
|
50
|
-
return { ok: true, frame: { v: FRAME_VERSION, type,
|
|
51
|
+
if (!sessionId || text === null)
|
|
52
|
+
return { ok: false, error: { code: "BAD_FRAME", name: type, message: "session.turn requires sessionId and text" } };
|
|
53
|
+
return { ok: true, frame: { v: FRAME_VERSION, type, sessionId, text } };
|
|
51
54
|
}
|
|
52
|
-
case "
|
|
53
|
-
const
|
|
55
|
+
case "session.approval": {
|
|
56
|
+
const sessionId = requireString("sessionId");
|
|
54
57
|
const requestId = requireString("requestId");
|
|
55
58
|
const verdict = frame.verdict === "allow" || frame.verdict === "deny" ? frame.verdict : null;
|
|
56
|
-
if (!
|
|
57
|
-
return { ok: false, error: { code: "BAD_FRAME", name: type, message: "
|
|
58
|
-
return { ok: true, frame: { v: FRAME_VERSION, type,
|
|
59
|
+
if (!sessionId || !requestId || !verdict)
|
|
60
|
+
return { ok: false, error: { code: "BAD_FRAME", name: type, message: "session.approval requires sessionId, requestId, verdict" } };
|
|
61
|
+
return { ok: true, frame: { v: FRAME_VERSION, type, sessionId, requestId, verdict } };
|
|
59
62
|
}
|
|
60
|
-
case "
|
|
61
|
-
case "
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
63
|
+
case "session.interrupt":
|
|
64
|
+
case "session.close":
|
|
65
|
+
case "session.reclaim": {
|
|
66
|
+
const sessionId = requireString("sessionId");
|
|
67
|
+
if (!sessionId)
|
|
68
|
+
return { ok: false, error: { code: "BAD_FRAME", name: type, message: `${type} requires sessionId` } };
|
|
69
|
+
return { ok: true, frame: { v: FRAME_VERSION, type, sessionId } };
|
|
66
70
|
}
|
|
67
71
|
default:
|
|
68
72
|
return { ok: false, error: { code: "UNKNOWN_FRAME", name: type, message: `unknown frame type: ${type}` } };
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { updateSessionRegistry } from "./registry.js";
|
|
4
|
+
import { ENGINE_SESSION_BRANCH_PREFIX, LEGACY_ENGINE_BRANCH_PREFIX, gitOutcome, legacyEngineHome, sessionsHome, } from "./worktree.js";
|
|
5
|
+
const NOT_ISOLATED = "the session ran in the checkout itself — there is nothing of its own to reclaim";
|
|
6
|
+
const FOREIGN_BRANCH = "its branch is not an engine session branch — this row is not the engine's to reclaim";
|
|
7
|
+
const FOREIGN_WORKTREE = "its worktree is outside the engine's worktrees directory — this row is not the engine's to reclaim";
|
|
8
|
+
function insideAnEngineHome(worktreePath, env) {
|
|
9
|
+
const live = path.join(sessionsHome(env), "worktrees") + path.sep;
|
|
10
|
+
const legacy = path.join(legacyEngineHome(env), "worktrees") + path.sep;
|
|
11
|
+
return worktreePath.startsWith(live) || worktreePath.startsWith(legacy);
|
|
12
|
+
}
|
|
13
|
+
function isEngineBranch(branch) {
|
|
14
|
+
return branch !== null && (branch.startsWith(ENGINE_SESSION_BRANCH_PREFIX) || branch.startsWith(LEGACY_ENGINE_BRANCH_PREFIX));
|
|
15
|
+
}
|
|
16
|
+
async function isDirectory(target) {
|
|
17
|
+
try {
|
|
18
|
+
return (await stat(target)).isDirectory();
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
function refuse(record, facts, reason) {
|
|
25
|
+
return {
|
|
26
|
+
sessionId: record.sessionId,
|
|
27
|
+
isolated: false,
|
|
28
|
+
checkoutPresent: false,
|
|
29
|
+
worktreePresent: false,
|
|
30
|
+
branchExists: false,
|
|
31
|
+
reading: "unknown",
|
|
32
|
+
dirty: false,
|
|
33
|
+
stale: false,
|
|
34
|
+
orphaned: false,
|
|
35
|
+
reclaimable: false,
|
|
36
|
+
...facts,
|
|
37
|
+
reason,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
function processIsGone(pid) {
|
|
41
|
+
try {
|
|
42
|
+
process.kill(pid, 0);
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
return error.code === "ESRCH";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function inspectSession(record, env = process.env) {
|
|
50
|
+
const orphaned = record.worktreePath.startsWith(path.join(legacyEngineHome(env), "worktrees") + path.sep);
|
|
51
|
+
const checkoutPresent = await isDirectory(record.projectPath);
|
|
52
|
+
const worktreePresent = await isDirectory(record.worktreePath);
|
|
53
|
+
const isolated = record.branch !== null && record.worktreePath !== record.projectPath;
|
|
54
|
+
if (!isolated)
|
|
55
|
+
return refuse(record, { checkoutPresent, orphaned }, NOT_ISOLATED);
|
|
56
|
+
if (!isEngineBranch(record.branch))
|
|
57
|
+
return refuse(record, { isolated, checkoutPresent, orphaned }, FOREIGN_BRANCH);
|
|
58
|
+
if (!insideAnEngineHome(record.worktreePath, env)) {
|
|
59
|
+
return refuse(record, { isolated, checkoutPresent, orphaned }, FOREIGN_WORKTREE);
|
|
60
|
+
}
|
|
61
|
+
if (!checkoutPresent) {
|
|
62
|
+
return refuse(record, { isolated, worktreePresent, orphaned }, `its checkout is gone — remove ${record.worktreePath} and the branch by hand`);
|
|
63
|
+
}
|
|
64
|
+
const branch = record.branch;
|
|
65
|
+
const branchExists = (await gitOutcome(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], record.projectPath)).ok;
|
|
66
|
+
const dirty = worktreePresent && (await gitOutcome(["status", "--porcelain"], record.worktreePath)).stdout !== "";
|
|
67
|
+
const stale = record.status === "open" && typeof record.enginePid === "number" && processIsGone(record.enginePid);
|
|
68
|
+
const reading = await readMerge(record, branch, branchExists, dirty);
|
|
69
|
+
const reclaimable = checkoutPresent &&
|
|
70
|
+
!dirty &&
|
|
71
|
+
(record.status === "closed" || stale) &&
|
|
72
|
+
(reading === "untouched" || reading === "merged" || reading === "merged-into-head" || !branchExists);
|
|
73
|
+
return {
|
|
74
|
+
sessionId: record.sessionId,
|
|
75
|
+
isolated,
|
|
76
|
+
checkoutPresent,
|
|
77
|
+
worktreePresent,
|
|
78
|
+
branchExists,
|
|
79
|
+
reading,
|
|
80
|
+
dirty,
|
|
81
|
+
stale,
|
|
82
|
+
orphaned,
|
|
83
|
+
reclaimable,
|
|
84
|
+
reason: explain({ reading, dirty, stale, branchExists, reclaimable, status: record.status, baseBranch: record.baseBranch ?? null }),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
async function readMerge(record, branch, branchExists, dirty) {
|
|
88
|
+
if (!branchExists)
|
|
89
|
+
return "unknown";
|
|
90
|
+
const cwd = record.projectPath;
|
|
91
|
+
if (record.baseCommit) {
|
|
92
|
+
const tip = await gitOutcome(["rev-parse", branch], cwd);
|
|
93
|
+
if (tip.ok && tip.stdout === record.baseCommit && !dirty)
|
|
94
|
+
return "untouched";
|
|
95
|
+
}
|
|
96
|
+
if (record.baseBranch) {
|
|
97
|
+
const againstBase = await gitOutcome(["merge-base", "--is-ancestor", branch, record.baseBranch], cwd);
|
|
98
|
+
if (againstBase.code === 0)
|
|
99
|
+
return "merged";
|
|
100
|
+
if (againstBase.code === 1)
|
|
101
|
+
return "unmerged";
|
|
102
|
+
}
|
|
103
|
+
const againstHead = await gitOutcome(["merge-base", "--is-ancestor", branch, "HEAD"], cwd);
|
|
104
|
+
if (againstHead.code === 0)
|
|
105
|
+
return "merged-into-head";
|
|
106
|
+
if (againstHead.code === 1)
|
|
107
|
+
return "unmerged";
|
|
108
|
+
return "unknown";
|
|
109
|
+
}
|
|
110
|
+
function explain(facts) {
|
|
111
|
+
if (facts.dirty)
|
|
112
|
+
return "its worktree has uncommitted changes — commit or discard them first";
|
|
113
|
+
if (facts.status === "open" && !facts.stale)
|
|
114
|
+
return "it is still open — close the session first";
|
|
115
|
+
if (facts.reading === "unmerged")
|
|
116
|
+
return "unmerged — its branch has work nothing has taken";
|
|
117
|
+
if (facts.reading === "unknown" && facts.branchExists)
|
|
118
|
+
return "its branch could not be read against any base";
|
|
119
|
+
if (!facts.reclaimable)
|
|
120
|
+
return "it cannot be reclaimed yet";
|
|
121
|
+
if (!facts.branchExists)
|
|
122
|
+
return "its branch is already gone — only the worktree is left";
|
|
123
|
+
if (facts.reading === "untouched")
|
|
124
|
+
return "nothing was ever done in this session, and its worktree is clean";
|
|
125
|
+
if (facts.reading === "merged")
|
|
126
|
+
return `merged into ${facts.baseBranch ?? "its base"} and its worktree is clean`;
|
|
127
|
+
return "merged into the checkout's current HEAD and its worktree is clean";
|
|
128
|
+
}
|
|
129
|
+
export async function readOrphanedWorktrees(env = process.env) {
|
|
130
|
+
const root = path.join(legacyEngineHome(env), "worktrees");
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = (await readdir(root, { withFileTypes: true })).filter((entry) => entry.isDirectory()).map((entry) => entry.name);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
return { records: [], undecipherable: [] };
|
|
137
|
+
}
|
|
138
|
+
const records = [];
|
|
139
|
+
const undecipherable = [];
|
|
140
|
+
for (const id of entries) {
|
|
141
|
+
const worktreePath = path.join(root, id);
|
|
142
|
+
const projectPath = await checkoutOfLinkedWorktree(worktreePath, id);
|
|
143
|
+
if (!projectPath) {
|
|
144
|
+
undecipherable.push(worktreePath);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
records.push({
|
|
148
|
+
sessionId: id,
|
|
149
|
+
projectPath,
|
|
150
|
+
worktreePath,
|
|
151
|
+
branch: `${LEGACY_ENGINE_BRANCH_PREFIX}${id}`,
|
|
152
|
+
provider: "claude-code",
|
|
153
|
+
providerSessionId: null,
|
|
154
|
+
createdAt: await directoryCreatedAt(worktreePath),
|
|
155
|
+
status: "closed",
|
|
156
|
+
nustack: 1,
|
|
157
|
+
projectId: null,
|
|
158
|
+
permissionMode: null,
|
|
159
|
+
baseBranch: null,
|
|
160
|
+
baseCommit: null,
|
|
161
|
+
enginePid: null,
|
|
162
|
+
closedAt: null,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
return { records, undecipherable };
|
|
166
|
+
}
|
|
167
|
+
async function checkoutOfLinkedWorktree(worktreePath, id) {
|
|
168
|
+
let body;
|
|
169
|
+
try {
|
|
170
|
+
body = await readFile(path.join(worktreePath, ".git"), "utf8");
|
|
171
|
+
}
|
|
172
|
+
catch {
|
|
173
|
+
return null;
|
|
174
|
+
}
|
|
175
|
+
const match = /^gitdir:\s*(.+)$/m.exec(body.trim());
|
|
176
|
+
if (!match?.[1])
|
|
177
|
+
return null;
|
|
178
|
+
const tail = path.join(".git", "worktrees", id);
|
|
179
|
+
const gitdir = match[1].trim();
|
|
180
|
+
if (!gitdir.endsWith(tail))
|
|
181
|
+
return null;
|
|
182
|
+
const checkout = gitdir.slice(0, gitdir.length - tail.length).replace(new RegExp(`${path.sep}+$`), "");
|
|
183
|
+
return checkout === "" ? null : checkout;
|
|
184
|
+
}
|
|
185
|
+
async function directoryCreatedAt(target) {
|
|
186
|
+
try {
|
|
187
|
+
return (await stat(target)).mtime.toISOString();
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
return new Date(0).toISOString();
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
export async function reclaimSession(record, options, env = process.env) {
|
|
194
|
+
const refused = (reason) => ({
|
|
195
|
+
sessionId: record.sessionId,
|
|
196
|
+
removedWorktree: false,
|
|
197
|
+
deletedBranch: false,
|
|
198
|
+
droppedRow: false,
|
|
199
|
+
reason,
|
|
200
|
+
});
|
|
201
|
+
if (!isEngineBranch(record.branch))
|
|
202
|
+
return refused(record.branch === null ? NOT_ISOLATED : FOREIGN_BRANCH);
|
|
203
|
+
if (record.worktreePath === record.projectPath)
|
|
204
|
+
return refused(NOT_ISOLATED);
|
|
205
|
+
if (!insideAnEngineHome(record.worktreePath, env))
|
|
206
|
+
return refused(FOREIGN_WORKTREE);
|
|
207
|
+
const facts = await inspectSession(record, env);
|
|
208
|
+
if (!facts.checkoutPresent)
|
|
209
|
+
return refused(facts.reason);
|
|
210
|
+
if (record.status === "open" && !facts.stale) {
|
|
211
|
+
return refused(`its engine is still running (pid ${String(record.enginePid ?? "unknown")}) — close the session first`);
|
|
212
|
+
}
|
|
213
|
+
const force = options.force === true;
|
|
214
|
+
if (!force && !facts.reclaimable)
|
|
215
|
+
return refused(facts.reason);
|
|
216
|
+
const branch = record.branch;
|
|
217
|
+
const removal = await gitOutcome(force ? ["worktree", "remove", "--force", record.worktreePath] : ["worktree", "remove", record.worktreePath], record.projectPath);
|
|
218
|
+
await gitOutcome(["worktree", "prune"], record.projectPath);
|
|
219
|
+
const deletion = await gitOutcome([...(force ? ["branch", "-D"] : ["branch", "-d"]), branch], record.projectPath);
|
|
220
|
+
const worktreeLeft = await isDirectory(record.worktreePath);
|
|
221
|
+
const branchLeft = (await gitOutcome(["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`], record.projectPath)).ok;
|
|
222
|
+
const cleared = !worktreeLeft && !branchLeft;
|
|
223
|
+
const droppedRow = cleared && !facts.orphaned
|
|
224
|
+
? await updateSessionRegistry(env, (sessions) => sessions.filter((row) => row.sessionId !== record.sessionId)).then(() => true)
|
|
225
|
+
: false;
|
|
226
|
+
return {
|
|
227
|
+
sessionId: record.sessionId,
|
|
228
|
+
removedWorktree: !worktreeLeft,
|
|
229
|
+
deletedBranch: !branchLeft,
|
|
230
|
+
droppedRow,
|
|
231
|
+
reason: cleared ? null : (deletion.stderr || removal.stderr || "the worktree or the branch could not be removed"),
|
|
232
|
+
};
|
|
233
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { sessionsHome } from "./worktree.js";
|
|
4
|
+
export const SESSION_REGISTRY_VERSION = 2;
|
|
5
|
+
export function sessionRegistryPath(env = process.env) {
|
|
6
|
+
return path.join(sessionsHome(env), "registry.json");
|
|
7
|
+
}
|
|
8
|
+
function asText(value) {
|
|
9
|
+
return typeof value === "string" && value.trim() !== "" ? value : null;
|
|
10
|
+
}
|
|
11
|
+
function asNullableText(value) {
|
|
12
|
+
return typeof value === "string" ? value : null;
|
|
13
|
+
}
|
|
14
|
+
function narrowRecord(value) {
|
|
15
|
+
if (typeof value !== "object" || value === null)
|
|
16
|
+
return null;
|
|
17
|
+
const row = value;
|
|
18
|
+
const sessionId = asText(row.sessionId);
|
|
19
|
+
const projectPath = asText(row.projectPath);
|
|
20
|
+
const worktreePath = asText(row.worktreePath);
|
|
21
|
+
const createdAt = asText(row.createdAt);
|
|
22
|
+
if (!sessionId || !projectPath || !worktreePath || !createdAt)
|
|
23
|
+
return null;
|
|
24
|
+
if (row.status !== "open" && row.status !== "closed")
|
|
25
|
+
return null;
|
|
26
|
+
if (row.provider !== "claude-code")
|
|
27
|
+
return null;
|
|
28
|
+
const enginePid = typeof row.enginePid === "number" && Number.isSafeInteger(row.enginePid) && row.enginePid > 0 ? row.enginePid : null;
|
|
29
|
+
return {
|
|
30
|
+
sessionId,
|
|
31
|
+
projectPath,
|
|
32
|
+
worktreePath,
|
|
33
|
+
branch: asNullableText(row.branch),
|
|
34
|
+
provider: "claude-code",
|
|
35
|
+
providerSessionId: asNullableText(row.providerSessionId),
|
|
36
|
+
createdAt,
|
|
37
|
+
status: row.status,
|
|
38
|
+
nustack: row.nustack === 0 ? 0 : 1,
|
|
39
|
+
projectId: asNullableText(row.projectId),
|
|
40
|
+
permissionMode: asNullableText(row.permissionMode),
|
|
41
|
+
baseBranch: asNullableText(row.baseBranch),
|
|
42
|
+
baseCommit: asNullableText(row.baseCommit),
|
|
43
|
+
enginePid,
|
|
44
|
+
closedAt: asNullableText(row.closedAt),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
export async function readSessionRegistry(env = process.env) {
|
|
48
|
+
let parsed;
|
|
49
|
+
try {
|
|
50
|
+
parsed = JSON.parse(await readFile(sessionRegistryPath(env), "utf8"));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return { records: [], dropped: 0 };
|
|
54
|
+
}
|
|
55
|
+
const rows = parsed?.sessions;
|
|
56
|
+
if (!Array.isArray(rows))
|
|
57
|
+
return { records: [], dropped: 0 };
|
|
58
|
+
const records = [];
|
|
59
|
+
let dropped = 0;
|
|
60
|
+
for (const row of rows) {
|
|
61
|
+
const record = narrowRecord(row);
|
|
62
|
+
if (record)
|
|
63
|
+
records.push(record);
|
|
64
|
+
else
|
|
65
|
+
dropped += 1;
|
|
66
|
+
}
|
|
67
|
+
return { records, dropped };
|
|
68
|
+
}
|
|
69
|
+
let registryWrites = Promise.resolve();
|
|
70
|
+
export function updateSessionRegistry(env, mutate) {
|
|
71
|
+
const run = async () => {
|
|
72
|
+
const file = sessionRegistryPath(env);
|
|
73
|
+
const { records } = await readSessionRegistry(env);
|
|
74
|
+
const body = JSON.stringify({ v: SESSION_REGISTRY_VERSION, sessions: mutate(records) }, null, 2) + "\n";
|
|
75
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
76
|
+
const temp = `${file}.${process.pid}.tmp`;
|
|
77
|
+
await writeFile(temp, body, "utf8");
|
|
78
|
+
await rename(temp, file);
|
|
79
|
+
};
|
|
80
|
+
const landed = registryWrites.then(run, run);
|
|
81
|
+
registryWrites = landed.then(() => undefined, () => undefined);
|
|
82
|
+
return landed;
|
|
83
|
+
}
|