rea-agents 0.4.0 → 1.0.0

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/README.md CHANGED
@@ -120,6 +120,17 @@ rea doctor
120
120
  rea analyze /Applications/Notes.app
121
121
  ```
122
122
 
123
+ Update that global installation in place:
124
+
125
+ ```bash
126
+ rea upgrade
127
+ ```
128
+
129
+ REA checks npm for the latest release and verifies that the running package is
130
+ the global installation it will replace. Source, local, and `npx` copies report
131
+ the manual `npm install --global rea-agents@latest` command instead of updating
132
+ an unrelated global package.
133
+
123
134
  Choose either the no-install commands or the global installation. You do not need both.
124
135
 
125
136
  ### Requirements
@@ -170,12 +181,12 @@ Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evid
170
181
 
171
182
  ### CLI or coding agent?
172
183
 
173
- | If you want to… | Use |
174
- | --------------------------------------------------------- | ---------------------------------------------- |
175
- | Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
176
- | Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
177
- | Validate or canonicalize an Evidence v2 bundle | `rea evidence-import` or `rea evidence-export` |
178
- | Import source as historical reference | `rea import-reference-source` |
184
+ | If you want to… | Use |
185
+ | --------------------------------------------------------- | -------------------------------------------------------------- |
186
+ | Ask an agent to investigate an app and build a feature | Install the skill, then talk to your agent |
187
+ | Inspect or decompile one part of an app from the Terminal | `rea analyze` or `rea decompile` |
188
+ | Validate, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
189
+ | Import source as historical reference | `rea import-reference-source` |
179
190
 
180
191
  Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
181
192
 
@@ -183,6 +194,7 @@ Filesystem evidence commands and MCP file tools are disabled until the operator
183
194
  export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
184
195
  rea evidence-import /absolute/path/to/evidence/bundle.json
185
196
  rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
197
+ rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
186
198
  ```
187
199
 
188
200
  Historical source import requires a separate allowlist and never treats source as current behavioral authority:
@@ -276,6 +288,8 @@ REA is growing into a toolkit for understanding software across static artifacts
276
288
 
277
289
  Roadmap items describe direction, not shipped support. New providers must produce the same evidence and safety metadata as existing capabilities before they become part of the public workflow.
278
290
 
291
+ See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
292
+
279
293
  ## Using REA with other coding agents
280
294
 
281
295
  Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
@@ -320,15 +334,27 @@ The agent workflow above is the easiest way to use REA. For a one-off overview f
320
334
 
321
335
  ```bash
322
336
  npx -y rea-agents analyze /Applications/Notes.app
337
+ npx -y rea-agents inspect /Applications/Notes.app
338
+ npx -y rea-agents inspect /Applications/Notes.app --detail detailed --limit 20
339
+ npx -y rea-agents search /Applications/Notes.app "offline"
340
+ npx -y rea-agents function /Applications/Notes.app 0x1000
341
+ npx -y rea-agents xrefs /Applications/Notes.app 0x1000
342
+ npx -y rea-agents trace /Applications/Notes.app "offline"
343
+ npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
344
+ npx -y rea-agents capabilities
345
+ npx -y rea-agents providers
323
346
  ```
324
347
 
325
- Run `npx -y rea-agents --help` for direct decompilation and other options.
348
+ Run `npx -y rea-agents --help` for direct decompilation, bounded search and
349
+ other options. `analyze` and `inspect` share the same overview workflow;
350
+ `function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP.
326
351
 
327
352
  Or install the `rea` command globally:
328
353
 
329
354
  ```bash
330
355
  npm install --global rea-agents
331
356
  rea --help
357
+ rea upgrade
332
358
  rea mcp
