@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.
- package/dist/commands/agent.js +1 -53
- package/dist/commands/obligationList.js +75 -0
- package/dist/commands/read.js +19 -0
- package/dist/commands/recordAliases.js +8 -0
- package/dist/commands/records.js +59 -36
- package/dist/commands/session.js +222 -0
- package/dist/lib/agent/desktopDoor.js +55 -0
- package/dist/lib/gate/bulkReadRelay.js +354 -0
- package/dist/lib/gate/hook.js +30 -3
- package/dist/lib/gate/outline.js +12 -0
- package/dist/lib/gate/settings.js +24 -1
- package/dist/lib/gate/tierOne.js +22 -0
- package/dist/lib/local_store/paths.js +12 -0
- package/dist/lib/local_store/routing.js +26 -0
- package/dist/lib/session-engine/claudeAdapter.js +1 -1
- package/dist/lib/session-engine/supervisor.js +27 -2
- package/dist/lib/sessions/brief.js +102 -0
- package/dist/lib/sessions/client.js +25 -0
- package/dist/lib/sessions/hookInput.js +38 -0
- package/dist/lib/sessions/memoryShip.js +89 -0
- package/dist/lib/sessions/mountFacts.js +42 -0
- package/dist/lib/sessions/outsideSessions.js +1 -0
- package/dist/lib/sessions/sessionEvents.js +203 -0
- package/dist/lib/sessions/sessionKey.js +16 -0
- package/dist/program.js +15 -1
- package/dist/records.schema.json +430 -0
- package/package.json +3 -3
package/dist/commands/agent.js
CHANGED
|
@@ -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
|
-
|
|
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)")
|
package/dist/commands/records.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
}
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|