rea-agents 1.0.0 → 1.2.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 (52) hide show
  1. package/README.md +49 -18
  2. package/dist/application/ArtifactInventory.js +80 -44
  3. package/dist/application/CommandShimReplay.js +136 -0
  4. package/dist/application/Doctor.js +4 -4
  5. package/dist/application/EvidenceBundleFiles.js +13 -4
  6. package/dist/application/MacHopper.js +175 -0
  7. package/dist/application/ProcessCaptureAuthority.js +25 -0
  8. package/dist/application/ProcessCaptureCapability.js +23 -0
  9. package/dist/application/ProcessCaptureError.js +5 -0
  10. package/dist/application/ProcessCaptureLifecycle.js +269 -0
  11. package/dist/application/ProcessCheckpoints.js +129 -0
  12. package/dist/application/ProcessCli.js +61 -0
  13. package/dist/application/ProcessEvidence.js +38 -0
  14. package/dist/application/ProcessHarness.js +252 -272
  15. package/dist/application/ProcessNormalization.js +10 -1
  16. package/dist/application/ProcessOwnership.js +39 -1
  17. package/dist/application/ProcessSampling.js +39 -23
  18. package/dist/application/Setup.js +104 -100
  19. package/dist/application/SetupSkill.js +44 -0
  20. package/dist/application/TerminalRenderer.js +92 -0
  21. package/dist/application/runtime.js +1 -1
  22. package/dist/artifacts/ArtifactProvider.js +18 -6
  23. package/dist/artifacts/ArtifactReader.js +3 -1
  24. package/dist/artifacts/AsarArtifactReader.js +8 -1
  25. package/dist/artifacts/DirectoryArtifactReader.js +1 -0
  26. package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
  27. package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
  28. package/dist/artifacts/ZipArtifactReader.js +1 -0
  29. package/dist/cli.js +60 -26
  30. package/dist/cliProcessCommands.js +19 -0
  31. package/dist/config.js +2 -0
  32. package/dist/contracts/artifactToolContracts.js +1 -0
  33. package/dist/contracts/investigationExamples.js +3 -3
  34. package/dist/contracts/processCaptureExample.js +50 -7
  35. package/dist/contracts/toolContracts.js +3 -2
  36. package/dist/domain/changedBehavior.js +2 -2
  37. package/dist/domain/errors.js +11 -3
  38. package/dist/domain/processCapture.js +99 -293
  39. package/dist/domain/processCaptureValidation.js +124 -0
  40. package/dist/domain/processComparison.js +176 -32
  41. package/dist/domain/processScenario.js +370 -0
  42. package/dist/domain/reconstructionVerification.js +3 -3
  43. package/dist/domain/runtimeVersion.js +8 -0
  44. package/dist/domain/staticRuntimeCorrelation.js +3 -2
  45. package/dist/identity.js +1 -0
  46. package/dist/server/registerProcessComparisonTool.js +28 -8
  47. package/dist/server/registerSessionTools.js +3 -25
  48. package/dist/server/sessionToolPolicies.js +1 -6
  49. package/install.sh +79 -164
  50. package/package.json +7 -4
  51. package/scripts/prepare-node-pty.mjs +35 -0
  52. package/skills/rea-analysis/SKILL.md +19 -4
@@ -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
  })
@@ -1,30 +1,71 @@
1
1
  import { access, copyFile, mkdir, readFile, rename, rm, writeFile, } from "node:fs/promises";
2
2
  import { homedir } from "node:os";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { execFile } from "node:child_process";
5
- import { promisify } from "node:util";
6
4
  import { z } from "zod";
7
5
  import writeFileAtomic from "write-file-atomic";
8
6
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
9
7
  import { PRODUCT_IDENTITY } from "../identity.js";
8
+ import { supportsNodeVersion } from "../domain/runtimeVersion.js";
10
9
  import { runDoctor, systemDoctorHost } from "./Doctor.js";
11
- import { probeHomebrew } from "./homebrew.js";
12
10
  import { installLinuxHopper, readLinuxDistribution, } from "./LinuxHopper.js";
