rea-agents 0.4.0 → 0.5.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
@@ -170,12 +170,12 @@ Uninstall preserves Hopper, Homebrew, Node.js, evidence, captures, external evid
170
170
 
171
171
  ### CLI or coding agent?
172
172
 
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` |
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, canonicalize, or compare Evidence v2 bundles | `rea evidence-import`, `rea evidence-export`, or `rea compare` |
178
+ | Import source as historical reference | `rea import-reference-source` |
179
179
 
180
180
  Filesystem evidence commands and MCP file tools are disabled until the operator approves absolute roots:
181
181
 
@@ -183,6 +183,7 @@ Filesystem evidence commands and MCP file tools are disabled until the operator
183
183
  export REA_EVIDENCE_ROOTS_JSON='["/absolute/path/to/evidence"]'
184
184
  rea evidence-import /absolute/path/to/evidence/bundle.json
185
185
  rea evidence-export /absolute/path/to/evidence/bundle.json /absolute/path/to/evidence/canonical.json
186
+ rea compare /absolute/path/to/evidence/left.json /absolute/path/to/evidence/right.json
186
187
  ```
187
188
 
188
189
  Historical source import requires a separate allowlist and never treats source as current behavioral authority:
@@ -276,6 +277,8 @@ REA is growing into a toolkit for understanding software across static artifacts
276
277
 
277
278
  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
279
 
280
+ See the [static-analysis provider evaluation](docs/provider-evaluation.md) for the current research matrix and admission gate.
281
+
279
282
  ## Using REA with other coding agents
280
283
 
281
284
  Setup currently configures Claude Desktop and Cursor automatically. Any coding agent that supports local MCP servers can use REA with the configuration below.
@@ -320,9 +323,20 @@ The agent workflow above is the easiest way to use REA. For a one-off overview f
320
323
 
321
324
  ```bash
322
325
  npx -y rea-agents analyze /Applications/Notes.app
326
+ npx -y rea-agents inspect /Applications/Notes.app
327
+ npx -y rea-agents inspect /Applications/Notes.app --detail detailed --limit 20
328
+ npx -y rea-agents search /Applications/Notes.app "offline"
329
+ npx -y rea-agents function /Applications/Notes.app 0x1000
330
+ npx -y rea-agents xrefs /Applications/Notes.app 0x1000
331
+ npx -y rea-agents trace /Applications/Notes.app "offline"
332
+ npx -y rea-agents compare /absolute/path/to/left-evidence.json /absolute/path/to/right-evidence.json
333
+ npx -y rea-agents capabilities
334
+ npx -y rea-agents providers
323
335
  ```
324
336
 
325
- Run `npx -y rea-agents --help` for direct decompilation and other options.
337
+ Run `npx -y rea-agents --help` for direct decompilation, bounded search and
338
+ other options. `analyze` and `inspect` share the same overview workflow;
339
+ `function`, `xrefs`, and `trace` return the same Evidence v2 envelopes as MCP.
326
340
 
327
341
  Or install the `rea` command globally:
328
342
 
@@ -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,5 +1,5 @@
1
1
  import { enhancedInputSchemas } from "../contracts/enhancedInputs.js";
2
- import { AnalysisInputError, AnalysisOutputError, } from "../domain/errors.js";
2
+ import { AnalysisInputError, AnalysisCancelledError, AnalysisOutputError, } from "../domain/errors.js";
3
3
  import { 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";
@@ -337,6 +337,8 @@ export class EnhancedTools {
337
337
  }
338
338
  }
339
339
  async #call(name, arguments_, signal) {
340
+ if (signal?.aborted === true)
341
+ return err(new AnalysisCancelledError(name));
340
342
  const execution = await this.analysis.execute(name, arguments_, signal === undefined ? {} : { signal });
341
343
  return execution.ok ? ok(execution.value.result) : execution;
342
344
  }