333
359
  ```
334
360
 
@@ -274,9 +274,15 @@ def _search_page(document, kind, params):
274
274
  raise ValueError("Invalid regex pattern") from error
275
275
  matches = lambda value: expression.search(value) is not None
276
276
 
277
- matching = [item for item in _search_inventory(document, kind) if matches(item[1])]
278
- selected = matching[offset:offset + limit]
279
- total = len(matching)
277
+ selected = []
278
+ total = 0
279
+ page_end = offset + limit
280
+ for item in _search_inventory(document, kind):
281
+ if not matches(item[1]):
282
+ continue
283
+ if offset <= total < page_end:
284
+ selected.append(item)
285
+ total += 1
280
286
  next_offset = offset + len(selected)
281
287
  has_more = next_offset < total
282
288
  return {
@@ -654,7 +660,7 @@ def _serve_connection(connection):
654
660
  should_stop = request["method"] == "shutdown"
655
661
  response = {"id": request_id, "result": _json_safe(result)}
656
662
  except Exception as error:
657
- response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512]}}
663
+ response = {"id": request_id if isinstance(request_id, int) else 0, "error": {"code": -32000, "message": str(error)[:512], "type": _diagnostic_type(error)}}
658
664
  file.write((json.dumps(response, separators=(",", ":")) + "\n").encode("utf-8"))
659
665
  file.flush()
660
666
  if should_stop:
@@ -663,6 +669,14 @@ def _serve_connection(connection):
663
669
  connection.close()
664
670
 
665
671
 
672
+ def _diagnostic_type(error):
673
+ if isinstance(error, PermissionError):
674
+ return "authorization"
675
+ if isinstance(error, (ValueError, TypeError, KeyError)):
676
+ return "invalid_request"
677
+ return "bridge_exception"
678
+
679
+
666
680
  def _run():
667
681
  """Own a permission-restricted, single-client Unix socket for this bridge."""
668
682
  if os.path.exists(REA_SOCKET):
@@ -180,6 +180,15 @@ export class BinarySession {
180
180
  name: this.#providerIdentity.name,
181
181
  version: this.#providerIdentity.version,
182
182
  };
183
+ const providers = new Map();
184
+ if (this.#capabilities === undefined)
185
+ providers.set(provider.id, provider);
186
+ else
187
+ for (const descriptor of this.#capabilities.values())
188
+ providers.set(descriptor.provider.id, descriptor.provider);
189
+ const providerList = [...providers.values()]
190
+ .sort((left, right) => left.id.localeCompare(right.id))
191
+ .map(({ id, name, version }) => ({ id, name, version }));
183
192
  const capabilities = this.#capabilities === undefined
184
193
  ? []
185
194
  : [...this.#capabilities.values()]
@@ -209,10 +218,11 @@ export class BinarySession {
209
218
  limitations: [...descriptor.limitations],
210
219
  }));
211
220
  return target === undefined
212
- ? { open: false, provider, capabilities }
221
+ ? { open: false, provider, providers: providerList, capabilities }
213
222
  : {
214
223
  open: true,
215
224
  provider,
225
+ providers: providerList,
216
226
  capabilities,
217
227
  path: target.path,
218
228
  sha256: target.sha256,
@@ -16,6 +16,19 @@ const WORKFLOW_PROVIDER = {
16
16
  export const runDirectAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, logger);
17
17
  /** Execute one provider-native semantic operation with atomic provenance. */
18
18
  export const runProviderAnalysis = async (path, tool, arguments_, logger = silentLogger) => runAnalysis(path, tool, arguments_, logger);
19
+ /** Describe configured providers without opening a target or launching Hopper. */
20
+ export const runSessionStatus = async (logger = silentLogger) => {
21
+ const config = parseConfig(process.env);
22
+ if (!config.ok)
23
+ return { error: config.error._tag, message: config.error.message };
24
+ const session = createBinarySession(config.value, logger);
25
+ try {
26
+ return session.status();
27
+ }
28
+ finally {
29
+ await session.close();
30
+ }
31
+ };
19
32
  const runAnalysis = async (path, tool, arguments_, logger) => {
20
33
  const config = parseConfig(process.env);
21
34
  if (!config.ok)
@@ -25,10 +38,14 @@ const runAnalysis = async (path, tool, arguments_, logger) => {
25
38
  const opened = await session.open(path);
26
39
  if (!opened.ok)
27
40
  return { error: opened.error._tag, message: opened.error.message };
28
- if (tool === "binary_overview") {
41
+ if (tool === "binary_overview" ||
42
+ tool === "analyze_function" ||
43
+ tool === "trace_feature") {
29
44
  const result = await new EnhancedTools(session).execute(tool, arguments_);
30
45
  return result.ok
31
- ? createEvidence(opened.value, WORKFLOW_PROVIDER, {
46
+ ? createEvidence(opened.value, tool === "analyze_function"
47
+ ? session.providerIdentity(tool)
48
+ : WORKFLOW_PROVIDER, {
32
49
  operation: tool,
33
50
  parameters: arguments_,
34
51
  result: result.value,
@@ -1,6 +1,6 @@
1
1
  import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
2
- import { AnalysisInputError, AnalysisOutputError, } from "../domain/errors.js";
3
- import { parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
2
+ import { AnalysisInputError, AnalysisCancelledError, AnalysisOutputError, projectAnalysisError, } from "../domain/errors.js";
3
+ import { addressDistance, parseDocuments, parseFunctionDossier, parseAddressedPage, parseListCount, parseRelatedAddresses, parseSegments, } from "../domain/hopperValues.js";
4
4
  import { err, ok } from "../domain/result.js";
5
5
  import { categorizeSwiftTypes, discoverObjcClasses, discoverObjcProtocols, discoverSwiftClasses, } from "../domain/symbolAnalysis.js";
6
6
  /**
@@ -89,20 +89,29 @@ export class EnhancedTools {
89
89
  return names.ok ? ok(discoverObjcProtocols(names.value)) : names;
90
90
  }
91
91
  async #batchDecompile(addresses, signal) {
92
- if (addresses.length === 0)
93
- return ok({ error: "No addresses provided" });
94
- const entries = await Promise.all(addresses.map(async (address) => {
92
+ const items = await Promise.all(addresses.map(async (address) => {
95
93
  const result = await this.#call("procedure_pseudo_code", { procedure: address }, signal);
96
- return [
97
- address,
98
- result.ok
99
- ? result.value === null || result.value === ""
100
- ? "No output"
101
- : result.value
102
- : `Error: ${result.error.message}`,
103
- ];
94
+ if (!result.ok)
95
+ return {
96
+ address,
97
+ status: "error",
98
+ error: projectAnalysisError(result.error),
99
+ };
100
+ if (typeof result.value !== "string" || result.value.length === 0)
101
+ return {
102
+ address,
103
+ status: "error",
104
+ error: projectAnalysisError(new AnalysisOutputError("procedure_pseudo_code", "provider returned empty pseudocode")),
105
+ };
106
+ return { address, status: "ok", pseudocode: result.value };
104
107
  }));
105
- return ok(Object.fromEntries(entries));
108
+ const succeeded = items.filter(({ status }) => status === "ok").length;
109
+ return ok({
110
+ items,
111
+ total: items.length,
112
+ succeeded,
113
+ failed: items.length - succeeded,
114
+ });
106
115
  }
107
116
  async #callGraph(input, signal) {
108
117
  const relation = input.direction === "forward" ? "callees" : "callers";
@@ -124,7 +133,8 @@ export class EnhancedTools {
124
133
  if (!result.ok) {
125
134
  graph[level].push({
126
135
  address: current.address,
127
- error: result.error.message,
136
+ status: "error",
137
+ error: projectAnalysisError(result.error),
128
138
  });
129
139
  continue;
130
140
  }
@@ -132,12 +142,14 @@ export class EnhancedTools {
132
142
  if (!related.ok) {
133
143
  graph[level].push({
134
144
  address: current.address,
135
- error: related.error.message,
145
+ status: "error",
146
+ error: projectAnalysisError(related.error),
136
147
  });
137
148
  continue;
138
149
  }
139
150
  graph[level].push({
140
151
  address: current.address,
152
+ status: "ok",
141
153
  calls: [...related.value],
142
154
  });
143
155
  if (current.depth + 1 < input.depth) {
@@ -158,16 +170,24 @@ export class EnhancedTools {
158
170
  : procedures;
159
171
  }
160
172
  async #findXrefs(name, signal) {
161
- const resolved = await this.#call("address_name", { address: name }, signal);
162
- if (!resolved.ok)
163
- return resolved;
164
- const address = resolveAddress(resolved.value);
165
- if (address === undefined)
166
- return ok({ error: `Could not resolve name: ${name}` });
167
- const xrefs = await this.#call("xrefs", { address }, signal);
173
+ const names = await this.#allAddressed("list_names", signal);
174
+ if (!names.ok)
175
+ return names;
176
+ const resolved = names.value.find((entry) => entry.name === name);
177
+ if (resolved === undefined)
178
+ return ok({ status: "unresolved", name, reason: "name_not_found" });
179
+ const xrefs = await this.#call("xrefs", { address: resolved.address }, signal);
168
180
  if (!xrefs.ok)
169
181
  return xrefs;
170
- return ok(Array.isArray(xrefs.value) ? { xrefs: xrefs.value } : xrefs.value);
182
+ if (!Array.isArray(xrefs.value) ||
183
+ xrefs.value.some((xref) => typeof xref !== "string"))
184
+ return err(new AnalysisOutputError("xrefs", "provider returned an invalid address list"));
185
+ return ok({
186
+ status: "resolved",
187
+ name,
188
+ address: resolved.address,
189
+ xrefs: xrefs.value,
190
+ });
171
191
  }
172
192
  async #binaryOverview(input, signal) {
173
193
  const [segmentsResult, documentsResult, procedures, stringsResult] = await Promise.all([
@@ -337,6 +357,8 @@ export class EnhancedTools {
337
357
  }
338
358
  }
339
359
  async #call(name, arguments_, signal) {
360
+ if (signal?.aborted === true)
361
+ return err(new AnalysisCancelledError(name));
340
362
  const execution = await this.analysis.execute(name, arguments_, signal === undefined ? {} : { signal });
341
363
  return execution.ok ? ok(execution.value.result) : execution;
342
364
  }
@@ -344,15 +366,3 @@ export class EnhancedTools {
344
366
  const invalidInput = (name, cause) => Promise.resolve(err(new AnalysisInputError(name, {
345
367
  cause,
346
368
  })));
347
- const resolveAddress = (value) => {
348
- if (typeof value === "string" && value.length > 0)
349
- return value;
350
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
351
- return undefined;
352
- }
353
- const candidate = value.address ?? value.name;
354
- return typeof candidate === "string" && candidate.length > 0
355
- ? candidate
356
- : undefined;
357
- };
358
- const addressDistance = (start, end) => Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));
@@ -1,4 +1,7 @@
1
+ import { EvidenceIntegrityError, } from "../domain/errors.js";
2
+ import { jsonValueSchema } from "../domain/jsonValue.js";
1
3
  import { err, ok } from "../domain/result.js";
4
+ import { compareBundles } from "../domain/bundleComparison.js";
2
5
  import { EvidenceLedger } from "./EvidenceLedger.js";
3
6
  import { readEvidenceBundle, writeEvidenceBundle, } from "./EvidenceBundleFiles.js";
4
7
  /** Validate and merge one bundle using the same bounded ledger as MCP. */
@@ -19,6 +22,25 @@ export const exportEvidenceBundleCommand = async (sourcePath, outputPath, overwr
19
22
  return loaded;
20
23
  return projectWrite(loaded.value, await writeEvidenceBundle(loaded.value, outputPath, overwrite, policy));
21
24
  };
25
+ /** Compare two validated canonical Evidence v2 bundles without session state. */
26
+ export const compareEvidenceBundlesCommand = async (input) => {
27
+ const [left, right] = await Promise.all([
28
+ readEvidenceBundle(input.leftPath, input.policy),
29
+ readEvidenceBundle(input.rightPath, input.policy),
30
+ ]);
31
+ if (!left.ok)
32
+ return left;
33
+ if (!right.ok)
34
+ return right;
35
+ try {
36
+ return ok(jsonValueSchema.parse(compareBundles(left.value, right.value, [], input.offset, input.limit)));
37
+ }
38
+ catch (cause) {
39
+ return err(new EvidenceIntegrityError("Evidence bundle comparison failed", {
40
+ cause,
41
+ }));
42
+ }
43
+ };
22
44
  const createLedger = () => new EvidenceLedger({ maxRecords: 10_000, maxBytes: 64 * 1024 * 1024 });
23
45
  const projectWrite = (bundle, written) => written.ok
24
46
  ? ok({
@@ -54,6 +54,21 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
54
54
  }
55
55
  if (members.length === 0)
56
56
  return { cleaned: true, signaled: false };
57
+ const leader = members.find((member) => member.pid === ownership.leaderPid);
58
+ if (leader !== undefined) {
59
+ if (ownership.expectedParentPid !== undefined &&
60
+ leader.parentPid !== ownership.expectedParentPid)
61
+ return {
62
+ cleaned: false,
63
+ reason: "owned launcher parent identity did not match",
64
+ };
65
+ if (ownership.expectedCommand !== undefined &&
66
+ !commandMatches(leader.command, ownership.expectedCommand))
67
+ return {
68
+ cleaned: false,
69
+ reason: "owned launcher command identity did not match",
70
+ };
71
+ }
57
72
  for (const member of members) {
58
73
  let environment;
59
74
  try {
@@ -82,3 +97,9 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
82
97
  }
83
98
  return { cleaned: true, signaled: true };
84
99
  };
100
+ const commandMatches = (actual, expected) => {
101
+ const normalizedActual = actual.trim();
102
+ const normalizedExpected = expected.trim();
103
+ return (normalizedActual === normalizedExpected ||
104
+ normalizedActual.startsWith(`${normalizedExpected} `));
105
+ };
@@ -25,6 +25,17 @@ export const firstProcedureAddress = (input) => {
25
25
  throw new TypeError("Procedure page was empty");
26
26
  return first.address;
27
27
  };
28
+ /** Reject two target paths that resolve to the same binary content. */
29
+ export const requireDistinctTargetHashes = (firstHash, secondHash) => {
30
+ if (typeof firstHash !== "string" ||
31
+ typeof secondHash !== "string" ||
32
+ firstHash.length === 0 ||
33
+ secondHash.length === 0) {
34
+ throw new TypeError("Target hashes must be non-empty strings");
35
+ }
36
+ if (firstHash === secondHash)
37
+ throw new TypeError("Real-Hopper verification requires distinct binaries");
38
+ };
28
39
  /** Reject empty and success-shaped embedded decompilation failures. */
29
40
  export const requirePseudocode = (input, operation) => {
30
41
  if (typeof input !== "string" || input.trim().length === 0)
@@ -0,0 +1,106 @@
1
+ import { execFile, spawn } from "node:child_process";
2
+ import { realpath } from "node:fs/promises";
3
+ import { basename, dirname, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { promisify } from "node:util";
6
+ import { z } from "zod";
7
+ import { PRODUCT_IDENTITY } from "../identity.js";
8
+ const execFileAsync = promisify(execFile);
9
+ const registryResponseSchema = z.object({ version: z.string().min(1) });
10
+ const UPGRADE_COMMAND = "npm install --global rea-agents@latest";
11
+ /** Check the npm registry and update the same global REA installation. */
12
+ export const runUpgrade = async (currentVersion, host = systemUpgradeHost(), output = "human") => {
13
+ const latestVersion = await host.latestVersion();
14
+ if (latestVersion === currentVersion)
15
+ return {
16
+ status: "current",
17
+ currentVersion,
18
+ latestVersion,
19
+ installMethod: "npm",
20
+ };
21
+ const installation = await host.installation();
22
+ if (installation === undefined)
23
+ return {
24
+ status: "failed",
25
+ currentVersion,
26
+ latestVersion: latestVersion ?? null,
27
+ reason: "unknown-install-method",
28
+ remediation: `Update manually with: ${UPGRADE_COMMAND}`,
29
+ };
30
+ if (!(await host.installLatest(installation, output)))
31
+ return {
32
+ status: "failed",
33
+ currentVersion,
34
+ latestVersion: latestVersion ?? null,
35
+ reason: "install",
36
+ remediation: `${UPGRADE_COMMAND} failed.`,
37
+ };
38
+ return {
39
+ status: "upgraded",
40
+ previousVersion: currentVersion,
41
+ latestVersion: latestVersion ?? null,
42
+ versionCheck: latestVersion === undefined ? "unavailable" : "available",
43
+ installMethod: "npm",
44
+ command: UPGRADE_COMMAND,
45
+ };
46
+ };
47
+ /** Create the npm registry and subprocess effects for a production upgrade. */
48
+ export const systemUpgradeHost = () => ({
49
+ latestVersion: async () => {
50
+ try {
51
+ const response = await fetch(`https://registry.npmjs.org/${PRODUCT_IDENTITY.packageName}/latest`, { signal: AbortSignal.timeout(10_000) });
52
+ if (!response.ok)
53
+ return undefined;
54
+ const parsed = registryResponseSchema.safeParse(await response.json());
55
+ return parsed.success ? parsed.data.version : undefined;
56
+ }
57
+ catch {
58
+ return undefined;
59
+ }
60
+ },
61
+ installation: () => detectNpmInstallation(fileURLToPath(new URL("../..", import.meta.url)), systemNpmInstallationHost),
62
+ installLatest: ({ prefix }, output) => runCommand("npm", [
63
+ "install",
64
+ "--global",
65
+ "--prefix",
66
+ prefix,
67
+ `${PRODUCT_IDENTITY.packageName}@latest`,
68
+ ], output),
69
+ });
70
+ const systemNpmInstallationHost = {
71
+ canonicalPath: realpath,
72
+ globalRoot: async () => (await execFileAsync("npm", ["root", "--global"])).stdout.trim(),
73
+ globalPrefix: async () => (await execFileAsync("npm", ["prefix", "--global"])).stdout.trim(),
74
+ };
75
+ /** Identify the npm prefix only when it owns the running package. */
76
+ export const detectNpmInstallation = async (packageRoot, host) => {
77
+ try {
78
+ const canonicalPackageRoot = await host.canonicalPath(packageRoot);
79
+ const npmRoot = await host.globalRoot();
80
+ const globalPackageRoot = await host.canonicalPath(resolve(npmRoot, PRODUCT_IDENTITY.packageName));
81
+ if (canonicalPackageRoot === globalPackageRoot) {
82
+ const prefix = await host.globalPrefix();
83
+ return prefix.length === 0 ? undefined : { prefix };
84
+ }
85
+ return inferUnixGlobalPrefix(canonicalPackageRoot);
86
+ }
87
+ catch {
88
+ return inferUnixGlobalPrefix(packageRoot);
89
+ }
90
+ };
91
+ const inferUnixGlobalPrefix = (packageRoot) => {
92
+ const nodeModules = dirname(packageRoot);
93
+ const library = dirname(nodeModules);
94
+ if (basename(packageRoot) !== PRODUCT_IDENTITY.packageName ||
95
+ basename(nodeModules) !== "node_modules" ||
96
+ basename(library) !== "lib")
97
+ return undefined;
98
+ return { prefix: dirname(library) };
99
+ };
100
+ const runCommand = (command, arguments_, output) => new Promise((resolveResult) => {
101
+ const child = spawn(command, [...arguments_], {
102
+ stdio: output === "human" ? "inherit" : ["inherit", process.stderr, "inherit"],
103
+ });
104
+ child.once("error", () => resolveResult(false));
105
+ child.once("exit", (code) => resolveResult(code === 0));
106
+ });
package/dist/cli.js CHANGED
@@ -1,14 +1,16 @@
1
1
  import { Cli, z } from "incur";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { runDoctor } from "./application/Doctor.js";
