@nurix/nustack 0.10.0-dev.19 → 0.10.0-dev.21

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.
@@ -8,16 +8,30 @@ const EXIT_FOR = {
8
8
  none: 1,
9
9
  unreachable: 2,
10
10
  };
11
+ const latestAnswered = (items) => items.find((item) => item.answer?.verdict === "signed" || item.answer?.verdict === "refused");
11
12
  export function decisionStateOf(items) {
12
- const latest = items.find((item) => item.answer?.verdict === "signed" || item.answer?.verdict === "refused");
13
+ const latest = latestAnswered(items);
13
14
  if (!latest)
14
15
  return "none";
15
16
  return latest.answer?.verdict === "signed" ? "signed" : "refused";
16
17
  }
17
- function checkLine(state, commit) {
18
+ export function waivedKeysOf(items) {
19
+ const latest = latestAnswered(items);
20
+ if (latest?.answer?.verdict !== "signed")
21
+ return [];
22
+ const waivers = latest.answer.waivers;
23
+ if (!Array.isArray(waivers))
24
+ return [];
25
+ return waivers
26
+ .map((waiver) => waiver?.criterionKey)
27
+ .filter((key) => typeof key === "string" && key.length > 0);
28
+ }
29
+ function checkLine(state, commit, waived) {
18
30
  switch (state) {
19
31
  case "signed":
20
- return `review check: ${commit} is signed off\n`;
32
+ return waived.length
33
+ ? `review check: ${commit} is signed off — ${waived.length} ${waived.length === 1 ? "criterion" : "criteria"} waived: ${waived.join(", ")}\n`
34
+ : `review check: ${commit} is signed off\n`;
21
35
  case "refused":
22
36
  return `review check: ${commit} was refused\n`;
23
37
  case "none":
@@ -34,11 +48,11 @@ export async function runReviewCheck(options, globals) {
34
48
  process.exitCode = 2;
35
49
  return;
36
50
  }
37
- const report = (state) => {
51
+ const report = (state, waived = []) => {
38
52
  if (json)
39
- printJsonSuccess("review check", { commit, state, exitCode: EXIT_FOR[state] });
53
+ printJsonSuccess("review check", { commit, state, exitCode: EXIT_FOR[state], waived });
40
54
  else
41
- process.stdout.write(checkLine(state, commit));
55
+ process.stdout.write(checkLine(state, commit, waived));
42
56
  process.exitCode = EXIT_FOR[state];
43
57
  };
44
58
  const baseUrl = resolveNuStackUrl(globals);
@@ -47,7 +61,7 @@ export async function runReviewCheck(options, globals) {
47
61
  return report("unreachable");
48
62
  try {
49
63
  const items = await makeReviewClient(baseUrl, deviceAuth.accessToken).readDecisions(commit, options.repository?.trim() || null);
50
- report(decisionStateOf(items));
64
+ report(decisionStateOf(items), waivedKeysOf(items));
51
65
  }
52
66
  catch (error) {
53
67
  if (error instanceof ReviewUnreachableError)
@@ -1,10 +1,15 @@
1
1
  import { readFile } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import * as p from "@clack/prompts";
2
4
  import { ensureAccessToken } from "../lib/auth_grant.js";
5
+ import { isInteractive } from "../lib/interactive.js";
3
6
  import { resolveNuStackUrl } from "../lib/nustack_client.js";
4
7
  import { makeSessionsClient, sessionsClientFromEnv } from "../lib/sessions/client.js";
5
8
  import { recordOutsideSessions, } from "../lib/sessions/outsideSessions.js";
9
+ import { inspectSession, readOrphanedWorktrees, reclaimSession, } from "../lib/session-engine/prune.js";
10
+ import { readSessionRegistry, sessionRegistryPath } from "../lib/session-engine/registry.js";
6
11
  import { SessionSupervisor } from "../lib/session-engine/supervisor.js";
7
- import { printJsonSuccess } from "../lib/json_output.js";
12
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
8
13
  export async function runSessionsServe(engineVersion) {
9
14
  const supervisor = new SessionSupervisor({
10
15
  input: process.stdin,
@@ -75,3 +80,134 @@ export async function runSessionsRecord(options, globals) {
75
80
  });
76
81
  report("sessions record", summary, json);
77
82
  }
83
+ async function readSessions() {
84
+ const { records, dropped } = await readSessionRegistry();
85
+ const orphans = await readOrphanedWorktrees();
86
+ const sessions = [];
87
+ for (const record of [...records, ...orphans.records]) {
88
+ sessions.push({ record, facts: await inspectSession(record) });
89
+ }
90
+ return { sessions, unreadable: dropped, undecipherableOrphans: orphans.undecipherable };
91
+ }
92
+ function statusWord(entry) {
93
+ if (entry.facts.orphaned)
94
+ return "orphaned";
95
+ if (entry.facts.stale)
96
+ return "stale";
97
+ return entry.record.status;
98
+ }
99
+ function formatSessionRow(entry) {
100
+ const checkout = path.basename(entry.record.projectPath);
101
+ return (`${entry.record.sessionId.padEnd(38)} ${checkout.padEnd(22)} ${(entry.record.branch ?? "—").padEnd(31)} ` +
102
+ `${statusWord(entry).padEnd(10)} ${entry.facts.reason}`);
103
+ }
104
+ function toJsonView(entry) {
105
+ const { record, facts } = entry;
106
+ return {
107
+ sessionId: record.sessionId,
108
+ projectPath: record.projectPath,
109
+ worktreePath: record.worktreePath,
110
+ branch: record.branch,
111
+ status: record.status,
112
+ nustack: record.nustack,
113
+ createdAt: record.createdAt,
114
+ closedAt: record.closedAt ?? null,
115
+ permissionMode: record.permissionMode ?? null,
116
+ baseBranch: record.baseBranch ?? null,
117
+ isolated: facts.isolated,
118
+ checkoutPresent: facts.checkoutPresent,
119
+ worktreePresent: facts.worktreePresent,
120
+ branchExists: facts.branchExists,
121
+ reading: facts.reading,
122
+ dirty: facts.dirty,
123
+ stale: facts.stale,
124
+ orphaned: facts.orphaned,
125
+ reclaimable: facts.reclaimable,
126
+ reason: facts.reason,
127
+ };
128
+ }
129
+ export async function runSessionsList(options) {
130
+ const json = options.json === true;
131
+ const { sessions, unreadable, undecipherableOrphans } = await readSessions();
132
+ if (json) {
133
+ printJsonSuccess("sessions list", {
134
+ registryPath: sessionRegistryPath(),
135
+ unreadable,
136
+ undecipherableOrphans,
137
+ sessions: sessions.map(toJsonView),
138
+ });
139
+ return;
140
+ }
141
+ if (sessions.length === 0 && unreadable === 0 && undecipherableOrphans.length === 0) {
142
+ console.log("No engine-opened sessions on this machine.");
143
+ return;
144
+ }
145
+ if (sessions.length > 0) {
146
+ console.log(`${"SESSION".padEnd(38)} ${"CHECKOUT".padEnd(22)} ${"BRANCH".padEnd(31)} ${"STATUS".padEnd(10)} HOLDS`);
147
+ for (const entry of sessions)
148
+ console.log(formatSessionRow(entry));
149
+ }
150
+ if (unreadable > 0)
151
+ console.log(`${unreadable} row(s) in the registry could not be read and were skipped.`);
152
+ for (const orphan of undecipherableOrphans) {
153
+ console.log(`1 worktree under the pre-rename engine home names no checkout and must be removed by hand: ${orphan}.`);
154
+ }
155
+ }
156
+ function previewLine(entry) {
157
+ const verb = entry.facts.reclaimable ? "reclaim" : "refuse";
158
+ return ` ${verb} ${entry.record.sessionId} ${entry.record.branch ?? "—"} — ${entry.facts.reason}`;
159
+ }
160
+ export async function runSessionsPrune(options) {
161
+ const json = options.json === true;
162
+ const force = options.force === true;
163
+ const { sessions } = await readSessions();
164
+ const scoped = options.session ? sessions.filter((entry) => entry.record.sessionId === options.session) : sessions;
165
+ if (options.session && scoped.length === 0) {
166
+ const message = `No session ${options.session} in this machine's registry — pass one from \`nustack sessions list\`.`;
167
+ if (json)
168
+ return printJsonError("sessions prune", { code: "SESSION_NOT_FOUND", message });
169
+ p.log.error(message);
170
+ process.exitCode = 1;
171
+ return;
172
+ }
173
+ const candidates = scoped.filter((entry) => force || entry.facts.reclaimable);
174
+ if (options.yes !== true) {
175
+ const plan = candidates.map((entry) => ({
176
+ sessionId: entry.record.sessionId,
177
+ branch: entry.record.branch,
178
+ worktreePath: entry.record.worktreePath,
179
+ orphaned: entry.facts.orphaned,
180
+ reclaimable: entry.facts.reclaimable,
181
+ reason: entry.facts.reason,
182
+ }));
183
+ if (json)
184
+ return printJsonSuccess("sessions prune", { applied: false, plan, note: "preview only — pass --yes to apply" });
185
+ if (plan.length === 0) {
186
+ console.log("Nothing to reclaim.");
187
+ return;
188
+ }
189
+ console.log(`Would reclaim ${plan.length} session(s):`);
190
+ for (const entry of candidates)
191
+ console.log(previewLine(entry));
192
+ if (!isInteractive()) {
193
+ console.log("Preview only — re-run with --yes to apply.");
194
+ return;
195
+ }
196
+ const answer = await p.confirm({ message: "Remove these worktrees and delete their branches?" });
197
+ if (p.isCancel(answer) || !answer) {
198
+ p.outro("Cancelled — nothing reclaimed.");
199
+ return;
200
+ }
201
+ }
202
+ const reclaimed = [];
203
+ const refused = [];
204
+ for (const entry of candidates) {
205
+ const outcome = await reclaimSession(entry.record, { force });
206
+ (outcome.reason === null ? reclaimed : refused).push(outcome);
207
+ }
208
+ if (json)
209
+ return printJsonSuccess("sessions prune", { applied: true, reclaimed, refused });
210
+ console.log(`Reclaimed ${reclaimed.length} session(s); refused ${refused.length}.`);
211
+ for (const outcome of refused)
212
+ console.log(` refused ${outcome.sessionId} — ${outcome.reason ?? "unknown"}`);
213
+ }
package/dist/index.js CHANGED
@@ -24,7 +24,7 @@ import { runOrganizationList, runOrganizationCreate, runOrganizationUse } from "
24
24
  import { runDocsList, runDocsShow, runDocsStatus } from "./commands/docs.js";
25
25
  import { runHandoffList, runHandoffShow } from "./commands/handoff.js";
26
26
  import { runToolkitStatus, runToolkitReconcile } from "./commands/toolkit.js";
27
- import { runSessionsRecord, runSessionsServe } from "./commands/sessions.js";
27
+ import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
28
28
  import { registerReviewCommands } from "./commands/review.js";
29
29
  import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
30
30
  import { printJsonError } from "./lib/json_output.js";
@@ -262,6 +262,19 @@ sessions
262
262
  .option("--scan <json>", "the scan payload as JSON, or @<path> to read it from a file")
263
263
  .option("--json", "emit the versioned machine envelope")
264
264
  .action(async (options) => runSessionsRecord(options, program.opts()));
265
+ sessions
266
+ .command("list")
267
+ .description("List the sessions this machine's engine opened, and what each one's worktree and branch still hold")
268
+ .option("--json", "emit the versioned machine envelope")
269
+ .action(async (options) => runSessionsList(options));
270
+ sessions
271
+ .command("prune")
272
+ .description("Reclaim the worktree and branch of a session that has ended (preview unless --yes)")
273
+ .option("--session <id>", "reclaim one session instead of every reclaimable one")
274
+ .option("--yes", "apply the preview without prompting")
275
+ .option("--force", "also reclaim an unmerged or dirty session — never one whose engine is running")
276
+ .option("--json", "emit the versioned machine envelope")
277
+ .action(async (options) => runSessionsPrune(options));
265
278
  const toolkit = program.command("toolkit").description("Inspect and reconcile the Project's toolkit pin");
266
279
  toolkit
267
280
  .command("status")
@@ -5,6 +5,8 @@ const INBOUND_TYPES = new Set([
5
5
  "session.approval",
6
6
  "session.interrupt",
7
7
  "session.close",
8
+ "session.list",
9
+ "session.reclaim",
8
10
  "engine.shutdown",
9
11
  ]);
10
12
  export function parseInboundFrame(line) {
@@ -31,6 +33,7 @@ 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
38
  case "session.open": {
36
39
  const sessionId = requireString("sessionId");
@@ -40,7 +43,12 @@ export function parseInboundFrame(line) {
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, sessionId, projectPath, permissionMode, isolated, resumeSessionId } };
46
+ const rawCommit = typeof frame.commit === "string" ? frame.commit : null;
47
+ if (rawCommit !== null && !/^[0-9a-f]{7,64}$/.test(rawCommit)) {
48
+ return { ok: false, error: { code: "BAD_FRAME", name: type, message: "session.open commit must be a hex object name" } };
49
+ }
50
+ const commit = rawCommit ?? undefined;
51
+ return { ok: true, frame: { v: FRAME_VERSION, type, sessionId, projectPath, permissionMode, isolated, commit, resumeSessionId } };
44
52
  }
45
53
  case "session.turn": {
46
54
  const sessionId = requireString("sessionId");
@@ -58,7 +66,8 @@ export function parseInboundFrame(line) {
58
66
  return { ok: true, frame: { v: FRAME_VERSION, type, sessionId, requestId, verdict } };
59
67
  }
60
68
  case "session.interrupt":
61
- case "session.close": {
69
+ case "session.close":
70
+ case "session.reclaim": {
62
71
  const sessionId = requireString("sessionId");
63
72
  if (!sessionId)
64
73
  return { ok: false, error: { code: "BAD_FRAME", name: type, message: `${type} requires sessionId` } };
@@ -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
+ }
@@ -1,10 +1,12 @@
1
1
  import { execFile } from "node:child_process";
2
- import { mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import { createInterface } from "node:readline";
5
5
  import { ClaudeSession } from "./claudeAdapter.js";
6
6
  import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
7
- import { createSessionWorktree, sessionsHome } from "./worktree.js";
7
+ import { inspectSession, reclaimSession } from "./prune.js";
8
+ import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
9
+ import { createSessionWorktree } from "./worktree.js";
8
10
  function defaultProbeClaude(env) {
9
11
  return new Promise((resolve) => {
10
12
  execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
@@ -37,9 +39,6 @@ export class SessionSupervisor {
37
39
  error(sessionId, code, message) {
38
40
  this.emit({ v: FRAME_VERSION, type: "session.error", sessionId, code, message });
39
41
  }
40
- registryPath() {
41
- return path.join(sessionsHome(this.env), "registry.json");
42
- }
43
42
  async boundProjectOf(projectPath) {
44
43
  try {
45
44
  const parsed = JSON.parse(await readFile(path.join(projectPath, ".nustack", "workspace.json"), "utf8"));
@@ -61,20 +60,7 @@ export class SessionSupervisor {
61
60
  }
62
61
  }
63
62
  async updateRegistry(mutate) {
64
- const run = async () => {
65
- const file = this.registryPath();
66
- let sessions = [];
67
- try {
68
- const parsed = JSON.parse(await readFile(file, "utf8"));
69
- if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.sessions)) {
70
- sessions = parsed.sessions;
71
- }
72
- }
73
- catch {
74
- }
75
- await mkdir(path.dirname(file), { recursive: true });
76
- await writeFile(file, JSON.stringify({ v: 2, sessions: mutate(sessions) }, null, 2) + "\n", "utf8");
77
- };
63
+ const run = () => updateSessionRegistry(this.env, mutate);
78
64
  this.registryWrites = this.registryWrites.then(run, run);
79
65
  return this.registryWrites;
80
66
  }
@@ -105,7 +91,7 @@ export class SessionSupervisor {
105
91
  await this.shutdown();
106
92
  return;
107
93
  case "session.open":
108
- await this.openSession(frame.sessionId, frame.projectPath, frame.permissionMode, frame.isolated, frame.resumeSessionId);
94
+ await this.openSession(frame.sessionId, frame.projectPath, frame.permissionMode, frame.isolated, frame.resumeSessionId, frame.commit);
109
95
  return;
110
96
  case "session.turn": {
111
97
  const session = this.sessions.get(frame.sessionId);
@@ -137,13 +123,24 @@ export class SessionSupervisor {
137
123
  session.adapter.stop();
138
124
  return;
139
125
  }
126
+ case "session.list":
127
+ await this.sendRoster();
128
+ return;
129
+ case "session.reclaim":
130
+ await this.reclaimRow(frame.sessionId);
131
+ return;
140
132
  }
141
133
  }
142
- async openSession(sessionId, projectPath, permissionMode, isolated, resumeSessionId) {
134
+ async openSession(sessionId, projectPath, permissionMode, isolated, resumeSessionId, commit) {
143
135
  if (this.sessions.has(sessionId))
144
136
  return this.error(sessionId, "SESSION_ALREADY_OPEN", `session ${sessionId} is already open`);
145
137
  this.status(sessionId, "starting");
146
- const worktree = await createSessionWorktree(projectPath, sessionId, this.env, isolated === true);
138
+ const worktree = await createSessionWorktree(projectPath, sessionId, this.env, isolated === true, commit);
139
+ if (worktree.commitRefusal !== null) {
140
+ this.error(sessionId, worktree.commitRefusal.code, worktree.commitRefusal.detail);
141
+ this.status(sessionId, "error", `could not open ${String(commit)}`);
142
+ return;
143
+ }
147
144
  const record = {
148
145
  sessionId,
149
146
  projectPath,
@@ -155,6 +152,11 @@ export class SessionSupervisor {
155
152
  status: "open",
156
153
  nustack: 1,
157
154
  projectId: await this.boundProjectOf(projectPath),
155
+ permissionMode: permissionMode ?? null,
156
+ baseBranch: worktree.baseBranch,
157
+ baseCommit: worktree.baseCommit,
158
+ enginePid: process.pid,
159
+ closedAt: null,
158
160
  };
159
161
  const adapterOptions = {
160
162
  cwd: worktree.worktreePath,
@@ -194,7 +196,7 @@ export class SessionSupervisor {
194
196
  if (!wasExpected)
195
197
  this.error(sessionId, "ADAPTER_FAILED", `claude exited unexpectedly (code ${String(code)})`);
196
198
  this.emit({ v: FRAME_VERSION, type: "session.closed", sessionId });
197
- void this.updateRegistry((sessions) => sessions.map((row) => (row.sessionId === sessionId ? { ...row, status: "closed" } : row)));
199
+ void this.updateRegistry((sessions) => sessions.map((row) => row.sessionId === sessionId ? { ...row, status: "closed", closedAt: new Date().toISOString() } : row));
198
200
  void this.moveSessionRow(record, wasExpected ? "done" : "failed");
199
201
  },
200
202
  };
@@ -232,16 +234,87 @@ export class SessionSupervisor {
232
234
  worktreePath: worktree.worktreePath,
233
235
  branch: worktree.branch,
234
236
  isolated: worktree.isolated,
237
+ commit: worktree.baseCommit,
235
238
  });
236
239
  this.status(sessionId, "ready");
237
240
  }
241
+ async sendRoster() {
242
+ const { records, dropped } = await readSessionRegistry(this.env);
243
+ const rows = [];
244
+ for (const record of records) {
245
+ const attached = this.sessions.has(record.sessionId);
246
+ const facts = await inspectSession(record, this.env);
247
+ const reclaimable = !attached && facts.reclaimable;
248
+ const reason = attached ? "this engine is running it — close the session first" : facts.reason;
249
+ rows.push({
250
+ sessionId: record.sessionId,
251
+ projectPath: record.projectPath,
252
+ worktreePath: record.worktreePath,
253
+ branch: record.branch,
254
+ createdAt: record.createdAt,
255
+ status: record.status,
256
+ closedAt: record.closedAt ?? null,
257
+ permissionMode: record.permissionMode ?? null,
258
+ baseBranch: record.baseBranch ?? null,
259
+ attached,
260
+ liveElsewhere: !attached && record.status === "open" && typeof record.enginePid === "number" && !facts.stale,
261
+ isolated: facts.isolated,
262
+ checkoutPresent: facts.checkoutPresent,
263
+ worktreePresent: facts.worktreePresent,
264
+ branchExists: facts.branchExists,
265
+ reading: facts.reading,
266
+ dirty: facts.dirty,
267
+ stale: facts.stale,
268
+ reclaimable,
269
+ reason,
270
+ });
271
+ }
272
+ this.emit({
273
+ v: FRAME_VERSION,
274
+ type: "session.roster",
275
+ registryPath: sessionRegistryPath(this.env),
276
+ unreadable: dropped,
277
+ sessions: rows,
278
+ });
279
+ }
280
+ async reclaimRow(sessionId) {
281
+ if (this.sessions.has(sessionId)) {
282
+ return this.error(sessionId, "SESSION_ATTACHED", `session ${sessionId} is open in this engine — close it first`);
283
+ }
284
+ const { records } = await readSessionRegistry(this.env);
285
+ const record = records.find((row) => row.sessionId === sessionId);
286
+ if (!record)
287
+ return this.error(sessionId, "SESSION_UNKNOWN", `no session ${sessionId} in this machine's registry`);
288
+ const reclaim = this.registryWrites.then(() => reclaimSession(record, {}, this.env), () => reclaimSession(record, {}, this.env));
289
+ this.registryWrites = reclaim.then(() => undefined, () => undefined);
290
+ try {
291
+ const outcome = await reclaim;
292
+ this.emit({
293
+ v: FRAME_VERSION,
294
+ type: "session.reclaimed",
295
+ sessionId,
296
+ removedWorktree: outcome.removedWorktree,
297
+ deletedBranch: outcome.deletedBranch,
298
+ droppedRow: outcome.droppedRow,
299
+ reason: outcome.reason,
300
+ });
301
+ }
302
+ catch (error) {
303
+ this.error(sessionId, "RECLAIM_FAILED", error instanceof Error ? error.message : String(error));
304
+ }
305
+ }
238
306
  async shutdown() {
239
307
  if (this.shuttingDown)
240
308
  return;
241
309
  this.shuttingDown = true;
310
+ const held = [...this.sessions.keys()];
242
311
  for (const [, session] of this.sessions)
243
312
  session.adapter.stop();
244
313
  this.sessions.clear();
314
+ if (held.length > 0) {
315
+ const closedAt = new Date().toISOString();
316
+ await this.updateRegistry((sessions) => sessions.map((row) => (held.includes(row.sessionId) && row.status === "open" ? { ...row, status: "closed", closedAt } : row)));
317
+ }
245
318
  this.lines?.close();
246
319
  this.resolveDone?.();
247
320
  }
@@ -9,25 +9,81 @@ export function sessionsHome(env = process.env) {
9
9
  return override;
10
10
  return path.join(os.homedir(), ".nustack", "sessions");
11
11
  }
12
+ export const LEGACY_ENGINE_BRANCH_PREFIX = "nustack/lane-";
13
+ const FETCH_TIMEOUT_MS = 10 * 60_000;
14
+ export function legacyEngineHome(env = process.env) {
15
+ const override = env.NUSTACK_LEGACY_ENGINE_HOME;
16
+ if (override && override !== "")
17
+ return override;
18
+ return path.join(os.homedir(), ".nustack", "lanes");
19
+ }
12
20
  function git(args, cwd) {
21
+ return gitOutcome(args, cwd).then((outcome) => (outcome.ok ? outcome.stdout : null));
22
+ }
23
+ export function gitOutcome(args, cwd, timeoutMs = 15_000) {
13
24
  return new Promise((resolve) => {
14
- execFile("git", args, { cwd, timeout: 15_000 }, (error, stdout) => {
15
- resolve(error ? null : stdout.trim());
16
- });
25
+ const failed = (error) => {
26
+ resolve({ ok: false, code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) });
27
+ };
28
+ try {
29
+ execFile("git", args, { cwd, timeout: timeoutMs }, (error, stdout, stderr) => {
30
+ const raw = error?.code;
31
+ const code = error ? (typeof raw === "number" ? raw : null) : 0;
32
+ resolve({ ok: !error, code, stdout: stdout.trim(), stderr: stderr.trim() });
33
+ });
34
+ }
35
+ catch (error) {
36
+ failed(error);
37
+ }
17
38
  });
18
39
  }
19
- export async function createSessionWorktree(projectPath, sessionId, env = process.env, isolate = false) {
20
- const inPlace = { worktreePath: projectPath, branch: null, isolated: false };
21
- if (!isolate)
40
+ export async function createSessionWorktree(projectPath, sessionId, env = process.env, isolate = false, commit) {
41
+ const inPlace = {
42
+ worktreePath: projectPath,
43
+ branch: null,
44
+ isolated: false,
45
+ baseBranch: null,
46
+ baseCommit: null,
47
+ commitRefusal: null,
48
+ };
49
+ if (!isolate && commit === undefined)
22
50
  return inPlace;
23
51
  const isRepo = await git(["rev-parse", "--is-inside-work-tree"], projectPath);
24
- if (isRepo !== "true")
25
- return inPlace;
52
+ if (isRepo !== "true") {
53
+ return commit === undefined
54
+ ? inPlace
55
+ : { ...inPlace, commitRefusal: { code: "COMMIT_UNREACHABLE", detail: `${projectPath} is not a git checkout, so ${commit} cannot be opened here.` } };
56
+ }
57
+ if (commit !== undefined) {
58
+ const refusal = await fetchCommit(projectPath, commit);
59
+ if (refusal !== null)
60
+ return { ...inPlace, commitRefusal: refusal };
61
+ }
62
+ const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], projectPath);
63
+ const baseBranch = head === null || head === "HEAD" ? null : head;
26
64
  const branch = `${ENGINE_SESSION_BRANCH_PREFIX}${sessionId}`;
27
65
  const worktreePath = path.join(sessionsHome(env), "worktrees", sessionId);
28
66
  await mkdir(path.dirname(worktreePath), { recursive: true });
29
- const added = await git(["worktree", "add", worktreePath, "-b", branch], projectPath);
30
- if (added === null)
31
- return inPlace;
32
- return { worktreePath, branch, isolated: true };
67
+ const added = await git(commit === undefined
68
+ ? ["worktree", "add", worktreePath, "-b", branch]
69
+ : ["worktree", "add", worktreePath, "-b", branch, commit], projectPath);
70
+ if (added === null) {
71
+ return commit === undefined
72
+ ? inPlace
73
+ : { ...inPlace, commitRefusal: { code: "COMMIT_UNREACHABLE", detail: `git refused a worktree for ${commit} in ${projectPath}.` } };
74
+ }
75
+ const baseCommit = await git(["rev-parse", "HEAD"], worktreePath);
76
+ return { worktreePath, branch, isolated: true, baseBranch, baseCommit, commitRefusal: null };
77
+ }
78
+ async function fetchCommit(projectPath, commit) {
79
+ const has = () => gitOutcome(["cat-file", "-e", `${commit}^{commit}`], projectPath);
80
+ if ((await has()).ok)
81
+ return null;
82
+ let fetched = await gitOutcome(["fetch", "origin", commit], projectPath, FETCH_TIMEOUT_MS);
83
+ if (!fetched.ok)
84
+ fetched = await gitOutcome(["fetch", "origin"], projectPath, FETCH_TIMEOUT_MS);
85
+ if ((await has()).ok)
86
+ return null;
87
+ const said = `${fetched.stderr}\n${fetched.stdout}`.trim();
88
+ return { code: "COMMIT_UNREACHABLE", detail: said === "" ? `${commit} is not in this checkout and origin did not have it.` : said };
33
89
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.10.0-dev.19",
3
+ "version": "0.10.0-dev.21",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -34,8 +34,8 @@
34
34
  "@types/node": "^22.19.20",
35
35
  "typescript": "^5.7.2",
36
36
  "vitest": "^4.1.11",
37
- "@nurix/nustack.ui": "0.0.0",
38
- "@nurix/nustack.studio": "0.0.0"
37
+ "@nurix/nustack.studio": "0.0.0",
38
+ "@nurix/nustack.ui": "0.0.0"
39
39
  },
40
40
  "publishConfig": {
41
41
  "access": "public"