@@ -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)
package/dist/cli.js CHANGED
@@ -1,14 +1,15 @@
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
7
  import { PRODUCT_IDENTITY } from "./identity.js";
8
8
  import { createLogger, parseLogLevel } from "./logger.js";
9
+ import { logCliCommand } from "./cliLogging.js";
9
10
  import { parseConfig } from "./config.js";
10
- import { exportEvidenceBundleCommand, importEvidenceBundleCommand, } from "./application/EvidenceBundleCommands.js";
11
11
  import { importReferenceSource } from "./application/ReferenceSourceImport.js";
12
+ import { registerEvidenceCommands } from "./cliEvidenceCommands.js";
12
13
  /**
13
14
  * Build the one-shot Incur CLI without starting Hopper at import time.
14
15
  * Analysis commands acquire and close their own sessions; bare `mcp` and
@@ -35,6 +36,23 @@ export const createCli = () => {
35
36
  ],
36
37
  },
37
38
  });
39
+ registerCoreCommands(cli, logger);
40
+ registerFunctionCommand(cli, logger);
41
+ registerSearchCommand(cli, logger);
42
+ registerXrefsCommand(cli, logger);
43
+ registerTraceCommand(cli, logger);
44
+ registerCapabilityCommands(cli, logger);
45
+ registerNativeCommands(cli, logger);
46
+ registerArtifactCommands(cli, logger);
47
+ registerEvidenceCommands(cli, logger);
48
+ registerReferenceSourceCommand(cli, logger);
49
+ return cli;
50
+ };
51
+ const registerCoreCommands = (cli, logger) => {
52
+ const overviewOptions = z.object({
53
+ detail: z.enum(["concise", "detailed"]).default("concise"),
54
+ limit: z.number().int().min(1).max(50).default(10),
55
+ });
38
56
  cli.command("setup", {
39
57
  description: "Install requirements and configure coding agents",
40
58
  options: z.object({
@@ -69,7 +87,16 @@ export const createCli = () => {
69
87
  args: z.object({
70
88
  path: z.string().describe("App, program, or Hopper database path"),
71
89
  }),
72
- run: ({ args }) => logCliCommand(logger, "analyze", () => runDirectAnalysis(args.path, "binary_overview", {}, logger)),
90
+ options: overviewOptions,
91
+ run: ({ args, options }) => logCliCommand(logger, "analyze", () => runDirectAnalysis(args.path, "binary_overview", { detail: options.detail, limit: options.limit }, logger)),
92
+ });
93
+ cli.command("inspect", {
94
+ description: "Inspect an app overview with evidence",
95
+ args: z.object({
96
+ path: z.string().describe("App, program, or Hopper database path"),
97
+ }),
98
+ options: overviewOptions,
99
+ run: ({ args, options }) => logCliCommand(logger, "inspect", () => runDirectAnalysis(args.path, "binary_overview", { detail: options.detail, limit: options.limit }, logger)),
73
100
  });
74
101
  cli.command("decompile", {
75
102
  description: "Read one part of an app as code",
@@ -79,11 +106,101 @@ export const createCli = () => {
79
106
  }),
80
107
  run: ({ args }) => logCliCommand(logger, "decompile", () => runDirectAnalysis(args.path, "procedure_pseudo_code", { procedure: args.address }, logger)),
81
108
  });
82
- registerNativeCommands(cli, logger);
83
- registerArtifactCommands(cli, logger);
84
- registerEvidenceCommands(cli, logger);
85
- registerReferenceSourceCommand(cli, logger);
86
- return cli;
109
+ };
110
+ const registerXrefsCommand = (cli, logger) => {
111
+ cli.command("xrefs", {
112
+ description: "List bounded references to an analyzed address",
113
+ args: z.object({
114
+ path: z.string().describe("App or program path"),
115
+ address: z.string().describe("Hexadecimal address"),
116
+ }),
117
+ run: ({ args }) => logCliCommand(logger, "xrefs", () => runDirectAnalysis(args.path, "xrefs", { address: args.address }, logger)),
118
+ });
119
+ };
120
+ const registerTraceCommand = (cli, logger) => {
121
+ cli.command("trace", {
122
+ description: "Trace a bounded literal feature through analyzed references",
123
+ args: z.object({
124
+ path: z.string().describe("App or program path"),
125
+ query: z.string().min(1).describe("Literal feature query"),
126
+ }),
127
+ options: z.object({
128
+ caseSensitive: z.boolean().default(false),
129
+ limit: z.number().int().min(1).max(100).default(20),
130
+ maxOperations: z.number().int().min(1).max(100).default(20),
131
+ }),
132
+ alias: {
133
+ caseSensitive: "case-sensitive",
134
+ maxOperations: "max-operations",
135
+ },
136
+ run: ({ args, options }) => logCliCommand(logger, "trace", () => runDirectAnalysis(args.path, "trace_feature", {
137
+ query: args.query,
138
+ case_sensitive: options.caseSensitive,
139
+ limit: options.limit,
140
+ max_operations: options.maxOperations,
141
+ }, logger)),
142
+ });
143
+ };
144
+ const registerCapabilityCommands = (cli, logger) => {
145
+ for (const command of ["capabilities", "providers"]) {
146
+ cli.command(command, {
147
+ description: command === "capabilities"
148
+ ? "List provider capabilities and side effects"
149
+ : "List configured analysis providers",
150
+ run: () => logCliCommand(logger, command, () => runSessionStatus(logger)),
151
+ });
152
+ }
153
+ };
154
+ const registerFunctionCommand = (cli, logger) => {
155
+ cli.command("function", {
156
+ description: "Analyze one bounded function with evidence",
157
+ args: z.object({
158
+ path: z.string().describe("App or program path"),
159
+ address: z.string().describe("Procedure name or address"),
160
+ }),
161
+ options: z.object({
162
+ includeAssembly: z.boolean().default(false),
163
+ limit: z.number().int().min(1).max(500).default(100),
164
+ maxPseudocodeChars: z.number().int().min(1).max(100_000).default(20_000),
165
+ maxInstructions: z.number().int().min(1).max(5_000).default(500),
166
+ }),
167
+ alias: {
168
+ includeAssembly: "include-assembly",
169
+ maxPseudocodeChars: "max-pseudocode-chars",
170
+ maxInstructions: "max-instructions",
171
+ },
172
+ run: ({ args, options }) => logCliCommand(logger, "function", () => runDirectAnalysis(args.path, "analyze_function", {
173
+ procedure: args.address,
174
+ include_assembly: options.includeAssembly,
175
+ limit: options.limit,
176
+ max_pseudocode_chars: options.maxPseudocodeChars,
177
+ max_instructions: options.maxInstructions,
178
+ }, logger)),
179
+ });
180
+ };
181
+ const registerSearchCommand = (cli, logger) => {
182
+ cli.command("search", {
183
+ description: "Search bounded analyzed strings or procedure names",
184
+ args: z.object({
185
+ path: z.string().describe("App or program path"),
186
+ pattern: z.string().min(1).describe("Literal text or regex pattern"),
187
+ }),
188
+ options: z.object({
189
+ kind: z.enum(["strings", "procedures"]).default("strings"),
190
+ mode: z.enum(["literal", "regex"]).default("literal"),
191
+ caseSensitive: z.boolean().default(false),
192
+ offset: z.number().int().min(0).default(0),
193
+ limit: z.number().int().min(1).max(100).default(100),
194
+ }),
195
+ alias: { caseSensitive: "case-sensitive" },
196
+ run: ({ args, options }) => logCliCommand(logger, "search", () => runDirectAnalysis(args.path, options.kind === "strings" ? "search_strings" : "search_procedures", {
197
+ pattern: args.pattern,
198
+ mode: options.mode,
199
+ case_sensitive: options.caseSensitive,
200
+ offset: options.offset,
201
+ limit: options.limit,
202
+ }, logger)),
203
+ });
87
204
  };
88
205
  const registerReferenceSourceCommand = (cli, logger) => {
89
206
  cli.command("import-reference-source", {
@@ -177,59 +294,3 @@ const registerNativeCommands = (cli, logger) => {
177
294
  run: ({ args }) => logCliCommand(logger, "demangle-swift", () => runProviderAnalysis(args.path, "demangle_swift", { symbols: args.symbols }, logger)),
178
295
  });
179
296
  };
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
+ };
@@ -69,12 +69,14 @@ const providerCapability = z.object({
69
69
  }),
70
70
  limitations: z.array(z.string()),
71
71
  });
72
+ const providerIdentity = z.object({
73
+ id: z.string(),
74
+ name: z.string(),
75
+ version: z.string().nullable(),
76
+ });
72
77
  const sessionProvider = z.object({
73
- provider: z.object({
74
- id: z.string(),
75
- name: z.string(),
76
- version: z.string().nullable(),
77
- }),
78
+ provider: providerIdentity,
79
+ providers: z.array(providerIdentity),
78
80
  capabilities: z.array(providerCapability),
79
81
  });
80
82
  const nullableText = z.string().nullable();
@@ -151,11 +151,13 @@ export class HopperProtocolError extends HopperError {
151
151
  export class HopperRemoteError extends HopperError {
152
152
  code;
153
153
  safeMessage;
154
+ diagnosticType;
154
155
  _tag = "HopperRemoteError";
155
- constructor(code, safeMessage) {
156
+ constructor(code, safeMessage, diagnosticType = "remote") {
156
157
  super(`Hopper request failed (${String(code)}): ${safeMessage}`);
157
158
  this.code = code;
158
159
  this.safeMessage = safeMessage;
160
+ this.diagnosticType = diagnosticType;
159
161
  }
160
162
  }
161
163
  /** The owned Hopper bridge stopped before its client was closed. */
