@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.
- package/dist/commands/desktopAgent.js +131 -0
- package/dist/commands/lane.js +70 -0
- package/dist/lib/desktop-agents/checkout.js +148 -0
- package/dist/lib/desktop-agents/claudeRun.js +265 -0
- package/dist/lib/desktop-agents/prompt.js +47 -0
- package/dist/lib/desktop-agents/publish.js +98 -0
- package/dist/lib/desktop-agents/request.js +152 -0
- package/dist/lib/desktop_settings.js +2 -1
- package/dist/lib/gate/agentHook.js +130 -0
- package/dist/lib/gate/grounding.js +121 -0
- package/dist/lib/gate/hook.js +23 -0
- package/dist/lib/gate/subagentStop.js +121 -0
- package/dist/lib/gate/subcommands.js +1 -1
- package/dist/lib/lane/credential.js +101 -0
- package/dist/lib/lane/decide.js +105 -0
- package/dist/lib/lane/install.js +74 -0
- package/dist/lib/lane/mainModel.js +47 -0
- package/dist/lib/lane/proxyRecord.js +67 -0
- package/dist/lib/lane/release.js +4 -0
- package/dist/lib/session-engine/frames.js +2 -1
- package/dist/lib/session-engine/supervisor.js +16 -3
- package/dist/program.js +42 -2
- package/package.json +2 -2
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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.
|
|
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
|
},
|