@nurix/nustack 0.23.13 → 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.
@@ -0,0 +1,102 @@
1
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { ensureAccessToken } from "../auth_grant.js";
4
+ import { briefCachePath } from "../local_store/paths.js";
5
+ import { assertCredentialOrigin } from "../nustack_client.js";
6
+ import { resolveDoor } from "../records/door.js";
7
+ export const BRIEF_FETCH_TIMEOUT_MS = 3_000;
8
+ export const BRIEF_CONTEXT_CAP = 4_000;
9
+ export const BRIEF_CONTEXT_OBLIGATIONS = 20;
10
+ export const HANDOVER_LINE = "Before you finish, leave a handover for the next session: `nustack session end --handover -` with the note on stdin.";
11
+ const EMPTY_BRIEF = { previousHandover: null, openObligations: { total: 0, items: [] }, generatedAt: "" };
12
+ export function readCache(file) {
13
+ try {
14
+ const parsed = JSON.parse(readFileSync(file, "utf8"));
15
+ return typeof parsed.fetchedAt === "string" && parsed.value !== undefined ? { fetchedAt: parsed.fetchedAt, value: parsed.value } : null;
16
+ }
17
+ catch {
18
+ return null;
19
+ }
20
+ }
21
+ export function writeCache(file, value) {
22
+ try {
23
+ mkdirSync(path.dirname(file), { recursive: true, mode: 0o700 });
24
+ const temporary = `${file}.${process.pid}.tmp`;
25
+ writeFileSync(temporary, `${JSON.stringify({ fetchedAt: new Date().toISOString(), value })}\n`, { mode: 0o600 });
26
+ renameSync(temporary, file);
27
+ }
28
+ catch {
29
+ }
30
+ }
31
+ export async function getFromDoor(root, globals, projectId, pathAndQuery, timeoutMs) {
32
+ try {
33
+ const door = await resolveDoor(root, globals, projectId);
34
+ if (door.kind !== "door")
35
+ return null;
36
+ await assertCredentialOrigin(door.doorUrl);
37
+ const auth = await ensureAccessToken();
38
+ if (!auth)
39
+ return null;
40
+ const response = await fetch(`${door.doorUrl}/api/v2/projects/${encodeURIComponent(projectId)}${pathAndQuery}`, {
41
+ headers: { authorization: `Bearer ${auth.accessToken}` },
42
+ signal: AbortSignal.timeout(timeoutMs),
43
+ });
44
+ if (!response.ok)
45
+ return null;
46
+ const body = (await response.json());
47
+ return body.data === undefined ? null : { data: body.data, meta: body.meta ?? null };
48
+ }
49
+ catch {
50
+ return null;
51
+ }
52
+ }
53
+ export async function readBrief(root, globals, projectId, sessionId, refusalCount) {
54
+ const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
55
+ const answered = await getFromDoor(root, globals, projectId, `/sessions/brief${query}`, BRIEF_FETCH_TIMEOUT_MS);
56
+ const cacheFile = briefCachePath(projectId);
57
+ if (answered && answered.data && typeof answered.data === "object") {
58
+ const brief = answered.data;
59
+ writeCache(cacheFile, brief);
60
+ return { ...brief, refusalCount, offline: null, source: "server" };
61
+ }
62
+ const cached = readCache(cacheFile);
63
+ const since = cached?.fetchedAt ?? new Date().toISOString();
64
+ return {
65
+ ...(cached?.value ?? EMPTY_BRIEF),
66
+ refusalCount,
67
+ offline: { since, durationMs: Math.max(0, Date.now() - Date.parse(since)) },
68
+ source: "cache",
69
+ };
70
+ }
71
+ function obligationLine(item) {
72
+ const claim = typeof item.payload.claim === "string" ? ` — ${item.payload.claim}` : "";
73
+ return `- ${item.naturalKey}${claim}`;
74
+ }
75
+ export function renderBrief(brief, options = {}) {
76
+ const lines = ["NuStack session brief"];
77
+ if (brief.offline)
78
+ lines.push(`(offline since ${brief.offline.since}; this is the cached brief)`);
79
+ if (brief.previousHandover) {
80
+ const runtime = brief.previousHandover.runtime ? ` (${brief.previousHandover.runtime.replace(/_/g, " ")})` : "";
81
+ lines.push("", `Previous handover${runtime}, ${brief.previousHandover.createdAt}:`, brief.previousHandover.handover);
82
+ }
83
+ else {
84
+ lines.push("", "No previous handover.");
85
+ }
86
+ const items = brief.openObligations.items.slice(0, options.obligationLimit ?? brief.openObligations.items.length);
87
+ lines.push("", `Open obligations: ${brief.openObligations.total}`);
88
+ for (const item of items)
89
+ lines.push(obligationLine(item));
90
+ if (brief.openObligations.total > items.length)
91
+ lines.push(`… and ${brief.openObligations.total - items.length} more (nustack obligation list --plan <anchor>)`);
92
+ if (brief.refusalCount > 0)
93
+ lines.push("", `Refused record writes on this device: ${brief.refusalCount} (nustack records list).`);
94
+ return lines.join("\n");
95
+ }
96
+ export function renderHookOutput(brief) {
97
+ const tail = `\n\n${HANDOVER_LINE}`;
98
+ let body = renderBrief(brief, { obligationLimit: BRIEF_CONTEXT_OBLIGATIONS });
99
+ if (body.length + tail.length > BRIEF_CONTEXT_CAP)
100
+ body = `${body.slice(0, BRIEF_CONTEXT_CAP - tail.length - 1)}…`;
101
+ return JSON.stringify({ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: `${body}${tail}` } });
102
+ }
@@ -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,11 +34,13 @@ 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";
40
41
  import { runCensus } from "./commands/census.js";
