@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,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
+ }
@@ -17,7 +17,7 @@ function sectionOf(value) {
17
17
  export function readDesktopSettings() {
18
18
  const defaults = {
19
19
  placement: { memory: DEFAULT_PLACEMENT, securityFindings: DEFAULT_PLACEMENT },
20
- routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null },
20
+ routing: { localReader: false, gateThresholdTokens: null, digestBudgetMs: null, lane: false },
21
21
  };
22
22
  let parsed;
23
23
  try {
@@ -39,6 +39,7 @@ export function readDesktopSettings() {
39
39
  localReader: routing.localReader === true,
40
40
  gateThresholdTokens: boundedIntegerOf(routing.gateThresholdTokens, GATE_THRESHOLD_TOKENS_MIN, GATE_THRESHOLD_TOKENS_MAX),
41
41
  digestBudgetMs: boundedIntegerOf(routing.digestBudgetMs, DIGEST_BUDGET_MS_MIN, DIGEST_BUDGET_MS_MAX),
42
+ lane: routing.lane === true,
42
43
  },
43
44
  };
44
45
  }
@@ -0,0 +1,130 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { readDesktopSettings } from "../desktop_settings.js";
5
+ import { readSettingsLayers } from "../lane/credential.js";
6
+ import { LANE_AGENT_NAME, LANE_AGENT_ROUTE, LANE_MODEL_ID } from "../lane/install.js";
7
+ import { isUnderPrefix, readProxy } from "../lane/proxyRecord.js";
8
+ import { GateInputError } from "./readGate.js";
9
+ export const READ_ONLY_TOOLS = new Set(["Read", "Grep", "Glob", "LS", "NotebookRead"]);
10
+ export const PASS_ON_AGENT_PERMISSION_RULE = true;
11
+ function parseAgentCall(text) {
12
+ let payload;
13
+ try {
14
+ payload = JSON.parse(text);
15
+ }
16
+ catch {
17
+ throw new GateInputError("stdin is not JSON");
18
+ }
19
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload))
20
+ throw new GateInputError("stdin is not a JSON object");
21
+ const fields = payload;
22
+ if (typeof fields.session_id !== "string" || fields.session_id === "")
23
+ throw new GateInputError("session_id is missing");
24
+ if (typeof fields.cwd !== "string" || fields.cwd === "")
25
+ throw new GateInputError("cwd is missing");
26
+ const toolInput = fields.tool_input;
27
+ if (toolInput === null || typeof toolInput !== "object" || Array.isArray(toolInput))
28
+ throw new GateInputError("tool_input is missing");
29
+ return { sessionId: fields.session_id, cwd: fields.cwd, toolInput: toolInput };
30
+ }
31
+ function frontmatterOf(content) {
32
+ const match = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(content);
33
+ return match ? match[1] : null;
34
+ }
35
+ export function declaredTools(frontmatter) {
36
+ const lines = frontmatter.split(/\r?\n/);
37
+ const index = lines.findIndex((line) => /^tools\s*:/.test(line));
38
+ if (index === -1)
39
+ return null;
40
+ const inline = lines[index].replace(/^tools\s*:/, "").trim();
41
+ let items;
42
+ if (inline !== "") {
43
+ items = inline.replace(/^\[|\]$/g, "").split(",");
44
+ }
45
+ else {
46
+ items = [];
47
+ for (const line of lines.slice(index + 1)) {
48
+ const item = /^\s*-\s*(.+)$/.exec(line);
49
+ if (!item)
50
+ break;
51
+ items.push(item[1]);
52
+ }
53
+ }
54
+ const tools = items.map((item) => item.trim().replace(/^["']|["']$/g, "")).filter((item) => item !== "");
55
+ return tools.length === 0 ? null : tools;
56
+ }
57
+ function nameOf(frontmatter) {
58
+ const match = /^name\s*:\s*(.+)$/m.exec(frontmatter);
59
+ return match ? match[1].trim().replace(/^["']|["']$/g, "") : null;
60
+ }
61
+ export function isReadOnlyDefinition(type, cwd, home) {
62
+ for (const folder of [path.join(cwd, ".claude", "agents"), path.join(home, ".claude", "agents")]) {
63
+ let entries;
64
+ try {
65
+ entries = readdirSync(folder).filter((entry) => entry.endsWith(".md"));
66
+ }
67
+ catch {
68
+ continue;
69
+ }
70
+ for (const entry of entries) {
71
+ let content;
72
+ try {
73
+ content = readFileSync(path.join(folder, entry), "utf8");
74
+ }
75
+ catch {
76
+ continue;
77
+ }
78
+ const frontmatter = frontmatterOf(content);
79
+ if (frontmatter === null || nameOf(frontmatter) !== type)
80
+ continue;
81
+ const tools = declaredTools(frontmatter);
82
+ return tools !== null && tools.every((tool) => READ_ONLY_TOOLS.has(tool));
83
+ }
84
+ }
85
+ return false;
86
+ }
87
+ export function hasAgentPermissionRule(layers) {
88
+ return layers.some((layer) => ["allow", "ask", "deny"].some((kind) => {
89
+ const rules = layer.permissions?.[kind];
90
+ return Array.isArray(rules) && rules.some((rule) => typeof rule === "string" && /^Agent(\(|$)/.test(rule.trim()));
91
+ }));
92
+ }
93
+ async function isLaneLatched(sessionId) {
94
+ const [{ openLocalStoreReadOnly }, { isLatched }] = await Promise.all([import("../local_store/database.js"), import("../local_store/routing.js")]);
95
+ const store = await openLocalStoreReadOnly();
96
+ if (store === null)
97
+ return false;
98
+ try {
99
+ return isLatched(store, sessionId, "lane.subagent");
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ finally {
105
+ store.close();
106
+ }
107
+ }
108
+ export async function answerAgentCall(text, env, home = os.homedir()) {
109
+ const call = parseAgentCall(text);
110
+ const baseUrl = env.ANTHROPIC_BASE_URL ?? "";
111
+ const proxy = readProxy();
112
+ if (!proxy.live || !isUnderPrefix(baseUrl, proxy.prefix))
113
+ return null;
114
+ if (!readDesktopSettings().routing.lane)
115
+ return null;
116
+ const model = call.toolInput.model;
117
+ if (model !== undefined && model !== null && model !== "haiku")
118
+ return null;
119
+ const type = call.toolInput.subagent_type;
120
+ if (typeof type !== "string")
121
+ return null;
122
+ if (type !== "Explore" && !isReadOnlyDefinition(type, call.cwd, home))
123
+ return null;
124
+ if (PASS_ON_AGENT_PERMISSION_RULE && hasAgentPermissionRule(readSettingsLayers({ env, cwd: call.cwd, home })))
125
+ return null;
126
+ if (await isLaneLatched(call.sessionId))
127
+ return null;
128
+ const updatedInput = LANE_AGENT_ROUTE === "model" ? { ...call.toolInput, model: LANE_MODEL_ID } : { ...call.toolInput, subagent_type: LANE_AGENT_NAME };
129
+ return `${JSON.stringify({ hookSpecificOutput: { hookEventName: "PreToolUse", permissionDecision: "allow", updatedInput } })}\n`;
130
+ }
@@ -0,0 +1,121 @@
1
+ import { readFileSync, statSync } from "node:fs";
2
+ import path from "node:path";
3
+ export const GROUNDING_MAX_PATHS = 50;
4
+ export const GROUNDING_MAX_IDENTIFIERS = 100;
5
+ export const GROUNDING_MAX_FILE_BYTES = 2 * 1024 * 1024;
6
+ const FILE_EXTENSIONS = new Set("ts tsx js jsx mjs cjs json md mdx rs py go toml yaml yml css scss html sh txt lock sql swift kt java c h cpp hpp rb php vue svelte astro proto graphql xml ini env cfg conf csv".split(" "));
7
+ const LINE_SUFFIX = String.raw `(?::(\d+)(?:-(\d+))?)?`;
8
+ const SLASHED_PATH = new RegExp(String.raw `(?<![\w./:-])((?:[\w.@-]+/)+[\w.@-]+\.[A-Za-z0-9]+)${LINE_SUFFIX}`, "g");
9
+ const BACKTICKED = /`([^`\n]+)`/g;
10
+ const BACKTICKED_FILE = new RegExp(String.raw `^([\w.@-]+\.([A-Za-z0-9]+))${LINE_SUFFIX}$`);
11
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)?$/;
12
+ function lineOf(text) {
13
+ return text === undefined ? null : Number.parseInt(text, 10);
14
+ }
15
+ function pushPath(paths, seen, claim) {
16
+ const key = `${claim.path}:${claim.startLine ?? ""}-${claim.endLine ?? ""}`;
17
+ if (seen.has(key) || paths.length >= GROUNDING_MAX_PATHS)
18
+ return;
19
+ seen.add(key);
20
+ paths.push(claim);
21
+ }
22
+ export function extractClaims(text) {
23
+ const paths = [];
24
+ const identifiers = [];
25
+ const seenPaths = new Set();
26
+ const seenIdentifiers = new Set();
27
+ for (const match of text.matchAll(SLASHED_PATH)) {
28
+ const before = text.slice(Math.max(0, (match.index ?? 0) - 3), match.index ?? 0);
29
+ if (before.endsWith("://"))
30
+ continue;
31
+ pushPath(paths, seenPaths, { path: match[1], startLine: lineOf(match[2]), endLine: lineOf(match[3]) });
32
+ }
33
+ for (const match of text.matchAll(BACKTICKED)) {
34
+ const token = match[1].trim();
35
+ if (token.includes("/"))
36
+ continue;
37
+ const file = BACKTICKED_FILE.exec(token);
38
+ if (file && FILE_EXTENSIONS.has(file[2].toLowerCase())) {
39
+ pushPath(paths, seenPaths, { path: file[1], startLine: lineOf(file[3]), endLine: lineOf(file[4]) });
40
+ continue;
41
+ }
42
+ if (token.length < 3 || !IDENTIFIER.test(token) || seenIdentifiers.has(token))
43
+ continue;
44
+ if (identifiers.length >= GROUNDING_MAX_IDENTIFIERS)
45
+ continue;
46
+ seenIdentifiers.add(token);
47
+ identifiers.push(token);
48
+ }
49
+ return { paths, identifiers };
50
+ }
51
+ function sizeOf(file) {
52
+ try {
53
+ const stats = statSync(file);
54
+ return stats.isFile() ? stats.size : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ function readSmall(file) {
61
+ const size = sizeOf(file);
62
+ if (size === null || size > GROUNDING_MAX_FILE_BYTES)
63
+ return null;
64
+ try {
65
+ return readFileSync(file, "utf8");
66
+ }
67
+ catch {
68
+ return null;
69
+ }
70
+ }
71
+ function lineCountOf(content) {
72
+ if (content === "")
73
+ return 0;
74
+ const lines = content.split("\n").length;
75
+ return content.endsWith("\n") ? lines - 1 : lines;
76
+ }
77
+ function resolveClaim(claim, cwd, readFiles) {
78
+ const direct = path.isAbsolute(claim.path) ? claim.path : path.resolve(cwd, claim.path);
79
+ if (sizeOf(direct) !== null)
80
+ return direct;
81
+ if (!claim.path.includes("/")) {
82
+ const byName = readFiles.find((file) => path.basename(file) === claim.path && sizeOf(file) !== null);
83
+ if (byName)
84
+ return byName;
85
+ }
86
+ return null;
87
+ }
88
+ export function verifyClaims(claims, cwd, readFiles) {
89
+ const citedFiles = [];
90
+ const fail = (failure) => ({ result: "fail", failure, citedFiles });
91
+ for (const claim of claims.paths) {
92
+ const file = resolveClaim(claim, cwd, readFiles);
93
+ if (file === null)
94
+ return fail(`no file ${claim.path}`);
95
+ if (!citedFiles.includes(file))
96
+ citedFiles.push(file);
97
+ const lastLine = Math.max(claim.startLine ?? 0, claim.endLine ?? 0);
98
+ if (lastLine === 0)
99
+ continue;
100
+ const content = readSmall(file);
101
+ if (content === null)
102
+ continue;
103
+ if (lastLine > lineCountOf(content))
104
+ return fail(`${claim.path}:${lastLine} is past the end of the file`);
105
+ }
106
+ const searchable = [...citedFiles, ...readFiles.map((file) => path.resolve(cwd, file)).filter((file) => !citedFiles.includes(file))];
107
+ const contents = new Map();
108
+ const contentOf = (file) => {
109
+ if (!contents.has(file))
110
+ contents.set(file, readSmall(file));
111
+ return contents.get(file) ?? null;
112
+ };
113
+ const hasSearchable = searchable.some((file) => contentOf(file) !== null);
114
+ if (hasSearchable) {
115
+ for (const identifier of claims.identifiers) {
116
+ if (!searchable.some((file) => contentOf(file)?.includes(identifier)))
117
+ return fail(`\`${identifier}\` appears in no cited or read file`);
118
+ }
119
+ }
120
+ return { result: "pass", failure: null, citedFiles };
121
+ }
@@ -56,6 +56,25 @@ function denialOf(reason) {
56
56
  },
57
57
  })}\n`;
58
58
  }
59
+ const LANE_URL_HEAD = "http://127.0.0.1:";
60
+ export function isLaneSessionEnv(env) {
61
+ const baseUrl = env.ANTHROPIC_BASE_URL;
62
+ return typeof baseUrl === "string" && baseUrl.startsWith(LANE_URL_HEAD) && baseUrl.includes("/lane/");
63
+ }
64
+ async function runLaneHook(subcommand, io) {
65
+ const text = await readStdin(io.stdin);
66
+ if (!isLaneSessionEnv(io.env))
67
+ return;
68
+ if (subcommand === "agent") {
69
+ const { answerAgentCall } = await import("./agentHook.js");
70
+ const answer = await answerAgentCall(text, io.env);
71
+ if (answer !== null)
72
+ io.stdout.write(answer);
73
+ return;
74
+ }
75
+ const { answerSubagentStop } = await import("./subagentStop.js");
76
+ await answerSubagentStop(text, io.env);
77
+ }
59
78
  function deniedPathOf(subcommand, call) {
60
79
  const written = subcommand === "read"
61
80
  ? call.toolInput.file_path
@@ -89,6 +108,10 @@ export async function runGateHook(subcommand, io = { stdin: process.stdin, stdou
89
108
  return;
90
109
  }
91
110
  try {
111
+ if (subcommand === "agent" || subcommand === "subagent-stop") {
112
+ await runLaneHook(subcommand, io);
113
+ return;
114
+ }
92
115
  const call = parseCall(await readStdin(io.stdin), startedAt);
93
116
  let answer;
94
117
  if (subcommand === "read")
@@ -0,0 +1,121 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { isUnderPrefix, readProxy } from "../lane/proxyRecord.js";
4
+ import { tokensOfLength } from "./estimate.js";
5
+ import { extractClaims, verifyClaims } from "./grounding.js";
6
+ import { GateInputError } from "./readGate.js";
7
+ function parseStopCall(text) {
8
+ let payload;
9
+ try {
10
+ payload = JSON.parse(text);
11
+ }
12
+ catch {
13
+ throw new GateInputError("stdin is not JSON");
14
+ }
15
+ if (payload === null || typeof payload !== "object" || Array.isArray(payload))
16
+ throw new GateInputError("stdin is not a JSON object");
17
+ const fields = payload;
18
+ if (typeof fields.session_id !== "string" || fields.session_id === "")
19
+ throw new GateInputError("session_id is missing");
20
+ const transcript = fields.agent_transcript_path;
21
+ const agentType = fields.agent_type;
22
+ return {
23
+ sessionId: fields.session_id,
24
+ cwd: typeof fields.cwd === "string" && fields.cwd !== "" ? fields.cwd : process.cwd(),
25
+ transcriptPath: typeof transcript === "string" && transcript !== "" ? transcript : null,
26
+ agentType: typeof agentType === "string" && agentType !== "" ? agentType : "subagent",
27
+ };
28
+ }
29
+ function isRoutedModel(model) {
30
+ return typeof model === "string" && model !== "" && !model.startsWith("claude") && model !== "<synthetic>";
31
+ }
32
+ function numberOf(value) {
33
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.round(value) : 0;
34
+ }
35
+ export function readSubagentRun(content) {
36
+ let routed = false;
37
+ let finalText = "";
38
+ let localTokens = 0;
39
+ let first = null;
40
+ let last = null;
41
+ let agentType = null;
42
+ const readFiles = [];
43
+ for (const line of content.split("\n")) {
44
+ if (line.trim() === "")
45
+ continue;
46
+ let entry;
47
+ try {
48
+ entry = JSON.parse(line);
49
+ }
50
+ catch {
51
+ continue;
52
+ }
53
+ const stamp = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : Number.NaN;
54
+ if (!Number.isNaN(stamp)) {
55
+ first ??= stamp;
56
+ last = stamp;
57
+ }
58
+ if (agentType === null && typeof entry.agentType === "string")
59
+ agentType = entry.agentType;
60
+ if (entry.type !== "assistant")
61
+ continue;
62
+ const message = entry.message;
63
+ if (!message)
64
+ continue;
65
+ if (isRoutedModel(message.model)) {
66
+ routed = true;
67
+ const usage = message.usage;
68
+ localTokens += numberOf(usage?.input_tokens) + numberOf(usage?.output_tokens);
69
+ }
70
+ const blocks = Array.isArray(message.content) ? message.content : [];
71
+ const texts = blocks.filter((block) => block?.type === "text" && typeof block.text === "string").map((block) => block.text);
72
+ if (texts.length > 0)
73
+ finalText = texts.join("\n");
74
+ for (const block of blocks) {
75
+ if (block?.type !== "tool_use" || block.name !== "Read")
76
+ continue;
77
+ const filePath = block.input?.file_path;
78
+ if (typeof filePath === "string" && !readFiles.includes(filePath))
79
+ readFiles.push(filePath);
80
+ }
81
+ }
82
+ return { routed, finalText, readFiles, localTokens, latencyMs: first !== null && last !== null ? Math.max(0, last - first) : 0, agentType };
83
+ }
84
+ export async function answerSubagentStop(text, env) {
85
+ const call = parseStopCall(text);
86
+ const proxy = readProxy();
87
+ if (!proxy.live || !isUnderPrefix(env.ANTHROPIC_BASE_URL ?? "", proxy.prefix))
88
+ return;
89
+ if (call.transcriptPath === null)
90
+ return;
91
+ const run = readSubagentRun(readFileSync(call.transcriptPath, "utf8"));
92
+ if (!run.routed)
93
+ return;
94
+ const verdict = verifyClaims(extractClaims(run.finalText), call.cwd, run.readFiles);
95
+ const [{ openLocalStore, withTransaction }, routing] = await Promise.all([import("../local_store/database.js"), import("../local_store/routing.js")]);
96
+ const store = await openLocalStore();
97
+ try {
98
+ const now = Date.now();
99
+ withTransaction(store, () => {
100
+ routing.insertLabelRow(store, {
101
+ id: `lr_${randomUUID()}`,
102
+ session_id: call.sessionId,
103
+ agent_type: call.agentType !== "subagent" ? call.agentType : (run.agentType ?? call.agentType),
104
+ path: verdict.citedFiles[0] ?? null,
105
+ estimated_tokens: run.localTokens,
106
+ returned_tokens: tokensOfLength(run.finalText.length),
107
+ handler: "local_model",
108
+ check_result: verdict.result,
109
+ bypass_used: 0,
110
+ latency_ms: run.latencyMs,
111
+ tokens_kept_out: 0,
112
+ created_at: now,
113
+ });
114
+ if (verdict.result === "fail")
115
+ routing.recordRoutingMiss(store, call.sessionId, "lane.subagent", "failure", now);
116
+ });
117
+ }
118
+ finally {
119
+ store.close();
120
+ }
121
+ }
@@ -1 +1 @@
1
- export const HOOK_SUBCOMMANDS = ["read", "bash"];
1
+ export const HOOK_SUBCOMMANDS = ["read", "bash", "agent", "subagent-stop"];