@nurix/nustack 0.23.17 → 0.23.18

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,70 @@
1
+ import os from "node:os";
2
+ import { printJsonSuccess } from "../lib/json_output.js";
3
+ import { decideFromFacts, LANE_SENTENCES, readLaneFacts, SUBAGENT_MODEL_OVERRIDES_ROUTE } from "../lib/lane/decide.js";
4
+ import { haikuSentence } from "../lib/lane/mainModel.js";
5
+ import { installLane, LANE_AGENT_ROUTE, laneHooksInstalled, removeLane } from "../lib/lane/install.js";
6
+ function enableBlockersOf(facts) {
7
+ const blockers = [];
8
+ if (facts.mainModel.haikuClass && facts.mainModel.scope === "user") {
9
+ blockers.push({ code: "haiku-main-model", sentence: haikuSentence(facts.mainModel) });
10
+ }
11
+ if (SUBAGENT_MODEL_OVERRIDES_ROUTE && facts.subagentModel !== null)
12
+ blockers.push({ code: "subagent-model-override", sentence: LANE_SENTENCES["subagent-model-override"] });
13
+ return blockers;
14
+ }
15
+ function factsFor(cwd, interactive) {
16
+ return readLaneFacts({ env: process.env, cwd: cwd ?? process.cwd(), interactive, home: os.homedir() });
17
+ }
18
+ export function runLaneEnv(options) {
19
+ const decision = decideFromFacts(factsFor(options.cwd, options.nonInteractive !== true));
20
+ if (decision.state === "on") {
21
+ process.stdout.write(`ANTHROPIC_BASE_URL=${decision.baseUrl}\n`);
22
+ return;
23
+ }
24
+ if (decision.reason !== "unreleased" && decision.reason !== "switch-off") {
25
+ process.stderr.write(`NuStack lane off: ${decision.sentence}\n`);
26
+ }
27
+ }
28
+ export async function readLaneStatus(cwd, interactive) {
29
+ const facts = factsFor(cwd, interactive);
30
+ const decision = decideFromFacts(facts);
31
+ return {
32
+ released: facts.released,
33
+ switch: facts.switchOn,
34
+ proxy: facts.proxy.record === null ? null : { live: facts.proxy.live, port: facts.proxy.record.port },
35
+ credential: facts.credential.state === "on" ? facts.credential.credential : null,
36
+ mainModel: { value: facts.mainModel.value, source: facts.mainModel.source, haikuClass: facts.mainModel.haikuClass },
37
+ upstream: facts.upstream.state === "ok" ? facts.upstream.url : null,
38
+ routeMode: LANE_AGENT_ROUTE,
39
+ hooks: await laneHooksInstalled(),
40
+ decision: decision.state === "on"
41
+ ? { state: "on", reason: null, sentence: null }
42
+ : { state: "off", reason: decision.reason, sentence: decision.sentence },
43
+ enableBlockers: enableBlockersOf(facts),
44
+ };
45
+ }
46
+ export async function runLaneStatus(options) {
47
+ const status = await readLaneStatus(options.cwd, false);
48
+ if (options.json) {
49
+ printJsonSuccess("lane status", status);
50
+ return;
51
+ }
52
+ const lines = [
53
+ `Released: ${status.released ? "yes" : "no"} · Switch: ${status.switch ? "on" : "off"}`,
54
+ `Proxy: ${status.proxy === null ? "no record" : `${status.proxy.live ? "running" : "stopped"} on 127.0.0.1:${status.proxy.port}`}`,
55
+ `Credential: ${status.credential ?? "none the lane carries"} · Upstream: ${status.upstream ?? "refused"}`,
56
+ `Main model: ${status.mainModel.value ?? "Claude Code's default"}${status.mainModel.source ? ` (from ${status.mainModel.source})` : ""}`,
57
+ `Route mode: ${status.routeMode} · Hooks: Agent ${status.hooks.agent ? "installed" : "not installed"}, SubagentStop ${status.hooks.subagentStop ? "installed" : "not installed"}`,
58
+ `Decision: ${status.decision.state}${status.decision.reason ? ` (${status.decision.reason}): ${status.decision.sentence}` : ""}`,
59
+ ...status.enableBlockers.map((blocker) => `Blocks enabling: ${blocker.sentence}`),
60
+ ];
61
+ console.log(lines.join("\n"));
62
+ }
63
+ export async function runLaneInstall() {
64
+ const { changed } = await installLane();
65
+ console.log(changed ? "Installed the light-model lane's Agent and SubagentStop hooks." : "Installed the light-model lane's Agent and SubagentStop hooks (already installed — no change).");
66
+ }
67
+ export async function runLaneRemove() {
68
+ const { removed } = await removeLane();
69
+ console.log(removed ? "Removed the light-model lane's hooks." : "Removed the light-model lane's hooks (already absent — no change).");
70
+ }
@@ -17,7 +17,7 @@ function sectionOf(value) {
17
17
  export function readDesktopSettings() {
18
18
  const defaults = {
19
19
  placement: { memory: DEFAULT_PLACEMENT, securityFindings: DEFAULT_PLACEMENT },
20
- routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null },
20
+ routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null, lane: false },
21
21
  };
22
22
  let parsed;
23
23
  try {
@@ -39,6 +39,7 @@ export function readDesktopSettings() {
39
39
  localReader: routing.localReader === true,
40
40
  gateThresholdTokens: boundedIntegerOf(routing.gateThresholdTokens, GATE_THRESHOLD_TOKENS_MIN, GATE_THRESHOLD_TOKENS_MAX),
41
41
  digestBudgetMs: boundedIntegerOf(routing.digestBudgetMs, DIGEST_BUDGET_MS_MIN, DIGEST_BUDGET_MS_MAX),
42
+ lane: routing.lane === true,
42
43
  },
43
44
  };
44
45
  }