13
- const execFileAsync = promisify(execFile);
11
+ import { installMacHopper } from "./MacHopper.js";
12
+ import { installCanonicalSkill } from "./SetupSkill.js";
13
+ export { installCanonicalSkill } from "./SetupSkill.js";
14
14
  const registrationCommand = () => process.env.npm_command === "exec"
15
15
  ? ["npx", "-y", PRODUCT_IDENTITY.packageName, "mcp"]
16
16
  : [resolve(process.argv[1] ?? PRODUCT_IDENTITY.cliBinary), "mcp"];
17
+ const setupPlan = (platform, hopperPath, clients) => [
18
+ ...(hopperPath === undefined
19
+ ? [
20
+ {
21
+ kind: "install_hopper",
22
+ target: platform === "darwin"
23
+ ? "~/Applications/Hopper Disassembler.app"
24
+ : "system package manager",
25
+ detail: "Download the official Hopper package, verify it, install it, and open Hopper for activation.",
26
+ external: true,
27
+ },
28
+ ]
29
+ : []),
30
+ ...clients
31
+ .filter(({ format }) => format !== "unsupported")
32
+ .map((client) => ({
33
+ kind: "configure_client",
34
+ target: client.configPath,
35
+ detail: `Add the REA MCP registration for ${client.name}; preserve unrelated configuration.`,
36
+ external: false,
37
+ })),
38
+ {
39
+ kind: "install_skill",
40
+ target: "~/.agents/skills/rea-analysis/SKILL.md",
41
+ detail: "Install or update the bundled REA analysis skill.",
42
+ external: false,
43
+ },
44
+ ];
45
+ const configureDetectedClients = async (options) => {
46
+ for (const client of options.detectedClients) {
47
+ const result = await options.host.configureClient(client, options.hopperPath, registrationCommand());
48
+ options.clients[client.name] = result;
49
+ if (result.status === "failed")
50
+ return `${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`;
51
+ if (result.status === "configured")
52
+ options.appliedActions.push(`configured_${client.name}`);
53
+ }
54
+ return undefined;
55
+ };
17
56
  /**
18
57
  * Install prerequisites and configure detected clients idempotently.
19
- * With `yes` false, mutations requiring consent are reported as `needs_human`;
20
- * with `yes` true, external installers still fail closed if they require UI.
58
+ * Discovery always precedes mutation. Interactive confirmation or explicit
59
+ * unattended options authorize only the actions represented in the result.
21
60
  */
