rea-agents 1.1.0 → 1.3.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.
Files changed (103) hide show
  1. package/README.md +94 -28
  2. package/bridge/hopper_bridge.py +161 -12
  3. package/dist/application/AnalysisSnapshotCache.js +131 -0
  4. package/dist/application/AnalysisSnapshotFiles.js +31 -0
  5. package/dist/application/ArtifactInventory.js +84 -44
  6. package/dist/application/AuthorizedArtifactInventory.js +16 -0
  7. package/dist/application/BinarySession.js +66 -36
  8. package/dist/application/BinarySessionPort.js +1 -0
  9. package/dist/application/BoundedJsonFiles.js +109 -0
  10. package/dist/application/CommandShimReplay.js +136 -0
  11. package/dist/application/CrossVersionInventory.js +98 -0
  12. package/dist/application/CrossVersionInvestigation.js +295 -0
  13. package/dist/application/DirectAnalysis.js +115 -22
  14. package/dist/application/Doctor.js +30 -1
  15. package/dist/application/EvidenceBundleFiles.js +16 -101
  16. package/dist/application/EvidenceLedger.js +3 -2
  17. package/dist/application/InvestigationProviders.js +48 -0
  18. package/dist/application/InvestigationWorkspaceStore.js +214 -0
  19. package/dist/application/LinuxHopper.js +72 -48
  20. package/dist/application/ProcessCaptureAuthority.js +25 -0
  21. package/dist/application/ProcessCaptureCapability.js +23 -0
  22. package/dist/application/ProcessCaptureError.js +17 -0
  23. package/dist/application/ProcessCaptureLifecycle.js +287 -0
  24. package/dist/application/ProcessCheckpoints.js +129 -0
  25. package/dist/application/ProcessCli.js +128 -0
  26. package/dist/application/ProcessEvidence.js +38 -0
  27. package/dist/application/ProcessHarness.js +252 -272
  28. package/dist/application/ProcessNormalization.js +10 -1
  29. package/dist/application/ProcessOwnership.js +39 -1
  30. package/dist/application/ProcessSampling.js +39 -23
  31. package/dist/application/ReferenceSourceImportEntries.js +18 -3
  32. package/dist/application/ReferenceSourceImportTypes.js +32 -0
  33. package/dist/application/Setup.js +44 -96
  34. package/dist/application/SetupClients.js +19 -0
  35. package/dist/application/SetupInstallFailure.js +53 -0
  36. package/dist/application/SetupPlan.js +31 -0
  37. package/dist/application/SetupSkill.js +44 -0
  38. package/dist/application/TerminalRenderer.js +92 -0
  39. package/dist/application/Uninstall.js +33 -23
  40. package/dist/application/UnknownEvidence.js +33 -0
  41. package/dist/application/Upgrade.js +1 -1
  42. package/dist/application/runtime.js +2 -2
  43. package/dist/artifacts/ArtifactProvider.js +20 -11
  44. package/dist/artifacts/ArtifactReader.js +3 -1
  45. package/dist/artifacts/AsarArtifactReader.js +8 -1
  46. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  47. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  48. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  49. package/dist/artifacts/ZipArtifactReader.js +1 -0
  50. package/dist/cli.js +57 -32
  51. package/dist/cliEvidenceCommands.js +11 -12
  52. package/dist/cliInvestigationCommands.js +86 -0
  53. package/dist/cliOutput.js +41 -0
  54. package/dist/cliProcessCommands.js +29 -0
  55. package/dist/config.js +41 -24
  56. package/dist/contracts/artifactToolContracts.js +1 -0
  57. package/dist/contracts/investigationExamples.js +3 -3
  58. package/dist/contracts/processCaptureExample.js +50 -7
  59. package/dist/contracts/promptContracts.js +256 -0
  60. package/dist/contracts/sessionLifecycleInputs.js +11 -0
  61. package/dist/contracts/toolContractTypes.js +1 -0
  62. package/dist/contracts/toolContracts.js +13 -8
  63. package/dist/contracts/toolOutputSchemas.js +33 -4
  64. package/dist/domain/analysisSnapshot.js +149 -0
  65. package/dist/domain/changedBehavior.js +32 -9
  66. package/dist/domain/errors.js +186 -39
  67. package/dist/domain/evidence.js +4 -2
  68. package/dist/domain/evidenceBundle.js +28 -0
  69. package/dist/domain/hopperStartupFailure.js +55 -0
  70. package/dist/domain/investigationWorkspace.js +214 -0
  71. package/dist/domain/jsonValue.js +2 -0
  72. package/dist/domain/nativeInspection.js +3 -2
  73. package/dist/domain/processCapture.js +100 -293
  74. package/dist/domain/processCaptureValidation.js +124 -0
  75. package/dist/domain/processComparison.js +177 -32
  76. package/dist/domain/processScenario.js +370 -0
  77. package/dist/domain/reconstructionVerification.js +3 -3
  78. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  79. package/dist/hopper/BridgeLauncher.js +47 -20
  80. package/dist/hopper/HopperClient.js +17 -2
  81. package/dist/hopper/HopperProvider.js +1 -0
  82. package/dist/identity.js +1 -0
  83. package/dist/main.js +47 -24
  84. package/dist/server/createServer.js +2 -0
  85. package/dist/server/promptCompletion.js +144 -0
  86. package/dist/server/registerEnhancedTools.js +2 -6
  87. package/dist/server/registerEvidenceTools.js +2 -8
  88. package/dist/server/registerFunctionComparisonTool.js +1 -1
  89. package/dist/server/registerInvestigationTools.js +31 -11
  90. package/dist/server/registerOfficialTools.js +6 -13
  91. package/dist/server/registerProcessComparisonTool.js +28 -14
  92. package/dist/server/registerPrompts.js +67 -0
  93. package/dist/server/registerSessionStatusTool.js +11 -0
  94. package/dist/server/registerSessionTools.js +55 -43
  95. package/dist/server/sessionEvidence.js +2 -8
  96. package/dist/server/sessionToolPolicies.js +2 -48
  97. package/dist/server/toolResult.js +7 -3
  98. package/install.sh +11 -11
  99. package/package.json +9 -3
  100. package/scripts/hopper-demo-x11.py +349 -0
  101. package/scripts/prepare-node-pty.mjs +35 -0
  102. package/scripts/rea.mjs +6 -3
  103. package/skills/rea-analysis/SKILL.md +27 -5