4
- import { runDirectAnalysis, runProviderAnalysis, } from "./application/DirectAnalysis.js";
4
+ import { runDirectAnalysis, runProviderAnalysis, runSessionStatus, } from "./application/DirectAnalysis.js";
5
5
  import { runSetup } from "./application/Setup.js";
6
6
  import { runUninstall } from "./application/Uninstall.js";
7
+ import { runUpgrade, systemUpgradeHost } from "./application/Upgrade.js";
7
8
  import { PRODUCT_IDENTITY } from "./identity.js";
8
9
  import { createLogger, parseLogLevel } from "./logger.js";
10
+ import { logCliCommand } from "./cliLogging.js";
9
11
  import { parseConfig } from "./config.js";
10
- import { exportEvidenceBundleCommand, importEvidenceBundleCommand, } from "./application/EvidenceBundleCommands.js";
11
12
  import { importReferenceSource } from "./application/ReferenceSourceImport.js";
13
+ import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
12
14
  /**
13
15
  * Build the one-shot Incur CLI without starting Hopper at import time.
14
16
  * Analysis commands acquire and close their own sessions; bare `mcp` and
@@ -35,6 +37,23 @@ export const createCli = () => {
35
37
  ],
36
38
  },
37
39
  });
40
+ registerCoreCommands(cli, logger);
41
+ registerFunctionCommand(cli, logger);
42
+ registerSearchCommand(cli, logger);
43
+ registerXrefsCommand(cli, logger);
44
+ registerTraceCommand(cli, logger);
45
+ registerCapabilityCommands(cli, logger);
46
+ registerNativeCommands(cli, logger);
47
+ registerArtifactCommands(cli, logger);
48
+ registerEvidenceCommands(cli, logger);
49
+ registerReferenceSourceCommand(cli, logger);
50
+ return cli;
51
+ };
52
+ const registerCoreCommands = (cli, logger) => {
53
+ const overviewOptions = z.object({
54
+ detail: z.enum(["concise", "detailed"]).default("concise"),
55
+ limit: z.number().int().min(1).max(50).default(10),
56
+ });
38
57
  cli.command("setup", {
39
58
  description: "Install requirements and configure coding agents",
40
59
  options: z.object({
@@ -64,12 +83,25 @@ export const createCli = () => {
64
83
  alias: { purgeData: "purge-data" },
65
84
  run: ({ options }) => logCliCommand(logger, "uninstall", () => runUninstall(options.purgeData)),
66
85
  });
86
+ cli.command("upgrade", {
87
+ description: "Upgrade a global npm installation to the latest REA release",
88
+ run: ({ formatExplicit }) => logCliCommand(logger, "upgrade", () => runUpgrade(process.env.REA_PACKAGE_VERSION ?? "0.0.0-development", systemUpgradeHost(), formatExplicit ? "structured" : "human")),
89
+ });
67
90
  cli.command("analyze", {
68
91
  description: "Get an overview of an app",
69
92
  args: z.object({
70
93
  path: z.string().describe("App, program, or Hopper database path"),
71
94
  }),
72
- run: ({ args }) => logCliCommand(logger, "analyze", () => runDirectAnalysis(args.path, "binary_overview", {}, logger)),
95
+ options: overviewOptions,
96
+ run: ({ args, options }) => logCliCommand(logger, "analyze", () => runDirectAnalysis(args.path, "binary_overview", { detail: options.detail, limit: options.limit }, logger)),
97
+ });
98
+ cli.command("inspect", {
99
+ description: "Inspect an app overview with evidence",
100
+ args: z.object({
101
+ path: z.string().describe("App, program, or Hopper database path"),
102
+ }),
103
+ options: overviewOptions,
104
+ run: ({ args, options }) => logCliCommand(logger, "inspect", () => runDirectAnalysis(args.path, "binary_overview", { detail: options.detail, limit: options.limit }, logger)),
73
105
  });
74
106
  cli.command("decompile", {
75
107
  description: "Read one part of an app as code",
@@ -79,11 +111,101 @@ export const createCli = () => {
79
111
  }),
80
112
  run: ({ args }) => logCliCommand(logger, "decompile", () => runDirectAnalysis(args.path, "procedure_pseudo_code", { procedure: args.address }, logger)),
81
113
  });
82
- registerNativeCommands(cli, logger);
83
- registerArtifactCommands(cli, logger);
84
- registerEvidenceCommands(cli, logger);
85
- registerReferenceSourceCommand(cli, logger);
86
- return cli;
114
+ };
115
+ const registerXrefsCommand = (cli, logger) => {
116
+ cli.command("xrefs", {
117
+ description: "List bounded references to an analyzed address",
118
+ args: z.object({
119
+ path: z.string().describe("App or program path"),
120
+ address: z.string().describe("Hexadecimal address"),
121
+ }),
122
+ run: ({ args }) => logCliCommand(logger, "xrefs", () => runDirectAnalysis(args.path, "xrefs", { address: args.address }, logger)),
123
+ });
124
+ };
125
+ const registerTraceCommand = (cli, logger) => {
126
+ cli.command("trace", {
127
+ description: "Trace a bounded literal feature through analyzed references",
128
+ args: z.object({
129
+ path: z.string().describe("App or program path"),
130
+ query: z.string().min(1).describe("Literal feature query"),
131
+ }),
132
+ options: z.object({
133
+ caseSensitive: z.boolean().default(false),
134
+ limit: z.number().int().min(1).max(100).default(20),
135
+ maxOperations: z.number().int().min(1).max(100).default(20),
136
+ }),
137
+ alias: {
138
+ caseSensitive: "case-sensitive",
139
+ maxOperations: "max-operations",
140
+ },
141
+ run: ({ args, options }) => logCliCommand(logger, "trace", () => runDirectAnalysis(args.path, "trace_feature", {
142
+ query: args.query,
143
+ case_sensitive: options.caseSensitive,
144
+ limit: options.limit,
145
+ max_operations: options.maxOperations,
146
+ }, logger)),
147
+ });
148
+ };
149
+ const registerCapabilityCommands = (cli, logger) => {
150
+ for (const command of ["capabilities", "providers"]) {
151
+ cli.command(command, {
152
+ description: command === "capabilities"
153
+ ? "List provider capabilities and side effects"
154
+ : "List configured analysis providers",
155
+ run: () => logCliCommand(logger, command, () => runSessionStatus(logger)),
156
+ });
157
+ }
158
+ };
159
+ const registerFunctionCommand = (cli, logger) => {
160
+ cli.command("function", {
161
+ description: "Analyze one bounded function with evidence",
162
+ args: z.object({
163
+ path: z.string().describe("App or program path"),
164
+ address: z.string().describe("Procedure name or address"),
165
+ }),
166
+ options: z.object({
167
+ includeAssembly: z.boolean().default(false),
168
+ limit: z.number().int().min(1).max(500).default(100),
169
+ maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000),
170
+ maxInstructions: z.number().int().min(1).max(5_000).default(500),
171
+ }),
172
+ alias: {
173
+ includeAssembly: "include-assembly",
174
+ maxPseudocodeChars: "max-pseudocode-chars",
175
+ maxInstructions: "max-instructions",
176
+ },
177
+ run: ({ args, options }) => logCliCommand(logger, "function", () => runDirectAnalysis(args.path, "analyze_function", {
178
+ procedure: args.address,
179
+ include_assembly: options.includeAssembly,
180
+ limit: options.limit,
181
+ max_pseudocode_chars: options.maxPseudocodeChars,
182
+ max_instructions: options.maxInstructions,
183
+ }, logger)),
184
+ });
185
+ };
186
+ const registerSearchCommand = (cli, logger) => {
187
+ cli.command("search", {
188
+ description: "Search bounded analyzed strings or procedure names",
189
+ args: z.object({
190
+ path: z.string().describe("App or program path"),
191
+ pattern: z.string().min(1).describe("Literal text or regex pattern"),
192
+ }),
193
+ options: z.object({
194
+ kind: z.enum(["strings", "procedures"]).default("strings"),
195
+ mode: z.enum(["literal", "regex"]).default("literal"),
196
+ caseSensitive: z.boolean().default(false),
197
+ offset: z.number().int().min(0).default(0),
198
+ limit: z.number().int().min(1).max(100).default(100),
199
+ }),
200
+ alias: { caseSensitive: "case-sensitive" },
201
+ run: ({ args, options }) => logCliCommand(logger, "search", () => runDirectAnalysis(args.path, options.kind === "strings" ? "search_strings" : "search_procedures", {
202
+ pattern: args.pattern,
203
+ mode: options.mode,
204
+ case_sensitive: options.caseSensitive,
205
+ offset: options.offset,
206
+ limit: options.limit,
207
+ }, logger)),
208
+ });
87
209
  };
88
210
  const registerReferenceSourceCommand = (cli, logger) => {
89
211
  cli.command("import-reference-source", {
@@ -177,59 +299,3 @@ const registerNativeCommands = (cli, logger) => {
177
299
  run: ({ args }) => logCliCommand(logger, "demangle-swift", () => runProviderAnalysis(args.path, "demangle_swift", { symbols: args.symbols }, logger)),
178
300
  });
179
301
  };
180
- const registerEvidenceCommands = (cli, logger) => {
181
- cli.command("evidence-import", {
182
- description: "Validate and import a bounded local Evidence v2 bundle",
183
- args: z.object({
184
- path: z.string().describe("Evidence bundle JSON path"),
185
- }),
186
- run: ({ args }) => logCliCommand(logger, "evidence-import", async () => {
187
- const config = parseConfig(process.env);
188
- if (!config.ok)
189
- return { error: config.error._tag, message: config.error.message };
190
- const imported = await importEvidenceBundleCommand(args.path, config.value.evidenceFilePolicy);
191
- return imported.ok
192
- ? imported.value
193
- : { error: imported.error._tag, message: imported.error.message };
194
- }),
195
- });
196
- cli.command("evidence-export", {
197
- description: "Validate and atomically export canonical Evidence v2 JSON",
198
- args: z.object({
199
- source: z.string().describe("Existing evidence bundle JSON path"),
200
- output: z.string().describe("Canonical output JSON path"),
201
- }),
202
- options: z.object({
203
- overwrite: z.boolean().default(false).describe("Replace output file"),
204
- }),
205
- run: ({ args, options }) => logCliCommand(logger, "evidence-export", async () => {
206
- const config = parseConfig(process.env);
207
- if (!config.ok)
208
- return { error: config.error._tag, message: config.error.message };
209
- const exported = await exportEvidenceBundleCommand(args.source, args.output, options.overwrite, config.value.evidenceFilePolicy);
210
- return exported.ok
211
- ? exported.value
212
- : { error: exported.error._tag, message: exported.error.message };
213
- }),
214
- });
215
- };
216
- const logCliCommand = async (logger, command, execute) => {
217
- const startedAt = performance.now();
218
- try {
219
- const value = await execute();
220
- logger.info({
221
- command,
222
- durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
223
- status: "ok",
224
- }, "CLI command completed");
225
- return value;
226
- }
227
- catch (cause) {
228
- logger.error({
229
- command,
230
- durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
231
- status: "error",
232
- }, "CLI command failed");
233
- throw cause;
234
- }
235
- };
@@ -0,0 +1,68 @@
1
+ import { Cli, z } from "incur";
2
+ import { compareEvidenceBundlesCommand, exportEvidenceBundleCommand, importEvidenceBundleCommand, } from "./application/EvidenceBundleCommands.js";
3
+ import { parseConfig } from "./config.js";
4
+ import { logCliCommand } from "./cliLogging.js";
5
+ /** Register filesystem-gated Evidence v2 commands. */
6
+ export const registerEvidenceCommands = (cli, logger) => {
7
+ cli.command("evidence-import", {
8
+ description: "Validate and import a bounded local Evidence v2 bundle",
9
+ args: z.object({
10
+ path: z.string().describe("Evidence bundle JSON path"),
11
+ }),
12
+ run: ({ args }) => logCliCommand(logger, "evidence-import", async () => {
13
+ const config = parseConfig(process.env);
14
+ if (!config.ok)
15
+ return { error: config.error._tag, message: config.error.message };
16
+ const imported = await importEvidenceBundleCommand(args.path, config.value.evidenceFilePolicy);
17
+ return imported.ok
18
+ ? imported.value
19
+ : { error: imported.error._tag, message: imported.error.message };
20
+ }),
21
+ });
22
+ cli.command("evidence-export", {
23
+ description: "Validate and atomically export canonical Evidence v2 JSON",
24
+ args: z.object({
25
+ source: z.string().describe("Existing evidence bundle JSON path"),
26
+ output: z.string().describe("Canonical output JSON path"),
27
+ }),
28
+ options: z.object({
29
+ overwrite: z.boolean().default(false).describe("Replace output file"),
30
+ }),
31
+ run: ({ args, options }) => logCliCommand(logger, "evidence-export", async () => {
32
+ const config = parseConfig(process.env);
33
+ if (!config.ok)
34
+ return { error: config.error._tag, message: config.error.message };
35
+ const exported = await exportEvidenceBundleCommand(args.source, args.output, options.overwrite, config.value.evidenceFilePolicy);
36
+ return exported.ok
37
+ ? exported.value
38
+ : { error: exported.error._tag, message: exported.error.message };
39
+ }),
40
+ });
41
+ cli.command("compare", {
42
+ aliases: ["compare-bundles"],
43
+ description: "Compare two canonical Evidence v2 bundles",
44
+ args: z.object({
45
+ left: z.string().describe("Left Evidence bundle JSON path"),
46
+ right: z.string().describe("Right Evidence bundle JSON path"),
47
+ }),
48
+ options: z.object({
49
+ offset: z.number().int().min(0).default(0),
50
+ limit: z.number().int().min(1).max(500).default(100),
51
+ }),
52
+ run: ({ args, options }) => logCliCommand(logger, "compare", async () => {
53
+ const config = parseConfig(process.env);
54
+ if (!config.ok)
55
+ return { error: config.error._tag, message: config.error.message };
56
+ const compared = await compareEvidenceBundlesCommand({
57
+ leftPath: args.left,
58
+ rightPath: args.right,
59
+ offset: options.offset,
60
+ limit: options.limit,
61
+ policy: config.value.evidenceFilePolicy,
62
+ });
63
+ return compared.ok
64
+ ? compared.value
65
+ : { error: compared.error._tag, message: compared.error.message };
66
+ }),
67
+ });
68
+ };
@@ -0,0 +1,21 @@
1
+ /** Log one CLI command with duration and a stable success or failure status. */
2
+ export const logCliCommand = async (logger, command, execute) => {
3
+ const startedAt = performance.now();
4
+ try {
5
+ const value = await execute();
6
+ logger.info({
7
+ command,
8
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
9
+ status: "ok",
10
+ }, "CLI command completed");
11
+ return value;
12
+ }
13
+ catch (cause) {
14
+ logger.error({
15
+ command,
16
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
17
+ status: "error",
18
+ }, "CLI command failed");
19
+ throw cause;
20
+ }
21
+ };
@@ -146,10 +146,10 @@ export const ENHANCED_TOOL_CONTRACTS = [
146
146
  enhanced("swift_classes", "Discover legacy-mangled Swift class procedures after exhaustively paging analyzed procedures. Returns at most 100 entries and scans at most 5,000 symbols; use analyze_swift_types for other Swift kinds.", enhancedInputSchemas.swift_classes),
147
147
  enhanced("get_objc_classes", "Discover and deduplicate Objective-C class labels after exhaustively paging names, optionally filtering by literal substring. Returns at most 100 classes; inspect matching metadata and references next.", enhancedInputSchemas.get_objc_classes),
148
148
  enhanced("get_objc_protocols", "Discover and deduplicate Objective-C and Swift protocol labels after exhaustively paging names. Returns at most 100 entries; use xrefs or analyze_function to connect a protocol to implementations.", enhancedInputSchemas.get_objc_protocols),
149
- enhanced("batch_decompile", "Decompile up to 20 explicit procedure symbols or addresses concurrently. Per-item strings may contain errors or no-output markers, so validate each result; use analyze_function for a richer single-function dossier.", enhancedInputSchemas.batch_decompile),
150
- enhanced("get_call_graph", "Traverse Hopper's caller or callee relationships from one symbol or address for at most five levels. Nodes preserve per-procedure errors; indirect calls may be missing and results are not a whole-program CFG.", enhancedInputSchemas.get_call_graph),
149
+ enhanced("batch_decompile", "Decompile up to 20 explicit procedure symbols or addresses concurrently. Returns ordered per-item ok/error variants and aggregate counts; use analyze_function for a richer single-function dossier.", enhancedInputSchemas.batch_decompile),
150
+ enhanced("get_call_graph", "Traverse Hopper's caller or callee relationships from one symbol or address for at most five levels. Every node has an ok/error status and failures use safe typed projections; indirect calls may be missing and results are not a whole-program CFG.", enhancedInputSchemas.get_call_graph),
151
151
  enhanced("analyze_swift_types", "Categorize exhaustively paged procedure names into Swift classes, structs, enums, protocols, extensions, and other symbols. Scans at most 5,000 names and returns at most 50 entries per category.", enhancedInputSchemas.analyze_swift_types),
152
- enhanced("find_xrefs_to_name", "Resolve a name through Hopper and return analyzed references to its address. Use when starting from a selector or symbol; resolution failure is returned explicitly and xrefs remain untyped.", enhancedInputSchemas.find_xrefs_to_name),
152
+ enhanced("find_xrefs_to_name", "Resolve an exact name through Hopper's exhaustively paged name inventory and return a resolved or unresolved result. Unresolved names use the stable name_not_found reason; xrefs remain untyped.", enhancedInputSchemas.find_xrefs_to_name),
153
153
  enhanced("binary_overview", "Use immediately after opening a target to summarize document, exhaustive procedure/string counts, and a bounded segment sample. detail controls segment fields and limit controls only the returned segment sample.", enhancedInputSchemas.binary_overview),
154
154
  enhanced("analyze_function", "Preferred bounded analysis for one procedure symbol or address. Returns identity, pseudocode, optional assembly, comments, calls, incoming references, and blocks; unsupported outgoing references and CFG edges carry explicit unavailable metadata.", enhancedInputSchemas.analyze_function),
155
155
  enhanced("trace_feature", "Trace a bounded literal feature query through matching strings and procedures, xrefs, and truthful containing-procedure resolution. Returns the operation budget, truncation, and residual unknowns; unknown_registry_approved: true records them durably without inferring reference kinds.", enhancedInputSchemas.trace_feature),
@@ -1,4 +1,5 @@
1
1
  import { z } from "zod";
2
+ import { ANALYSIS_ERROR_TAGS } from "../domain/errors.js";
2
3
  import { evidenceSchema } from "../domain/evidence.js";
3
4
  import { processCaptureComparisonSchema, processCaptureSchema, } from "../domain/processCapture.js";
4
5
  import { evidenceBundleSchema } from "../domain/evidenceBundle.js";
@@ -69,12 +70,14 @@ const providerCapability = z.object({
69
70
  }),
70
71
  limitations: z.array(z.string()),
71
72
  });