22
- export const runSetup = async (yes, host = systemSetupHost()) => {
23
- const actions = [];
61
+ export const runSetup = async (options, host = systemSetupHost(), confirm) => {
62
+ const appliedActions = [];
63
+ let plannedActions = [];
24
64
  const clients = {};
25
65
  const fail = async (remediation) => ({
26
66
  status: "needs_human",
27
- actions,
67
+ plannedActions,
68
+ appliedActions,
28
69
  clients,
29
70
  doctor: await host.doctor(),
30
71
  remediation,
@@ -32,43 +73,59 @@ export const runSetup = async (yes, host = systemSetupHost()) => {
32
73
  const unsupported = await hostRemediation(host);
33
74
  if (unsupported !== undefined)
34
75
  return fail(unsupported);
35
- if (host.platform === "darwin" && !(await host.hasHomebrew())) {
36
- if (!yes)
37
- return fail("Re-run with --yes to install Homebrew.");
38
- if (!(await host.installHomebrew()))
39
- return fail("Homebrew installation was interrupted; resolve the installer prompt and re-run setup.");
40
- actions.push("installed_homebrew");
41
- }
42
76
  let hopperPath = await host.hopperPath();
43
- if (hopperPath === undefined) {
44
- if (!yes)
45
- return fail("Re-run with --yes to install Hopper.");
46
- if (!(await host.installHopper()))
47
- return fail("Hopper installation was interrupted; resolve the system prompt and re-run setup.");
48
- actions.push("installed_hopper");
49
- hopperPath = await host.hopperPath();
50
- if (hopperPath === undefined)
51
- return fail("Hopper installation completed but its launcher was not found.");
77
+ const detectedClients = await host.detectedClients();
78
+ plannedActions = setupPlan(host.platform, hopperPath, detectedClients);
79
+ let approved = options.approved;
80
+ let interactiveApproval = false;
81
+ if (!approved && confirm !== undefined && !options.structured) {
82
+ interactiveApproval = await confirm(plannedActions);
83
+ approved = interactiveApproval;
52
84
  }
53
- for (const client of await host.detectedClients()) {
54
- const result = await host.configureClient(client, hopperPath, registrationCommand());
55
- clients[client.name] = result;
56
- if (result.status === "failed")
57
- return fail(`${client.name} configuration ${result.reason} verification failed; no successful configuration was reported.`);
58
- if (result.status === "configured")
59
- actions.push(`configured_${client.name}`);
85
+ if (!approved)
86
+ return {
87
+ status: confirm === undefined ? "needs_confirmation" : "planned",
88
+ plannedActions,
89
+ appliedActions,
90
+ clients,
91
+ doctor: await host.doctor(),
92
+ remediation: "Review the setup plan, then rerun interactively or with --yes.",
93
+ };
94
+ if (hopperPath === undefined &&
95
+ (interactiveApproval || options.installHopper)) {
96
+ hopperPath = await host.installHopper();
97
+ if (hopperPath === undefined)
98
+ return {
99
+ status: "needs_human",
100
+ plannedActions,
101
+ appliedActions,
102
+ clients,
103
+ doctor: await host.doctor(),
104
+ remediation: "Hopper installation failed; install Hopper manually or rerun setup after resolving the reported system error.",
105
+ };
106
+ appliedActions.push("installed_hopper");
60
107
  }
108
+ const clientFailure = await configureDetectedClients({
109
+ host,
110
+ detectedClients,
111
+ hopperPath,
112
+ clients,
113
+ appliedActions,
114
+ });
115
+ if (clientFailure !== undefined)
116
+ return fail(clientFailure);
61
117
  const skill = await host.installSkill();
62
118
  if (skill === "failed")
63
119
  return fail("Agent skill installation or readback failed.");
64
120
  if (skill === "installed")
65
- actions.push("installed_skill");
121
+ appliedActions.push("installed_skill");
66
122
  const doctor = await host.doctor();
67
- const activationRequired = actions.includes("installed_hopper");
123
+ const activationRequired = appliedActions.includes("installed_hopper");
68
124
  const ready = doctor.healthy && !activationRequired;
69
125
  return {
70
126
  status: ready ? "ready" : "needs_human",
71
- actions,
127
+ plannedActions,
128
+ appliedActions,
72
129
  clients,
73
130
  doctor,
74
131
  ...(ready
@@ -76,15 +133,17 @@ export const runSetup = async (yes, host = systemSetupHost()) => {
76
133
  : {
77
134
  remediation: activationRequired
78
135
  ? "Open Hopper, complete its one-time activation, then rerun rea doctor --json."
79
- : "Run rea doctor and apply each reported remediation.",
136
+ : hopperPath === undefined
137
+ ? "Hopper is optional for non-Hopper providers. Rerun with --yes --install-hopper for deep native analysis."
138
+ : "Run rea doctor and apply each reported remediation.",
80
139
  }),
81
140
  };
82
141
  };
83
142
  const hostRemediation = async (host) => {
84
143
  if (host.platform !== "darwin" && host.platform !== "linux")
85
144
  return "REA supports Hopper on macOS and selected 64-bit Linux distributions.";
86
- if (major(host.nodeVersion) < 24)
87
- return "Install Node.js 24.18 or newer.";
145
+ if (!supportsNodeVersion(host.nodeVersion))
146
+ return "Install Node.js 22.19+ or 24.11+ and rerun setup.";
88
147
  if (host.platform === "darwin") {
89
148
  const version = await host.macosVersion();
90
149
  return version === undefined || major(version) < 12
@@ -95,7 +154,7 @@ const hostRemediation = async (host) => {
95
154
  ? undefined
96
155
  : "REA supports Hopper on Ubuntu 24.04+, Fedora 41+, and 64-bit Arch Linux.";
97
156
  };
98
- /** Production setup effects for macOS, Homebrew, JSON MCP clients, and the canonical skill directory. */
157
+ /** Production setup effects for Hopper, agent configuration, and the canonical skill directory. */
99
158
  const systemSetupHost = () => {
100
159
  const doctorHost = systemDoctorHost();
101
160
  return {
@@ -103,15 +162,13 @@ const systemSetupHost = () => {
103
162
  nodeVersion: process.versions.node,
104
163
  macosVersion: () => doctorHost.macosVersion(),
105
164
  linuxDistribution: readLinuxDistribution,
106
- hasHomebrew: () => brewSucceeds(["--version"]),
107
- installHomebrew: () => commandSucceeds("/bin/bash", [
108
- "-c",
109
- "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)",
110
- ], { ...process.env, NONINTERACTIVE: "1" }),
111
165
  hopperPath: async () => (await runDoctor(undefined, doctorHost)).hopperPath,
112
- installHopper: () => process.platform === "linux"
113
- ? installLinuxHopper().then(({ status }) => status === "installed")
114
- : brewSucceeds(["install", "--cask", "hopper-disassembler"]),
166
+ installHopper: async () => {
167
+ const result = process.platform === "linux"
168
+ ? await installLinuxHopper()
169
+ : await installMacHopper();
170
+ return result.status === "installed" ? result.launcherPath : undefined;
171
+ },
115
172
  detectedClients: () => detectClients(homedir()),
116
173
  configureClient: (client, hopperPath, command) => client.format === "unsupported"
117
174
  ? Promise.resolve({ status: "skipped" })
@@ -311,59 +368,6 @@ export const configureTomlClient = async (client, hopperPath, command = [
311
368
  ...(backupPath === undefined ? {} : { backupPath }),
312
369
  };
313
370
  };
314
- /** Transactionally install or upgrade the versioned canonical REA skill. */
315
- export const installCanonicalSkill = async (home) => {
316
- const destination = join(home, ".agents/skills", PRODUCT_IDENTITY.skillName, "SKILL.md");
317
- const backup = `${destination}.rea.backup`;
318
- let original;
319
- try {
320
- const content = await readFile(new URL(`../../skills/${PRODUCT_IDENTITY.skillName}/SKILL.md`, import.meta.url), "utf8");
321
- original = await readFile(destination, "utf8").catch(() => undefined);
322
- if (original === content)
323
- return "unchanged";
324
- await mkdir(dirname(destination), { recursive: true });
325
- if (original !== undefined)
326
- await writeFileAtomic(backup, original, {
327
- encoding: "utf8",
328
- mode: 0o600,
329
- });
330
- await writeFileAtomic(destination, content, {
331
- encoding: "utf8",
332
- mode: 0o600,
333
- });
334
- if ((await readFile(destination, "utf8")) !== content)
335
- throw new Error("skill readback mismatch");
336
- return "installed";
337
- }
338
- catch {
339
- try {
340
- if (original === undefined)
341
- await rm(destination, { force: true });
342
- else
343
- await writeFileAtomic(destination, original, {
344
- encoding: "utf8",
345
- mode: 0o600,
346
- });
347
- }
348
- catch {
349
- // The backup remains beside the skill for explicit operator recovery.
350
- }
351
- return "failed";
352
- }
353
- };
354
- const commandSucceeds = async (command, args, env) => {
355
- try {
356
- await execFileAsync(command, args, env === undefined ? {} : { env });
357
- return true;
358
- }
359
- catch {
360
- return false;
361
- }
362
- };
363
- const brewSucceeds = async (args) => {
364
- const result = await probeHomebrew(async (command) => (await commandSucceeds(command, args)) ? true : undefined);
365
- return result === true;
366
- };
367
371
  const major = (version) => Number.parseInt(version.split(".")[0] ?? "0", 10);
368
372
  const exists = async (path) => {
369
373
  try {
@@ -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
+ };
@@ -0,0 +1,92 @@
1
+ import { createRequire } from "node:module";
2
+ const require = createRequire(import.meta.url);
3
+ // SAFETY: both pinned xterm packages publish CommonJS at runtime and matching declarations.
4
+ const HeadlessPackage = require("@xterm/headless");
5
+ // SAFETY: the addon package is pinned with the compatible headless xterm release.
6
+ const SerializePackage = require("@xterm/addon-serialize");
7
+ /** Owns one headless terminal and serializes writes into deterministic frames. */
8
+ /**
9
+ * Reconstructs bounded terminal states while raw PTY chunks remain authoritative.
10
+ *
11
+ * Rendered frames answer what an operator saw after control-sequence handling;
12
+ * raw frames preserve byte/chunk differences that can render identically.
13
+ * Comparisons retain both because neither representation subsumes the other.
14
+ */
15
+ export class TerminalRenderer {
16
+ options;
17
+ #terminal;
18
+ #serializeAddon = new SerializePackage.SerializeAddon();
19
+ #frames = [];
20
+ #pending = Promise.resolve();
21
+ #capturedBytes = 0;
22
+ #truncated = false;
23
+ constructor(options) {
24
+ this.options = options;
25
+ this.#terminal = new HeadlessPackage.Terminal({
26
+ allowProposedApi: true,
27
+ cols: options.columns,
28
+ rows: options.rows,
29
+ scrollback: options.scrollback,
30
+ });
31
+ this.#terminal.loadAddon(this.#serializeAddon);
32
+ }
33
+ /** Queue one PTY chunk and capture state only after xterm has parsed it. */
34
+ write(data, atMs) {
35
+ this.#pending = this.#pending.then(() => new Promise((resolveWrite) => {
36
+ this.#terminal.write(data, () => {
37
+ this.#capture(atMs);
38
+ resolveWrite();
39
+ });
40
+ }));
41
+ }
42
+ /** Queue a terminal resize after every preceding write. */
43
+ resize(columns, rows, atMs) {
44
+ this.#pending = this.#pending.then(() => {
45
+ this.#terminal.resize(columns, rows);
46
+ this.#capture(atMs);
47
+ });
48
+ }
49
+ /** Await all queued parsing and return immutable rendered observations. */
50
+ async frames() {
51
+ await this.#pending;
52
+ return this.#frames;
53
+ }
54
+ /** Whether a rendered observation exceeded its independent capture budget. */
55
+ truncated() {
56
+ return this.#truncated;
57
+ }
58
+ /** Release addon and terminal resources after all writes settle. */
59
+ async dispose() {
60
+ await this.#pending;
61
+ this.#serializeAddon.dispose();
62
+ this.#terminal.dispose();
63
+ }
64
+ #capture(atMs) {
65
+ const buffer = this.#terminal.buffer.active;
66
+ const lines = [];
67
+ for (let row = 0; row < this.#terminal.rows; row += 1) {
68
+ const line = buffer.getLine(buffer.viewportY + row);
69
+ lines.push(this.options.normalize((line?.translateToString(false, 0, this.#terminal.cols) ?? "").padEnd(this.#terminal.cols, " ")));
70
+ }
71
+ const serializedState = this.options.normalize(this.#serializeAddon.serialize());
72
+ const bytes = Buffer.byteLength(serializedState) +
73
+ lines.reduce((total, line) => total + Buffer.byteLength(line), 0);
74
+ if (this.#frames.length >= this.options.maxFrames ||
75
+ this.#capturedBytes + bytes > this.options.maxBytes) {
76
+ this.#truncated = true;
77
+ return;
78
+ }
79
+ this.#capturedBytes += bytes;
80
+ this.#frames.push({
81
+ sequence: this.#frames.length,
82
+ at_ms: atMs,
83
+ columns: this.#terminal.cols,
84
+ rows: this.#terminal.rows,
85
+ cursor_x: buffer.cursorX,
86
+ cursor_y: buffer.cursorY,
87
+ active_buffer: buffer.type,
88
+ lines,
89
+ serialized_state: serializedState,
90
+ });
91
+ }
92
+ }
@@ -11,7 +11,7 @@ import { silentLogger } from "../logger.js";
11
11
  */
12
12
  export const createBinarySession = (config, logger = silentLogger) => {
13
13
  return new BinarySession(new CompositeProvider([
14
- new ArtifactProvider(),
14
+ new ArtifactProvider(config.artifactNativeMountEnabled),
15
15
  new NativeMacOSProvider(),
16
16
  new HopperProvider(config, logger),
17
17
  ]));