@@ -43,7 +43,13 @@ const systemHost = {
43
43
  process.kill(-processGroupId, signal);
44
44
  },
45
45
  };
46
- /** Verify run-token ownership before signaling a POSIX process group. */
46
+ /**
47
+ * Verify run-token ownership before signaling a POSIX process group.
48
+ *
49
+ * Process IDs and group IDs can be reused. REA therefore re-reads every live
50
+ * member's environment immediately before signaling and fails closed if any
51
+ * member cannot be inspected or lacks the per-capture run token.
52
+ */
47
53
  export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) => {
48
54
  let members;
49
55
  try {
@@ -97,6 +103,38 @@ export const cleanupOwnedProcessGroup = async (ownership, host = systemHost) =>
97
103
  }
98
104
  return { cleaned: true, signaled: true };
99
105
  };
106
+ /** Observe one group without signaling it, failing closed on identity doubt. */
107
+ export const observeOwnedProcessGroup = async (ownership, host = systemHost) => {
108
+ let members;
109
+ try {
110
+ members = await host.listMembers(ownership.processGroupId);
111
+ }
112
+ catch {
113
+ return {
114
+ state: "unverifiable",
115
+ reason: "process group could not be inspected",
116
+ };
117
+ }
118
+ if (members.length === 0)
119
+ return { state: "empty" };
120
+ for (const member of members) {
121
+ try {
122
+ if ((await host.environment(member.pid)).REA_PROCESS_RUN_ID !==
123
+ ownership.runId)
124
+ return {
125
+ state: "unverifiable",
126
+ reason: "process ownership did not match",
127
+ };
128
+ }
129
+ catch {
130
+ return {
131
+ state: "unverifiable",
132
+ reason: "process ownership could not be revalidated",
133
+ };
134
+ }
135
+ }
136
+ return { state: "alive" };
137
+ };
100
138
  const commandMatches = (actual, expected) => {
101
139
  const normalizedActual = actual.trim();
102
140
  const normalizedExpected = expected.trim();
@@ -24,17 +24,25 @@ const parseProcStat = (identifier, stat) => {
24
24
  .split(/\s+/);
25
25
  const pid = Number(identifier);
26
26
  const parentPid = Number(fields[1]);
27
+ const processGroupId = Number(fields[2]);
28
+ const sessionId = Number(fields[3]);
27
29
  const startTime = fields[19];
28
30
  if (!Number.isSafeInteger(pid) ||
29
31
  pid <= 0 ||
30
32
  !Number.isSafeInteger(parentPid) ||
31
33
  parentPid < 0 ||
34
+ !Number.isSafeInteger(processGroupId) ||
35
+ processGroupId <= 0 ||
36
+ !Number.isSafeInteger(sessionId) ||
37
+ sessionId <= 0 ||
32
38
  startTime === undefined ||
33
39
  !/^\d+$/.test(startTime))
34
40
  return undefined;
35
41
  return {
36
42
  pid,
37
43
  parent_pid: parentPid,
44
+ process_group_id: processGroupId,
45
+ session_id: sessionId,
38
46
  command: "",
39
47
  startTime,
40
48
  };
@@ -104,13 +112,15 @@ const inspectProcess = async (pid, expectedParent, signal, identities) => {
104
112
  return {
105
113
  pid: before.pid,
106
114
  parent_pid: before.parent_pid,
115
+ process_group_id: before.process_group_id,
116
+ session_id: before.session_id,
107
117
  startTime: before.startTime,
108
118
  command,
109
119
  children: children ?? [],
110
120
  };
111
121
  };
112
122
  const sampleLinux = async (context) => {
113
- const { rootPid, limit, signal, sampledPids, identities } = context;
123
+ const { rootPid, limit, signal, identities } = context;
114
124
  if (limit <= 0)
115
125
  return [];
116
126
  const rootStat = await readProcessStat(rootPid, signal);
@@ -131,11 +141,9 @@ const sampleLinux = async (context) => {
131
141
  for (const node of batchResults) {
132
142
  if (node === undefined)
133
143
  continue;
134
- if (!sampledPids.has(node.pid)) {
135
- if (rows.length >= limit)
136
- break;
137
- rows.push(node);
138
- }
144
+ if (rows.length >= limit)
145
+ break;
146
+ rows.push(node);
139
147
  if (rows.length >= limit)
140
148
  break;
141
149
  for (const child of node.children) {
@@ -152,15 +160,17 @@ const sampleLinux = async (context) => {
152
160
  return rows;
153
161
  };
154
162
  const readPsRows = async (signal) => {
155
- const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,command="], { signal });
163
+ const { stdout } = await execFileAsync("ps", ["-axo", "pid=,ppid=,pgid=,sess=,command="], { signal });
156
164
  return stdout
157
165
  .split("\n")
158
- .map((line) => /\s*(\d+)\s+(\d+)\s+(.*)/u.exec(line))
166
+ .map((line) => /\s*(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(.*)/u.exec(line))
159
167
  .filter((match) => match !== null)
160
168
  .map((match) => ({
161
169
  pid: Number(match[1]),
162
170
  parent_pid: Number(match[2]),
163
- command: match[3] ?? "",
171
+ process_group_id: Number(match[3]),
172
+ session_id: Number(match[4]),
173
+ command: match[5] ?? "",
164
174
  startTime: undefined,
165
175
  }))
166
176
  .filter((row) => Number.isSafeInteger(row.pid) &&
@@ -170,7 +180,7 @@ const readPsRows = async (signal) => {
170
180
  .sort((left, right) => left.pid - right.pid);
171
181
  };
172
182
  const samplePs = async (context) => {
173
- const { rootPid, limit, signal, sampledPids } = context;
183
+ const { rootPid, limit, signal } = context;
174
184
  if (limit <= 0)
175
185
  return [];
176
186
  const rows = await readPsRows(signal);
@@ -194,9 +204,8 @@ const samplePs = async (context) => {
194
204
  continue;
195
205
  visited.add(pid);
196
206
  const row = rowByPid.get(pid);
197
- if (row !== undefined && !sampledPids.has(row.pid)) {
207
+ if (row !== undefined)
198
208
  result.push(row);
199
- }
200
209
  if (result.length >= limit)
201
210
  break;
202
211
  for (const child of childrenByParent.get(pid) ?? []) {
@@ -212,11 +221,9 @@ const sampleProcesses = async (context) => {
212
221
  const rows = process.platform === "linux"
213
222
  ? await sampleLinux(context)
214
223
  : await samplePs(context);
215
- const { elapsedMs, sampledPids, identities } = context;
224
+ const { elapsedMs, identities } = context;
216
225
  const samples = [];
217
226
  for (const row of rows) {
218
- if (sampledPids.has(row.pid))
219
- continue;
220
227
  if (row.startTime !== undefined) {
221
228
  const existing = identities.get(row.pid);
222
229
  if (existing !== undefined && existing !== row.startTime)
@@ -228,6 +235,8 @@ const sampleProcesses = async (context) => {
228
235
  pid: row.pid,
229
236
  parent_pid: row.parent_pid,
230
237
  command: row.command,
238
+ process_group_id: row.process_group_id,
239
+ session_id: row.session_id,
231
240
  });
232
241
  }
233
242
  return samples;
@@ -235,31 +244,38 @@ const sampleProcesses = async (context) => {
235
244
  /** Start bounded process-tree sampling and expose an awaited stop. */
236
245
  export const startProcessSampler = (rootPid, started, limit, samples) => {
237
246
  const identities = new Map();
238
- const sampledPids = new Set(samples.map(({ pid }) => pid));
247
+ const lastObservations = new Map();
239
248
  let pending;
240
249
  let stopped = false;
241
250
  let abortCurrent;
242
251
  let partial = false;
243
252
  const sample = () => {
244
- if (stopped || pending !== undefined || sampledPids.size >= limit)
253
+ if (stopped || pending !== undefined)
245
254
  return;
246
255
  const controller = new AbortController();
247
256
  abortCurrent = () => controller.abort();
248
257
  pending = sampleProcesses({
249
258
  rootPid,
250
259
  elapsedMs: Date.now() - started,
251
- limit: limit - sampledPids.size,
252
- sampledPids,
260
+ limit: limit + 1,
253
261
  identities,
254
262
  signal: controller.signal,
255
263
  })
256
264
  .then((values) => {
257
265
  for (const value of values) {
258
- if (sampledPids.size >= limit)
259
- break;
260
- if (sampledPids.has(value.pid))
266
+ const observation = JSON.stringify({
267
+ parent_pid: value.parent_pid,
268
+ command: value.command,
269
+ process_group_id: value.process_group_id,
270
+ session_id: value.session_id,
271
+ });
272
+ if (lastObservations.get(value.pid) === observation)
273
+ continue;
274
+ if (samples.length >= limit) {
275
+ partial = true;
261
276
  continue;
262
- sampledPids.add(value.pid);
277
+ }
278
+ lastObservations.set(value.pid, observation);
263
279
  samples.push(value);
264
280
  }
265
281
  })
@@ -3,16 +3,31 @@ import { posix } from "node:path";
3
3
  import { classifyReferenceSourcePath, detectReferenceSourceLanguage, } from "../domain/referenceSourceClassification.js";
4
4
  import { parseReferenceSourceImports } from "../domain/referenceSourceImportParsing.js";
5
5
  import { PARSEABLE_REFERENCE_SOURCE_LANGUAGES } from "./ReferenceSourceImportTypes.js";
6
+ /** Project a low-level entry failure into safe import guidance. */
7
+ export const projectReferenceSourceEntryFailure = (entry) => {
8
+ if (entry.code === "cancelled")
9
+ return "This entry was not read because the import was cancelled. Start the import again when ready.";
10
+ if (entry.code === "limit")
11
+ return "This entry was not read because an import limit was reached. Import a smaller directory or raise the configured limits.";
12
+ if (entry.code === "unsupported")
13
+ return "This entry cannot be read safely on this system. Exclude it or import the directory on a supported system.";
14
+ if (entry.kind === "directory")
15
+ return "This directory could not be read. Check its permissions, then try again.";
16
+ if (entry.kind === "symlink")
17
+ return "This symbolic link could not be read safely. Check the link and its permissions, then try again.";
18
+ return "This file could not be read. Check its permissions, then try again.";
19
+ };
6
20
  const hashBytes = (bytes) => createHash("sha256").update(bytes).digest("hex");
7
21
  const failedEntry = (entry) => {
8
22
  const classifications = classifyReferenceSourcePath(entry.path);
23
+ const limitation = projectReferenceSourceEntryFailure(entry);
9
24
  if (entry.kind === "directory")
10
25
  return {
11
26
  path: entry.path,
12
27
  kind: "directory",
13
28
  classifications,
14
29
  tree_state: entry.code === "limit" ? "partial" : "unreadable",
15
- limitations: [entry.message],
30
+ limitations: [limitation],
16
31
  };
17
32
  if (entry.kind === "symlink")
18
33
  return {
@@ -21,7 +36,7 @@ const failedEntry = (entry) => {
21
36
  target: "<unreadable>",
22
37
  target_state: "unreadable",
23
38
  classifications,
24
- limitations: [entry.message],
39
+ limitations: [limitation],
25
40
  };
26
41
  return {
27
42
  path: entry.path,
@@ -31,7 +46,7 @@ const failedEntry = (entry) => {
31
46
  language: null,
32
47
  classifications: entry.kind === "file" ? classifications : ["unknown"],
33
48
  content_state: entry.code === "limit" ? "too-large" : "unreadable",
34
- limitations: [entry.message],
49
+ limitations: [limitation],
35
50
  };
36
51
  };
37
52
  const resolveInternalSpecifier = (fromPath, specifier, filePaths) => {
@@ -1,3 +1,35 @@
1
+ /** Safe CLI projection for a historical-source import failure. */
2
+ export const projectReferenceSourceImportError = (error) => {
3
+ if (error.code === "cancelled")
4
+ return {
5
+ category: "cancelled",
6
+ message: "Reference source import was cancelled. Start it again when ready.",
7
+ };
8
+ if (error.code === "invalid-limits")
9
+ return {
10
+ category: "invalid_input",
11
+ message: "Reference source limits are invalid. Use positive integer limits, then try again.",
12
+ };
13
+ if (error.code === "invalid-root")
14
+ return {
15
+ category: "invalid_input",
16
+ message: "Reference source directory could not be opened. Check that the path exists, is readable, and points to a directory.",
17
+ };
18
+ if (error.code === "policy")
19
+ return {
20
+ category: "permission_required",
21
+ message: "Reference source directory is not approved. Add its directory to `REA_REFERENCE_ROOTS_JSON`, restart REA, then try again.",
22
+ };
23
+ if (error.code === "io")
24
+ return {
25
+ category: "execution_failure",
26
+ message: "Reference source files could not be read. Check directory permissions and try again.",
27
+ };
28
+ return {
29
+ category: "execution_failure",
30
+ message: "Reference source could not be indexed. Check that the source tree is readable, then try again.",
31
+ };
32
+ };
1
33
  export const DEFAULT_REFERENCE_SOURCE_IGNORE_PATTERNS = [
2
34
  ".git/",
3
35
  ".git/hooks/",
@@ -9,6 +9,11 @@ import { supportsNodeVersion } from "../domain/runtimeVersion.js";
9
9
  import { runDoctor, systemDoctorHost } from "./Doctor.js";
10
10
  import { installLinuxHopper, readLinuxDistribution, } from "./LinuxHopper.js";
11
11
  import { installMacHopper } from "./MacHopper.js";
12
+ import { configureDetectedClients } from "./SetupClients.js";
13
+ import { installCanonicalSkill } from "./SetupSkill.js";
14
+ import { setupPlan } from "./SetupPlan.js";
15
+ import { setupInstallFailure, } from "./SetupInstallFailure.js";
16
+ export { installCanonicalSkill } from "./SetupSkill.js";
12
17
  const registrationCommand = () => process.env.npm_command === "exec"
13
18
  ? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
14
19
  : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
@@ -33,35 +38,13 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
33
38
  if (unsupported !== undefined)
34
39
  return fail(unsupported);
35
40
  let hopperPath = await host.hopperPath();
41
+ const initialDoctor = await host.doctor();
42
+ const linuxHopperRepairNeeded = initialDoctor.checks.some(({ name, ok, detail }) => !ok &&
43
+ (name === "hopper-demo-runtime" ||
44
+ (name === "hopper-version" && detail === "/opt/hopper/bin/Hopper")));
45
+ const installHopper = hopperPath === undefined || linuxHopperRepairNeeded;
36
46
  const detectedClients = await host.detectedClients();
37
- plannedActions = [
38
- ...(hopperPath === undefined
39
- ? [
40
- {
41
- kind: "install_hopper",
42
- target: host.platform === "darwin"
43
- ? "~/Applications/Hopper Disassembler.app"
44
- : "system package manager",
45
- detail: "Download the official Hopper package, verify it, install it, and open Hopper for activation.",
46
- external: true,
47
- },
48
- ]
49
- : []),
50
- ...detectedClients
51
- .filter(({ format }) => format !== "unsupported")
52
- .map((client) => ({
53
- kind: "configure_client",
54
- target: client.configPath,
55
- detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
56
- external: false,
57
- })),
58
- {
59
- kind: "install_skill",
60
- target: "~/.agents/skills/rea-analysis/SKILL.md",
61
- detail: "Install or update the bundled REA analysis skill.",
62
- external: false,
63
- },
64
- ];
47
+ plannedActions = setupPlan(host.platform, installHopper, detectedClients);
65
48
  let approved = options.approved;
66
49
  let interactiveApproval = false;
67
50
  if (!approved && confirm !== undefined && !options.structured) {
@@ -77,53 +60,56 @@ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
77
60
  doctor: await host.doctor(),
78
61
  remediation: "Review the setup plan, then rerun interactively or with --yes.",
79
62
  };
80
- if (hopperPath === undefined &&
81
- (interactiveApproval || options.installHopper)) {
82
- hopperPath = await host.installHopper();
83
- if (hopperPath === undefined)
63
+ if (installHopper && (interactiveApproval || options.installHopper)) {
64
+ const installed = await host.installHopper();
65
+ if (installed.status === "failed")
84
66
  return {
85
67
  status: "needs_human",
86
68
  plannedActions,
87
69
  appliedActions,
88
70
  clients,
89
71
  doctor: await host.doctor(),
90
- remediation: "Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
72
+ code: installed.code,
73
+ remediation: installed.remediation,
91
74
  };
75
+ hopperPath = installed.launcherPath;
92
76
  appliedActions.push("installed_hopper");
93
77
  }
94
- for (const client of detectedClients) {
95
- const result = await host.configureClient(client, hopperPath, registrationCommand());
96
- clients[client.name] = result;
97
- if (result.status === "failed")
98
- return fail(`${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`);
99
- if (result.status === "configured")
100
- appliedActions.push(`configured_${client.name}`);
101
- }
78
+ const clientFailure = await configureDetectedClients({
79
+ host,
80
+ detectedClients,
81
+ hopperPath,
82
+ command: registrationCommand(),
83
+ clients,
84
+ appliedActions,
85
+ });
86
+ if (clientFailure !== undefined)
87
+ return fail(clientFailure);
102
88
  const skill = await host.installSkill();
103
89
  if (skill === "failed")
104
- return fail("Agent skill installation or readback failed.");
90
+ return fail("REA analysis skill could not be installed or verified. Check permissions for `~/.agents/skills`, then rerun setup.");
105
91
  if (skill === "installed")
106
92
  appliedActions.push("installed_skill");
107
93
  const doctor = await host.doctor();
108
- const activationRequired = appliedActions.includes("installed_hopper");
109
- const ready = doctor.healthy && !activationRequired;
94
+ const remediation = finalSetupRemediation(host.platform, appliedActions.includes("installed_hopper"), doctor.healthy, hopperPath);
110
95
  return {
111
- status: ready ? "ready" : "needs_human",
96
+ status: remediation === undefined ? "ready" : "needs_human",
112
97
  plannedActions,
113
98
  appliedActions,
114
99
  clients,
115
100
  doctor,
116
- ...(ready
117
- ? {}
118
- : {
119
- remediation: activationRequired
120
- ? "Open Hopper, complete its one-time activation, then rerun rea doctor --json."
121
- : hopperPath === undefined
122
- ? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis."
123
- : "Run rea doctor and apply each reported remediation.",
124
- }),
101
+ ...(remediation === undefined ? {} : { remediation }),
125
102
  };
126
103
  };
104
+ const finalSetupRemediation = (platform, installedHopper, healthy, hopperPath) => {
105
+ if (platform === "darwin" && installedHopper)
106
+ return "Open Hopper, choose its demo mode or activate a license, then rerun rea doctor --json.";
107
+ if (healthy)
108
+ return undefined;
109
+ return hopperPath === undefined
110
+ ? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis."
111
+ : "Run rea doctor and apply each reported remediation.";
112
+ };
127
113
  const hostRemediation = async (host) => {
128
114
  if (host.platform !== "darwin" && host.platform !== "linux")
129
115
  return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
@@ -152,7 +138,9 @@ const systemSetupHost = () => {
152
138
  const result = process.platform === "linux"
153
139
  ? await installLinuxHopper()
154
140
  : await installMacHopper();
155
- return result.status === "installed" ? result.launcherPath : undefined;
141
+ if (result.status === "installed")
142
+ return result;
143
+ return setupInstallFailure(result.reason);
156
144
  },
157
145
  detectedClients: () => detectClients(homedir()),
158
146
  configureClient: (client, hopperPath, command) => client.format === "unsupported"
@@ -164,7 +152,7 @@ const systemSetupHost = () => {
164
152
  doctor: () => runDoctor(undefined, doctorHost),
165
153
  };
166
154
  };
167
- /** Detect supported coding agents from stable per-user installation markers. */
155
+ /** Detect supported agents from stable per-user installation markers. */
168
156
  export const detectClients = async (home) => {
169
157
  const detected = [];
170
158
  for (const candidate of supportedClients(home))
@@ -353,46 +341,6 @@ export const configureTomlClient = async (client, hopperPath, command = [
353
341
  ...(backupPath === undefined ? {} : { backupPath }),
354
342
  };
355
343
  };
356
- /** Transactionally install or upgrade the versioned canonical REA skill. */
357
- export const installCanonicalSkill = async (home) => {
358
- const destination = join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
359
- const backup = `${destination}.rea.backup`;
360
- let original;
361
- try {
362
- const content = await readFile(new URL(`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, import.meta.url), "utf8");
363
- original = await readFile(destination, "utf8").catch(() => undefined);
364
- if (original === content)
365
- return "unchanged";
366
- await mkdir(dirname(destination), { recursive: true });
367
- if (original !== undefined)
368
- await writeFileAtomic(backup, original, {
369
- encoding: "utf8",
370
- mode: 0o600,
371
- });
372
- await writeFileAtomic(destination, content, {
373
- encoding: "utf8",
374
- mode: 0o600,
375
- });
376
- if ((await readFile(destination, "utf8")) !== content)
377
- throw new Error("skill readback mismatch");
378
- return "installed";
379
- }
380
- catch {
381
- try {
382
- if (original === undefined)
383
- await rm(destination, { force: true });
384
- else
385
- await writeFileAtomic(destination, original, {
386
- encoding: "utf8",
387
- mode: 0o600,
388
- });
389
- }
390
- catch {
391
- // The backup remains beside the skill for explicit operator recovery.
392
- }
393
- return "failed";
394
- }
395
- };
396
344
  const major = (version) => Number.parseInt(version.split(".")[0] ?? "0", 10);
397
345
  const exists = async (path) => {
398
346
  try {
@@ -0,0 +1,19 @@
1
+ const failedConfigurationMessage = (reason) => {
2
+ if (reason === "backup")
3
+ return "Agent configuration could not be backed up, so no change was made. Check file permissions, then rerun setup.";
4
+ if (reason === "write")
5
+ return "Agent configuration could not be updated. Check file permissions, then rerun setup.";
6
+ return "Agent configuration could not be verified after writing. Repair the configuration file or restore its `.rea.backup`, then rerun setup.";
7
+ };
8
+ /** Configure each detected agent, stopping after the first failed transaction. */
9
+ export const configureDetectedClients = async (options) => {
10
+ for (const client of options.detectedClients) {
11
+ const result = await options.host.configureClient(client, options.hopperPath, options.command);
12
+ options.clients[client.name] = result;
13
+ if (result.status === "failed")
14
+ return failedConfigurationMessage(result.reason);
15
+ if (result.status === "configured")
16
+ options.appliedActions.push(`configured_${client.name}`);
17
+ }
18
+ return undefined;
19
+ };
@@ -0,0 +1,53 @@
1
+ /** Map provider installer failures to stable setup codes and safe recovery. */
2
+ export const setupInstallFailure = (reason) => {
3
+ switch (reason) {
4
+ case "unsupported_host":
5
+ return {
6
+ status: "failed",
7
+ code: "unsupported_host",
8
+ remediation: "Use a supported host, then rerun rea setup.",
9
+ };
10
+ case "integrity":
11
+ return {
12
+ status: "failed",
13
+ code: "integrity_mismatch",
14
+ remediation: "The Hopper download failed integrity verification. Retry setup; if it repeats, update REA before installing.",
15
+ };
16
+ case "authorization_or_package_manager":
17
+ return {
18
+ status: "failed",
19
+ code: "authorization_or_package_manager_failed",
20
+ remediation: "Allow the system package-manager operation or install the planned Hopper package and Linux runtime dependencies, then rerun rea setup.",
21
+ };
22
+ case "launcher_missing":
23
+ return {
24
+ status: "failed",
25
+ code: "launcher_missing",
26
+ remediation: "The package operation completed but Hopper is not runnable. Run rea doctor and repair the reported launcher issue.",
27
+ };
28
+ case "runtime_dependencies":
29
+ return {
30
+ status: "failed",
31
+ code: "runtime_dependency_unavailable",
32
+ remediation: "Hopper was installed but required shared libraries are missing. Rerun rea setup, then apply the hopper-demo-runtime remediation from rea doctor.",
33
+ };
34
+ case "unsupported_hopper_build":
35
+ return {
36
+ status: "failed",
37
+ code: "unsupported_hopper_build",
38
+ remediation: "The installed Hopper launcher does not match the build supported by this REA release. Unset HOPPER_LAUNCHER_PATH, reinstall through rea setup, or update REA.",
39
+ };
40
+ case "cancelled":
41
+ return {
42
+ status: "failed",
43
+ code: "setup_cancelled",
44
+ remediation: "Setup was cancelled before Hopper was installed. Rerun rea setup when ready.",
45
+ };
46
+ default:
47
+ return {
48
+ status: "failed",
49
+ code: "download_failed",
50
+ remediation: "REA could not download or unpack the official Hopper package. Check network and filesystem access, then retry setup.",
51
+ };
52
+ }
53
+ };
@@ -0,0 +1,31 @@
1
+ /** Build the complete setup mutation plan before approval. */
2
+ export const setupPlan = (platform, installHopper, clients) => [
3
+ ...(installHopper
4
+ ? [
5
+ {
6
+ kind: "install_hopper",
7
+ target: platform === "darwin"
8
+ ? "~/Applications/Hopper Disassembler.app"
9
+ : "system package manager",
10
+ detail: platform === "linux"
11
+ ? "Download, verify, and install Hopper plus its Xvfb demo-session dependencies. For the supported demo build, REA uses a private display and selects Hopper's offered demo mode for each analysis session."
12
+ : "Download the official Hopper package, verify it, and install it. Hopper may show its demo or license prompt when first opened.",
13
+ external: true,
14
+ },
15
+ ]
16
+ : []),
17
+ ...clients
18
+ .filter(({ format }) => format !== "unsupported")
19
+ .map((client) => ({
20
+ kind: "configure_client",
21
+ target: client.configPath,
22
+ detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
23
+ external: false,
24
+ })),
25
+ {
26
+ kind: "install_skill",
27
+ target: "~/.agents/skills/rea-analysis/SKILL.md",
28
+ detail: "Install or update the bundled REA analysis skill.",
29
+ external: false,
30
+ },
31
+ ];
@@ -0,0 +1,44 @@
1
+ import { mkdir, readFile, rm } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import writeFileAtomic from "write-file-atomic";
4
+ import { PRODUCT_IDENTITY } from "../identity.js";
5
+ /** Transactionally install or upgrade the versioned canonical REA skill. */
6
+ export const installCanonicalSkill = async (home) => {
7
+ const destination = join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
8
+ const backup = `${destination}.rea.backup`;
9
+ let original;
10
+ try {
11
+ const content = await readFile(new URL(`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, import.meta.url), "utf8");
12
+ original = await readFile(destination, "utf8").catch(() => undefined);
13
+ if (original === content)
14
+ return "unchanged";
15
+ await mkdir(dirname(destination), { recursive: true });
16
+ if (original !== undefined)
17
+ await writeFileAtomic(backup, original, {
18
+ encoding: "utf8",
19
+ mode: 0o600,
20
+ });
21
+ await writeFileAtomic(destination, content, {
22
+ encoding: "utf8",
23
+ mode: 0o600,
24
+ });
25
+ if ((await readFile(destination, "utf8")) !== content)
26
+ throw new Error("skill readback mismatch");
27
+ return "installed";
28
+ }
29
+ catch {
30
+ try {
31
+ if (original === undefined)
32
+ await rm(destination, { force: true });
33
+ else
34
+ await writeFileAtomic(destination, original, {
35
+ encoding: "utf8",
36
+ mode: 0o600,
37
+ });
38
+ }
39
+ catch {
40
+ // The backup remains beside the skill for explicit operator recovery.
41
+ }
42
+ return "failed";
43
+ }
44
+ };