@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.
@@ -1,59 +1,7 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import os from "node:os";
3
2
  import path from "node:path";
4
3
  import { installSessionStartHook, removeSessionStartHook } from "../lib/agent/claudeHooks.js";
5
- const PROBE_TIMEOUT_MS = 1500;
6
- function desktopConfigDir() {
7
- return (process.env.NUSTACK_DESKTOP_CONFIG_DIR ||
8
- path.join(os.homedir(), "Library", "Application Support", "co.nurix.nustack"));
9
- }
10
- async function readServerRecord() {
11
- const recordPath = path.join(desktopConfigDir(), "server.json");
12
- try {
13
- const parsed = JSON.parse(await readFile(recordPath, "utf8"));
14
- if (typeof parsed.port !== "number" ||
15
- typeof parsed.token !== "string" ||
16
- typeof parsed.pid !== "number" ||
17
- typeof parsed.startedAt !== "string") {
18
- return null;
19
- }
20
- return parsed;
21
- }
22
- catch {
23
- return null;
24
- }
25
- }
26
- function isPidAlive(pid) {
27
- try {
28
- process.kill(pid, 0);
29
- return true;
30
- }
31
- catch (error) {
32
- return error.code === "EPERM";
33
- }
34
- }
35
- function isRosterProject(value) {
36
- return (value !== null &&
37
- typeof value === "object" &&
38
- typeof value.path === "string" &&
39
- typeof value.label === "string");
40
- }
41
- async function probeProjects(record) {
42
- try {
43
- const response = await fetch(`http://127.0.0.1:${record.port}/api/projects`, {
44
- headers: { Authorization: `Bearer ${record.token}` },
45
- signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
46
- });
47
- if (!response.ok)
48
- return null;
49
- const body = (await response.json());
50
- const projects = body.data?.projects;
51
- return Array.isArray(projects) ? projects.filter(isRosterProject) : null;
52
- }
53
- catch {
54
- return null;
55
- }
56
- }
4
+ import { isPidAlive, probeProjects, readServerRecord } from "../lib/agent/desktopDoor.js";
57
5
  function describeContext(record, projects) {
58
6
  const baseUrl = `http://127.0.0.1:${record.port}`;
59
7
  const roster = projects.length
@@ -0,0 +1,75 @@
1
+ import { obligationsCachePath } from "../lib/local_store/paths.js";
2
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
3
+ import { resolveProjectId } from "../lib/project_context.js";
4
+ import { getFromDoor, readCache, writeCache } from "../lib/sessions/brief.js";
5
+ const MAX_PAGES = 100;
6
+ const PAGE_TIMEOUT_MS = 10_000;
7
+ function lineOf(item) {
8
+ if (typeof item.naturalKey !== "string")
9
+ return null;
10
+ const payload = (item.payload ?? {});
11
+ return {
12
+ naturalKey: item.naturalKey,
13
+ state: typeof payload.state === "string" ? payload.state : "unknown",
14
+ verdict: typeof payload.outcome?.verdict === "string" ? payload.outcome.verdict : null,
15
+ };
16
+ }
17
+ async function fetchAll(root, globals, projectId, plan) {
18
+ const lines = [];
19
+ let cursor = null;
20
+ for (let page = 0; page < MAX_PAGES; page += 1) {
21
+ const query = `/records?kind=obligation&planAnchor=${encodeURIComponent(plan)}&limit=100${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`;
22
+ const answered = await getFromDoor(root, globals, projectId, query, PAGE_TIMEOUT_MS);
23
+ const items = answered?.data?.items;
24
+ if (!answered || !Array.isArray(items))
25
+ return null;
26
+ for (const item of items) {
27
+ const line = lineOf(item);
28
+ if (line)
29
+ lines.push(line);
30
+ }
31
+ cursor = answered.meta?.nextCursor ?? null;
32
+ if (!cursor)
33
+ return lines;
34
+ }
35
+ return lines;
36
+ }
37
+ export async function runObligationList(options, globals) {
38
+ const root = process.cwd();
39
+ const project = await resolveProjectId(root, options.project ? { project: options.project } : {});
40
+ if (!project.ok) {
41
+ if (options.json)
42
+ printJsonError("obligation list", { code: "NO_PROJECT", message: project.message });
43
+ else {
44
+ process.stderr.write(`obligation list: ${project.message}\n`);
45
+ process.exitCode = 1;
46
+ }
47
+ return;
48
+ }
49
+ const cacheFile = obligationsCachePath(project.projectId, options.plan);
50
+ const fetched = await fetchAll(root, globals, project.projectId, options.plan);
51
+ let obligations;
52
+ let source;
53
+ let fetchedAt = null;
54
+ if (fetched) {
55
+ writeCache(cacheFile, fetched);
56
+ obligations = fetched;
57
+ source = "server";
58
+ }
59
+ else {
60
+ const cached = readCache(cacheFile);
61
+ obligations = cached?.value ?? [];
62
+ fetchedAt = cached?.fetchedAt ?? null;
63
+ source = "cache";
64
+ }
65
+ if (options.json) {
66
+ printJsonSuccess("obligation list", { plan: options.plan, projectId: project.projectId, source, fetchedAt, obligations });
67
+ return;
68
+ }
69
+ if (source === "cache")
70
+ process.stderr.write(`obligation list: offline; the cached list${fetchedAt ? ` from ${fetchedAt}` : " is empty"}\n`);
71
+ for (const line of obligations)
72
+ process.stdout.write(`${line.naturalKey} ${line.state}${line.verdict ? ` ${line.verdict}` : ""}\n`);
73
+ if (obligations.length === 0)
74
+ process.stdout.write(`obligation list: no obligations under ${options.plan}\n`);
75
+ }
@@ -0,0 +1,19 @@
1
+ import { InvalidArgumentError } from "commander";
2
+ import { runReadBulk as runRelay } from "../lib/gate/bulkReadRelay.js";
3
+ import { isReadBulkBudgetInRange, READ_BULK_BUDGET_BOUNDS_MS } from "../lib/gate/settings.js";
4
+ export function parseBudgetMs(value) {
5
+ const parsed = /^\d+$/.test(value.trim()) ? Number.parseInt(value.trim(), 10) : Number.NaN;
6
+ if (!isReadBulkBudgetInRange(parsed)) {
7
+ throw new InvalidArgumentError(`the budget is an integer from ${READ_BULK_BUDGET_BOUNDS_MS.min} to ${READ_BULK_BUDGET_BOUNDS_MS.max} milliseconds.`);
8
+ }
9
+ return parsed;
10
+ }
11
+ export function parseLineWindow(value) {
12
+ if (!/^\d+$/.test(value.trim()) || Number.parseInt(value.trim(), 10) < 1) {
13
+ throw new InvalidArgumentError("the line window is a positive integer.");
14
+ }
15
+ return Number.parseInt(value.trim(), 10);
16
+ }
17
+ export async function runReadBulk(options) {
18
+ await runRelay(options);
19
+ }
@@ -3,6 +3,7 @@ import { openLocalStore } from "../lib/local_store/database.js";
3
3
  import { idempotencyKeyOf, splitObligationKey } from "../lib/records/naturalKey.js";
4
4
  import { findRecordByKey } from "../lib/records/recordsTable.js";
5
5
  import { emitRows, refuseEmit, resolveCaller } from "./records.js";
6
+ import { runObligationList } from "./obligationList.js";
6
7
  export const RECORD_ALIAS_GROUPS = ["obligation", "backlog", "changelog", "journal"];
7
8
  const VERDICTS_NEEDING_REASON = new Set(["owes_nothing", "blocked", "dismissed"]);
8
9
  const PLAN_ANCHOR = /^\d{4}-\d{2}-\d{2}-[a-z0-9][a-z0-9-]*$/;
@@ -152,6 +153,13 @@ export function registerRecordAliases(program, getGlobalOptions) {
152
153
  .requiredOption("--key <naturalKey>", "the obligation's natural key, <origin>#<type>:<subject>")
153
154
  .requiredOption("--outcome <verdict>", "updated, satisfied, fixed, owes_nothing, blocked or dismissed")
154
155
  .option("--reason <text>", "why; required for owes_nothing, blocked and dismissed")).action(async (options) => runObligationDischarge(options, getGlobalOptions()));
156
+ obligation
157
+ .command("list")
158
+ .description("List a plan's obligations as NuStack holds them (the cached list when offline)")
159
+ .requiredOption("--plan <anchor>", "the plan anchor")
160
+ .option("--project <id>", "the Project, when the folder carries no binding marker")
161
+ .option("--json", "emit the versioned machine envelope")
162
+ .action(async (options) => runObligationList(options, getGlobalOptions()));
155
163
  withCommon(program
156
164
  .command("backlog")
157
165
  .description("Log backlog items (harness records)")
@@ -11,6 +11,7 @@ import { DEVICE_TOKEN_VARIABLE, DRAIN_CHILD_VARIABLE, SESSION_ID_VARIABLE, isFla
11
11
  import { UNKNOWN_KIND_MESSAGE, assembleRecord, parseRowsText, translateRow, } from "../lib/records/recordInput.js";
12
12
  import { AgentOwnedError, OBLIGATION_AGENT_OWNED, countRecords, hasDueRecord, listRecords, releaseUnboundRows, writeRecords, } from "../lib/records/recordsTable.js";
13
13
  import { logRefusals } from "../lib/records/refusalLog.js";
14
+ import { drainSessionLegs } from "../lib/sessions/sessionEvents.js";
14
15
  async function readStdin() {
15
16
  if (process.stdin.isTTY)
16
17
  return "";
@@ -35,23 +36,25 @@ export async function resolveCaller(request) {
35
36
  },
36
37
  };
37
38
  }
38
- export async function refuseEmit(request, code, gate, message, globals, caller) {
39
+ async function logRefusal(request, gate, message, globals, caller) {
39
40
  const who = caller ?? (await resolveCaller(request).catch(() => null));
40
- if (who) {
41
+ if (!who)
42
+ return;
43
+ try {
44
+ const store = await openLocalStore();
41
45
  try {
42
- const store = await openLocalStore();
43
- try {
44
- if (logRefusals(store, [{ gate, verdict: "block", target: request.target, reason: message }], who.refusalContext) > 0) {
45
- startDetachedDrain({ nustackUrl: globals.nustackUrl });
46
- }
47
- }
48
- finally {
49
- store.close();
46
+ if (logRefusals(store, [{ gate, verdict: "block", target: request.target, reason: message }], who.refusalContext) > 0) {
47
+ startDetachedDrain({ nustackUrl: globals.nustackUrl });
50
48
  }
51
49
  }
52
- catch {
50
+ finally {
51
+ store.close();
53
52
  }
54
53
  }
54
+ catch {
55
+ }
56
+ }
57
+ function printRefusal(request, code, message) {
55
58
  if (request.json)
56
59
  printJsonError(request.command, { code, message });
57
60
  else {
@@ -59,9 +62,16 @@ export async function refuseEmit(request, code, gate, message, globals, caller)
59
62
  process.exitCode = 1;
60
63
  }
61
64
  }
62
- export async function emitRows(request, globals) {
65
+ export async function refuseEmit(request, code, gate, message, globals, caller) {
66
+ await logRefusal(request, gate, message, globals, caller);
67
+ printRefusal(request, code, message);
68
+ }
69
+ export async function writeRecordsLocally(request, globals) {
63
70
  const caller = await resolveCaller(request);
64
- const refuse = (code, gate, message) => refuseEmit(request, code, gate, message, globals, caller);
71
+ const refuse = async (code, gate, message) => {
72
+ await logRefusal(request, gate, message, globals, caller);
73
+ return { ok: false, code, message };
74
+ };
65
75
  if (!isEmitKind(request.kind))
66
76
  return refuse("VALIDATION_FAILED", "records-unknown-kind", UNKNOWN_KIND_MESSAGE);
67
77
  const kind = request.kind;
@@ -110,37 +120,44 @@ export async function emitRows(request, globals) {
110
120
  owned = error;
111
121
  written = [];
112
122
  }
113
- if (owned)
114
- return;
115
- if (caller.projectId !== null)
116
- releaseUnboundRows(store, caller.root);
117
- logRefusals(store, flags.map((flag) => ({
118
- gate: "secret-scan",
119
- verdict: "flag",
120
- target: request.target,
121
- reason: `redacted a ${flag.patternId} match at ${flag.path}`,
122
- })), caller.refusalContext);
123
+ if (!owned) {
124
+ if (caller.projectId !== null)
125
+ releaseUnboundRows(store, caller.root);
126
+ logRefusals(store, flags.map((flag) => ({
127
+ gate: "secret-scan",
128
+ verdict: "flag",
129
+ target: request.target,
130
+ reason: `redacted a ${flag.patternId} match at ${flag.path}`,
131
+ })), caller.refusalContext);
132
+ }
123
133
  }
124
134
  finally {
125
135
  store.close();
126
- if (owned)
127
- await refuse(OBLIGATION_AGENT_OWNED, "agent-owned-obligation", owned.message);
128
136
  }
129
137
  if (owned)
130
- return;
138
+ return refuse(OBLIGATION_AGENT_OWNED, "agent-owned-obligation", owned.message);
131
139
  if (flags.length > 0 || written.some((row) => row.syncState === "pending"))
132
140
  startDetachedDrain({ nustackUrl: globals.nustackUrl });
133
- const data = {
134
- written: written.length,
135
- keys: written.map((row) => row.idempotencyKey),
136
- local: written.filter((row) => row.syncState === "local").length,
137
- flags,
138
- projectId: caller.projectId,
141
+ return {
142
+ ok: true,
143
+ data: {
144
+ written: written.length,
145
+ keys: written.map((row) => row.idempotencyKey),
146
+ local: written.filter((row) => row.syncState === "local").length,
147
+ flags,
148
+ projectId: caller.projectId,
149
+ },
139
150
  };
151
+ }
152
+ export async function emitRows(request, globals) {
153
+ const outcome = await writeRecordsLocally(request, globals);
154
+ if (!outcome.ok)
155
+ return printRefusal(request, outcome.code, outcome.message);
156
+ const data = outcome.data;
140
157
  if (request.json)
141
158
  printJsonSuccess(request.command, data);
142
159
  else {
143
- for (const flag of flags)
160
+ for (const flag of data.flags)
144
161
  process.stderr.write(`${request.command}: redacted a ${flag.patternId} match at ${flag.path}\n`);
145
162
  process.stdout.write(`${request.command}: wrote ${data.written} record(s) locally; sending in the background\n`);
146
163
  }
@@ -159,12 +176,18 @@ export async function runRecordsEmit(options, globals) {
159
176
  }, globals);
160
177
  }
161
178
  async function drainAs(store, globals, isChild) {
162
- if (!isChild)
163
- return drainRecords(store, globals, { isForeground: true });
179
+ if (!isChild) {
180
+ const drained = await drainRecords(store, globals, { isForeground: true });
181
+ await drainSessionLegs(store, globals);
182
+ return drained;
183
+ }
164
184
  if (!acquireDrainLock())
165
185
  return null;
166
186
  try {
167
- return await drainRecords(store, globals, { onPass: () => refreshDrainLock() });
187
+ const drained = await drainRecords(store, globals, { onPass: () => refreshDrainLock() });
188
+ refreshDrainLock();
189
+ await drainSessionLegs(store, globals);
190
+ return drained;
168
191
  }
169
192
  finally {
170
193
  releaseDrainLock();
@@ -0,0 +1,222 @@
1
+ import { appendFileSync } from "node:fs";
2
+ import { openLocalStore } from "../lib/local_store/database.js";
3
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
4
+ import { resolveProjectId } from "../lib/project_context.js";
5
+ import { startDetachedDrain } from "../lib/records/drain.js";
6
+ import { SESSION_ID_VARIABLE, readVariable } from "../lib/records/environment.js";
7
+ import { countRefusals } from "../lib/records/refusalLog.js";
8
+ import { scanPayload } from "../lib/records/secretScan.js";
9
+ import { readBrief, renderBrief, renderHookOutput } from "../lib/sessions/brief.js";
10
+ import { parseHookInput, readAllStdin, resolveModel } from "../lib/sessions/hookInput.js";
11
+ import { shipChangedMemory } from "../lib/sessions/memoryShip.js";
12
+ import { mountFacts } from "../lib/sessions/mountFacts.js";
13
+ import { isSessionRuntime, sessionRowIdOf } from "../lib/sessions/sessionKey.js";
14
+ import { readSpool, spoolEvent } from "../lib/sessions/sessionEvents.js";
15
+ import { writeRecordsLocally } from "./records.js";
16
+ async function hookContext(runtimeFlag, raw) {
17
+ if (!isSessionRuntime(runtimeFlag))
18
+ return null;
19
+ const projectDir = readVariable("CLAUDE_PROJECT_DIR");
20
+ if (runtimeFlag === "claude_code" && !projectDir)
21
+ return null;
22
+ const input = parseHookInput(raw);
23
+ const engineRowId = readVariable(SESSION_ID_VARIABLE);
24
+ if (!engineRowId && !input.sessionId)
25
+ return null;
26
+ const root = projectDir ?? input.cwd ?? process.cwd();
27
+ const project = await resolveProjectId(root, {});
28
+ if (!project.ok)
29
+ return null;
30
+ return {
31
+ input,
32
+ runtime: runtimeFlag,
33
+ root,
34
+ projectId: project.projectId,
35
+ rowId: engineRowId ?? sessionRowIdOf(runtimeFlag, input.sessionId),
36
+ isEngineSession: engineRowId !== null,
37
+ };
38
+ }
39
+ const identityOf = (context) => ({
40
+ rowId: context.rowId,
41
+ projectId: context.projectId,
42
+ root: context.root,
43
+ runtime: context.runtime,
44
+ runtimeSessionId: context.input.sessionId,
45
+ isEngineSession: context.isEngineSession,
46
+ });
47
+ async function refusalCountOf(projectId) {
48
+ try {
49
+ const store = await openLocalStore();
50
+ try {
51
+ return countRefusals(store, { projectId });
52
+ }
53
+ finally {
54
+ store.close();
55
+ }
56
+ }
57
+ catch {
58
+ return 0;
59
+ }
60
+ }
61
+ async function runBriefHook(options, globals) {
62
+ const context = await hookContext(options.runtime, await readAllStdin());
63
+ if (!context)
64
+ return;
65
+ const facts = await mountFacts(context.root, context.runtime);
66
+ const model = resolveModel(context.input);
67
+ const event = {
68
+ sessionId: context.rowId,
69
+ ...(context.input.sessionId ? { runtimeSessionId: context.input.sessionId } : {}),
70
+ phase: "start",
71
+ occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
72
+ extension: {
73
+ runtime: context.runtime,
74
+ ...(model ? { model } : {}),
75
+ ...(facts.harnessSemver ? { harnessSemver: facts.harnessSemver } : {}),
76
+ ...(facts.harnessDigest ? { harnessDigest: facts.harnessDigest } : {}),
77
+ ...(facts.configDigest ? { configDigest: facts.configDigest } : {}),
78
+ },
79
+ isEngineSession: context.isEngineSession,
80
+ };
81
+ spoolEvent(identityOf(context), event);
82
+ const envFile = readVariable("CLAUDE_ENV_FILE");
83
+ if (envFile) {
84
+ try {
85
+ appendFileSync(envFile, `export ${SESSION_ID_VARIABLE}=${context.rowId}\n`);
86
+ }
87
+ catch {
88
+ }
89
+ }
90
+ startDetachedDrain({ nustackUrl: globals.nustackUrl });
91
+ const brief = await readBrief(context.root, globals, context.projectId, context.rowId, await refusalCountOf(context.projectId));
92
+ process.stdout.write(`${renderHookOutput(brief)}\n`);
93
+ }
94
+ export async function runSessionBrief(options, globals) {
95
+ if (options.hook) {
96
+ try {
97
+ await runBriefHook(options, globals);
98
+ }
99
+ catch {
100
+ }
101
+ process.exitCode = 0;
102
+ return;
103
+ }
104
+ const root = process.cwd();
105
+ const project = await resolveProjectId(root, options.project ? { project: options.project } : {});
106
+ if (!project.ok) {
107
+ if (options.json)
108
+ printJsonError("session brief", { code: "NO_PROJECT", message: project.message });
109
+ else {
110
+ process.stderr.write(`session brief: ${project.message}\n`);
111
+ process.exitCode = 1;
112
+ }
113
+ return;
114
+ }
115
+ const sessionId = options.session ?? readVariable(SESSION_ID_VARIABLE);
116
+ const brief = await readBrief(root, globals, project.projectId, sessionId, await refusalCountOf(project.projectId));
117
+ if (options.json)
118
+ printJsonSuccess("session brief", brief);
119
+ else
120
+ process.stdout.write(`${renderBrief(brief)}\n`);
121
+ }
122
+ async function handoverOf(flag) {
123
+ if (flag === undefined)
124
+ return null;
125
+ const raw = (flag === "-" ? await readAllStdin() : flag).trim();
126
+ if (!raw)
127
+ return null;
128
+ const scanned = scanPayload({ handover: raw });
129
+ return { text: scanned.payload.handover.slice(0, 20_000), flags: scanned.flags.length };
130
+ }
131
+ async function runEndHook(options, globals) {
132
+ const context = await hookContext(options.runtime, await readAllStdin());
133
+ if (!context)
134
+ return;
135
+ if (context.input.hookEventName === "SessionEnd") {
136
+ const event = {
137
+ sessionId: context.rowId,
138
+ ...(context.input.sessionId ? { runtimeSessionId: context.input.sessionId } : {}),
139
+ phase: "end",
140
+ occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
141
+ extension: { runtime: context.runtime },
142
+ isEngineSession: context.isEngineSession,
143
+ };
144
+ spoolEvent(identityOf(context), event, { isClosePending: true });
145
+ }
146
+ if (context.runtime === "claude_code") {
147
+ await shipChangedMemory({ repositoryRoot: context.root, projectId: context.projectId, sessionId: context.rowId }, globals, writeRecordsLocally);
148
+ }
149
+ startDetachedDrain({ nustackUrl: globals.nustackUrl });
150
+ }
151
+ export async function runSessionEnd(options, globals) {
152
+ if (options.hook) {
153
+ try {
154
+ await runEndHook(options, globals);
155
+ }
156
+ catch {
157
+ }
158
+ process.exitCode = 0;
159
+ return;
160
+ }
161
+ const refuse = (code, message) => {
162
+ if (options.json)
163
+ printJsonError("session end", { code, message });
164
+ else {
165
+ process.stderr.write(`session end: ${message}\n`);
166
+ process.exitCode = 1;
167
+ }
168
+ };
169
+ const handover = await handoverOf(options.handover);
170
+ if (!handover)
171
+ return refuse("VALIDATION_FAILED", "a handover is required: --handover <text>, or --handover - to read it from stdin");
172
+ const rowId = options.session ?? readVariable(SESSION_ID_VARIABLE);
173
+ if (!rowId)
174
+ return refuse("NO_SESSION", `no session: run it inside a session (${SESSION_ID_VARIABLE}) or pass --session <id>`);
175
+ const held = readSpool(rowId);
176
+ const runtime = held?.runtime ?? (isSessionRuntime(options.runtime) ? options.runtime : "claude_code");
177
+ let projectId = held?.projectId ?? null;
178
+ const root = held?.root ?? process.cwd();
179
+ if (!projectId) {
180
+ const project = await resolveProjectId(root, options.project ? { project: options.project } : {});
181
+ if (!project.ok)
182
+ return refuse("NO_PROJECT", project.message);
183
+ projectId = project.projectId;
184
+ }
185
+ spoolEvent({ rowId, projectId, root, runtime, runtimeSessionId: held?.runtimeSessionId ?? null, isEngineSession: held?.isEngineSession ?? false }, {
186
+ sessionId: rowId,
187
+ phase: "end",
188
+ occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
189
+ extension: { runtime, handover: handover.text },
190
+ });
191
+ startDetachedDrain({ nustackUrl: globals.nustackUrl });
192
+ const data = { sessionId: rowId, projectId, redacted: handover.flags };
193
+ if (options.json)
194
+ printJsonSuccess("session end", data);
195
+ else {
196
+ if (handover.flags > 0)
197
+ process.stderr.write(`session end: redacted ${handover.flags} secret match(es) in the handover\n`);
198
+ process.stdout.write("session end: handover recorded; sending in the background\n");
199
+ }
200
+ }
201
+ export function registerSessionCommands(program, getGlobalOptions) {
202
+ const session = program.command("session").description("What a session opens with and what it leaves the next one");
203
+ session
204
+ .command("brief")
205
+ .description("The Project's previous handover and open obligations")
206
+ .option("--hook", "SessionStart hook mode: read the hook JSON on stdin, print the brief as context, always exit 0")
207
+ .option("--runtime <runtime>", "the runtime reporting: claude_code, codex, opencode, copilot_cli or nustack (required with --hook)")
208
+ .option("--project <id>", "the Project, when the folder carries no binding marker")
209
+ .option("--session <id>", `the asking session, left out of the previous handover (default: $${SESSION_ID_VARIABLE})`)
210
+ .option("--json", "emit the versioned machine envelope")
211
+ .action(async (options) => runSessionBrief(options, getGlobalOptions()));
212
+ session
213
+ .command("end")
214
+ .description("Leave a handover for the next session; in hook mode, ship memory and close the session")
215
+ .option("--handover <text>", "the handover; - reads it from stdin")
216
+ .option("--hook", "Stop / SessionEnd hook mode: read the hook JSON on stdin, always exit 0")
217
+ .option("--runtime <runtime>", "the runtime reporting (required with --hook)")
218
+ .option("--session <id>", `the session row (default: $${SESSION_ID_VARIABLE})`)
219
+ .option("--project <id>", "the Project, when the folder carries no binding marker")
220
+ .option("--json", "emit the versioned machine envelope")
221
+ .action(async (options) => runSessionEnd(options, getGlobalOptions()));
222
+ }
@@ -0,0 +1,55 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ const PROBE_TIMEOUT_MS = 1500;
5
+ export function desktopConfigDir() {
6
+ return (process.env.NUSTACK_DESKTOP_CONFIG_DIR ||
7
+ path.join(os.homedir(), "Library", "Application Support", "co.nurix.nustack"));
8
+ }
9
+ export async function readServerRecord() {
10
+ const recordPath = path.join(desktopConfigDir(), "server.json");
11
+ try {
12
+ const parsed = JSON.parse(await readFile(recordPath, "utf8"));
13
+ if (typeof parsed.port !== "number" ||
14
+ typeof parsed.token !== "string" ||
15
+ typeof parsed.pid !== "number" ||
16
+ typeof parsed.startedAt !== "string") {
17
+ return null;
18
+ }
19
+ return parsed;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ export function isPidAlive(pid) {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ }
30
+ catch (error) {
31
+ return error.code === "EPERM";
32
+ }
33
+ }
34
+ export function isRosterProject(value) {
35
+ return (value !== null &&
36
+ typeof value === "object" &&
37
+ typeof value.path === "string" &&
38
+ typeof value.label === "string");
39
+ }
40
+ export async function probeProjects(record) {
41
+ try {
42
+ const response = await fetch(`http://127.0.0.1:${record.port}/api/projects`, {
43
+ headers: { Authorization: `Bearer ${record.token}` },
44
+ signal: AbortSignal.timeout(PROBE_TIMEOUT_MS),
45
+ });
46
+ if (!response.ok)
47
+ return null;
48
+ const body = (await response.json());
49
+ const projects = body.data?.projects;
50
+ return Array.isArray(projects) ? projects.filter(isRosterProject) : null;
51
+ }
52
+ catch {
53
+ return null;
54
+ }
55
+ }