73
+ const providerIdentity = z.object({
74
+ id: z.string(),
75
+ name: z.string(),
76
+ version: z.string().nullable(),
77
+ });
72
78
  const sessionProvider = z.object({
73
- provider: z.object({
74
- id: z.string(),
75
- name: z.string(),
76
- version: z.string().nullable(),
77
- }),
79
+ provider: providerIdentity,
80
+ providers: z.array(providerIdentity),
78
81
  capabilities: z.array(providerCapability),
79
82
  });
80
83
  const nullableText = z.string().nullable();
@@ -147,9 +150,24 @@ const symbolDiscoveryOutput = (property) => resultOf(z.object({
147
150
  count: z.number().int().min(0),
148
151
  [property]: z.array(addressedEntry),
149
152
  }));
150
- const graphNode = z.union([
151
- z.object({ address: z.string(), calls: z.array(z.string()) }),
152
- z.object({ address: z.string(), error: z.string() }),
153
+ const analysisErrorProjectionSchema = z
154
+ .object({
155
+ tag: z.enum(ANALYSIS_ERROR_TAGS),
156
+ message: z.string(),
157
+ details: z.record(z.string(), z.union([z.string(), z.number(), z.null()])),
158
+ })
159
+ .strict();
160
+ const graphNode = z.discriminatedUnion("status", [
161
+ z.object({
162
+ address: z.string(),
163
+ status: z.literal("ok"),
164
+ calls: z.array(z.string()),
165
+ }),
166
+ z.object({
167
+ address: z.string(),
168
+ status: z.literal("error"),
169
+ error: analysisErrorProjectionSchema,
170
+ }),
153
171
  ]);
154
172
  const functionDossierOutput = resultOf(functionDossierSchema);
155
173
  /** Exact structured-content schemas for the direct Hopper operations. */
@@ -205,10 +223,23 @@ export const enhancedOutputSchemas = {
205
223
  swift_classes: symbolDiscoveryOutput("classes"),
206
224
  get_objc_classes: symbolDiscoveryOutput("classes"),
207
225
  get_objc_protocols: symbolDiscoveryOutput("protocols"),
208
- batch_decompile: resultOf(z.union([
209
- z.object({ error: z.literal("No addresses provided") }),
210
- z.record(z.string(), z.string()),
211
- ])),
226
+ batch_decompile: resultOf(z.object({
227
+ items: z.array(z.discriminatedUnion("status", [
228
+ z.object({
229
+ address: z.string(),
230
+ status: z.literal("ok"),
231
+ pseudocode: z.string().min(1),
232
+ }),
233
+ z.object({
234
+ address: z.string(),
235
+ status: z.literal("error"),
236
+ error: analysisErrorProjectionSchema,
237
+ }),
238
+ ])),
239
+ total: z.number().int().min(0),
240
+ succeeded: z.number().int().min(0),
241
+ failed: z.number().int().min(0),
242
+ })),
212
243
  get_call_graph: resultOf(z.record(z.string(), z.array(graphNode))),
213
244
  analyze_swift_types: resultOf(z.object({
214
245
  total: z.number().int().min(0),
@@ -217,9 +248,18 @@ export const enhancedOutputSchemas = {
217
248
  items: z.array(addressedEntry),
218
249
  })),
219
250
  })),
220
- find_xrefs_to_name: resultOf(z.union([
221
- z.object({ xrefs: addressList }),
222
- z.object({ error: z.string() }),
251
+ find_xrefs_to_name: resultOf(z.discriminatedUnion("status", [
252
+ z.object({
253
+ status: z.literal("resolved"),
254
+ name: z.string(),
255
+ address: z.string(),
256
+ xrefs: addressList,
257
+ }),
258
+ z.object({
259
+ status: z.literal("unresolved"),
260
+ name: z.string(),
261
+ reason: z.literal("name_not_found"),
262
+ }),
223
263
  ])),
224
264
  binary_overview: resultOf(z.object({
225
265
  document: z.string(),
@@ -1,3 +1,29 @@
1
+ /** Stable tags exposed by safe analysis-error projections. */
2
+ export const ANALYSIS_ERROR_TAGS = [
3
+ "AnalysisProtocolError",
4
+ "AnalysisInputError",
5
+ "AnalysisOutputError",
6
+ "AnalysisCapabilityUnavailableError",
7
+ "AnalysisCancelledError",
8
+ "AnalysisTimeoutError",
9
+ "ProviderSelectionError",
10
+ "ProviderAdapterError",
11
+ "ArtifactOperationError",
12
+ "ProcessCaptureError",
13
+ "EvidenceIntegrityError",
14
+ "EvidenceLimitError",
15
+ "EvidenceFileError",
16
+ "UnknownRegistryError",
17
+ "HopperTimeoutError",
18
+ "HopperCancelledError",
19
+ "HopperProtocolError",
20
+ "HopperRemoteError",
21
+ "HopperProcessError",
22
+ "HopperStartError",
23
+ "ConfigurationError",
24
+ "NoBinaryOpenError",
25
+ "BinaryTargetError",
26
+ ];
1
27
  /** Base class for expected analysis, provider, and session failures. */
2
28
  export class AnalysisError extends Error {
3
29
  }
@@ -151,11 +177,13 @@ export class HopperProtocolError extends HopperError {
151
177
  export class HopperRemoteError extends HopperError {
152
178
  code;
153
179
  safeMessage;
180
+ diagnosticType;
154
181
  _tag = "HopperRemoteError";
155
- constructor(code, safeMessage) {
182
+ constructor(code, safeMessage, diagnosticType = "remote") {
156
183
  super(`Hopper request failed (${String(code)}): ${safeMessage}`);
157
184
  this.code = code;
158
185
  this.safeMessage = safeMessage;
186
+ this.diagnosticType = diagnosticType;
159
187
  }
160
188
  }
161
189
  /** The owned Hopper bridge stopped before its client was closed. */
@@ -233,7 +261,11 @@ const safeDetails = (error) => {
233
261
  if (error instanceof HopperTimeoutError)
234
262
  return { timeoutMs: error.timeoutMs };
235
263
  if (error instanceof HopperRemoteError)
236
- return { code: error.code, safeMessage: error.safeMessage };
264
+ return {
265
+ code: error.code,
266
+ safeMessage: error.safeMessage,
267
+ diagnosticType: error.diagnosticType,
268
+ };
237
269
  if (error instanceof HopperProcessError)
238
270
  return { exitCode: error.exitCode };
239
271
  return {};
@@ -1,6 +1,8 @@
1
1
  import { z } from "zod";
2
2
  import { AnalysisOutputError, HopperProtocolError } from "./errors.js";
3
3
  import { err, ok } from "./result.js";
4
+ /** Return the non-negative byte distance between hexadecimal addresses. */
5
+ export const addressDistance = (start, end) => Math.max(0, Number.parseInt(end, 16) - Number.parseInt(start, 16));
4
6
  const procedureMapSchema = z.record(z.string(), z.string());
5
7
  const addressedNamesSchema = z.array(z.object({ address: z.string(), name: z.string() }));
6
8
  const addressedNameMapSchema = z.record(z.string(), z.string());
@@ -12,10 +14,30 @@ const segmentSchema = z.object({
12
14
  writable: z.boolean().nullable().default(null),
13
15
  executable: z.boolean().nullable().default(null),
14
16
  });
15
- const addressedPageSchema = z.object({
17
+ const addressedPageSchema = z
18
+ .object({
16
19
  items: z.array(z.object({ address: z.string(), value: z.string() })),
20
+ offset: z.number().int().min(0),
21
+ limit: z.number().int().min(1),
22
+ total: z.number().int().min(0),
17
23
  next_offset: z.number().int().min(0).nullable(),
18
24
  has_more: z.boolean(),
25
+ })
26
+ .superRefine((value, context) => {
27
+ if (value.has_more && value.next_offset === null) {
28
+ context.addIssue({
29
+ code: "custom",
30
+ message: "a page with more results must provide next_offset",
31
+ path: ["next_offset"],
32
+ });
33
+ }
34
+ if (!value.has_more && value.next_offset !== null) {
35
+ context.addIssue({
36
+ code: "custom",
37
+ message: "a complete page must not provide next_offset",
38
+ path: ["next_offset"],
39
+ });
40
+ }
19
41
  });
20
42
  const unavailableSchema = z
21
43
  .object({ available: z.literal(false), reason: z.string() })
@@ -94,21 +94,13 @@ export class HopperApplicationLauncher {
94
94
  await writeFileAtomic(join(session.directory, "ownership.json"), `${JSON.stringify(ownership)}\n`, { encoding: "utf8", mode: 0o600 });
95
95
  }
96
96
  catch (cause) {
97
- await cleanupOwnedProcessGroup({
98
- runId: session.runId,
99
- leaderPid: pid,
100
- processGroupId: pid,
101
- });
97
+ await cleanupOwnedProcessGroup(ownedProcessGroup(session, pid, this.options.launcherPath));
102
98
  return err(new HopperStartError({ cause }));
103
99
  }
104
100
  return ok({
105
101
  process: child,
106
102
  ownsProcessLifetime: true,
107
- cleanup: () => cleanupOwnedProcessGroup({
108
- runId: session.runId,
109
- leaderPid: pid,
110
- processGroupId: pid,
111
- }),
103
+ cleanup: () => cleanupOwnedProcessGroup(ownedProcessGroup(session, pid, this.options.launcherPath)),
112
104
  });
113
105
  }
114
106
  catch (cause) {
@@ -116,6 +108,13 @@ export class HopperApplicationLauncher {
116
108
  }
117
109
  }
118
110
  }
111
+ const ownedProcessGroup = (session, pid, launcherPath) => ({
112
+ runId: session.runId,
113
+ leaderPid: pid,
114
+ processGroupId: pid,
115
+ expectedCommand: launcherPath,
116
+ expectedParentPid: process.pid,
117
+ });
119
118
  const prepareHopperApplication = async (launcherPath, signal) => {
120
119
  const appBundle = hopperApplicationBundle(launcherPath);
121
120
  if (appBundle === undefined)
@@ -9,7 +9,18 @@ const responseSchema = z.union([
9
9
  }),
10
10
  z.object({
11
11
  id: z.number().int().nonnegative(),
12
- error: z.object({ code: z.number().int(), message: z.string() }),
12
+ error: z.object({
13
+ code: z.number().int(),
14
+ message: z.string(),
15
+ type: z
16
+ .enum([
17
+ "remote",
18
+ "authorization",
19
+ "invalid_request",
20
+ "bridge_exception",
21
+ ])
22
+ .default("remote"),
23
+ }),
13
24
  }),
14
25
  ]);
15
26
  /** Parse one complete Hopper NDJSON response line. */
@@ -28,5 +39,5 @@ export const parseResponseLine = (line) => {
28
39
  };
29
40
  /** Project a parsed response into its result or expected remote failure. */
30
41
  export const responseResult = (response) => "error" in response
31
- ? err(new HopperRemoteError(response.error.code, response.error.message))
42
+ ? err(new HopperRemoteError(response.error.code, response.error.message, response.error.type))
32
43
  : ok(response.result);
@@ -81,6 +81,8 @@ class NativeMacOSClient {
81
81
  this.runner = runner;
82
82
  }
83
83
  async execute(operation, parameters, options) {
84
+ if (options?.signal?.aborted === true)
85
+ return err(new AnalysisCancelledError(operation));
84
86
  if (operation === "health")
85
87
  return ok(createAnalysisExecution(null, IDENTITY));
86
88
  if (!isNativeOperation(operation))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rea-agents",
3
- "version": "0.4.0",
3
+ "version": "1.0.0",
4
4
  "description": "Reverse engineer anything from your terminal or coding agent with one CLI and MCP server.",
5
5
  "license": "MIT",
6
6
  "repository": {