@nurix/nustack 0.23.17 → 0.23.19

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,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
@@ -38,10 +38,12 @@ import { registerSessionCommands } from "./commands/session.js";
38
38
  import { isPartCheckStale, startTelemetryReconcile } from "./lib/telemetry/part_state.js";
39
39
  import { RECORD_ALIAS_GROUPS, registerRecordAliases } from "./commands/recordAliases.js";
40
40
  import { runAgentContext, runAgentHookInstall, runAgentHookRemove } from "./commands/agent.js";
41
+ import { runDesktopAgentDiscard, runDesktopAgentPrompt, runDesktopAgentPublish, runDesktopAgentRun } from "./commands/desktopAgent.js";
41
42
  import { runCensus } from "./commands/census.js";
42
43
  import { runGateInstall, runGateRemove, runGateStatus } from "./commands/gate.js";
43
44
  import { parseBudgetMs, parseLineWindow, runReadBulk } from "./commands/read.js";
44
45
  import { HOOK_SUBCOMMANDS } from "./lib/gate/subcommands.js";
46
+ import { runLaneEnv, runLaneInstall, runLaneRemove, runLaneStatus } from "./commands/lane.js";
45
47
  import { printJsonError } from "./lib/json_output.js";
46
48
  import { setCliVersion } from "./lib/auth_grant.js";
47
49
  const { version } = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
@@ -250,7 +252,7 @@ registerReviewCommands(program.command("review").description("Read and mirror Nu
250
252
  registerRecordsCommands(program.command("records").description("Emit, send and list harness records"), () => program.opts());
251
253
  registerRecordAliases(program, () => program.opts());
252
254
  const LOCAL_ONLY_COMMANDS = ["census", "check"];
253
- const UNDRAINED_GROUPS = ["records", "gate", "session", "read", ...RECORD_ALIAS_GROUPS];
255
+ const UNDRAINED_GROUPS = ["records", "gate", "session", "read", "lane", ...RECORD_ALIAS_GROUPS];
254
256
  program.hook("preAction", async (_program, action) => {
255
257
  if (program.opts().nonInteractive)
256
258
  process.env[NON_INTERACTIVE_VARIABLE] = "1";
@@ -372,6 +374,27 @@ agentHook
372
374
  .command("remove")
373
375
  .description("Remove exactly the managed SessionStart hook entry")
374
376
  .action(async () => runAgentHookRemove());
377
+ const desktopAgent = program.command("desktop-agent").description("Run a device-placed agent for the NuStack desktop (stdin request, JSON envelope)");
378
+ desktopAgent
379
+ .command("run")
380
+ .description("Run one desktop agent request read on stdin in the agent's own checkout")
381
+ .option("--json", "emit the versioned machine envelope")
382
+ .action(async () => runDesktopAgentRun());
383
+ desktopAgent
384
+ .command("prompt")
385
+ .description("Print the pre-filled prompt for a desktop agent request read on stdin")
386
+ .option("--json", "emit the versioned machine envelope")
387
+ .action(async () => runDesktopAgentPrompt());
388
+ desktopAgent
389
+ .command("publish")
390
+ .description("Push an approved memory proposal's branch as a pull request")
391
+ .option("--json", "emit the versioned machine envelope")
392
+ .action(async () => runDesktopAgentPublish());
393
+ desktopAgent
394
+ .command("discard")
395
+ .description("Delete a discarded memory proposal's local branch")
396
+ .option("--json", "emit the versioned machine envelope")
397
+ .action(async () => runDesktopAgentDiscard());
375
398
  program
376
399
  .command("census")
377
400
  .description("Measure where your Claude Code tokens go, from your own transcripts, and recommend the large-read gate's threshold")
@@ -383,7 +406,7 @@ program
383
406
  const gate = program.command("gate").description("The large-read gate: answer a large full read with an outline, once per session");
384
407
  for (const subcommand of HOOK_SUBCOMMANDS) {
385
408
  gate.command(subcommand)
386
- .description(`Claude Code PreToolUse hook entry point (${subcommand}); reads the hook JSON on stdin`)
409
+ .description(`Claude Code hook entry point (${subcommand}); reads the hook JSON on stdin`)
387
410
  .action(async () => {
388
411
  const { runGateHook } = await import("./lib/gate/hook.js");
389
412
  await runGateHook(subcommand);
@@ -410,6 +433,23 @@ read.command("bulk")
410
433
  .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
434
  .option("--json", "emit the versioned machine envelope")
412
435
  .action(async (options) => runReadBulk(options));
436
+ const lane = program.command("lane").description("The light-model lane: send read-only sub-agents of API-key sessions to your local model");
437
+ lane.command("env")
438
+ .description("Print ANTHROPIC_BASE_URL=<lane url> when the lane is on for a session in --cwd, nothing otherwise")
439
+ .option("--cwd <dir>", "the session's folder (default: the current folder)")
440
+ .option("--non-interactive", "decide for a non-interactive launch (no API-key approval check)")
441
+ .action((options) => runLaneEnv({ ...options, nonInteractive: options.nonInteractive === true || program.opts().nonInteractive === true }));
442
+ lane.command("status")
443
+ .description("Report the lane's release, switch, proxy, credential, main model and decision")
444
+ .option("--cwd <dir>", "the session's folder (default: the current folder)")
445
+ .option("--json", "emit the versioned machine envelope")
446
+ .action(async (options) => runLaneStatus(options));
447
+ lane.command("install")
448
+ .description("Register the lane's Agent and SubagentStop hooks in ~/.claude/settings.json (idempotent)")
449
+ .action(async () => runLaneInstall());
450
+ lane.command("remove")
451
+ .description("Remove exactly the lane's hook entries")
452
+ .action(async () => runLaneRemove());
413
453
  function describeError(error) {
414
454
  const directCode = error?.code;
415
455
  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.19",
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
  },