@nurix/nustack 0.23.14 → 0.23.15

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.
@@ -1,6 +1,28 @@
1
1
  import { assertSafeOpaqueId } from "../guards.js";
2
2
  import { apiError } from "../nustack_client.js";
3
3
  const REQUEST_TIMEOUT_MS = 10_000;
4
+ export async function postSessionEvents(baseUrl, accessToken, projectId, request) {
5
+ let response;
6
+ try {
7
+ const safeId = assertSafeOpaqueId("The Project id", projectId);
8
+ response = await globalThis.fetch(`${baseUrl}/api/v2/projects/${encodeURIComponent(safeId)}/records/session-events`, {
9
+ method: "POST",
10
+ headers: { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json" },
11
+ body: JSON.stringify(request),
12
+ signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS),
13
+ });
14
+ }
15
+ catch (error) {
16
+ return { ok: false, status: 0, code: null, message: `the door did not answer: ${error instanceof Error ? error.message : String(error)}` };
17
+ }
18
+ const body = (await response.json().catch(() => null));
19
+ if (!response.ok) {
20
+ const code = typeof body?.error?.code === "string" ? body.error.code : null;
21
+ const detail = typeof body?.error?.message === "string" ? body.error.message : `HTTP ${response.status}`;
22
+ return { ok: false, status: response.status, code, message: `${code ?? `HTTP ${response.status}`}: ${detail}` };
23
+ }
24
+ return { ok: true, landed: body?.data?.landed ?? 0, skipped: body?.data?.skipped ?? 0 };
25
+ }
4
26
  export function makeSessionsClient(baseUrl, accessToken) {
5
27
  const headers = {
6
28
  Authorization: `Bearer ${accessToken}`,
@@ -34,6 +56,9 @@ export function makeSessionsClient(baseUrl, accessToken) {
34
56
  async setVendorSessionId(projectId, sessionId, vendorSessionId) {
35
57
  return this.transitionSession(projectId, sessionId, "running", { vendorSessionId });
36
58
  },
59
+ postSessionEvents(projectId, request) {
60
+ return postSessionEvents(baseUrl, accessToken, projectId, request);
61
+ },
37
62
  };
38
63
  }
39
64
  export function sessionsClientFromEnv(env = process.env) {
@@ -0,0 +1,38 @@
1
+ import { readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const text = (value) => (typeof value === "string" && value.trim() ? value.trim() : null);
5
+ export function parseHookInput(raw) {
6
+ let parsed = null;
7
+ try {
8
+ parsed = raw.trim() ? JSON.parse(raw) : null;
9
+ }
10
+ catch {
11
+ parsed = null;
12
+ }
13
+ const body = (parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {});
14
+ const model = text(body.model) ?? (body.model && typeof body.model === "object" ? text(body.model.id) : null);
15
+ return { sessionId: text(body.session_id), cwd: text(body.cwd), hookEventName: text(body.hook_event_name), model };
16
+ }
17
+ export function resolveModel(input, env = process.env, home = os.homedir()) {
18
+ if (input.model)
19
+ return input.model;
20
+ const fromEnv = text(env.ANTHROPIC_MODEL);
21
+ if (fromEnv)
22
+ return fromEnv;
23
+ try {
24
+ const settings = JSON.parse(readFileSync(path.join(home, ".claude", "settings.json"), "utf8"));
25
+ return text(settings.model);
26
+ }
27
+ catch {
28
+ return null;
29
+ }
30
+ }
31
+ export async function readAllStdin() {
32
+ if (process.stdin.isTTY)
33
+ return "";
34
+ const chunks = [];
35
+ for await (const chunk of process.stdin)
36
+ chunks.push(chunk);
37
+ return Buffer.concat(chunks).toString("utf8");
38
+ }
@@ -0,0 +1,89 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, writeFileSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { memoryManifestPath } from "../local_store/paths.js";
5
+ import { claudeProjectDir } from "../telemetry/provider_paths.js";
6
+ function walk(root, relative = "") {
7
+ const files = [];
8
+ let names;
9
+ try {
10
+ names = readdirSync(path.join(root, relative));
11
+ }
12
+ catch {
13
+ return files;
14
+ }
15
+ for (const name of names.sort()) {
16
+ if (name.startsWith("."))
17
+ continue;
18
+ const next = relative ? `${relative}/${name}` : name;
19
+ const stat = lstatSync(path.join(root, next));
20
+ if (stat.isSymbolicLink())
21
+ continue;
22
+ if (stat.isDirectory())
23
+ files.push(...walk(root, next));
24
+ else if (stat.isFile())
25
+ files.push(next);
26
+ }
27
+ return files;
28
+ }
29
+ function readManifest(file) {
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
32
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
33
+ }
34
+ catch {
35
+ return {};
36
+ }
37
+ }
38
+ function writeManifest(file, manifest) {
39
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
40
+ const temporary = `${file}.${process.pid}.tmp`;
41
+ writeFileSync(temporary, `${JSON.stringify(manifest, null, 2)}\n`, { mode: 0o600 });
42
+ renameSync(temporary, file);
43
+ }
44
+ export function memoryRootOf(repositoryRoot) {
45
+ return path.join(claudeProjectDir(repositoryRoot), "memory");
46
+ }
47
+ export async function shipChangedMemory(input, globals, write) {
48
+ const result = { written: 0, refused: 0, unchanged: 0 };
49
+ try {
50
+ const memoryRoot = memoryRootOf(input.repositoryRoot);
51
+ const manifestFile = memoryManifestPath(input.projectId);
52
+ const manifest = readManifest(manifestFile);
53
+ const decoder = new TextDecoder("utf-8", { fatal: true });
54
+ for (const relative of walk(memoryRoot)) {
55
+ const bytes = readFileSync(path.join(memoryRoot, relative));
56
+ const sha256 = createHash("sha256").update(bytes).digest("hex");
57
+ if (manifest[relative] === sha256) {
58
+ result.unchanged += 1;
59
+ continue;
60
+ }
61
+ let body;
62
+ try {
63
+ body = decoder.decode(bytes);
64
+ }
65
+ catch {
66
+ continue;
67
+ }
68
+ const outcome = await write({
69
+ command: "session end",
70
+ target: "session end --hook (memory)",
71
+ kind: "memory",
72
+ rows: [{ payload: { path: relative, body } }],
73
+ root: input.repositoryRoot,
74
+ project: input.projectId,
75
+ session: input.sessionId,
76
+ json: true,
77
+ }, globals);
78
+ if (outcome.ok)
79
+ result.written += outcome.data.written;
80
+ else
81
+ result.refused += 1;
82
+ manifest[relative] = sha256;
83
+ writeManifest(manifestFile, manifest);
84
+ }
85
+ }
86
+ catch {
87
+ }
88
+ return result;
89
+ }
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { loadHarness } from "../harness/vendor.js";
4
+ const MOUNT_RUNTIME = {
5
+ claude_code: "claude-code",
6
+ codex: "codex",
7
+ opencode: "opencode",
8
+ copilot_cli: "copilot-cli",
9
+ nustack: null,
10
+ };
11
+ const NONE = { harnessSemver: null, harnessDigest: null, configDigest: null };
12
+ const SHA256 = /^[0-9a-f]{64}$/;
13
+ export async function mountFacts(root, runtime) {
14
+ const key = MOUNT_RUNTIME[runtime];
15
+ if (!key)
16
+ return NONE;
17
+ try {
18
+ const { readMountRecord } = await loadHarness("mount");
19
+ const { contentDigest } = await loadHarness("digest");
20
+ const record = readMountRecord(root);
21
+ const entry = record?.runtimes?.[key];
22
+ if (!record || !entry)
23
+ return NONE;
24
+ const harnessDigest = typeof record.digest === "string" && SHA256.test(record.digest) ? record.digest : SHA256.test(entry.digest) ? entry.digest : null;
25
+ const harnessSemver = typeof record.version === "string" ? record.version : entry.semver || null;
26
+ if (!harnessDigest)
27
+ return NONE;
28
+ const onDisk = [];
29
+ for (const file of entry.files ?? []) {
30
+ try {
31
+ onDisk.push({ path: file.path, content: readFileSync(path.join(root, file.path)) });
32
+ }
33
+ catch {
34
+ }
35
+ }
36
+ const recomputed = contentDigest(onDisk);
37
+ return { harnessSemver, harnessDigest, configDigest: recomputed === entry.treeDigest ? harnessDigest : recomputed };
38
+ }
39
+ catch {
40
+ return NONE;
41
+ }
42
+ }
@@ -33,6 +33,7 @@ export async function recordOutsideSessions(client, input) {
33
33
  nustack: isEngineSession ? 1 : 0,
34
34
  vendor: kinds.vendor,
35
35
  renderer: kinds.renderer,
36
+ runtime: transcript.provider,
36
37
  status: stale ? "done" : "running",
37
38
  ...(vendorSessionId ? { vendorSessionId } : {}),
38
39
  });
@@ -0,0 +1,203 @@
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { ensureAccessToken } from "../auth_grant.js";
4
+ import { sessionEventsDir } from "../local_store/paths.js";
5
+ import { labelRowWire, listPendingLabelRows, markLabelRowsRefused, markLabelRowsSynced } from "../local_store/routing.js";
6
+ import { RECORDS_BATCH_CAP, resolveDoor } from "../records/door.js";
7
+ import { makeSessionsClient, postSessionEvents } from "./client.js";
8
+ export const SESSION_SPOOL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
9
+ const SESSION_DRAIN_PASSES = 3;
10
+ const spoolPath = (rowId) => path.join(sessionEventsDir(), `${rowId}.json`);
11
+ export function readSpool(rowId) {
12
+ try {
13
+ const parsed = JSON.parse(readFileSync(spoolPath(rowId), "utf8"));
14
+ return parsed && typeof parsed.rowId === "string" && Array.isArray(parsed.pending) ? parsed : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function writeSpool(spool) {
21
+ mkdirSync(sessionEventsDir(), { recursive: true, mode: 0o700 });
22
+ const target = spoolPath(spool.rowId);
23
+ const temporary = `${target}.${process.pid}.tmp`;
24
+ writeFileSync(temporary, `${JSON.stringify(spool, null, 2)}\n`, { mode: 0o600 });
25
+ renameSync(temporary, target);
26
+ }
27
+ export function listSpools() {
28
+ if (!existsSync(sessionEventsDir()))
29
+ return [];
30
+ const spools = [];
31
+ for (const name of readdirSync(sessionEventsDir())) {
32
+ if (!name.endsWith(".json"))
33
+ continue;
34
+ const spool = readSpool(name.slice(0, -".json".length));
35
+ if (spool)
36
+ spools.push(spool);
37
+ }
38
+ return spools;
39
+ }
40
+ export function spoolEvent(identity, event, options = {}) {
41
+ const held = readSpool(identity.rowId);
42
+ const spool = {
43
+ ...identity,
44
+ runtimeSessionId: identity.runtimeSessionId ?? held?.runtimeSessionId ?? null,
45
+ pending: [...(held?.pending ?? []), ...(event ? [event] : [])],
46
+ isClosePending: (held?.isClosePending ?? false) || options.isClosePending === true,
47
+ lastSyncedAt: held?.lastSyncedAt ?? null,
48
+ lastEventAt: new Date().toISOString(),
49
+ };
50
+ writeSpool(spool);
51
+ return spool;
52
+ }
53
+ async function doorFor(root, globals, bearers) {
54
+ const door = await resolveDoor(root, globals).catch(() => null);
55
+ if (!door || door.kind !== "door")
56
+ return null;
57
+ if (!bearers.has(door.doorUrl)) {
58
+ bearers.set(door.doorUrl, await ensureAccessToken().then((auth) => auth?.accessToken ?? null, () => null));
59
+ }
60
+ const bearer = bearers.get(door.doorUrl) ?? null;
61
+ return bearer ? { doorUrl: door.doorUrl, bearer } : null;
62
+ }
63
+ const isRefusal = (outcome) => !outcome.ok && outcome.status === 400;
64
+ export async function drainSessionEvents(globals, result, bearers = new Map()) {
65
+ for (const spool of listSpools()) {
66
+ if (spool.pending.length === 0 && !spool.isClosePending)
67
+ continue;
68
+ const door = await doorFor(spool.root, globals, bearers);
69
+ if (!door)
70
+ continue;
71
+ let pending = spool.pending;
72
+ if (pending.length > 0) {
73
+ const kept = [];
74
+ for (let start = 0; start < pending.length; start += RECORDS_BATCH_CAP) {
75
+ const batch = pending.slice(start, start + RECORDS_BATCH_CAP);
76
+ const outcome = await postSessionEvents(door.doorUrl, door.bearer, spool.projectId, { events: batch, labelRows: [] });
77
+ if (outcome.ok) {
78
+ result.eventsSent += batch.length;
79
+ continue;
80
+ }
81
+ result.lastError = outcome.message;
82
+ if (!isRefusal(outcome)) {
83
+ kept.push(...batch);
84
+ continue;
85
+ }
86
+ for (const event of batch) {
87
+ const alone = await postSessionEvents(door.doorUrl, door.bearer, spool.projectId, { events: [event], labelRows: [] });
88
+ if (alone.ok)
89
+ result.eventsSent += 1;
90
+ else if (isRefusal(alone))
91
+ result.eventsDropped += 1;
92
+ else
93
+ kept.push(event);
94
+ }
95
+ }
96
+ const fresh = readSpool(spool.rowId) ?? spool;
97
+ pending = [...kept, ...fresh.pending.slice(spool.pending.length)];
98
+ writeSpool({ ...fresh, pending, lastSyncedAt: kept.length === 0 ? new Date().toISOString() : fresh.lastSyncedAt });
99
+ }
100
+ const current = readSpool(spool.rowId) ?? spool;
101
+ if (pending.length > 0 || current.pending.length > 0 || !current.isClosePending)
102
+ continue;
103
+ if (spool.isEngineSession) {
104
+ writeSpool({ ...(readSpool(spool.rowId) ?? spool), isClosePending: false });
105
+ continue;
106
+ }
107
+ try {
108
+ await makeSessionsClient(door.doorUrl, door.bearer).transitionSession(spool.projectId, spool.rowId, "done");
109
+ result.closed += 1;
110
+ writeSpool({ ...(readSpool(spool.rowId) ?? spool), isClosePending: false });
111
+ }
112
+ catch (error) {
113
+ const code = error.code;
114
+ if (typeof code === "string" && !["UNEXPECTED", "SIGNED_OUT", "RATE_LIMITED", "INTERNAL_ERROR"].includes(code)) {
115
+ writeSpool({ ...(readSpool(spool.rowId) ?? spool), isClosePending: false });
116
+ }
117
+ else {
118
+ result.lastError = error instanceof Error ? error.message : String(error);
119
+ }
120
+ }
121
+ }
122
+ }
123
+ function spoolOf(sessionId, spools) {
124
+ return spools.find((spool) => spool.rowId === sessionId) ?? spools.find((spool) => spool.runtimeSessionId === sessionId) ?? null;
125
+ }
126
+ export async function drainLabelRows(store, globals, result, bearers = new Map(), now = Date.now()) {
127
+ const rows = listPendingLabelRows(store, RECORDS_BATCH_CAP);
128
+ if (rows.length === 0)
129
+ return;
130
+ const spools = listSpools();
131
+ const groups = new Map();
132
+ const stale = [];
133
+ for (const record of rows) {
134
+ const spool = spoolOf(record.session_id, spools);
135
+ if (!spool) {
136
+ if (now - record.created_at > SESSION_SPOOL_RETENTION_MS)
137
+ stale.push(record.id);
138
+ continue;
139
+ }
140
+ const key = `${spool.root}\u0000${spool.projectId}`;
141
+ const group = groups.get(key) ?? { spool, rows: [] };
142
+ group.rows.push({ record, wire: { ...labelRowWire(record), sessionId: spool.rowId } });
143
+ groups.set(key, group);
144
+ }
145
+ if (stale.length > 0)
146
+ result.labelRowsRefused += markLabelRowsRefused(store, stale);
147
+ for (const { spool, rows: batch } of groups.values()) {
148
+ const door = await doorFor(spool.root, globals, bearers);
149
+ if (!door)
150
+ continue;
151
+ const request = { events: [], labelRows: batch.map((row) => row.wire) };
152
+ const outcome = await postSessionEvents(door.doorUrl, door.bearer, spool.projectId, request);
153
+ if (outcome.ok) {
154
+ result.labelRowsSynced += markLabelRowsSynced(store, batch.map((row) => row.record.id));
155
+ continue;
156
+ }
157
+ result.lastError = outcome.message;
158
+ if (!isRefusal(outcome))
159
+ continue;
160
+ for (const row of batch) {
161
+ const alone = await postSessionEvents(door.doorUrl, door.bearer, spool.projectId, { events: [], labelRows: [row.wire] });
162
+ if (alone.ok)
163
+ result.labelRowsSynced += markLabelRowsSynced(store, [row.record.id]);
164
+ else if (isRefusal(alone))
165
+ result.labelRowsRefused += markLabelRowsRefused(store, [row.record.id]);
166
+ }
167
+ }
168
+ }
169
+ export function pruneSpools(now = Date.now()) {
170
+ let pruned = 0;
171
+ for (const spool of listSpools()) {
172
+ if (spool.pending.length > 0 || spool.isClosePending)
173
+ continue;
174
+ if (now - Date.parse(spool.lastEventAt) <= SESSION_SPOOL_RETENTION_MS)
175
+ continue;
176
+ try {
177
+ unlinkSync(spoolPath(spool.rowId));
178
+ pruned += 1;
179
+ }
180
+ catch {
181
+ }
182
+ }
183
+ return pruned;
184
+ }
185
+ export async function drainSessionLegs(store, globals) {
186
+ const result = { eventsSent: 0, eventsDropped: 0, closed: 0, labelRowsSynced: 0, labelRowsRefused: 0, lastError: null };
187
+ const bearers = new Map();
188
+ try {
189
+ for (let pass = 0; pass < SESSION_DRAIN_PASSES; pass += 1) {
190
+ const before = result.eventsSent + result.eventsDropped + result.closed;
191
+ await drainSessionEvents(globals, result, bearers);
192
+ const isIdle = listSpools().every((spool) => spool.pending.length === 0 && !spool.isClosePending);
193
+ if (isIdle || result.eventsSent + result.eventsDropped + result.closed === before)
194
+ break;
195
+ }
196
+ await drainLabelRows(store, globals, result, bearers);
197
+ pruneSpools();
198
+ }
199
+ catch (error) {
200
+ result.lastError = error instanceof Error ? error.message : String(error);
201
+ }
202
+ return result;
203
+ }
@@ -0,0 +1,16 @@
1
+ import { createHash } from "node:crypto";
2
+ const SESSION_KEY_LENGTH = 32;
3
+ export function sessionRowIdOf(runtime, runtimeSessionId) {
4
+ return createHash("sha256").update(`${runtime}:${runtimeSessionId}`).digest("hex").slice(0, SESSION_KEY_LENGTH);
5
+ }
6
+ export const RUNTIME_VENDOR = {
7
+ claude_code: { vendor: "claude_code", renderer: "claude_transcript" },
8
+ codex: { vendor: "codex", renderer: "codex_transcript" },
9
+ opencode: { vendor: "opencode", renderer: "opencode_log" },
10
+ copilot_cli: { vendor: "nustack", renderer: "chat" },
11
+ nustack: { vendor: "nustack", renderer: "chat" },
12
+ };
13
+ export const HOOK_RUNTIMES = ["claude_code", "codex", "opencode", "copilot_cli", "nustack"];
14
+ export function isSessionRuntime(value) {
15
+ return typeof value === "string" && HOOK_RUNTIMES.includes(value);
16
+ }
package/dist/program.js CHANGED
@@ -34,6 +34,7 @@ import { runCheck } from "./commands/check.js";
34
34
  import { runSessionsList, runSessionsPrune, runSessionsRecord, runSessionsServe, } from "./commands/sessions.js";
35
35
  import { registerReviewCommands } from "./commands/review.js";
36
36
  import { registerRecordsCommands, startDrainIfDue } from "./commands/records.js";
37
+ import { registerSessionCommands } from "./commands/session.js";
37
38
  import { isPartCheckStale, startTelemetryReconcile } from "./lib/telemetry/part_state.js";
38
39
  import { RECORD_ALIAS_GROUPS, registerRecordAliases } from "./commands/recordAliases.js";
39
40
  import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
@@ -249,7 +250,7 @@ registerReviewCommands(program.command("review").description("Read and mirror Nu
249
250
  registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
250
251
  registerRecordAliases(program, () => program.opts());
251
252
  const LOCAL_ONLY_COMMANDS = ["census", "check"];
252
- const UNDRAINED_GROUPS = ["records", "gate", "read", ...RECORD_ALIAS_GROUPS];
253
+ const UNDRAINED_GROUPS = ["records", "gate", "session", "read", ...RECORD_ALIAS_GROUPS];
253
254
  program.hook("preAction", async (_program, action) => {
254
255
  if (program.opts().nonInteractive)
255
256
  process.env[NON_INTERACTIVE_VARIABLE] = "1";
@@ -327,6 +328,7 @@ sessions
327
328
  .option("--force", "also reclaim an unmerged or dirty session — never one whose engine is running")
328
329
  .option("--json", "emit the versioned machine envelope")
329
330
  .action(async (options) => runSessionsPrune(options));
331
+ registerSessionCommands(program, () => program.opts());
330
332
  const harness = program.command("harness").description("Mount the Project's harness and compare it with the cloud pin");
331
333
  harness
332
334
  .command("mount [path]")