@nurix/nustack 0.23.18 → 0.23.20

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,131 @@
1
+ import { DEVICE_TOKEN_VARIABLE } from "../lib/records/environment.js";
2
+ import { printJsonError, printJsonSuccess } from "../lib/json_output.js";
3
+ import { gitOutcome } from "../lib/session-engine/worktree.js";
4
+ import { ensureBinding, mountHarness, prepareCheckout, writeNustackShim } from "../lib/desktop-agents/checkout.js";
5
+ import { claudeArgs, claudeEnv, gitDirsOf, homeOf, realCheckout, runClaude, syncRecords } from "../lib/desktop-agents/claudeRun.js";
6
+ import { composePrompt } from "../lib/desktop-agents/prompt.js";
7
+ import { discardProposal, publishProposal, publishRun } from "../lib/desktop-agents/publish.js";
8
+ import { failedResult, parseProposalRequest, parseRunRequest, } from "../lib/desktop-agents/request.js";
9
+ async function readStdin() {
10
+ const chunks = [];
11
+ for await (const chunk of process.stdin)
12
+ chunks.push(chunk);
13
+ return Buffer.concat(chunks).toString("utf8");
14
+ }
15
+ async function readRequest(command, parse) {
16
+ let raw;
17
+ try {
18
+ raw = JSON.parse(await readStdin());
19
+ }
20
+ catch {
21
+ printJsonError(command, { code: "REQUEST_INVALID", message: "stdin must be one JSON request." });
22
+ return null;
23
+ }
24
+ const parsed = parse(raw);
25
+ if (!parsed.ok) {
26
+ printJsonError(command, { code: parsed.code, message: parsed.message });
27
+ return null;
28
+ }
29
+ return parsed.value;
30
+ }
31
+ function log(line) {
32
+ console.error(`desktop-agent: ${line}`);
33
+ }
34
+ function nustackHostOf(env) {
35
+ const url = env.NUSTACK_URL ?? env.NUSTACK_API_BASE_URL;
36
+ if (!url)
37
+ return "nustack.tech";
38
+ try {
39
+ return new URL(url).host;
40
+ }
41
+ catch {
42
+ return null;
43
+ }
44
+ }
45
+ function pullRequestBody(request, costCents) {
46
+ return [
47
+ `Agent: ${request.agent.label} (\`${request.agent.slug}\`)`,
48
+ `Firing session: ${request.sessionId ?? "local"}`,
49
+ `Engine: ${request.engine}`,
50
+ `Cost: $${(costCents / 100).toFixed(2)}`,
51
+ "",
52
+ "Opened by a NuStack desktop agent run.",
53
+ ].join("\n");
54
+ }
55
+ async function runAccepted(request) {
56
+ const env = process.env;
57
+ let prepared;
58
+ try {
59
+ prepared = await prepareCheckout(request, env);
60
+ }
61
+ catch (error) {
62
+ log(`checkout: ${error instanceof Error ? error.message : String(error)}`);
63
+ return failedResult("checkout_unbound", "none");
64
+ }
65
+ const { worktreePath, branch, base } = prepared;
66
+ if (!(await ensureBinding(worktreePath, request))) {
67
+ log(`checkout: ${worktreePath} is not bound to Project ${request.projectId}`);
68
+ return failedResult("checkout_unbound", "none");
69
+ }
70
+ const mountEnv = { ...env };
71
+ delete mountEnv[DEVICE_TOKEN_VARIABLE];
72
+ const mounted = await mountHarness(worktreePath, mountEnv);
73
+ if (!mounted.proceed) {
74
+ log(mounted.line);
75
+ return failedResult("harness_unmounted", "none");
76
+ }
77
+ if (mounted.line)
78
+ log(mounted.line);
79
+ const binDir = await writeNustackShim(env);
80
+ const checkout = realCheckout(worktreePath);
81
+ const commonDir = await gitOutcome(["rev-parse", "--git-common-dir"], worktreePath);
82
+ const gitDir = await gitOutcome(["rev-parse", "--git-dir"], worktreePath);
83
+ const dirs = gitDirsOf(commonDir.stdout, gitDir.stdout, worktreePath);
84
+ const inputs = { checkout, ...dirs, home: homeOf(env), nustackHost: nustackHostOf(env) };
85
+ const modelEnv = claudeEnv(request, env, binDir);
86
+ const outcome = await runClaude(request, claudeArgs(request, inputs, modelEnv), modelEnv, worktreePath, composePrompt(request));
87
+ if (request.sessionId !== null) {
88
+ const synced = await syncRecords(env, worktreePath);
89
+ log(`records sync exited ${synced.code ?? "unspawned"}`);
90
+ }
91
+ const shared = { vendorSessionId: outcome.vendorSessionId, costCents: outcome.costCents, engineCheck: outcome.engineCheck, mount: mounted.mount };
92
+ if (outcome.reason) {
93
+ await gitOutcome(["reset", "--hard"], worktreePath);
94
+ await gitOutcome(["checkout", "--detach"], worktreePath);
95
+ await gitOutcome(["branch", "-D", branch], worktreePath);
96
+ return failedResult(outcome.reason, mounted.mount, shared);
97
+ }
98
+ const published = await publishRun(request, worktreePath, branch, base, pullRequestBody(request, outcome.costCents), env);
99
+ return { outcome: "done", ...shared, result: published.result, pullRequestUrl: published.pullRequestUrl };
100
+ }
101
+ export async function runDesktopAgentRun() {
102
+ const request = await readRequest("desktop-agent run", parseRunRequest);
103
+ if (!request)
104
+ return;
105
+ printJsonSuccess("desktop-agent run", await runAccepted(request));
106
+ }
107
+ export async function runDesktopAgentPrompt() {
108
+ const request = await readRequest("desktop-agent prompt", parseRunRequest);
109
+ if (!request)
110
+ return;
111
+ printJsonSuccess("desktop-agent prompt", { prompt: composePrompt(request) });
112
+ }
113
+ export async function runDesktopAgentPublish() {
114
+ const request = await readRequest("desktop-agent publish", parseProposalRequest);
115
+ if (!request)
116
+ return;
117
+ const published = await publishProposal(request, process.env);
118
+ const result = published
119
+ ? { outcome: "done", vendorSessionId: null, costCents: 0, result: published.result, pullRequestUrl: published.pullRequestUrl, engineCheck: { apiKeySource: null, model: null }, mount: "none" }
120
+ : { outcome: "failed", vendorSessionId: null, costCents: 0, result: null, pullRequestUrl: null, engineCheck: { apiKeySource: null, model: null }, mount: "none" };
121
+ if (!published)
122
+ log(`publish: ${request.branch} could not be renamed or pushed`);
123
+ printJsonSuccess("desktop-agent publish", result);
124
+ }
125
+ export async function runDesktopAgentDiscard() {
126
+ const request = await readRequest("desktop-agent discard", parseProposalRequest);
127
+ if (!request)
128
+ return;
129
+ const discarded = await discardProposal(request);
130
+ printJsonSuccess("desktop-agent discard", { discarded });
131
+ }
@@ -0,0 +1,148 @@
1
+ import { spawn } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { chmod, copyFile, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { HARNESS_CODES } from "../harness/codes.js";
7
+ import { loadHarness } from "../harness/vendor.js";
8
+ import { projectJsonPath, readBindingMarker } from "../project_context.js";
9
+ import { gitOutcome, sessionsHome } from "../session-engine/worktree.js";
10
+ export const DESKTOP_AGENT_CHECKOUTS_DIR = "agents";
11
+ export const DESKTOP_AGENT_BIN_DIR = ".bin";
12
+ export const DESKTOP_AGENT_PROPOSAL_BRANCH_PREFIX = "nustack/proposal/";
13
+ export const DESKTOP_AGENT_EXCLUDE_START = "# nustack:desktop-agent:start";
14
+ export const DESKTOP_AGENT_EXCLUDE_END = "# nustack:desktop-agent:end";
15
+ const MARKER_RELATIVE = ".nustack/workspace.json";
16
+ const FETCH_TIMEOUT_MS = 10 * 60_000;
17
+ export function agentCheckoutsRoot(env = process.env) {
18
+ return path.join(sessionsHome(env), DESKTOP_AGENT_CHECKOUTS_DIR);
19
+ }
20
+ export function agentCheckoutPath(projectId, slug, env = process.env) {
21
+ return path.join(agentCheckoutsRoot(env), projectId, slug);
22
+ }
23
+ export async function runBranchName(slug, runId) {
24
+ const { AGENT_BRANCH_PREFIX } = await loadHarness("rules");
25
+ return `${AGENT_BRANCH_PREFIX}${slug}/${runId}`;
26
+ }
27
+ export function proposalBranchName(slug, runId) {
28
+ return `${DESKTOP_AGENT_PROPOSAL_BRANCH_PREFIX}${slug}/${runId}`;
29
+ }
30
+ export function agentBinDir(env = process.env) {
31
+ return path.join(agentCheckoutsRoot(env), DESKTOP_AGENT_BIN_DIR);
32
+ }
33
+ const CLI_ENTRY = fileURLToPath(new URL("../../index.js", import.meta.url));
34
+ export async function writeNustackShim(env = process.env) {
35
+ const dir = agentBinDir(env);
36
+ await mkdir(dir, { recursive: true });
37
+ const shim = path.join(dir, "nustack");
38
+ await writeFile(shim, `#!/bin/sh\nexec ${shellQuote(process.execPath)} ${shellQuote(CLI_ENTRY)} "$@"\n`);
39
+ await chmod(shim, 0o755);
40
+ return dir;
41
+ }
42
+ function shellQuote(value) {
43
+ return `'${value.replace(/'/g, "'\\''")}'`;
44
+ }
45
+ async function gitText(args, cwd) {
46
+ const outcome = await gitOutcome(args, cwd);
47
+ return outcome.ok ? outcome.stdout : null;
48
+ }
49
+ async function gitMust(args, cwd, timeoutMs) {
50
+ const outcome = await gitOutcome(args, cwd, timeoutMs);
51
+ if (!outcome.ok)
52
+ throw new Error(`git ${args.join(" ")} failed: ${outcome.stderr || outcome.stdout}`);
53
+ }
54
+ export async function defaultBranchOf(checkoutPath) {
55
+ const head = await gitText(["symbolic-ref", "--short", "refs/remotes/origin/HEAD"], checkoutPath);
56
+ if (head && head.startsWith("origin/"))
57
+ return head.slice("origin/".length);
58
+ return "main";
59
+ }
60
+ export async function resolveBase(checkoutPath, prBase) {
61
+ if (prBase === "change") {
62
+ const current = await gitText(["rev-parse", "--abbrev-ref", "HEAD"], checkoutPath);
63
+ if (current && current !== "HEAD") {
64
+ const remote = await gitText(["ls-remote", "--heads", "origin", current], checkoutPath);
65
+ if (remote && remote.trim() !== "")
66
+ return current;
67
+ }
68
+ }
69
+ return defaultBranchOf(checkoutPath);
70
+ }
71
+ export async function prepareCheckout(request, env = process.env) {
72
+ const worktreePath = agentCheckoutPath(request.projectId, request.agent.slug, env);
73
+ if (!existsSync(path.join(worktreePath, ".git"))) {
74
+ await mkdir(path.dirname(worktreePath), { recursive: true });
75
+ await gitOutcome(["worktree", "prune"], request.checkoutPath);
76
+ await gitMust(["worktree", "add", "--detach", worktreePath], request.checkoutPath);
77
+ }
78
+ await gitMust(["fetch", "origin"], worktreePath, FETCH_TIMEOUT_MS);
79
+ await gitMust(["reset", "--hard"], worktreePath);
80
+ await gitMust(["clean", "-fd"], worktreePath);
81
+ const base = await resolveBase(request.checkoutPath, request.prBase);
82
+ const branch = await runBranchName(request.agent.slug, request.runId);
83
+ await gitMust(["checkout", "-B", branch, `origin/${base}`], worktreePath);
84
+ return { worktreePath, branch, base };
85
+ }
86
+ async function excludePathOf(worktreePath) {
87
+ const relative = await gitText(["rev-parse", "--git-path", "info/exclude"], worktreePath);
88
+ if (!relative)
89
+ return null;
90
+ return path.isAbsolute(relative) ? relative : path.join(worktreePath, relative);
91
+ }
92
+ async function writeExcludeBlock(worktreePath) {
93
+ const excludePath = await excludePathOf(worktreePath);
94
+ if (!excludePath)
95
+ return;
96
+ const held = existsSync(excludePath) ? await readFile(excludePath, "utf8") : "";
97
+ if (held.includes(DESKTOP_AGENT_EXCLUDE_START))
98
+ return;
99
+ await mkdir(path.dirname(excludePath), { recursive: true });
100
+ const block = [DESKTOP_AGENT_EXCLUDE_START, MARKER_RELATIVE, DESKTOP_AGENT_EXCLUDE_END].join("\n");
101
+ await writeFile(excludePath, `${held.replace(/\n*$/, held ? "\n" : "")}${block}\n`);
102
+ }
103
+ export async function ensureBinding(worktreePath, request) {
104
+ const own = await readBindingMarker(worktreePath);
105
+ if (own)
106
+ return own.projectId === request.projectId;
107
+ const person = await readBindingMarker(request.checkoutPath);
108
+ if (!person || person.projectId !== request.projectId)
109
+ return false;
110
+ await mkdir(path.dirname(projectJsonPath(worktreePath)), { recursive: true });
111
+ await copyFile(projectJsonPath(request.checkoutPath), projectJsonPath(worktreePath));
112
+ await writeExcludeBlock(worktreePath);
113
+ return true;
114
+ }
115
+ export function mountAnswerOf(lastLine) {
116
+ let envelope = null;
117
+ try {
118
+ envelope = lastLine ? JSON.parse(lastLine) : null;
119
+ }
120
+ catch {
121
+ envelope = null;
122
+ }
123
+ if (envelope?.ok === true)
124
+ return { proceed: true, mount: "nustack", line: null };
125
+ const code = typeof envelope?.error?.code === "string" ? envelope.error.code : "HARNESS_MOUNT_FAILED";
126
+ const message = typeof envelope?.error?.message === "string" ? envelope.error.message : "the mount printed no envelope";
127
+ if (code === HARNESS_CODES.HARNESS_UNAVAILABLE)
128
+ return { proceed: true, mount: "none", line: `harness: no version to mount yet (${message})` };
129
+ if (code === HARNESS_CODES.ETNA_MOUNT_PRESENT)
130
+ return { proceed: true, mount: "etna", line: `harness: etna's committed mount hook runs inside the session (${message})` };
131
+ return { proceed: false, code, line: `harness: mount failed ${code}: ${message}` };
132
+ }
133
+ export async function mountHarness(worktreePath, env) {
134
+ const lastLine = await new Promise((resolve) => {
135
+ const child = spawn(process.execPath, [CLI_ENTRY, "harness", "mount", worktreePath, "--json"], { cwd: worktreePath, env, stdio: ["ignore", "pipe", "ignore"] });
136
+ let stdout = "";
137
+ child.stdout.on("data", (chunk) => (stdout += chunk.toString("utf8")));
138
+ child.on("error", () => resolve(null));
139
+ child.on("close", () => {
140
+ const lines = stdout.split("\n").map((line) => line.trim()).filter((line) => line !== "");
141
+ resolve(lines.at(-1) ?? null);
142
+ });
143
+ });
144
+ return mountAnswerOf(lastLine);
145
+ }
146
+ export function cliEntry() {
147
+ return CLI_ENTRY;
148
+ }
@@ -0,0 +1,265 @@
1
+ import { spawn } from "node:child_process";
2
+ import { realpathSync } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { createInterface } from "node:readline";
6
+ import { DEVICE_TOKEN_VARIABLE, RECORD_CREDENTIAL_VARIABLE, SESSION_ID_VARIABLE } from "../records/environment.js";
7
+ import { cliEntry } from "./checkout.js";
8
+ export const DESKTOP_AGENT_NUSTACK_VERBS = ["nustack records emit *", "nustack obligation add *", "nustack obligation discharge *", "nustack backlog add *"];
9
+ const REVIEW_NUSTACK_VERBS = ["nustack obligation add *", "nustack obligation discharge *"];
10
+ const STRIPPED_FOR_MODEL = [DEVICE_TOKEN_VARIABLE, "NUSTACK_API_BASE_URL", SESSION_ID_VARIABLE, RECORD_CREDENTIAL_VARIABLE];
11
+ const LOCAL_MODEL_KEYS = ["ANTHROPIC_MODEL", "ANTHROPIC_DEFAULT_OPUS_MODEL", "ANTHROPIC_DEFAULT_SONNET_MODEL", "ANTHROPIC_DEFAULT_HAIKU_MODEL", "CLAUDE_CODE_SUBAGENT_MODEL"];
12
+ export const DESKTOP_AGENT_SANDBOX_DENY_READ = [
13
+ "~/.ssh",
14
+ "~/.aws",
15
+ "~/.config",
16
+ "~/Library",
17
+ "~/.claude-manager",
18
+ "~/.nustack/credentials.json",
19
+ "~/.nustack/device.json",
20
+ "~/.nustack/device_id",
21
+ "~/.nustack/local-engine.json",
22
+ "~/.nustack/recovery",
23
+ "~/.nustack/cache",
24
+ "~/Library/Application Support/co.nurix.nustack",
25
+ ];
26
+ export function absoluteRule(absolutePath) {
27
+ return `//${absolutePath.replace(/^\/+/, "")}`;
28
+ }
29
+ export function readTools(checkout) {
30
+ const scope = `${absoluteRule(checkout)}/**`;
31
+ return [`Read(${scope})`, `Glob(${scope})`, `Grep(${scope})`];
32
+ }
33
+ const GIT_READ_FORMS = ["Bash(git status)", "Bash(git status *)", "Bash(git diff)", "Bash(git diff *)", "Bash(git log)", "Bash(git log *)"];
34
+ export function allowedTools(checkout) {
35
+ const scope = `${absoluteRule(checkout)}/**`;
36
+ return [
37
+ ...readTools(checkout),
38
+ `Edit(${scope})`,
39
+ `Write(${scope})`,
40
+ ...GIT_READ_FORMS,
41
+ "Bash(git add *)",
42
+ "Bash(git commit -m *)",
43
+ ...DESKTOP_AGENT_NUSTACK_VERBS.map((verb) => `Bash(${verb})`),
44
+ ];
45
+ }
46
+ export function reviewTools(checkout) {
47
+ return [...readTools(checkout), ...GIT_READ_FORMS, ...REVIEW_NUSTACK_VERBS.map((verb) => `Bash(${verb})`)];
48
+ }
49
+ export function deniedTools(checkout) {
50
+ const gitDir = `${absoluteRule(checkout)}/.git/**`;
51
+ return [
52
+ `Edit(${gitDir})`,
53
+ `Write(${gitDir})`,
54
+ "Bash(git push *)",
55
+ "Bash(git -C *)",
56
+ "Bash(git -c *)",
57
+ "Bash(git --git-dir*)",
58
+ "Bash(git --work-tree*)",
59
+ "Bash(git diff --no-index*)",
60
+ "Bash(git commit -F*)",
61
+ "Bash(git commit --file*)",
62
+ "Bash(git commit -t*)",
63
+ "Bash(git commit --template*)",
64
+ "Bash(git * --output*)",
65
+ "Bash(nustack records list*)",
66
+ "Bash(nustack obligation list*)",
67
+ "Read(~/.ssh/**)",
68
+ "Read(~/.aws/**)",
69
+ "Read(~/.config/**)",
70
+ "Read(~/Library/**)",
71
+ "Read(~/.claude-manager/**)",
72
+ "Read(~/.nustack/*.db)",
73
+ "Read(~/.nustack/credentials.json)",
74
+ "Read(~/.nustack/device.json)",
75
+ "Read(~/.nustack/device_id)",
76
+ "Read(~/.nustack/local-engine.json)",
77
+ ];
78
+ }
79
+ export function realCheckout(checkout) {
80
+ try {
81
+ return realpathSync(checkout);
82
+ }
83
+ catch {
84
+ return checkout;
85
+ }
86
+ }
87
+ export function sandboxSettings(inputs) {
88
+ const home = realCheckout(inputs.home);
89
+ const nustackHome = path.join(home, ".nustack");
90
+ const expand = (entry) => (entry.startsWith("~/") ? path.join(home, entry.slice(2)) : entry);
91
+ return {
92
+ sandbox: {
93
+ enabled: true,
94
+ failIfUnavailable: true,
95
+ allowUnsandboxedCommands: false,
96
+ autoAllowBashIfSandboxed: false,
97
+ filesystem: {
98
+ allowWrite: [
99
+ inputs.checkout,
100
+ path.join(inputs.gitCommonDir, "objects"),
101
+ path.join(inputs.gitCommonDir, "refs"),
102
+ path.join(inputs.gitCommonDir, "logs"),
103
+ inputs.worktreeGitDir,
104
+ path.join(nustackHome, "state.db"),
105
+ path.join(nustackHome, "state.db-wal"),
106
+ path.join(nustackHome, "state.db-shm"),
107
+ path.join(nustackHome, "locks"),
108
+ path.join(nustackHome, "session-events"),
109
+ ],
110
+ denyRead: DESKTOP_AGENT_SANDBOX_DENY_READ.map(expand),
111
+ },
112
+ network: { allowedDomains: inputs.nustackHost ? [inputs.nustackHost] : [] },
113
+ },
114
+ };
115
+ }
116
+ export function runSettings(request, inputs, env) {
117
+ const settings = sandboxSettings(inputs);
118
+ if (request.engine === "local_model") {
119
+ const block = {};
120
+ for (const key of LOCAL_MODEL_KEYS) {
121
+ const value = env[key];
122
+ if (value)
123
+ block[key] = value;
124
+ }
125
+ settings.env = block;
126
+ }
127
+ return settings;
128
+ }
129
+ export function modelOf(request, env) {
130
+ if (request.engine === "local_model")
131
+ return env.ANTHROPIC_MODEL ?? request.agent.model;
132
+ return request.agent.model;
133
+ }
134
+ export function claudeArgs(request, inputs, env) {
135
+ const allowed = request.toolProfile === "review" ? reviewTools(inputs.checkout) : allowedTools(inputs.checkout);
136
+ const args = [
137
+ "-p",
138
+ "--no-session-persistence",
139
+ "--output-format",
140
+ "stream-json",
141
+ "--verbose",
142
+ "--permission-mode",
143
+ "dontAsk",
144
+ "--allowedTools",
145
+ allowed.join(","),
146
+ "--disallowedTools",
147
+ deniedTools(inputs.checkout).join(","),
148
+ ];
149
+ const model = modelOf(request, env);
150
+ if (model)
151
+ args.push("--model", model);
152
+ args.push("--settings", JSON.stringify(runSettings(request, inputs, env)));
153
+ if (request.budgetCents !== null)
154
+ args.push("--max-budget-usd", (request.budgetCents / 100).toFixed(2));
155
+ return args;
156
+ }
157
+ export function claudeEnv(request, inherited, binDir) {
158
+ const env = { ...inherited };
159
+ for (const name of STRIPPED_FOR_MODEL)
160
+ delete env[name];
161
+ if (request.sessionId !== null) {
162
+ env[SESSION_ID_VARIABLE] = request.sessionId;
163
+ env[RECORD_CREDENTIAL_VARIABLE] = "device";
164
+ }
165
+ env.PATH = [binDir, inherited.PATH ?? ""].filter((part) => part !== "").join(path.delimiter);
166
+ return env;
167
+ }
168
+ export function engineFits(request, init, env) {
169
+ if (request.engine === "api_key")
170
+ return init.apiKeySource === null || init.apiKeySource === "ANTHROPIC_API_KEY";
171
+ if (request.engine === "seat")
172
+ return init.apiKeySource === null || init.apiKeySource === "none";
173
+ const expected = modelOf(request, env);
174
+ return init.model === null || expected === null || init.model === expected;
175
+ }
176
+ const KILL_GRACE_MS = 3_000;
177
+ export function runClaude(request, args, env, cwd, prompt) {
178
+ return new Promise((resolve) => {
179
+ const child = spawn(env.NUSTACK_CLAUDE_BIN ?? "claude", args, { cwd, env, stdio: ["pipe", "pipe", "pipe"], detached: true });
180
+ let vendorSessionId = null;
181
+ let costCents = 0;
182
+ let sawResult = false;
183
+ let isResultError = false;
184
+ let killedFor = null;
185
+ const engineCheck = { apiKeySource: null, model: null };
186
+ let settled = false;
187
+ const killGroup = (reason) => {
188
+ if (killedFor)
189
+ return;
190
+ killedFor = reason;
191
+ const signal = (sig) => {
192
+ try {
193
+ if (child.pid)
194
+ process.kill(-child.pid, sig);
195
+ }
196
+ catch {
197
+ }
198
+ };
199
+ signal("SIGTERM");
200
+ setTimeout(() => signal("SIGKILL"), KILL_GRACE_MS).unref();
201
+ };
202
+ const wall = setTimeout(() => killGroup("wall_clock"), request.wallSeconds * 1000);
203
+ const finish = (code) => {
204
+ if (settled)
205
+ return;
206
+ settled = true;
207
+ clearTimeout(wall);
208
+ let reason = killedFor;
209
+ if (!reason && (code !== 0 || !sawResult || isResultError))
210
+ reason = "model_failed";
211
+ resolve({ reason, vendorSessionId, costCents, engineCheck });
212
+ };
213
+ child.on("error", () => finish(null));
214
+ child.on("close", (code) => finish(code));
215
+ child.stderr.on("data", () => {
216
+ });
217
+ createInterface({ input: child.stdout }).on("line", (line) => {
218
+ let frame;
219
+ try {
220
+ const parsed = JSON.parse(line);
221
+ if (typeof parsed !== "object" || parsed === null)
222
+ return;
223
+ frame = parsed;
224
+ }
225
+ catch {
226
+ return;
227
+ }
228
+ if (frame.type === "system" && frame.subtype === "init") {
229
+ if (typeof frame.session_id === "string")
230
+ vendorSessionId = frame.session_id;
231
+ engineCheck.apiKeySource = typeof frame.apiKeySource === "string" ? frame.apiKeySource : null;
232
+ engineCheck.model = typeof frame.model === "string" ? frame.model : null;
233
+ if (!engineFits(request, engineCheck, env))
234
+ killGroup("engine_mismatch");
235
+ }
236
+ if (frame.type === "result") {
237
+ sawResult = true;
238
+ isResultError = frame.is_error === true;
239
+ if (typeof frame.total_cost_usd === "number")
240
+ costCents = Math.round(frame.total_cost_usd * 100);
241
+ if (typeof frame.session_id === "string" && !vendorSessionId)
242
+ vendorSessionId = frame.session_id;
243
+ }
244
+ });
245
+ child.stdin.on("error", () => {
246
+ });
247
+ child.stdin.end(prompt);
248
+ });
249
+ }
250
+ export function gitDirsOf(gitCommonDirOutput, gitDirOutput, worktree) {
251
+ const absolute = (value) => realCheckout(path.isAbsolute(value) ? value : path.join(worktree, value));
252
+ return { gitCommonDir: absolute(gitCommonDirOutput), worktreeGitDir: absolute(gitDirOutput) };
253
+ }
254
+ export function syncRecords(env, cwd) {
255
+ return new Promise((resolve) => {
256
+ const child = spawn(process.execPath, [cliEntry(), "records", "sync", "--json"], { cwd, env, stdio: ["ignore", "pipe", "pipe"] });
257
+ let output = "";
258
+ child.stdout.on("data", (chunk) => (output += chunk.toString("utf8")));
259
+ child.on("error", () => resolve({ code: null, output }));
260
+ child.on("close", (code) => resolve({ code, output: output.trim() }));
261
+ });
262
+ }
263
+ export function homeOf(env) {
264
+ return env.HOME ?? os.homedir();
265
+ }
@@ -0,0 +1,47 @@
1
+ export const LOCAL_ROWS_MAX = 50;
2
+ export const LOCAL_DISMISSALS_MAX = 20;
3
+ export const PREFILLED_PROMPT_MAX_CHARS = 8_000;
4
+ export const LOCAL_TASK_TEMPLATES = {
5
+ distillation: "Distill the device-kept memory rows below into durable project memory. Edit the memory documents in this checkout, then commit. Nothing is pushed; the person approves the proposal.",
6
+ boundary_security_reviewer: "Review the open device-kept security findings below against this checkout. For each, discharge the obligation with your verdict through `nustack obligation discharge`, weighing the past dismissals. Do not edit or commit.",
7
+ };
8
+ const BRANCH_RULE = "Work on the current branch and commit here with `git commit -m`; never push. Write records only through the `nustack` record verbs.";
9
+ function section(title, body) {
10
+ if (!body || body.trim() === "")
11
+ return null;
12
+ return `## ${title}\n\n${body.trim()}`;
13
+ }
14
+ function rowsSection(title, rows, max) {
15
+ if (!rows || rows.length === 0)
16
+ return null;
17
+ const kept = rows.slice(0, max).map((row) => `- ${JSON.stringify(row)}`);
18
+ return `## ${title}\n\n${kept.join("\n")}`;
19
+ }
20
+ function taskOf(request) {
21
+ const mandate = request.agent.mandate?.trim();
22
+ const task = request.task.trim();
23
+ if (mandate && task.startsWith(mandate))
24
+ return task.slice(mandate.length).trim();
25
+ if (task !== "")
26
+ return request.task;
27
+ const template = LOCAL_TASK_TEMPLATES[request.agent.slug];
28
+ return request.localRows !== undefined && template ? template : request.task;
29
+ }
30
+ function bodySections(request) {
31
+ const isReviewer = request.agent.slug === "boundary_security_reviewer";
32
+ return [
33
+ section("Mandate", request.agent.mandate),
34
+ section("Task", taskOf(request)),
35
+ section("Branch rule", BRANCH_RULE),
36
+ rowsSection("Device rows", request.localRows, LOCAL_ROWS_MAX),
37
+ isReviewer ? rowsSection("Past device dismissals", request.dismissals, LOCAL_DISMISSALS_MAX) : null,
38
+ ].filter((part) => part !== null);
39
+ }
40
+ export function composePrompt(request) {
41
+ const body = bodySections(request).join("\n\n");
42
+ const instructions = section("Instructions", request.instructions);
43
+ const whole = instructions ? `${instructions}\n\n${body}` : body;
44
+ if (whole.length <= PREFILLED_PROMPT_MAX_CHARS)
45
+ return whole;
46
+ return body.slice(0, PREFILLED_PROMPT_MAX_CHARS);
47
+ }
@@ -0,0 +1,98 @@
1
+ import { execFile } from "node:child_process";
2
+ import { gitOutcome } from "../session-engine/worktree.js";
3
+ import { proposalBranchName } from "./checkout.js";
4
+ const PUSH_TIMEOUT_MS = 5 * 60_000;
5
+ const PULL_REQUEST_URL = /https?:\/\/\S+\/pull\/(\d+)/;
6
+ export function commitSubject(label, task) {
7
+ const first = task.split("\n").find((line) => line.trim() !== "")?.trim() ?? "agent run";
8
+ return `${label}: ${first}`.slice(0, 120);
9
+ }
10
+ async function gitOk(args, cwd, timeoutMs) {
11
+ return (await gitOutcome(args, cwd, timeoutMs)).ok;
12
+ }
13
+ async function commitLeftovers(worktree, subject) {
14
+ const status = await gitOutcome(["status", "--porcelain"], worktree);
15
+ if (!status.ok || status.stdout === "")
16
+ return;
17
+ await gitOutcome(["add", "-A"], worktree);
18
+ await gitOutcome(["commit", "-m", subject], worktree);
19
+ }
20
+ async function commitsAhead(worktree, base) {
21
+ const counted = await gitOutcome(["rev-list", "--count", `origin/${base}..HEAD`], worktree);
22
+ return counted.ok ? Number(counted.stdout) || 0 : 0;
23
+ }
24
+ async function dropLocalBranch(worktree, branch) {
25
+ await gitOutcome(["checkout", "--detach"], worktree);
26
+ await gitOutcome(["branch", "-D", branch], worktree);
27
+ }
28
+ function openPullRequest(cwd, args, env) {
29
+ return new Promise((resolve) => {
30
+ try {
31
+ execFile("gh", ["pr", "create", "--base", args.base, "--head", args.head, "--title", args.title, "--body", args.body], { cwd, env, timeout: PUSH_TIMEOUT_MS }, (error, stdout) => {
32
+ if (error)
33
+ return resolve(null);
34
+ const match = PULL_REQUEST_URL.exec(stdout);
35
+ resolve(match ? { url: match[0], number: Number(match[1]) } : null);
36
+ });
37
+ }
38
+ catch {
39
+ resolve(null);
40
+ }
41
+ });
42
+ }
43
+ async function pushAndOpen(worktree, branch, base, title, body, env) {
44
+ if (!(await gitOk(["push", "-u", "origin", branch], worktree, PUSH_TIMEOUT_MS)))
45
+ return null;
46
+ const opened = await openPullRequest(worktree, { base, head: branch, title, body }, env);
47
+ return addressOf(branch, opened);
48
+ }
49
+ export function addressOf(branch, opened) {
50
+ if (!opened)
51
+ return { result: { kind: "branch", ref: branch }, pullRequestUrl: null };
52
+ return { result: { kind: "pull_request", ref: branch, number: opened.number }, pullRequestUrl: opened.url };
53
+ }
54
+ export function pullRequestNumberOf(url) {
55
+ const match = PULL_REQUEST_URL.exec(url);
56
+ return match ? Number(match[1]) : null;
57
+ }
58
+ export async function publishRun(request, worktree, branch, base, body, env) {
59
+ if (request.publish === "none") {
60
+ await gitOutcome(["reset", "--hard"], worktree);
61
+ await dropLocalBranch(worktree, branch);
62
+ return { result: null, pullRequestUrl: null };
63
+ }
64
+ const subject = commitSubject(request.agent.label, request.task);
65
+ await commitLeftovers(worktree, subject);
66
+ if ((await commitsAhead(worktree, base)) === 0) {
67
+ await dropLocalBranch(worktree, branch);
68
+ return { result: null, pullRequestUrl: null };
69
+ }
70
+ if (request.publish === "local") {
71
+ await gitOutcome(["checkout", "--detach"], worktree);
72
+ return { result: { kind: "branch", ref: branch }, pullRequestUrl: null };
73
+ }
74
+ const published = await pushAndOpen(worktree, branch, base, subject, body, env);
75
+ if (!published) {
76
+ await gitOutcome(["checkout", "--detach"], worktree);
77
+ return { result: { kind: "branch", ref: branch }, pullRequestUrl: null };
78
+ }
79
+ await dropLocalBranch(worktree, branch);
80
+ return published;
81
+ }
82
+ function slugOf(branch, runId) {
83
+ const parts = branch.split("/");
84
+ return parts.length >= 2 && parts.at(-1) === runId ? (parts.at(-2) ?? "agent") : "agent";
85
+ }
86
+ export async function publishProposal(request, env) {
87
+ const proposal = proposalBranchName(slugOf(request.branch, request.runId), request.runId);
88
+ if (!(await gitOk(["branch", "-M", request.branch, proposal], request.checkoutPath)))
89
+ return null;
90
+ const body = `Memory proposal from a desktop distillation run (${request.runId}), approved on this device.`;
91
+ const published = await pushAndOpen(request.checkoutPath, proposal, request.base, request.title, body, env);
92
+ if (published)
93
+ await gitOutcome(["branch", "-D", proposal], request.checkoutPath);
94
+ return published;
95
+ }
96
+ export async function discardProposal(request) {
97
+ return gitOk(["branch", "-D", request.branch], request.checkoutPath);
98
+ }
@@ -0,0 +1,152 @@
1
+ export const DESKTOP_AGENT_REQUEST_VERSION = 1;
2
+ export const DESKTOP_AGENT_REFUSAL_CODES = ["REQUEST_INVALID", "REQUEST_VERSION_UNSUPPORTED"];
3
+ export const DESKTOP_AGENT_RUN_REASONS = [
4
+ "checkout_unbound",
5
+ "harness_unmounted",
6
+ "wall_clock",
7
+ "engine_mismatch",
8
+ "model_failed",
9
+ "app_exit",
10
+ "cli_output_unreadable",
11
+ ];
12
+ export const DESKTOP_AGENT_REF_SEGMENT = /^[A-Za-z0-9_-]+$/;
13
+ export const DESKTOP_AGENT_PR_BASES = ["change", "default"];
14
+ export const DESKTOP_AGENT_PUBLISH_MODES = ["push", "local", "none"];
15
+ export const DESKTOP_AGENT_TOOL_PROFILES = ["change", "review"];
16
+ export const DESKTOP_AGENT_ENGINES = ["api_key", "local_model", "seat"];
17
+ export const DESKTOP_AGENT_MOUNT_VALUES = ["nustack", "none", "etna"];
18
+ function invalid(field, expected) {
19
+ return { ok: false, code: "REQUEST_INVALID", message: `${field} must be ${expected}.` };
20
+ }
21
+ function isRecord(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ function isNonEmptyString(value) {
25
+ return typeof value === "string" && value.trim() !== "";
26
+ }
27
+ function isNullableString(value) {
28
+ return value === null || typeof value === "string";
29
+ }
30
+ function oneOf(values, value) {
31
+ return typeof value === "string" && values.includes(value);
32
+ }
33
+ function readVersion(raw) {
34
+ if (typeof raw.requestVersion !== "number" || !Number.isInteger(raw.requestVersion))
35
+ return invalid("requestVersion", "an integer");
36
+ if (raw.requestVersion !== DESKTOP_AGENT_REQUEST_VERSION) {
37
+ return { ok: false, code: "REQUEST_VERSION_UNSUPPORTED", message: `requestVersion ${raw.requestVersion} is not supported; this CLI reads ${DESKTOP_AGENT_REQUEST_VERSION}.` };
38
+ }
39
+ return null;
40
+ }
41
+ export function parseRunRequest(raw) {
42
+ if (!isRecord(raw))
43
+ return invalid("the request", "a JSON object");
44
+ const version = readVersion(raw);
45
+ if (version)
46
+ return version;
47
+ if (!isNonEmptyString(raw.runId) || !DESKTOP_AGENT_REF_SEGMENT.test(raw.runId))
48
+ return invalid("runId", "a string of letters, digits, '_' or '-'");
49
+ if (!isNonEmptyString(raw.projectId))
50
+ return invalid("projectId", "a non-empty string");
51
+ if (!(raw.sessionId === null || isNonEmptyString(raw.sessionId)))
52
+ return invalid("sessionId", "a non-empty string or null");
53
+ if (!isNonEmptyString(raw.checkoutPath))
54
+ return invalid("checkoutPath", "a non-empty string");
55
+ const agent = raw.agent;
56
+ if (!isRecord(agent))
57
+ return invalid("agent", "an object");
58
+ if (!isNonEmptyString(agent.slug) || !DESKTOP_AGENT_REF_SEGMENT.test(agent.slug))
59
+ return invalid("agent.slug", "a string of letters, digits, '_' or '-'");
60
+ if (!isNonEmptyString(agent.label))
61
+ return invalid("agent.label", "a non-empty string");
62
+ if (!isNullableString(agent.mandate))
63
+ return invalid("agent.mandate", "a string or null");
64
+ if (!isNullableString(agent.model))
65
+ return invalid("agent.model", "a string or null");
66
+ if (typeof raw.task !== "string")
67
+ return invalid("task", "a string");
68
+ if (!isNullableString(raw.instructions))
69
+ return invalid("instructions", "a string or null");
70
+ if (raw.localRows !== undefined && !(Array.isArray(raw.localRows) && raw.localRows.every(isRecord)))
71
+ return invalid("localRows", "an array of objects");
72
+ if (raw.dismissals !== undefined && !(Array.isArray(raw.dismissals) && raw.dismissals.every(isRecord)))
73
+ return invalid("dismissals", "an array of objects");
74
+ if (!oneOf(DESKTOP_AGENT_PR_BASES, raw.prBase))
75
+ return invalid("prBase", `one of ${DESKTOP_AGENT_PR_BASES.join(", ")}`);
76
+ if (!oneOf(DESKTOP_AGENT_PUBLISH_MODES, raw.publish))
77
+ return invalid("publish", `one of ${DESKTOP_AGENT_PUBLISH_MODES.join(", ")}`);
78
+ if (!oneOf(DESKTOP_AGENT_TOOL_PROFILES, raw.toolProfile))
79
+ return invalid("toolProfile", `one of ${DESKTOP_AGENT_TOOL_PROFILES.join(", ")}`);
80
+ if (!oneOf(DESKTOP_AGENT_ENGINES, raw.engine))
81
+ return invalid("engine", `one of ${DESKTOP_AGENT_ENGINES.join(", ")}`);
82
+ if (!(raw.budgetCents === null || (typeof raw.budgetCents === "number" && Number.isInteger(raw.budgetCents) && raw.budgetCents >= 0))) {
83
+ return invalid("budgetCents", "a whole number of cents or null");
84
+ }
85
+ if (typeof raw.wallSeconds !== "number" || !Number.isInteger(raw.wallSeconds) || raw.wallSeconds <= 0)
86
+ return invalid("wallSeconds", "a positive whole number");
87
+ return {
88
+ ok: true,
89
+ value: {
90
+ requestVersion: DESKTOP_AGENT_REQUEST_VERSION,
91
+ runId: raw.runId,
92
+ projectId: raw.projectId,
93
+ sessionId: raw.sessionId,
94
+ checkoutPath: raw.checkoutPath,
95
+ agent: { slug: agent.slug, label: agent.label, mandate: agent.mandate, model: agent.model },
96
+ task: raw.task,
97
+ instructions: raw.instructions,
98
+ ...(raw.localRows ? { localRows: raw.localRows } : {}),
99
+ ...(raw.dismissals ? { dismissals: raw.dismissals } : {}),
100
+ prBase: raw.prBase,
101
+ publish: raw.publish,
102
+ toolProfile: raw.toolProfile,
103
+ engine: raw.engine,
104
+ budgetCents: raw.budgetCents,
105
+ wallSeconds: raw.wallSeconds,
106
+ },
107
+ };
108
+ }
109
+ export function parseProposalRequest(raw) {
110
+ if (!isRecord(raw))
111
+ return invalid("the request", "a JSON object");
112
+ const version = readVersion(raw);
113
+ if (version)
114
+ return version;
115
+ if (!isNonEmptyString(raw.runId) || !DESKTOP_AGENT_REF_SEGMENT.test(raw.runId))
116
+ return invalid("runId", "a string of letters, digits, '_' or '-'");
117
+ if (!isNonEmptyString(raw.projectId))
118
+ return invalid("projectId", "a non-empty string");
119
+ if (!isNonEmptyString(raw.checkoutPath))
120
+ return invalid("checkoutPath", "a non-empty string");
121
+ if (!isNonEmptyString(raw.branch))
122
+ return invalid("branch", "a non-empty string");
123
+ if (!isNonEmptyString(raw.base))
124
+ return invalid("base", "a non-empty string");
125
+ if (typeof raw.title !== "string")
126
+ return invalid("title", "a string");
127
+ return {
128
+ ok: true,
129
+ value: {
130
+ requestVersion: DESKTOP_AGENT_REQUEST_VERSION,
131
+ runId: raw.runId,
132
+ projectId: raw.projectId,
133
+ checkoutPath: raw.checkoutPath,
134
+ branch: raw.branch,
135
+ base: raw.base,
136
+ title: raw.title,
137
+ },
138
+ };
139
+ }
140
+ export function failedResult(reason, mount, partial = {}) {
141
+ return {
142
+ outcome: "failed",
143
+ reason,
144
+ vendorSessionId: null,
145
+ costCents: 0,
146
+ result: null,
147
+ pullRequestUrl: null,
148
+ engineCheck: { apiKeySource: null, model: null },
149
+ mount,
150
+ ...partial,
151
+ };
152
+ }
package/dist/program.js CHANGED
@@ -38,6 +38,7 @@ 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";
@@ -373,6 +374,27 @@ agentHook
373
374
  .command("remove")
374
375
  .description("Remove exactly the managed SessionStart hook entry")
375
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());
376
398
  program
377
399
  .command("census")
378
400
  .description("Measure where your Claude Code tokens go, from your own transcripts, and recommend the large-read gate's threshold")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nurix/nustack",
3
- "version": "0.23.18",
3
+ "version": "0.23.20",
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,10 +34,10 @@
34
34
  "@types/node": "^22.19.20",
35
35
  "typescript": "^5.7.2",
36
36
  "vitest": "^4.1.11",
37
- "@nurix/contracts": "2.0.2",
38
37
  "@nurix/harness": "0.0.0",
39
38
  "@nurix/nustack.studio": "0.0.0",
40
- "@nurix/nustack.ui": "0.0.0"
39
+ "@nurix/nustack.ui": "0.0.0",
40
+ "@nurix/contracts": "2.0.2"
41
41
  },
42
42
  "publishConfig": {
43
43
  "access": "public"