@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.
@@ -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
+ }
@@ -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
+ }
@@ -19,6 +19,18 @@ export function desktopSettingsPath() {
19
19
  export function cacheDir() {
20
20
  return path.join(nustackHomeDir(), "cache");
21
21
  }
22
+ export function sessionEventsDir() {
23
+ return path.join(nustackHomeDir(), "session-events");
24
+ }
25
+ export function briefCachePath(projectId) {
26
+ return path.join(cacheDir(), "brief", `${projectId}.json`);
27
+ }
28
+ export function obligationsCachePath(projectId, planAnchor) {
29
+ return path.join(cacheDir(), "obligations", projectId, `${planAnchor}.json`);
30
+ }
31
+ export function memoryManifestPath(projectId) {
32
+ return path.join(cacheDir(), "memory-shipped", `${projectId}.json`);
33
+ }
22
34
  export function recoveryDir() {
23
35
  return path.join(nustackHomeDir(), "recovery");
24
36
  }
@@ -72,7 +72,7 @@ export class ClaudeSession {
72
72
  }
73
73
  if (!this.sessionIdSeen && frame.type === "system" && frame.subtype === "init" && typeof frame.session_id === "string") {
74
74
  this.sessionIdSeen = true;
75
- this.options.onSessionId?.(frame.session_id);
75
+ this.options.onSessionId?.(frame.session_id, typeof frame.model === "string" && frame.model ? frame.model : undefined);
76
76
  }
77
77
  this.options.onEvent(frame);
78
78
  }
@@ -4,6 +4,8 @@ import { ClaudeSession } from "./claudeAdapter.js";
4
4
  import { FRAME_VERSION, parseInboundFrame, serializeFrame, } from "./frames.js";
5
5
  import { inspectSession, reclaimSession } from "./prune.js";
6
6
  import { sessionRegistryPath, readSessionRegistry, updateSessionRegistry } from "./registry.js";
7
+ import { SESSION_ID_VARIABLE } from "../records/environment.js";
8
+ import { mountFacts } from "../sessions/mountFacts.js";
7
9
  import { createSessionWorktree } from "./worktree.js";
8
10
  import { readProjectContract } from "../project_contract.js";
9
11
  function defaultProbeClaude(env) {
@@ -164,11 +166,18 @@ export class SessionSupervisor {
164
166
  };
165
167
  if (record.projectId)
166
168
  await this.mountInto(sessionId, worktree.worktreePath, record.projectId);
169
+ const facts = record.projectId ? await mountFacts(worktree.worktreePath, "claude_code") : null;
170
+ const extension = {
171
+ runtime: "claude_code",
172
+ ...(facts?.harnessSemver ? { harnessSemver: facts.harnessSemver } : {}),
173
+ ...(facts?.harnessDigest ? { harnessDigest: facts.harnessDigest } : {}),
174
+ ...(facts?.configDigest ? { configDigest: facts.configDigest } : {}),
175
+ };
167
176
  const adapterOptions = {
168
177
  cwd: worktree.worktreePath,
169
178
  permissionMode,
170
179
  resumeSessionId,
171
- env: this.env,
180
+ env: { ...this.env, [SESSION_ID_VARIABLE]: sessionId },
172
181
  onEvent: (event) => {
173
182
  this.emit({ v: FRAME_VERSION, type: "session.event", sessionId, event });
174
183
  if (typeof event === "object" && event !== null && event.type === "result") {
@@ -188,12 +197,27 @@ export class SessionSupervisor {
188
197
  });
189
198
  },
190
199
  onProtocolError: (code, message) => this.error(sessionId, code, message),
191
- onSessionId: (vendorSessionId) => {
200
+ onSessionId: (vendorSessionId, model) => {
192
201
  record.providerSessionId = vendorSessionId;
193
202
  void this.updateRegistry((sessions) => sessions.map((row) => (row.sessionId === sessionId ? { ...row, providerSessionId: vendorSessionId } : row)));
194
203
  const client = this.options.sessions;
195
204
  if (client && record.projectId) {
196
205
  void client.setVendorSessionId(record.projectId, sessionId, vendorSessionId).catch(() => undefined);
206
+ void client
207
+ .postSessionEvents?.(record.projectId, {
208
+ events: [
209
+ {
210
+ sessionId,
211
+ runtimeSessionId: vendorSessionId,
212
+ phase: "start",
213
+ occurredAt: new Date().toISOString().replace(/\.\d{3}Z$/, "Z"),
214
+ extension: { ...extension, ...(model ? { model } : {}) },
215
+ isEngineSession: true,
216
+ },
217
+ ],
218
+ labelRows: [],
219
+ })
220
+ .catch(() => undefined);
197
221
  }
198
222
  },
199
223
  onExit: (code) => {
@@ -227,6 +251,7 @@ export class SessionSupervisor {
227
251
  vendor: "claude_code",
228
252
  renderer: "claude_transcript",
229
253
  status: "created",
254
+ ...extension,
230
255
  });
231
256
  }
232
257
  catch {
@@ -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
+ }