@@ -233,7 +235,11 @@ const safeDetails = (error) => {
233
235
  if (error instanceof HopperTimeoutError)
234
236
  return { timeoutMs: error.timeoutMs };
235
237
  if (error instanceof HopperRemoteError)
236
- return { code: error.code, safeMessage: error.safeMessage };
238
+ return {
239
+ code: error.code,
240
+ safeMessage: error.safeMessage,
241
+ diagnosticType: error.diagnosticType,
242
+ };
237
243
  if (error instanceof HopperProcessError)
238
244
  return { exitCode: error.exitCode };
239
245
  return {};
@@ -12,10 +12,30 @@ const segmentSchema = z.object({
12
12
  writable: z.boolean().nullable().default(null),
13
13
  executable: z.boolean().nullable().default(null),
14
14
  });
15
- const addressedPageSchema = z.object({
15
+ const addressedPageSchema = z
16
+ .object({
16
17
  items: z.array(z.object({ address: z.string(), value: z.string() })),
18
+ offset: z.number().int().min(0),
19
+ limit: z.number().int().min(1),
20
+ total: z.number().int().min(0),
17
21
  next_offset: z.number().int().min(0).nullable(),
18
22
  has_more: z.boolean(),
23
+ })
24
+ .superRefine((value, context) => {
25
+ if (value.has_more && value.next_offset === null) {
26
+ context.addIssue({
27
+ code: "custom",
28
+ message: "a page with more results must provide next_offset",
29
+ path: ["next_offset"],
30
+ });
31
+ }
32
+ if (!value.has_more && value.next_offset !== null) {
33
+ context.addIssue({
34
+ code: "custom",
35
+ message: "a complete page must not provide next_offset",
36
+ path: ["next_offset"],
37
+ });
38
+ }
19
39
  });
20
40
  const unavailableSchema = z
21
41
  .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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "rea-agents",
3
- "version": "0.4.0",
3
+ "version": "0.5.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": {