41
42
  import { runGateInstall, runGateRemove, runGateStatus } from "./commands/gate.js";
43
+ import { parseBudgetMs, parseLineWindow, runReadBulk } from "./commands/read.js";
42
44
  import { HOOK_SUBCOMMANDS } from "./lib/gate/subcommands.js";
43
45
  import { printJsonError } from "./lib/json_output.js";
44
46
  import { setCliVersion } from "./lib/auth_grant.js";
@@ -248,7 +250,7 @@ registerReviewCommands(program.command("review").description("Read and mirror Nu
248
250
  registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
249
251
  registerRecordAliases(program, () => program.opts());
250
252
  const LOCAL_ONLY_COMMANDS = ["census", "check"];
251
- const UNDRAINED_GROUPS = ["records", "gate", ...RECORD_ALIAS_GROUPS];
253
+ const UNDRAINED_GROUPS = ["records", "gate", "session", "read", ...RECORD_ALIAS_GROUPS];
252
254
  program.hook("preAction", async (_program, action) => {
253
255
  if (program.opts().nonInteractive)
254
256
  process.env[NON_INTERACTIVE_VARIABLE] = "1";
@@ -326,6 +328,7 @@ sessions
326
328
  .option("--force", "also reclaim an unmerged or dirty session — never one whose engine is running")
327
329
  .option("--json", "emit the versioned machine envelope")
328
330
  .action(async (options) => runSessionsPrune(options));
331
+ registerSessionCommands(program, () => program.opts());
329
332
  const harness = program.command("harness").description("Mount the Project's harness and compare it with the cloud pin");
330
333
  harness
331
334
  .command("mount [path]")
@@ -396,6 +399,17 @@ gate.command("status")
396
399
  .description("Report the gate's hooks, the Local Reader switch, the threshold, the outline and the store version")
397
400
  .option("--json", "emit the versioned machine envelope")
398
401
  .action(async (options) => runGateStatus(options));
402
+ const read = program.command("read").description("Ask the local model about large files through the running NuStack desktop");
403
+ read.command("bulk")
404
+ .description("Ask the local model one question about a few large files; a digest that fails its check is never printed")
405
+ .requiredOption("--paths <path...>", "the files to read, at most eight, inside one NuStack project")
406
+ .requiredOption("--question <text>", "what to answer about those files")
407
+ .option("--session <id>", "the Claude Code session this read belongs to (its latch and label rows); omitted, the read runs as `direct`")
408
+ .option("--agent-type <type>", "the sub-agent type the label rows name (default `main`)")
409
+ .option("--lines <n>", "line window per model turn (the desktop's default 400)", parseLineWindow)
410
+ .option("--budget-ms <n>", "the whole call's wall-time budget, 5000 to 300000 ms (default: NUSTACK_READ_BULK_BUDGET_MS, the desktop setting, then the desktop's own)", parseBudgetMs)
411
+ .option("--json", "emit the versioned machine envelope")
412
+ .action(async (options) => runReadBulk(options));
399
413
  function describeError(error) {
400
414
  const directCode = error?.code;
401
415
  if (directCode === "PROJECT_CONTEXT_MISSING") {