@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,315 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { createInterface } from "node:readline";
|
|
5
|
+
import { ClaudeSession } from "./claudeAdapter.js";
|
|
6
|
+
import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
|
|
7
|
+
import { inspectSession, reclaimSession } from "./prune.js";
|
|
8
|
+
import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
|
|
9
|
+
import { createSessionWorktree } from "./worktree.js";
|
|
10
|
+
function defaultProbeClaude(env) {
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
|
|
13
|
+
if (error)
|
|
14
|
+
resolve({ found: false, version: null });
|
|
15
|
+
else
|
|
16
|
+
resolve({ found: true, version: stdout.trim() });
|
|
17
|
+
});
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export class SessionSupervisor {
|
|
21
|
+
options;
|
|
22
|
+
env;
|
|
23
|
+
sessions = new Map();
|
|
24
|
+
closingSessions = new Set();
|
|
25
|
+
shuttingDown = false;
|
|
26
|
+
resolveDone = null;
|
|
27
|
+
lines = null;
|
|
28
|
+
registryWrites = Promise.resolve();
|
|
29
|
+
constructor(options) {
|
|
30
|
+
this.options = options;
|
|
31
|
+
this.env = options.env ?? process.env;
|
|
32
|
+
}
|
|
33
|
+
emit(frame) {
|
|
34
|
+
this.options.output.write(serializeFrame(frame));
|
|
35
|
+
}
|
|
36
|
+
status(sessionId, status, message = null) {
|
|
37
|
+
this.emit({ v: FRAME_VERSION, type: "session.status", sessionId, status, message });
|
|
38
|
+
}
|
|
39
|
+
error(sessionId, code, message) {
|
|
40
|
+
this.emit({ v: FRAME_VERSION, type: "session.error", sessionId, code, message });
|
|
41
|
+
}
|
|
42
|
+
async boundProjectOf(projectPath) {
|
|
43
|
+
try {
|
|
44
|
+
const parsed = JSON.parse(await readFile(path.join(projectPath, ".nustack", "workspace.json"), "utf8"));
|
|
45
|
+
const id = parsed?.workspaceId;
|
|
46
|
+
return typeof id === "string" && id.trim() ? id.trim() : null;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async moveSessionRow(record, to) {
|
|
53
|
+
const client = this.options.sessions;
|
|
54
|
+
if (!client || !record.projectId)
|
|
55
|
+
return;
|
|
56
|
+
try {
|
|
57
|
+
await client.transitionSession(record.projectId, record.sessionId, to);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
async updateRegistry(mutate) {
|
|
63
|
+
const run = () => updateSessionRegistry(this.env, mutate);
|
|
64
|
+
this.registryWrites = this.registryWrites.then(run, run);
|
|
65
|
+
return this.registryWrites;
|
|
66
|
+
}
|
|
67
|
+
async run() {
|
|
68
|
+
const probe = this.options.probeClaude ?? (() => defaultProbeClaude(this.env));
|
|
69
|
+
const claude = await probe();
|
|
70
|
+
this.emit({ v: FRAME_VERSION, type: "engine.hello", engineVersion: this.options.engineVersion, claude });
|
|
71
|
+
const lines = createInterface({ input: this.options.input });
|
|
72
|
+
this.lines = lines;
|
|
73
|
+
lines.on("line", (line) => {
|
|
74
|
+
if (line.trim() === "")
|
|
75
|
+
return;
|
|
76
|
+
const parsed = parseInboundFrame(line);
|
|
77
|
+
if (!parsed.ok) {
|
|
78
|
+
this.error(null, parsed.error.code, parsed.error.message);
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
void this.handle(parsed.frame);
|
|
82
|
+
});
|
|
83
|
+
await new Promise((resolve) => {
|
|
84
|
+
this.resolveDone = resolve;
|
|
85
|
+
lines.on("close", () => void this.shutdown());
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
async handle(frame) {
|
|
89
|
+
switch (frame.type) {
|
|
90
|
+
case "engine.shutdown":
|
|
91
|
+
await this.shutdown();
|
|
92
|
+
return;
|
|
93
|
+
case "session.open":
|
|
94
|
+
await this.openSession(frame.sessionId, frame.projectPath, frame.permissionMode, frame.isolated, frame.resumeSessionId);
|
|
95
|
+
return;
|
|
96
|
+
case "session.turn": {
|
|
97
|
+
const session = this.sessions.get(frame.sessionId);
|
|
98
|
+
if (!session)
|
|
99
|
+
return this.error(frame.sessionId, "SESSION_UNKNOWN", `no open session ${frame.sessionId}`);
|
|
100
|
+
session.adapter.sendTurn(frame.text);
|
|
101
|
+
this.status(frame.sessionId, "running");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
case "session.approval": {
|
|
105
|
+
const session = this.sessions.get(frame.sessionId);
|
|
106
|
+
if (!session)
|
|
107
|
+
return this.error(frame.sessionId, "SESSION_UNKNOWN", `no open session ${frame.sessionId}`);
|
|
108
|
+
session.adapter.respondApproval(frame.requestId, frame.verdict);
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
case "session.interrupt": {
|
|
112
|
+
const session = this.sessions.get(frame.sessionId);
|
|
113
|
+
if (!session)
|
|
114
|
+
return this.error(frame.sessionId, "SESSION_UNKNOWN", `no open session ${frame.sessionId}`);
|
|
115
|
+
session.adapter.interrupt();
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
case "session.close": {
|
|
119
|
+
const session = this.sessions.get(frame.sessionId);
|
|
120
|
+
if (!session)
|
|
121
|
+
return this.error(frame.sessionId, "SESSION_UNKNOWN", `no open session ${frame.sessionId}`);
|
|
122
|
+
this.closingSessions.add(frame.sessionId);
|
|
123
|
+
session.adapter.stop();
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
case "session.list":
|
|
127
|
+
await this.sendRoster();
|
|
128
|
+
return;
|
|
129
|
+
case "session.reclaim":
|
|
130
|
+
await this.reclaimRow(frame.sessionId);
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
async openSession(sessionId, projectPath, permissionMode, isolated, resumeSessionId) {
|
|
135
|
+
if (this.sessions.has(sessionId))
|
|
136
|
+
return this.error(sessionId, "SESSION_ALREADY_OPEN", `session ${sessionId} is already open`);
|
|
137
|
+
this.status(sessionId, "starting");
|
|
138
|
+
const worktree = await createSessionWorktree(projectPath, sessionId, this.env, isolated === true);
|
|
139
|
+
const record = {
|
|
140
|
+
sessionId,
|
|
141
|
+
projectPath,
|
|
142
|
+
worktreePath: worktree.worktreePath,
|
|
143
|
+
branch: worktree.branch,
|
|
144
|
+
provider: "claude-code",
|
|
145
|
+
providerSessionId: null,
|
|
146
|
+
createdAt: new Date().toISOString(),
|
|
147
|
+
status: "open",
|
|
148
|
+
nustack: 1,
|
|
149
|
+
projectId: await this.boundProjectOf(projectPath),
|
|
150
|
+
permissionMode: permissionMode ?? null,
|
|
151
|
+
baseBranch: worktree.baseBranch,
|
|
152
|
+
baseCommit: worktree.baseCommit,
|
|
153
|
+
enginePid: process.pid,
|
|
154
|
+
closedAt: null,
|
|
155
|
+
};
|
|
156
|
+
const adapterOptions = {
|
|
157
|
+
cwd: worktree.worktreePath,
|
|
158
|
+
permissionMode,
|
|
159
|
+
resumeSessionId,
|
|
160
|
+
env: this.env,
|
|
161
|
+
onEvent: (event) => {
|
|
162
|
+
this.emit({ v: FRAME_VERSION, type: "session.event", sessionId, event });
|
|
163
|
+
if (typeof event === "object" && event !== null && event.type === "result") {
|
|
164
|
+
this.status(sessionId, "ready");
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
onApprovalRequest: (request) => {
|
|
168
|
+
this.emit({
|
|
169
|
+
v: FRAME_VERSION,
|
|
170
|
+
type: "session.approvalRequest",
|
|
171
|
+
sessionId,
|
|
172
|
+
requestId: request.requestId,
|
|
173
|
+
toolName: request.toolName,
|
|
174
|
+
displayName: request.displayName,
|
|
175
|
+
toolInput: request.toolInput,
|
|
176
|
+
description: request.description,
|
|
177
|
+
});
|
|
178
|
+
},
|
|
179
|
+
onProtocolError: (code, message) => this.error(sessionId, code, message),
|
|
180
|
+
onSessionId: (vendorSessionId) => {
|
|
181
|
+
record.providerSessionId = vendorSessionId;
|
|
182
|
+
void this.updateRegistry((sessions) => sessions.map((row) => (row.sessionId === sessionId ? { ...row, providerSessionId: vendorSessionId } : row)));
|
|
183
|
+
const client = this.options.sessions;
|
|
184
|
+
if (client && record.projectId) {
|
|
185
|
+
void client.setVendorSessionId(record.projectId, sessionId, vendorSessionId).catch(() => undefined);
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
onExit: (code) => {
|
|
189
|
+
const wasExpected = this.closingSessions.delete(sessionId) || this.shuttingDown;
|
|
190
|
+
this.sessions.delete(sessionId);
|
|
191
|
+
if (!wasExpected)
|
|
192
|
+
this.error(sessionId, "ADAPTER_FAILED", `claude exited unexpectedly (code ${String(code)})`);
|
|
193
|
+
this.emit({ v: FRAME_VERSION, type: "session.closed", sessionId });
|
|
194
|
+
void this.updateRegistry((sessions) => sessions.map((row) => row.sessionId === sessionId ? { ...row, status: "closed", closedAt: new Date().toISOString() } : row));
|
|
195
|
+
void this.moveSessionRow(record, wasExpected ? "done" : "failed");
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
const create = this.options.createAdapter ?? ((opts) => new ClaudeSession(opts));
|
|
199
|
+
let adapter;
|
|
200
|
+
try {
|
|
201
|
+
adapter = create(adapterOptions);
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
return this.error(sessionId, "ADAPTER_FAILED", error instanceof Error ? error.message : String(error));
|
|
205
|
+
}
|
|
206
|
+
this.sessions.set(sessionId, { adapter, record });
|
|
207
|
+
await this.updateRegistry((sessions) => [...sessions.filter((row) => row.sessionId !== sessionId), record]);
|
|
208
|
+
if (this.options.sessions && record.projectId) {
|
|
209
|
+
try {
|
|
210
|
+
await this.options.sessions.createSession(record.projectId, {
|
|
211
|
+
id: sessionId,
|
|
212
|
+
startedBy: "person",
|
|
213
|
+
substrate: "device",
|
|
214
|
+
mode: "synchronous",
|
|
215
|
+
nustack: 1,
|
|
216
|
+
vendor: "claude_code",
|
|
217
|
+
renderer: "claude_transcript",
|
|
218
|
+
status: "created",
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
this.emit({
|
|
225
|
+
v: FRAME_VERSION,
|
|
226
|
+
type: "session.opened",
|
|
227
|
+
sessionId,
|
|
228
|
+
projectPath,
|
|
229
|
+
worktreePath: worktree.worktreePath,
|
|
230
|
+
branch: worktree.branch,
|
|
231
|
+
isolated: worktree.isolated,
|
|
232
|
+
});
|
|
233
|
+
this.status(sessionId, "ready");
|
|
234
|
+
}
|
|
235
|
+
async sendRoster() {
|
|
236
|
+
const { records, dropped } = await readSessionRegistry(this.env);
|
|
237
|
+
const rows = [];
|
|
238
|
+
for (const record of records) {
|
|
239
|
+
const attached = this.sessions.has(record.sessionId);
|
|
240
|
+
const facts = await inspectSession(record, this.env);
|
|
241
|
+
const reclaimable = !attached && facts.reclaimable;
|
|
242
|
+
const reason = attached ? "this engine is running it — close the session first" : facts.reason;
|
|
243
|
+
rows.push({
|
|
244
|
+
sessionId: record.sessionId,
|
|
245
|
+
projectPath: record.projectPath,
|
|
246
|
+
worktreePath: record.worktreePath,
|
|
247
|
+
branch: record.branch,
|
|
248
|
+
createdAt: record.createdAt,
|
|
249
|
+
status: record.status,
|
|
250
|
+
closedAt: record.closedAt ?? null,
|
|
251
|
+
permissionMode: record.permissionMode ?? null,
|
|
252
|
+
baseBranch: record.baseBranch ?? null,
|
|
253
|
+
attached,
|
|
254
|
+
liveElsewhere: !attached && record.status === "open" && typeof record.enginePid === "number" && !facts.stale,
|
|
255
|
+
isolated: facts.isolated,
|
|
256
|
+
checkoutPresent: facts.checkoutPresent,
|
|
257
|
+
worktreePresent: facts.worktreePresent,
|
|
258
|
+
branchExists: facts.branchExists,
|
|
259
|
+
reading: facts.reading,
|
|
260
|
+
dirty: facts.dirty,
|
|
261
|
+
stale: facts.stale,
|
|
262
|
+
reclaimable,
|
|
263
|
+
reason,
|
|
264
|
+
});
|
|
265
|
+
}
|
|
266
|
+
this.emit({
|
|
267
|
+
v: FRAME_VERSION,
|
|
268
|
+
type: "session.roster",
|
|
269
|
+
registryPath: sessionRegistryPath(this.env),
|
|
270
|
+
unreadable: dropped,
|
|
271
|
+
sessions: rows,
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async reclaimRow(sessionId) {
|
|
275
|
+
if (this.sessions.has(sessionId)) {
|
|
276
|
+
return this.error(sessionId, "SESSION_ATTACHED", `session ${sessionId} is open in this engine — close it first`);
|
|
277
|
+
}
|
|
278
|
+
const { records } = await readSessionRegistry(this.env);
|
|
279
|
+
const record = records.find((row) => row.sessionId === sessionId);
|
|
280
|
+
if (!record)
|
|
281
|
+
return this.error(sessionId, "SESSION_UNKNOWN", `no session ${sessionId} in this machine's registry`);
|
|
282
|
+
const reclaim = this.registryWrites.then(() => reclaimSession(record, {}, this.env), () => reclaimSession(record, {}, this.env));
|
|
283
|
+
this.registryWrites = reclaim.then(() => undefined, () => undefined);
|
|
284
|
+
try {
|
|
285
|
+
const outcome = await reclaim;
|
|
286
|
+
this.emit({
|
|
287
|
+
v: FRAME_VERSION,
|
|
288
|
+
type: "session.reclaimed",
|
|
289
|
+
sessionId,
|
|
290
|
+
removedWorktree: outcome.removedWorktree,
|
|
291
|
+
deletedBranch: outcome.deletedBranch,
|
|
292
|
+
droppedRow: outcome.droppedRow,
|
|
293
|
+
reason: outcome.reason,
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
catch (error) {
|
|
297
|
+
this.error(sessionId, "RECLAIM_FAILED", error instanceof Error ? error.message : String(error));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
async shutdown() {
|
|
301
|
+
if (this.shuttingDown)
|
|
302
|
+
return;
|
|
303
|
+
this.shuttingDown = true;
|
|
304
|
+
const held = [...this.sessions.keys()];
|
|
305
|
+
for (const [, session] of this.sessions)
|
|
306
|
+
session.adapter.stop();
|
|
307
|
+
this.sessions.clear();
|
|
308
|
+
if (held.length > 0) {
|
|
309
|
+
const closedAt = new Date().toISOString();
|
|
310
|
+
await this.updateRegistry((sessions) => sessions.map((row) => (held.includes(row.sessionId) && row.status === "open" ? { ...row, status: "closed", closedAt } : row)));
|
|
311
|
+
}
|
|
312
|
+
this.lines?.close();
|
|
313
|
+
this.resolveDone?.();
|
|
314
|
+
}
|
|
315
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { mkdir } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export const ENGINE_SESSION_BRANCH_PREFIX = "nustack/session-";
|
|
6
|
+
export function sessionsHome(env = process.env) {
|
|
7
|
+
const override = env.NUSTACK_SESSIONS_HOME;
|
|
8
|
+
if (override && override !== "")
|
|
9
|
+
return override;
|
|
10
|
+
return path.join(os.homedir(), ".nustack", "sessions");
|
|
11
|
+
}
|
|
12
|
+
export const LEGACY_ENGINE_BRANCH_PREFIX = "nustack/lane-";
|
|
13
|
+
export function legacyEngineHome(env = process.env) {
|
|
14
|
+
const override = env.NUSTACK_LEGACY_ENGINE_HOME;
|
|
15
|
+
if (override && override !== "")
|
|
16
|
+
return override;
|
|
17
|
+
return path.join(os.homedir(), ".nustack", "lanes");
|
|
18
|
+
}
|
|
19
|
+
function git(args, cwd) {
|
|
20
|
+
return gitOutcome(args, cwd).then((outcome) => (outcome.ok ? outcome.stdout : null));
|
|
21
|
+
}
|
|
22
|
+
export function gitOutcome(args, cwd) {
|
|
23
|
+
return new Promise((resolve) => {
|
|
24
|
+
const failed = (error) => {
|
|
25
|
+
resolve({ ok: false, code: null, stdout: "", stderr: error instanceof Error ? error.message : String(error) });
|
|
26
|
+
};
|
|
27
|
+
try {
|
|
28
|
+
execFile("git", args, { cwd, timeout: 15_000 }, (error, stdout, stderr) => {
|
|
29
|
+
const raw = error?.code;
|
|
30
|
+
const code = error ? (typeof raw === "number" ? raw : null) : 0;
|
|
31
|
+
resolve({ ok: !error, code, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
catch (error) {
|
|
35
|
+
failed(error);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
export async function createSessionWorktree(projectPath, sessionId, env = process.env, isolate = false) {
|
|
40
|
+
const inPlace = { worktreePath: projectPath, branch: null, isolated: false, baseBranch: null, baseCommit: null };
|
|
41
|
+
if (!isolate)
|
|
42
|
+
return inPlace;
|
|
43
|
+
const isRepo = await git(["rev-parse", "--is-inside-work-tree"], projectPath);
|
|
44
|
+
if (isRepo !== "true")
|
|
45
|
+
return inPlace;
|
|
46
|
+
const head = await git(["rev-parse", "--abbrev-ref", "HEAD"], projectPath);
|
|
47
|
+
const baseBranch = head === null || head === "HEAD" ? null : head;
|
|
48
|
+
const baseCommit = await git(["rev-parse", "HEAD"], projectPath);
|
|
49
|
+
const branch = `${ENGINE_SESSION_BRANCH_PREFIX}${sessionId}`;
|
|
50
|
+
const worktreePath = path.join(sessionsHome(env), "worktrees", sessionId);
|
|
51
|
+
await mkdir(path.dirname(worktreePath), { recursive: true });
|
|
52
|
+
const added = await git(["worktree", "add", worktreePath, "-b", branch], projectPath);
|
|
53
|
+
if (added === null)
|
|
54
|
+
return inPlace;
|
|
55
|
+
return { worktreePath, branch, isolated: true, baseBranch, baseCommit };
|
|
56
|
+
}
|
|
@@ -9,7 +9,7 @@ function vendorOf(provider) {
|
|
|
9
9
|
const AT_REST = new Set(["done", "failed", "cancelled"]);
|
|
10
10
|
export async function recordOutsideSessions(client, input) {
|
|
11
11
|
const now = input.now ?? Date.now;
|
|
12
|
-
const
|
|
12
|
+
const engineIds = new Set((input.engineSessionIds ?? []).filter((id) => Boolean(id)));
|
|
13
13
|
const report = { created: 0, finished: 0, skipped: 0, failed: 0 };
|
|
14
14
|
for (const transcript of input.transcripts) {
|
|
15
15
|
if (!transcript.projectId) {
|
|
@@ -23,14 +23,14 @@ export async function recordOutsideSessions(client, input) {
|
|
|
23
23
|
}
|
|
24
24
|
const stale = now() - transcript.modifiedMs >= STOPPED_GROWING_MS;
|
|
25
25
|
const vendorSessionId = transcript.vendorSessionId?.trim() || null;
|
|
26
|
-
const
|
|
26
|
+
const isEngineSession = vendorSessionId !== null && engineIds.has(vendorSessionId);
|
|
27
27
|
try {
|
|
28
28
|
const { created, session } = await client.createSession(transcript.projectId, {
|
|
29
29
|
id: transcript.sessionKey,
|
|
30
30
|
startedBy: "person",
|
|
31
31
|
substrate: "device",
|
|
32
32
|
mode: "synchronous",
|
|
33
|
-
|
|
33
|
+
nustack: isEngineSession ? 1 : 0,
|
|
34
34
|
vendor: kinds.vendor,
|
|
35
35
|
renderer: kinds.renderer,
|
|
36
36
|
status: stale ? "done" : "running",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nurix/nustack",
|
|
3
|
-
"version": "0.10.0-dev.
|
|
3
|
+
"version": "0.10.0-dev.20",
|
|
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,
|
|
@@ -26,15 +26,16 @@
|
|
|
26
26
|
"commander": "^14.0.0"
|
|
27
27
|
},
|
|
28
28
|
"optionalDependencies": {
|
|
29
|
-
"@nurix/codegraph": "0.2.1"
|
|
29
|
+
"@nurix/codegraph": "0.2.1",
|
|
30
|
+
"@nurix/logscope": "0.1.0"
|
|
30
31
|
},
|
|
31
32
|
"devDependencies": {
|
|
32
33
|
"@ngneat/falso": "^8.0.2",
|
|
33
34
|
"@types/node": "^22.19.20",
|
|
34
35
|
"typescript": "^5.7.2",
|
|
35
36
|
"vitest": "^4.1.11",
|
|
36
|
-
"@nurix/nustack.
|
|
37
|
-
"@nurix/nustack.
|
|
37
|
+
"@nurix/nustack.ui": "0.0.0",
|
|
38
|
+
"@nurix/nustack.studio": "0.0.0"
|
|
38
39
|
},
|
|
39
40
|
"publishConfig": {
|
|
40
41
|
"access": "public"
|
package/dist/commands/lane.js
DELETED
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
import { LaneSupervisor } from "../lib/lanes/supervisor.js";
|
|
2
|
-
import { sessionsClientFromEnv } from "../lib/sessions/client.js";
|
|
3
|
-
export async function runLaneServe(engineVersion) {
|
|
4
|
-
const supervisor = new LaneSupervisor({
|
|
5
|
-
input: process.stdin,
|
|
6
|
-
output: process.stdout,
|
|
7
|
-
engineVersion,
|
|
8
|
-
sessions: sessionsClientFromEnv(process.env),
|
|
9
|
-
});
|
|
10
|
-
await supervisor.run();
|
|
11
|
-
process.exit(0);
|
|
12
|
-
}
|
|
@@ -1,247 +0,0 @@
|
|
|
1
|
-
import { execFile } from "node:child_process";
|
|
2
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { createInterface } from "node:readline";
|
|
5
|
-
import { ClaudeLane } from "./claudeAdapter.js";
|
|
6
|
-
import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
|
|
7
|
-
import { createLaneWorktree, lanesHome } from "./worktree.js";
|
|
8
|
-
function defaultProbeClaude(env) {
|
|
9
|
-
return new Promise((resolve) => {
|
|
10
|
-
execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
|
|
11
|
-
if (error)
|
|
12
|
-
resolve({ found: false, version: null });
|
|
13
|
-
else
|
|
14
|
-
resolve({ found: true, version: stdout.trim() });
|
|
15
|
-
});
|
|
16
|
-
});
|
|
17
|
-
}
|
|
18
|
-
export class LaneSupervisor {
|
|
19
|
-
options;
|
|
20
|
-
env;
|
|
21
|
-
lanes = new Map();
|
|
22
|
-
closingLanes = new Set();
|
|
23
|
-
shuttingDown = false;
|
|
24
|
-
resolveDone = null;
|
|
25
|
-
lines = null;
|
|
26
|
-
registryWrites = Promise.resolve();
|
|
27
|
-
constructor(options) {
|
|
28
|
-
this.options = options;
|
|
29
|
-
this.env = options.env ?? process.env;
|
|
30
|
-
}
|
|
31
|
-
emit(frame) {
|
|
32
|
-
this.options.output.write(serializeFrame(frame));
|
|
33
|
-
}
|
|
34
|
-
status(laneId, status, message = null) {
|
|
35
|
-
this.emit({ v: FRAME_VERSION, type: "lane.status", laneId, status, message });
|
|
36
|
-
}
|
|
37
|
-
error(laneId, code, message) {
|
|
38
|
-
this.emit({ v: FRAME_VERSION, type: "lane.error", laneId, code, message });
|
|
39
|
-
}
|
|
40
|
-
registryPath() {
|
|
41
|
-
return path.join(lanesHome(this.env), "registry.json");
|
|
42
|
-
}
|
|
43
|
-
async boundProjectOf(projectPath) {
|
|
44
|
-
try {
|
|
45
|
-
const parsed = JSON.parse(await readFile(path.join(projectPath, ".nustack", "workspace.json"), "utf8"));
|
|
46
|
-
const id = parsed?.workspaceId;
|
|
47
|
-
return typeof id === "string" && id.trim() ? id.trim() : null;
|
|
48
|
-
}
|
|
49
|
-
catch {
|
|
50
|
-
return null;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
async moveLaneSession(record, to) {
|
|
54
|
-
const client = this.options.sessions;
|
|
55
|
-
if (!client || !record.projectId)
|
|
56
|
-
return;
|
|
57
|
-
try {
|
|
58
|
-
await client.transitionSession(record.projectId, record.laneId, to);
|
|
59
|
-
}
|
|
60
|
-
catch {
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
async updateRegistry(mutate) {
|
|
64
|
-
const run = async () => {
|
|
65
|
-
const file = this.registryPath();
|
|
66
|
-
let lanes = [];
|
|
67
|
-
try {
|
|
68
|
-
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
69
|
-
if (typeof parsed === "object" && parsed !== null && Array.isArray(parsed.lanes)) {
|
|
70
|
-
lanes = parsed.lanes;
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
}
|
|
75
|
-
await mkdir(path.dirname(file), { recursive: true });
|
|
76
|
-
await writeFile(file, JSON.stringify({ v: 1, lanes: mutate(lanes) }, null, 2) + "\n", "utf8");
|
|
77
|
-
};
|
|
78
|
-
this.registryWrites = this.registryWrites.then(run, run);
|
|
79
|
-
return this.registryWrites;
|
|
80
|
-
}
|
|
81
|
-
async run() {
|
|
82
|
-
const probe = this.options.probeClaude ?? (() => defaultProbeClaude(this.env));
|
|
83
|
-
const claude = await probe();
|
|
84
|
-
this.emit({ v: FRAME_VERSION, type: "engine.hello", engineVersion: this.options.engineVersion, claude });
|
|
85
|
-
const lines = createInterface({ input: this.options.input });
|
|
86
|
-
this.lines = lines;
|
|
87
|
-
lines.on("line", (line) => {
|
|
88
|
-
if (line.trim() === "")
|
|
89
|
-
return;
|
|
90
|
-
const parsed = parseInboundFrame(line);
|
|
91
|
-
if (!parsed.ok) {
|
|
92
|
-
this.error(null, parsed.error.code, parsed.error.message);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
void this.handle(parsed.frame);
|
|
96
|
-
});
|
|
97
|
-
await new Promise((resolve) => {
|
|
98
|
-
this.resolveDone = resolve;
|
|
99
|
-
lines.on("close", () => void this.shutdown());
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
async handle(frame) {
|
|
103
|
-
switch (frame.type) {
|
|
104
|
-
case "engine.shutdown":
|
|
105
|
-
await this.shutdown();
|
|
106
|
-
return;
|
|
107
|
-
case "lane.open":
|
|
108
|
-
await this.openLane(frame.laneId, frame.projectPath, frame.permissionMode, frame.isolated, frame.resumeSessionId);
|
|
109
|
-
return;
|
|
110
|
-
case "lane.turn": {
|
|
111
|
-
const lane = this.lanes.get(frame.laneId);
|
|
112
|
-
if (!lane)
|
|
113
|
-
return this.error(frame.laneId, "LANE_UNKNOWN", `no open lane ${frame.laneId}`);
|
|
114
|
-
lane.adapter.sendTurn(frame.text);
|
|
115
|
-
this.status(frame.laneId, "running");
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
case "lane.approval": {
|
|
119
|
-
const lane = this.lanes.get(frame.laneId);
|
|
120
|
-
if (!lane)
|
|
121
|
-
return this.error(frame.laneId, "LANE_UNKNOWN", `no open lane ${frame.laneId}`);
|
|
122
|
-
lane.adapter.respondApproval(frame.requestId, frame.verdict);
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
case "lane.interrupt": {
|
|
126
|
-
const lane = this.lanes.get(frame.laneId);
|
|
127
|
-
if (!lane)
|
|
128
|
-
return this.error(frame.laneId, "LANE_UNKNOWN", `no open lane ${frame.laneId}`);
|
|
129
|
-
lane.adapter.interrupt();
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
case "lane.close": {
|
|
133
|
-
const lane = this.lanes.get(frame.laneId);
|
|
134
|
-
if (!lane)
|
|
135
|
-
return this.error(frame.laneId, "LANE_UNKNOWN", `no open lane ${frame.laneId}`);
|
|
136
|
-
this.closingLanes.add(frame.laneId);
|
|
137
|
-
lane.adapter.stop();
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
}
|
|
142
|
-
async openLane(laneId, projectPath, permissionMode, isolated, resumeSessionId) {
|
|
143
|
-
if (this.lanes.has(laneId))
|
|
144
|
-
return this.error(laneId, "LANE_ALREADY_OPEN", `lane ${laneId} is already open`);
|
|
145
|
-
this.status(laneId, "starting");
|
|
146
|
-
const worktree = await createLaneWorktree(projectPath, laneId, this.env, isolated === true);
|
|
147
|
-
const record = {
|
|
148
|
-
laneId,
|
|
149
|
-
projectPath,
|
|
150
|
-
worktreePath: worktree.worktreePath,
|
|
151
|
-
branch: worktree.branch,
|
|
152
|
-
provider: "claude-code",
|
|
153
|
-
providerSessionId: null,
|
|
154
|
-
createdAt: new Date().toISOString(),
|
|
155
|
-
status: "open",
|
|
156
|
-
projectId: await this.boundProjectOf(projectPath),
|
|
157
|
-
};
|
|
158
|
-
const adapterOptions = {
|
|
159
|
-
cwd: worktree.worktreePath,
|
|
160
|
-
permissionMode,
|
|
161
|
-
resumeSessionId,
|
|
162
|
-
env: this.env,
|
|
163
|
-
onEvent: (event) => {
|
|
164
|
-
this.emit({ v: FRAME_VERSION, type: "lane.event", laneId, event });
|
|
165
|
-
if (typeof event === "object" && event !== null && event.type === "result") {
|
|
166
|
-
this.status(laneId, "ready");
|
|
167
|
-
}
|
|
168
|
-
},
|
|
169
|
-
onApprovalRequest: (request) => {
|
|
170
|
-
this.emit({
|
|
171
|
-
v: FRAME_VERSION,
|
|
172
|
-
type: "lane.approvalRequest",
|
|
173
|
-
laneId,
|
|
174
|
-
requestId: request.requestId,
|
|
175
|
-
toolName: request.toolName,
|
|
176
|
-
displayName: request.displayName,
|
|
177
|
-
toolInput: request.toolInput,
|
|
178
|
-
description: request.description,
|
|
179
|
-
});
|
|
180
|
-
},
|
|
181
|
-
onProtocolError: (code, message) => this.error(laneId, code, message),
|
|
182
|
-
onSessionId: (sessionId) => {
|
|
183
|
-
record.providerSessionId = sessionId;
|
|
184
|
-
void this.updateRegistry((lanes) => lanes.map((row) => (row.laneId === laneId ? { ...row, providerSessionId: sessionId } : row)));
|
|
185
|
-
const client = this.options.sessions;
|
|
186
|
-
if (client && record.projectId) {
|
|
187
|
-
void client.setVendorSessionId(record.projectId, laneId, sessionId).catch(() => undefined);
|
|
188
|
-
}
|
|
189
|
-
},
|
|
190
|
-
onExit: (code) => {
|
|
191
|
-
const wasExpected = this.closingLanes.delete(laneId) || this.shuttingDown;
|
|
192
|
-
this.lanes.delete(laneId);
|
|
193
|
-
if (!wasExpected)
|
|
194
|
-
this.error(laneId, "ADAPTER_FAILED", `claude exited unexpectedly (code ${String(code)})`);
|
|
195
|
-
this.emit({ v: FRAME_VERSION, type: "lane.closed", laneId });
|
|
196
|
-
void this.updateRegistry((lanes) => lanes.map((row) => (row.laneId === laneId ? { ...row, status: "closed" } : row)));
|
|
197
|
-
void this.moveLaneSession(record, wasExpected ? "done" : "failed");
|
|
198
|
-
},
|
|
199
|
-
};
|
|
200
|
-
const create = this.options.createAdapter ?? ((opts) => new ClaudeLane(opts));
|
|
201
|
-
let adapter;
|
|
202
|
-
try {
|
|
203
|
-
adapter = create(adapterOptions);
|
|
204
|
-
}
|
|
205
|
-
catch (error) {
|
|
206
|
-
return this.error(laneId, "ADAPTER_FAILED", error instanceof Error ? error.message : String(error));
|
|
207
|
-
}
|
|
208
|
-
this.lanes.set(laneId, { adapter, record });
|
|
209
|
-
await this.updateRegistry((lanes) => [...lanes.filter((row) => row.laneId !== laneId), record]);
|
|
210
|
-
if (this.options.sessions && record.projectId) {
|
|
211
|
-
try {
|
|
212
|
-
await this.options.sessions.createSession(record.projectId, {
|
|
213
|
-
id: laneId,
|
|
214
|
-
startedBy: "person",
|
|
215
|
-
substrate: "device",
|
|
216
|
-
mode: "synchronous",
|
|
217
|
-
origin: "inside",
|
|
218
|
-
vendor: "claude_code",
|
|
219
|
-
renderer: "claude_transcript",
|
|
220
|
-
status: "created",
|
|
221
|
-
});
|
|
222
|
-
}
|
|
223
|
-
catch {
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
this.emit({
|
|
227
|
-
v: FRAME_VERSION,
|
|
228
|
-
type: "lane.opened",
|
|
229
|
-
laneId,
|
|
230
|
-
projectPath,
|
|
231
|
-
worktreePath: worktree.worktreePath,
|
|
232
|
-
branch: worktree.branch,
|
|
233
|
-
isolated: worktree.isolated,
|
|
234
|
-
});
|
|
235
|
-
this.status(laneId, "ready");
|
|
236
|
-
}
|
|
237
|
-
async shutdown() {
|
|
238
|
-
if (this.shuttingDown)
|
|
239
|
-
return;
|
|
240
|
-
this.shuttingDown = true;
|
|
241
|
-
for (const [, lane] of this.lanes)
|
|
242
|
-
lane.adapter.stop();
|
|
243
|
-
this.lanes.clear();
|
|
244
|
-
this.lines?.close();
|
|
245
|
-
this.resolveDone?.();
|
|
246
|
-
}
|
|
247
|
-
}
|