@@ -0,0 +1,130 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { readDesktopSettings } from "../desktop_settings.js";
5
+ import { readSettingsLayers } from "../lane/credential.js";
6
+ import { LANE_AGENT_NAME, LANE_AGENT_ROUTE, LANE_MODEL_ID } from "../lane/install.js";
7
+ import { isUnderPrefix, readProxy } from "../lane/proxyRecord.js";
8
+ import { GateInputError } from "./readGate.js";
9
+ export const READ_ONLY_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead"]);
10
+ export const PASS_ON_AGENT_PERMISSION_RULE = true;
11
+ function parseAgentCall(text) {
12
+ let payload;
13
+ try {
14
+ payload = JSON.parse(text);
15
+ }
16
+ catch {
17
+ throw new GateInputError("stdin is not JSON");
18
+ }
19
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload))
20
+ throw new GateInputError("stdin is not a JSON object");
21
+ const fields = payload;
22
+ if (typeof fields.session_id !== "string" || fields.session_id === "")
23
+ throw new GateInputError("session_id is missing");
24
+ if (typeof fields.cwd !== "string" || fields.cwd === "")
25
+ throw new GateInputError("cwd is missing");
26
+ const toolInput = fields.tool_input;
27
+ if (toolInput === null || typeof toolInput !== "object" || Array.isArray(toolInput))
28
+ throw new GateInputError("tool_input is missing");
29
+ return { sessionId: fields.session_id, cwd: fields.cwd, toolInput: toolInput };
30
+ }
31
+ function frontmatterOf(content) {
32
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content);
33
+ return match ? match[1] : null;
34
+ }
35
+ export function declaredTools(frontmatter) {
36
+ const lines = frontmatter.split(/\r?\n/);
37
+ const index = lines.findIndex((line) => /^tools\s*:/.test(line));
38
+ if (index === -1)
39
+ return null;
40
+ const inline = lines[index].replace(/^tools\s*:/, "").trim();
41
+ let items;
42
+ if (inline !== "") {
43
+ items = inline.replace(/^\[|\]$/g, "").split(",");
44
+ }
45
+ else {
46
+ items = [];
47
+ for (const line of lines.slice(index + 1)) {
48
+ const item = /^\s*-\s*(.+)$/.exec(line);
49
+ if (!item)
50
+ break;
51
+ items.push(item[1]);
52
+ }
53
+ }
54
+ const tools = items.map((item) => item.trim().replace(/^["']|["']$/g, "")).filter((item) => item !== "");
55
+ return tools.length === 0 ? null : tools;
56
+ }
57
+ function nameOf(frontmatter) {
58
+ const match = /^name\s*:\s*(.+)$/m.exec(frontmatter);
59
+ return match ? match[1].trim().replace(/^["']|["']$/g, "") : null;
60
+ }
61
+ export function isReadOnlyDefinition(type, cwd, home) {
62
+ for (const folder of [path.join(cwd, ".claude", "agents"), path.join(home, ".claude", "agents")]) {
63
+ let entries;
64
+ try {
65
+ entries = readdirSync(folder).filter((entry) => entry.endsWith(".md"));
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ for (const entry of entries) {
71
+ let content;
72
+ try {
73
+ content = readFileSync(path.join(folder, entry), "utf8");
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ const frontmatter = frontmatterOf(content);
79
+ if (frontmatter === null || nameOf(frontmatter) !== type)
80
+ continue;
81
+ const tools = declaredTools(frontmatter);
82
+ return tools !== null && tools.every((tool) => READ_ONLY_TOOLS.has(tool));
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ export function hasAgentPermissionRule(layers) {
88
+ return layers.some((layer) => ["allow", "ask", "deny"].some((kind) => {
89
+ const rules = layer.permissions?.[kind];
90
+ return Array.isArray(rules) && rules.some((rule) => typeof rule === "string" && /^Agent(\(|$)/.test(rule.trim()));
91
+ }));
92
+ }
93
+ async function isLaneLatched(sessionId) {
94
+ const [{ openLocalStoreReadOnly }, { isLatched }] = await Promise.all([import("../local_store/database.js"), import("../local_store/routing.js")]);
95
+ const store = await openLocalStoreReadOnly();
96
+ if (store === null)
97
+ return false;
98
+ try {
99
+ return isLatched(store, sessionId, "lane.subagent");
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ finally {
105
+ store.close();
106
+ }
107
+ }
108
+ export async function answerAgentCall(text, env, home = os.homedir()) {
109
+ const call = parseAgentCall(text);
110
+ const baseUrl = env.ANTHROPIC_BASE_URL ?? "";
111
+ const proxy = readProxy();
112
+ if (!proxy.live || !isUnderPrefix(baseUrl, proxy.prefix))
113
+ return null;
114
+ if (!readDesktopSettings().routing.lane)
115
+ return null;
116
+ const model = call.toolInput.model;
117
+ if (model !== undefined && model !== null && model !== "haiku")
118
+ return null;
119
+ const type = call.toolInput.subagent_type;
120
+ if (typeof type !== "string")
121
+ return null;
122
+ if (type !== "Explore" && !isReadOnlyDefinition(type, call.cwd, home))
123
+ return null;
124
+ if (PASS_ON_AGENT_PERMISSION_RULE && hasAgentPermissionRule(readSettingsLayers({ env, cwd: call.cwd, home })))
125
+ return null;
126
+ if (await isLaneLatched(call.sessionId))
127
+ return null;
128
+ const updatedInput = LANE_AGENT_ROUTE === "model" ? { ...call.toolInput, model: LANE_MODEL_ID } : { ...call.toolInput, subagent_type: LANE_AGENT_NAME };
129
+ return `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput } })}\n`;
130
+ }
@@ -0,0 +1,121 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const GROUNDING_MAX_PATHS = 50;
4
+ export const GROUNDING_MAX_IDENTIFIERS = 100;
5
+ export const GROUNDING_MAX_FILE_BYTES = 2 * 1024 * 1024;
6
+ const FILE_EXTENSIONS = new Set("ts tsx js jsx mjs cjs json md mdx rs py go toml yaml yml css scss html sh txt lock sql swift kt java c h cpp hpp rb php vue svelte astro proto graphql xml ini env cfg conf csv".split(" "));
7
+ const LINE_SUFFIX = String.raw `(?::(\d+)(?:-(\d+))?)?`;
8
+ const SLASHED_PATH = new RegExp(String.raw `(?<![\w./:-])((?:[\w.@-]+/)+[\w.@-]+\.[A-Za-z0-9]+)${LINE_SUFFIX}`, "g");
9
+ const BACKTICKED = /`([^`\n]+)`/g;
10
+ const BACKTICKED_FILE = new RegExp(String.raw `^([\w.@-]+\.([A-Za-z0-9]+))${LINE_SUFFIX}$`);
11
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/;
12
+ function lineOf(text) {
13
+ return text === undefined ? null : Number.parseInt(text, 10);
14
+ }
15
+ function pushPath(paths, seen, claim) {
16
+ const key = `${claim.path}:${claim.startLine ?? ""}-${claim.endLine ?? ""}`;
17
+ if (seen.has(key) || paths.length >= GROUNDING_MAX_PATHS)
18
+ return;
19
+ seen.add(key);
20
+ paths.push(claim);
21
+ }
22
+ export function extractClaims(text) {
23
+ const paths = [];
24
+ const identifiers = [];
25
+ const seenPaths = new Set();
26
+ const seenIdentifiers = new Set();
27
+ for (const match of text.matchAll(SLASHED_PATH)) {
28
+ const before = text.slice(Math.max(0, (match.index ?? 0) - 3), match.index ?? 0);
29
+ if (before.endsWith("://"))
30
+ continue;
31
+ pushPath(paths, seenPaths, { path: match[1], startLine: lineOf(match[2]), endLine: lineOf(match[3]) });
32
+ }
33
+ for (const match of text.matchAll(BACKTICKED)) {
34
+ const token = match[1].trim();
35
+ if (token.includes("/"))
36
+ continue;
37
+ const file = BACKTICKED_FILE.exec(token);
38
+ if (file && FILE_EXTENSIONS.has(file[2].toLowerCase())) {
39
+ pushPath(paths, seenPaths, { path: file[1], startLine: lineOf(file[3]), endLine: lineOf(file[4]) });
40
+ continue;
41
+ }
42
+ if (token.length < 3 || !IDENTIFIER.test(token) || seenIdentifiers.has(token))
43
+ continue;
44
+ if (identifiers.length >= GROUNDING_MAX_IDENTIFIERS)
45
+ continue;
46
+ seenIdentifiers.add(token);
47
+ identifiers.push(token);
48
+ }
49
+ return { paths, identifiers };
50
+ }
51
+ function sizeOf(file) {
52
+ try {
53
+ const stats = statSync(file);
54
+ return stats.isFile() ? stats.size : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ function readSmall(file) {
61
+ const size = sizeOf(file);
62
+ if (size === null || size > GROUNDING_MAX_FILE_BYTES)
63
+ return null;
64
+ try {
65
+ return readFileSync(file, "utf8");
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function lineCountOf(content) {
72
+ if (content === "")
73
+ return 0;
74
+ const lines = content.split("\n").length;
75
+ return content.endsWith("\n") ? lines - 1 : lines;
76
+ }
77
+ function resolveClaim(claim, cwd, readFiles) {
78
+ const direct = path.isAbsolute(claim.path) ? claim.path : path.resolve(cwd, claim.path);
79
+ if (sizeOf(direct) !== null)
80
+ return direct;
81
+ if (!claim.path.includes("/")) {
82
+ const byName = readFiles.find((file) => path.basename(file) === claim.path && sizeOf(file) !== null);
83
+ if (byName)
84
+ return byName;
85
+ }
86
+ return null;
87
+ }
88
+ export function verifyClaims(claims, cwd, readFiles) {
89
+ const citedFiles = [];
90
+ const fail = (failure) => ({ result: "fail", failure, citedFiles });
91
+ for (const claim of claims.paths) {
92
+ const file = resolveClaim(claim, cwd, readFiles);
93
+ if (file === null)
94
+ return fail(`no file ${claim.path}`);
95
+ if (!citedFiles.includes(file))
96
+ citedFiles.push(file);
97
+ const lastLine = Math.max(claim.startLine ?? 0, claim.endLine ?? 0);
98
+ if (lastLine === 0)
99
+ continue;
100
+ const content = readSmall(file);
101
+ if (content === null)
102
+ continue;
103
+ if (lastLine > lineCountOf(content))
104
+ return fail(`${claim.path}:${lastLine} is past the end of the file`);
105
+ }
106
+ const searchable = [...citedFiles, ...readFiles.map((file) => path.resolve(cwd, file)).filter((file) => !citedFiles.includes(file))];
107
+ const contents = new Map();
108
+ const contentOf = (file) => {
109
+ if (!contents.has(file))
110
+ contents.set(file, readSmall(file));
111
+ return contents.get(file) ?? null;
112
+ };
113
+ const hasSearchable = searchable.some((file) => contentOf(file) !== null);
114
+ if (hasSearchable) {
115
+ for (const identifier of claims.identifiers) {
116
+ if (!searchable.some((file) => contentOf(file)?.includes(identifier)))
117
+ return fail(`\`${identifier}\` appears in no cited or read file`);
118
+ }
119
+ }
120
+ return { result: "pass", failure: null, citedFiles };
121
+ }
@@ -56,6 +56,25 @@ function denialOf(reason) {
56
56
  },
57
57
  })}\n`;
58
58
  }
59
+ const LANE_URL_HEAD = "http://127.0.0.1:";
60
+ export function isLaneSessionEnv(env) {
61
+ const baseUrl = env.ANTHROPIC_BASE_URL;
62
+ return typeof baseUrl === "string" && baseUrl.startsWith(LANE_URL_HEAD) && baseUrl.includes("/lane/");
63
+ }
64
+ async function runLaneHook(subcommand, io) {
65
+ const text = await readStdin(io.stdin);
66
+ if (!isLaneSessionEnv(io.env))
67
+ return;
68
+ if (subcommand === "agent") {
69
+ const { answerAgentCall } = await import("./agentHook.js");
70
+ const answer = await answerAgentCall(text, io.env);
71
+ if (answer !== null)
72
+ io.stdout.write(answer);
73
+ return;
74
+ }
75
+ const { answerSubagentStop } = await import("./subagentStop.js");
76
+ await answerSubagentStop(text, io.env);
77
+ }
59
78
  function deniedPathOf(subcommand, call) {
60
79
  const written = subcommand === "read"
61
80
  ? call.toolInput.file_path
@@ -89,6 +108,10 @@ export async function runGateHook(subcommand, io = { stdin: process.stdin, stdou
89
108
  return;
90
109
  }
91
110
  try {
111
+ if (subcommand === "agent" || subcommand === "subagent-stop") {
112
+ await runLaneHook(subcommand, io);
113
+ return;
114
+ }
92
115
  const call = parseCall(await readStdin(io.stdin), startedAt);
93
116
  let answer;
94
117
  if (subcommand === "read")
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { isUnderPrefix, readProxy } from "../lane/proxyRecord.js";
4
+ import { tokensOfLength } from "./estimate.js";
5
+ import { extractClaims, verifyClaims } from "./grounding.js";
6
+ import { GateInputError } from "./readGate.js";
7
+ function parseStopCall(text) {
8
+ let payload;
9
+ try {
10
+ payload = JSON.parse(text);
11
+ }
12
+ catch {
13
+ throw new GateInputError("stdin is not JSON");
14
+ }
15
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload))
16
+ throw new GateInputError("stdin is not a JSON object");
17
+ const fields = payload;
18
+ if (typeof fields.session_id !== "string" || fields.session_id === "")
19
+ throw new GateInputError("session_id is missing");
20
+ const transcript = fields.agent_transcript_path;
21
+ const agentType = fields.agent_type;
22
+ return {
23
+ sessionId: fields.session_id,
24
+ cwd: typeof fields.cwd === "string" && fields.cwd !== "" ? fields.cwd : process.cwd(),
25
+ transcriptPath: typeof transcript === "string" && transcript !== "" ? transcript : null,
26
+ agentType: typeof agentType === "string" && agentType !== "" ? agentType : "subagent",
27
+ };
28
+ }
29
+ function isRoutedModel(model) {
30
+ return typeof model === "string" && model !== "" && !model.startsWith("claude") && model !== "<synthetic>";
31
+ }
32
+ function numberOf(value) {
33
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
34
+ }
35
+ export function readSubagentRun(content) {
36
+ let routed = false;
37
+ let finalText = "";
38
+ let localTokens = 0;
39
+ let first = null;
40
+ let last = null;
41
+ let agentType = null;
42
+ const readFiles = [];
43
+ for (const line of content.split("\n")) {
44
+ if (line.trim() === "")
45
+ continue;
46
+ let entry;
47
+ try {
48
+ entry = JSON.parse(line);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ const stamp = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
54
+ if (!Number.isNaN(stamp)) {
55
+ first ??= stamp;
56
+ last = stamp;
57
+ }
58
+ if (agentType === null && typeof entry.agentType === "string")
59
+ agentType = entry.agentType;
60
+ if (entry.type !== "assistant")
61
+ continue;
62
+ const message = entry.message;
63
+ if (!message)
64
+ continue;
65
+ if (isRoutedModel(message.model)) {
66
+ routed = true;
67
+ const usage = message.usage;
68
+ localTokens += numberOf(usage?.input_tokens) + numberOf(usage?.output_tokens);
69
+ }
70
+ const blocks = Array.isArray(message.content) ? message.content : [];
71
+ const texts = blocks.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
72
+ if (texts.length > 0)
73
+ finalText = texts.join("\n");
74
+ for (const block of blocks) {
75
+ if (block?.type !== "tool_use" || block.name !== "Read")
76
+ continue;
77
+ const filePath = block.input?.file_path;
78
+ if (typeof filePath === "string" && !readFiles.includes(filePath))
79
+ readFiles.push(filePath);
80
+ }
81
+ }
82
+ return { routed, finalText, readFiles, localTokens, latencyMs: first !== null && last !== null ? Math.max(0, last - first) : 0, agentType };
83
+ }
84
+ export async function answerSubagentStop(text, env) {
85
+ const call = parseStopCall(text);
86
+ const proxy = readProxy();
87
+ if (!proxy.live || !isUnderPrefix(env.ANTHROPIC_BASE_URL ?? "", proxy.prefix))
88
+ return;
89
+ if (call.transcriptPath === null)
90
+ return;
91
+ const run = readSubagentRun(readFileSync(call.transcriptPath, "utf8"));
92
+ if (!run.routed)
93
+ return;
94
+ const verdict = verifyClaims(extractClaims(run.finalText), call.cwd, run.readFiles);
95
+ const [{ openLocalStore, withTransaction }, routing] = await Promise.all([import("../local_store/database.js"), import("../local_store/routing.js")]);
96
+ const store = await openLocalStore();
97
+ try {
98
+ const now = Date.now();
99
+ withTransaction(store, () => {
100
+ routing.insertLabelRow(store, {
101
+ id: `lr_${randomUUID()}`,
102
+ session_id: call.sessionId,
103
+ agent_type: call.agentType !== "subagent" ? call.agentType : (run.agentType ?? call.agentType),
104
+ path: verdict.citedFiles[0] ?? null,
105
+ estimated_tokens: run.localTokens,
106
+ returned_tokens: tokensOfLength(run.finalText.length),
107
+ handler: "local_model",
108
+ check_result: verdict.result,
109
+ bypass_used: 0,
110
+ latency_ms: run.latencyMs,
111
+ tokens_kept_out: 0,
112
+ created_at: now,
113
+ });
114
+ if (verdict.result === "fail")
115
+ routing.recordRoutingMiss(store, call.sessionId, "lane.subagent", "failure", now);
116
+ });
117
+ }
118
+ finally {
119
+ store.close();
120
+ }
121
+ }
@@ -1 +1 @@
1
- export const HOOK_SUBCOMMANDS = ["read", "bash"];
1
+ export const HOOK_SUBCOMMANDS = ["read", "bash", "agent", "subagent-stop"];
@@ -0,0 +1,101 @@
1
+ import { readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ export const CLAUDE_MANAGED_SETTINGS_PATH = "/Library/Application Support/ClaudeCode/managed-settings.json";
5
+ function objectOf(value) {
6
+ return value !== null && typeof value === "object" && !Array.isArray(value) ? value : null;
7
+ }
8
+ function readJsonObject(file) {
9
+ try {
10
+ return objectOf(JSON.parse(readFileSync(file, "utf8")));
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ function layerOf(scope, label, file) {
17
+ const settings = readJsonObject(file);
18
+ if (settings === null)
19
+ return null;
20
+ const env = {};
21
+ for (const [key, value] of Object.entries(objectOf(settings.env) ?? {})) {
22
+ if (typeof value === "string")
23
+ env[key] = value;
24
+ else if (typeof value === "number" || typeof value === "boolean")
25
+ env[key] = String(value);
26
+ }
27
+ return {
28
+ scope,
29
+ label,
30
+ env,
31
+ model: typeof settings.model === "string" && settings.model !== "" ? settings.model : null,
32
+ apiKeyHelper: typeof settings.apiKeyHelper === "string" && settings.apiKeyHelper !== "" ? settings.apiKeyHelper : null,
33
+ permissions: objectOf(settings.permissions),
34
+ };
35
+ }
36
+ function homeOf(context) {
37
+ return context.home ?? os.homedir();
38
+ }
39
+ export function readSettingsLayers(context) {
40
+ const home = homeOf(context);
41
+ const candidates = [
42
+ ["user", "~/.claude/settings.json", path.join(home, ".claude", "settings.json")],
43
+ ["project", ".claude/settings.json", path.join(context.cwd, ".claude", "settings.json")],
44
+ ["local", ".claude/settings.local.json", path.join(context.cwd, ".claude", "settings.local.json")],
45
+ ["managed", "managed settings", context.managedSettingsPath ?? CLAUDE_MANAGED_SETTINGS_PATH],
46
+ ];
47
+ const layers = [];
48
+ const seen = new Set();
49
+ for (const [scope, label, file] of candidates) {
50
+ const resolved = path.resolve(file);
51
+ if (seen.has(resolved))
52
+ continue;
53
+ seen.add(resolved);
54
+ const layer = layerOf(scope, label, resolved);
55
+ if (layer !== null)
56
+ layers.push(layer);
57
+ }
58
+ return layers;
59
+ }
60
+ export function effectiveEnv(env, layers) {
61
+ const merged = {};
62
+ for (const [key, value] of Object.entries(env))
63
+ if (typeof value === "string")
64
+ merged[key] = value;
65
+ for (const layer of layers)
66
+ Object.assign(merged, layer.env);
67
+ return merged;
68
+ }
69
+ function isSet(value) {
70
+ return value !== undefined && value.trim() !== "";
71
+ }
72
+ function isTruthy(value) {
73
+ return isSet(value) && !["0", "false", "no", "off"].includes(value.trim().toLowerCase());
74
+ }
75
+ export function isKeyApproved(key, home) {
76
+ const config = readJsonObject(path.join(home, ".claude.json"));
77
+ const approved = objectOf(config?.customApiKeyResponses)?.approved;
78
+ if (!Array.isArray(approved))
79
+ return false;
80
+ return approved.includes(key.slice(-20));
81
+ }
82
+ export function detectCredential(effective, layers, options) {
83
+ if (isTruthy(effective.CLAUDE_CODE_USE_BEDROCK) || isTruthy(effective.CLAUDE_CODE_USE_VERTEX)) {
84
+ return { state: "off", reason: "bedrock-or-vertex" };
85
+ }
86
+ const authToken = effective.ANTHROPIC_AUTH_TOKEN;
87
+ if (isSet(effective.CLAUDE_CODE_OAUTH_TOKEN) || (isSet(authToken) && authToken.startsWith("sk-ant-oat"))) {
88
+ return { state: "off", reason: "subscription-credential" };
89
+ }
90
+ if (isSet(authToken))
91
+ return { state: "on", credential: "gateway" };
92
+ const apiKey = effective.ANTHROPIC_API_KEY;
93
+ if (isSet(apiKey)) {
94
+ if (options.interactive && !isKeyApproved(apiKey, options.home))
95
+ return { state: "off", reason: "key-not-approved" };
96
+ return { state: "on", credential: "api-key" };
97
+ }
98
+ if (layers.some((layer) => layer.apiKeyHelper !== null))
99
+ return { state: "on", credential: "api-key-helper" };
100
+ return { state: "off", reason: "no-api-credential" };
101
+ }
@@ -0,0 +1,105 @@
1
+ import os from "node:os";
2
+ import { readDesktopSettings } from "../desktop_settings.js";
3
+ import { detectCredential, effectiveEnv, readSettingsLayers } from "./credential.js";
4
+ import { effectiveMainModel, haikuSentence } from "./mainModel.js";
5
+ import { readProxy } from "./proxyRecord.js";
6
+ import { isLaneReleased } from "./release.js";
7
+ export const DEFAULT_UPSTREAM = "https://api.anthropic.com";
8
+ export const SUBAGENT_MODEL_ENV = "CLAUDE_CODE_SUBAGENT_MODEL";
9
+ export const SUBAGENT_MODEL_OVERRIDES_ROUTE = false;
10
+ export const LANE_SENTENCES = {
11
+ unreleased: "The light-model lane is not released in this build.",
12
+ "switch-off": "The lane is switched off in NuStack's Routing & agents settings.",
13
+ "proxy-down": "NuStack's lane proxy is not running; open the NuStack desktop app.",
14
+ "bedrock-or-vertex": "This session runs on Bedrock or Vertex, which the lane does not carry.",
15
+ "subscription-credential": "This session signs in with a Claude subscription, which the lane never carries.",
16
+ "key-not-approved": "ANTHROPIC_API_KEY is set but not approved in Claude Code, so this terminal session would use your claude.ai login; approve the key in Claude Code first.",
17
+ "no-api-credential": "This session has no API key or gateway credential; a Claude subscription session gets nothing from the lane.",
18
+ "upstream-not-https": "ANTHROPIC_BASE_URL must be https: (or http: on loopback) for the lane to forward to it.",
19
+ "base-url-in-settings": "ANTHROPIC_BASE_URL is set in a Claude Code settings file, which would override the lane's.",
20
+ "haiku-main-model": "",
21
+ "subagent-model-override": `${SUBAGENT_MODEL_ENV} is set, and Claude Code lets it override the lane's sub-agent model.`,
22
+ };
23
+ export function base64Url(text) {
24
+ return Buffer.from(text, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
25
+ }
26
+ export function laneBaseUrl(port, token, upstream) {
27
+ const prefix = `http://127.0.0.1:${port}/lane/${token}`;
28
+ return upstream === null || upstream === DEFAULT_UPSTREAM ? prefix : `${prefix}/up/${base64Url(upstream)}`;
29
+ }
30
+ const LOOPBACK_HOSTS = new Set(["127.0.0.1", "localhost", "[::1]", "::1"]);
31
+ export function isUpstreamAllowed(url) {
32
+ let parsed;
33
+ try {
34
+ parsed = new URL(url);
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ if (parsed.protocol === "https:")
40
+ return true;
41
+ return parsed.protocol === "http:" && LOOPBACK_HOSTS.has(parsed.hostname);
42
+ }
43
+ function upstreamOf(env, layers) {
44
+ if (layers.some((layer) => layer.env.ANTHROPIC_BASE_URL !== undefined))
45
+ return { state: "off", reason: "base-url-in-settings" };
46
+ const preset = env.ANTHROPIC_BASE_URL?.trim();
47
+ if (!preset)
48
+ return { state: "ok", url: DEFAULT_UPSTREAM, preset: false };
49
+ if (!isUpstreamAllowed(preset))
50
+ return { state: "off", reason: "upstream-not-https" };
51
+ return { state: "ok", url: preset.replace(/\/+$/, ""), preset: true };
52
+ }
53
+ function subagentModelOf(env, layers) {
54
+ for (const layer of [...layers].reverse()) {
55
+ const value = layer.env[SUBAGENT_MODEL_ENV]?.trim();
56
+ if (value)
57
+ return { value, source: layer.label };
58
+ }
59
+ const fromEnv = env[SUBAGENT_MODEL_ENV]?.trim();
60
+ return fromEnv ? { value: fromEnv, source: SUBAGENT_MODEL_ENV } : null;
61
+ }
62
+ export function readLaneFacts(input) {
63
+ const home = input.home ?? os.homedir();
64
+ const layers = readSettingsLayers({ ...input, home });
65
+ const effective = effectiveEnv(input.env, layers);
66
+ return {
67
+ released: isLaneReleased(input.env),
68
+ switchOn: readDesktopSettings().routing.lane,
69
+ proxy: readProxy(),
70
+ layers,
71
+ effective,
72
+ credential: detectCredential(effective, layers, { interactive: input.interactive, home }),
73
+ upstream: upstreamOf(input.env, layers),
74
+ mainModel: effectiveMainModel(effective, layers),
75
+ subagentModel: subagentModelOf(input.env, layers),
76
+ };
77
+ }
78
+ function off(reason, sentence = LANE_SENTENCES[reason]) {
79
+ return { state: "off", reason, sentence };
80
+ }
81
+ export function decideFromFacts(facts) {
82
+ if (!facts.released)
83
+ return off("unreleased");
84
+ if (!facts.switchOn)
85
+ return off("switch-off");
86
+ if (!facts.proxy.live || !facts.proxy.record.accepting)
87
+ return off("proxy-down");
88
+ if (facts.credential.state === "off")
89
+ return off(facts.credential.reason);
90
+ if (facts.upstream.state === "off")
91
+ return off(facts.upstream.reason);
92
+ if (facts.mainModel.haikuClass)
93
+ return off("haiku-main-model", haikuSentence(facts.mainModel));
94
+ if (SUBAGENT_MODEL_OVERRIDES_ROUTE && facts.subagentModel !== null)
95
+ return off("subagent-model-override");
96
+ const { port, token } = facts.proxy.record;
97
+ return {
98
+ state: "on",
99
+ baseUrl: laneBaseUrl(port, token, facts.upstream.preset ? facts.upstream.url : null),
100
+ credential: facts.credential.credential,
101
+ };
102
+ }
103
+ export function decideLane(input) {
104
+ return decideFromFacts(readLaneFacts(input));
105
+ }
@@ -0,0 +1,74 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { mkdir, rm, writeFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { hasManagedHook, installManagedHook, removeManagedHook } from "../agent/claudeHooks.js";
6
+ export const LANE_MODEL_ID = "nustack-local";
7
+ export const LANE_AGENT_NAME = "nustack-local-explore";
8
+ export const LANE_AGENT_ROUTE = "definition";
9
+ export const LANE_HOOK_TIMEOUT_SECONDS = 10;
10
+ export const LANE_HOOKS = [
11
+ { event: "PreToolUse", matcher: "Agent", command: "nustack gate agent" },
12
+ { event: "SubagentStop", matcher: "", command: "nustack gate subagent-stop" },
13
+ ];
14
+ const MANAGED_MARKER = "nustack:managed";
15
+ export function claudeSettingsPath(home = os.homedir()) {
16
+ return path.join(home, ".claude", "settings.json");
17
+ }
18
+ export function laneAgentDefinitionPath(home = os.homedir()) {
19
+ return path.join(home, ".claude", "agents", `${LANE_AGENT_NAME}.md`);
20
+ }
21
+ export function laneAgentDefinition() {
22
+ return [
23
+ "---",
24
+ `name: ${LANE_AGENT_NAME}`,
25
+ "description: Read-only exploration on your own local model, through NuStack's light-model lane. Use for finding files, reading code and answering where-is questions.",
26
+ "tools: Read, Grep, Glob",
27
+ `model: ${LANE_MODEL_ID}`,
28
+ "---",
29
+ "",
30
+ `<!-- ${MANAGED_MARKER}: written by \`nustack lane install\`, removed by \`nustack lane remove\` -->`,
31
+ "",
32
+ "You explore a codebase read-only and report what you found, citing every file as a path with its line range.",
33
+ "",
34
+ ].join("\n");
35
+ }
36
+ export async function installLane(home = os.homedir(), route = LANE_AGENT_ROUTE) {
37
+ let changed = false;
38
+ for (const hook of LANE_HOOKS) {
39
+ const result = await installManagedHook(claudeSettingsPath(home), hook.event, hook.matcher, hook.command, {
40
+ timeout: LANE_HOOK_TIMEOUT_SECONDS,
41
+ });
42
+ changed ||= result.changed;
43
+ }
44
+ if (route === "definition") {
45
+ const file = laneAgentDefinitionPath(home);
46
+ const wanted = laneAgentDefinition();
47
+ if (!existsSync(file) || readFileSync(file, "utf8") !== wanted) {
48
+ await mkdir(path.dirname(file), { recursive: true });
49
+ await writeFile(file, wanted, "utf8");
50
+ changed = true;
51
+ }
52
+ }
53
+ return { changed };
54
+ }
55
+ export async function removeLane(home = os.homedir()) {
56
+ let removed = false;
57
+ for (const hook of LANE_HOOKS) {
58
+ const result = await removeManagedHook(claudeSettingsPath(home), hook.event, hook.matcher);
59
+ removed ||= result.removed;
60
+ }
61
+ const file = laneAgentDefinitionPath(home);
62
+ if (existsSync(file) && readFileSync(file, "utf8").includes(MANAGED_MARKER)) {
63
+ await rm(file, { force: true });
64
+ removed = true;
65
+ }
66
+ return { removed };
67
+ }
68
+ export async function laneHooksInstalled(home = os.homedir()) {
69
+ const settingsPath = claudeSettingsPath(home);
70
+ return {
71
+ agent: await hasManagedHook(settingsPath, "PreToolUse", "Agent"),
72
+ subagentStop: await hasManagedHook(settingsPath, "SubagentStop", ""),
73
+ };
74
+ }
@@ -0,0 +1,47 @@
1
+ const SONNET_ALIASES = new Set(["sonnet", "default"]);
2
+ const OPUS_ALIASES = new Set(["opus", "opusplan"]);
3
+ function aliasOf(value) {
4
+ return value.replace(/\[[^\]]*\]$/, "").trim().toLowerCase();
5
+ }
6
+ export function resolveAlias(value, effective) {
7
+ const alias = aliasOf(value);
8
+ const sonnet = effective.ANTHROPIC_DEFAULT_SONNET_MODEL?.trim();
9
+ const opus = effective.ANTHROPIC_DEFAULT_OPUS_MODEL?.trim();
10
+ if (SONNET_ALIASES.has(alias) && sonnet)
11
+ return { value: sonnet, via: "ANTHROPIC_DEFAULT_SONNET_MODEL" };
12
+ if (OPUS_ALIASES.has(alias) && opus)
13
+ return { value: opus, via: "ANTHROPIC_DEFAULT_OPUS_MODEL" };
14
+ return { value, via: null };
15
+ }
16
+ export function effectiveMainModel(effective, layers) {
17
+ let raw = null;
18
+ let source = null;
19
+ let scope = null;
20
+ const fromEnv = effective.ANTHROPIC_MODEL?.trim();
21
+ if (fromEnv) {
22
+ raw = fromEnv;
23
+ source = "ANTHROPIC_MODEL";
24
+ }
25
+ else {
26
+ for (const layer of [...layers].reverse()) {
27
+ if (layer.model !== null) {
28
+ raw = layer.model;
29
+ source = layer.label;
30
+ scope = layer.scope;
31
+ break;
32
+ }
33
+ }
34
+ }
35
+ if (raw === null)
36
+ return { value: null, source: null, scope: null, haikuClass: false };
37
+ const resolved = resolveAlias(raw, effective);
38
+ return {
39
+ value: resolved.value,
40
+ source: resolved.via === null ? source : `${source} via ${resolved.via}`,
41
+ scope,
42
+ haikuClass: /haiku/i.test(resolved.value),
43
+ };
44
+ }
45
+ export function haikuSentence(model) {
46
+ return `Your main model is Haiku-class (\`${model.value ?? ""}\` from \`${model.source ?? ""}\`); the lane would trade Anthropic's light model for a local one at the smallest saving.`;
47
+ }
@@ -0,0 +1,67 @@
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import { desktopConfigDir } from "../agent/desktopDoor.js";
4
+ export const PROXY_RECORD_FILE = "proxy.json";
5
+ export function proxyRecordPath() {
6
+ return path.join(desktopConfigDir(), PROXY_RECORD_FILE);
7
+ }
8
+ export function parseProxyRecord(text) {
9
+ let parsed;
10
+ try {
11
+ parsed = JSON.parse(text);
12
+ }
13
+ catch {
14
+ return null;
15
+ }
16
+ if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
17
+ return null;
18
+ const fields = parsed;
19
+ if (fields.schemaVersion !== 1)
20
+ return null;
21
+ if (typeof fields.port !== "number" || !Number.isInteger(fields.port) || fields.port < 1 || fields.port > 65535)
22
+ return null;
23
+ if (typeof fields.token !== "string" || !/^[0-9a-f]{16,128}$/.test(fields.token))
24
+ return null;
25
+ if (fields.pid !== null && (typeof fields.pid !== "number" || !Number.isInteger(fields.pid) || fields.pid < 1))
26
+ return null;
27
+ if (typeof fields.startedAt !== "string" || typeof fields.accepting !== "boolean")
28
+ return null;
29
+ return {
30
+ schemaVersion: 1,
31
+ port: fields.port,
32
+ token: fields.token,
33
+ pid: fields.pid,
34
+ startedAt: fields.startedAt,
35
+ accepting: fields.accepting,
36
+ };
37
+ }
38
+ export function isPidAlive(pid) {
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ }
43
+ catch (error) {
44
+ return error.code === "EPERM";
45
+ }
46
+ }
47
+ export function lanePrefixOf(record) {
48
+ return `http://127.0.0.1:${record.port}/lane/${record.token}`;
49
+ }
50
+ export function isUnderPrefix(baseUrl, prefix) {
51
+ return baseUrl === prefix || baseUrl.startsWith(`${prefix}/`);
52
+ }
53
+ export function readProxy(recordPath = proxyRecordPath()) {
54
+ let text;
55
+ try {
56
+ text = readFileSync(recordPath, "utf8");
57
+ }
58
+ catch {
59
+ return { live: false, record: null };
60
+ }
61
+ const record = parseProxyRecord(text);
62
+ if (record === null)
63
+ return { live: false, record: null };
64
+ if (record.pid === null || !isPidAlive(record.pid))
65
+ return { live: false, record };
66
+ return { live: true, record, prefix: lanePrefixOf(record) };
67
+ }
@@ -0,0 +1,4 @@
1
+ export const LANE_RELEASED_ENV = "NUSTACK_LANE_RELEASED";
2
+ export function isLaneReleased(env) {
3
+ return env[LANE_RELEASED_ENV] === "1";
4
+ }
@@ -48,7 +48,8 @@ export function parseInboundFrame(line) {
48
48
  return { ok: false, error: { code: "BAD_FRAME", name: type, message: "session.open commit must be a hex object name" } };
49
49
  }
50
50
  const commit = rawCommit ?? undefined;
51
- return { ok: true, frame: { v: FRAME_VERSION, type, sessionId, projectPath, permissionMode, isolated, commit, resumeSessionId } };
51
+ const lane = frame.lane === true ? true : undefined;
52
+ return { ok: true, frame: { v: FRAME_VERSION, type, sessionId, projectPath, permissionMode, isolated, commit, resumeSessionId, lane } };
52
53
  }
53
54
  case "session.turn": {
54
55
  const sessionId = requireString("sessionId");
@@ -8,6 +8,7 @@ import { SESSION_ID_VARIABLE } from "../records/environment.js";
8
8
  import { mountFacts } from "../sessions/mountFacts.js";
9
9
  import { createSessionWorktree } from "./worktree.js";
10
10
  import { readProjectContract } from "../project_contract.js";
11
+ import { decideLane } from "../lane/decide.js";
11
12
  function defaultProbeClaude(env) {
12
13
  return new Promise((resolve) => {
13
14
  execFile("claude", ["--version"], { env, timeout: 5_000 }, (error, stdout) => {
@@ -85,7 +86,7 @@ export class SessionSupervisor {
85
86
  await this.shutdown();
86
87
  return;
87
88
  case "session.open":
88
- await this.openSession(frame.sessionId, frame.projectPath, frame.permissionMode, frame.isolated, frame.resumeSessionId, frame.commit);
89
+ await this.openSession(frame.sessionId, frame.projectPath, frame.permissionMode, frame.isolated, frame.resumeSessionId, frame.commit, frame.lane);
89
90
  return;
90
91
  case "session.turn": {
91
92
  const session = this.sessions.get(frame.sessionId);
@@ -137,7 +138,7 @@ export class SessionSupervisor {
137
138
  log(`nustack: harness mount skipped for session ${sessionId} - ${error instanceof Error ? error.message : String(error)}`);
138
139
  }
139
140
  }
140
- async openSession(sessionId, projectPath, permissionMode, isolated, resumeSessionId, commit) {
141
+ async openSession(sessionId, projectPath, permissionMode, isolated, resumeSessionId, commit, lane) {
141
142
  if (this.sessions.has(sessionId))
142
143
  return this.error(sessionId, "SESSION_ALREADY_OPEN", `session ${sessionId} is already open`);
143
144
  this.status(sessionId, "starting");
@@ -173,11 +174,14 @@ export class SessionSupervisor {
173
174
  ...(facts?.harnessDigest ? { harnessDigest: facts.harnessDigest } : {}),
174
175
  ...(facts?.configDigest ? { configDigest: facts.configDigest } : {}),
175
176
  };
177
+ const sessionEnv = { ...this.env, [SESSION_ID_VARIABLE]: sessionId };
178
+ const laneOutcome = lane === true ? this.laneFor(sessionEnv, worktree.worktreePath) : null;
179
+ const spawnEnv = laneOutcome?.state === "on" ? { ...sessionEnv, ANTHROPIC_BASE_URL: laneOutcome.baseUrl } : sessionEnv;
176
180
  const adapterOptions = {
177
181
  cwd: worktree.worktreePath,
178
182
  permissionMode,
179
183
  resumeSessionId,
180
- env: { ...this.env, [SESSION_ID_VARIABLE]: sessionId },
184
+ env: spawnEnv,
181
185
  onEvent: (event) => {
182
186
  this.emit({ v: FRAME_VERSION, type: "session.event", sessionId, event });
183
187
  if (typeof event === "object" && event !== null && event.type === "result") {
@@ -266,9 +270,18 @@ export class SessionSupervisor {
266
270
  branch: worktree.branch,
267
271
  isolated: worktree.isolated,
268
272
  commit: worktree.baseCommit,
273
+ ...(laneOutcome === null ? {} : { lane: { state: laneOutcome.state, reason: laneOutcome.state === "on" ? null : laneOutcome.reason } }),
269
274
  });
270
275
  this.status(sessionId, "ready");
271
276
  }
277
+ laneFor(env, cwd) {
278
+ try {
279
+ return decideLane({ env, cwd, interactive: false, home: env.HOME || undefined });
280
+ }
281
+ catch (error) {
282
+ return { state: "off", reason: "proxy-down", sentence: error instanceof Error ? error.message : String(error) };
283
+ }
284
+ }
272
285
  async sendRoster() {
273
286
  const { records, dropped } = await readSessionRegistry(this.env);
274
287
  const rows = [];
package/dist/program.js CHANGED
@@ -42,6 +42,7 @@ import { runCensus } from "./commands/census.js";
42
42
  import { runGateInstall, runGateRemove, runGateStatus } from "./commands/gate.js";
43
43
  import { parseBudgetMs, parseLineWindow, runReadBulk } from "./commands/read.js";
44
44
  import { HOOK_SUBCOMMANDS } from "./lib/gate/subcommands.js";
45
+ import { runLaneEnv, runLaneInstall, runLaneRemove, runLaneStatus } from "./commands/lane.js";
45
46
  import { printJsonError } from "./lib/json_output.js";
46
47
  import { setCliVersion } from "./lib/auth_grant.js";
47
48
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -250,7 +251,7 @@ registerReviewCommands(program.command("review").description("Read and mirror Nu
250
251
  registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
251
252
  registerRecordAliases(program, () => program.opts());
252
253
  const LOCAL_ONLY_COMMANDS = ["census", "check"];
253
- const UNDRAINED_GROUPS = ["records", "gate", "session", "read", ...RECORD_ALIAS_GROUPS];
254
+ const UNDRAINED_GROUPS = ["records", "gate", "session", "read", "lane", ...RECORD_ALIAS_GROUPS];
254
255
  program.hook("preAction", async (_program, action) => {
255
256
  if (program.opts().nonInteractive)
256
257
  process.env[NON_INTERACTIVE_VARIABLE] = "1";
@@ -383,7 +384,7 @@ program
383
384
  const gate = program.command("gate").description("The large-read gate: answer a large full read with an outline, once per session");
384
385
  for (const subcommand of HOOK_SUBCOMMANDS) {
385
386
  gate.command(subcommand)
386
- .description(`Claude Code PreToolUse hook entry point (${subcommand}); reads the hook JSON on stdin`)
387
+ .description(`Claude Code hook entry point (${subcommand}); reads the hook JSON on stdin`)
387
388
  .action(async () => {
388
389
  const { runGateHook } = await import("./lib/gate/hook.js");
389
390
  await runGateHook(subcommand);
@@ -410,6 +411,23 @@ read.command("bulk")
410
411
  .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
412
  .option("--json", "emit the versioned machine envelope")
412
413
  .action(async (options) => runReadBulk(options));
414
+ const lane = program.command("lane").description("The light-model lane: send read-only sub-agents of API-key sessions to your local model");
415
+ lane.command("env")
416
+ .description("Print ANTHROPIC_BASE_URL=<lane url> when the lane is on for a session in --cwd, nothing otherwise")
417
+ .option("--cwd <dir>", "the session's folder (default: the current folder)")
418
+ .option("--non-interactive", "decide for a non-interactive launch (no API-key approval check)")
419
+ .action((options) => runLaneEnv({ ...options, nonInteractive: options.nonInteractive === true || program.opts().nonInteractive === true }));
420
+ lane.command("status")
421
+ .description("Report the lane's release, switch, proxy, credential, main model and decision")
422
+ .option("--cwd <dir>", "the session's folder (default: the current folder)")
423
+ .option("--json", "emit the versioned machine envelope")
424
+ .action(async (options) => runLaneStatus(options));
425
+ lane.command("install")
426
+ .description("Register the lane's Agent and SubagentStop hooks in ~/.claude/settings.json (idempotent)")
427
+ .action(async () => runLaneInstall());
428
+ lane.command("remove")
429
+ .description("Remove exactly the lane's hook entries")
430
+ .action(async () => runLaneRemove());
413
431
  function describeError(error) {
414
432
  const directCode = error?.code;
415
433
  if (directCode === "PROJECT_CONTEXT_MISSING") {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.23.17",
3
+ "version": "0.23.18",
4
4
  "description": "The nustack CLI — bootstrap NuStack services into a repo via the NuStack discovery plane.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -34,8 +34,8 @@
34
34
  "@types/node": "^22.19.20",
35
35
  "typescript": "^5.7.2",
36
36
  "vitest": "^4.1.11",
37
- "@nurix/harness": "0.0.0",
38
37
  "@nurix/contracts": "2.0.2",
38
+ "@nurix/harness": "0.0.0",
39
39
  "@nurix/nustack.studio": "0.0.0",
40
40
  "@nurix/nustack.ui": "0.0.0"
41
41
  },