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.
- package/README.md +49 -18
- package/dist/application/ArtifactInventory.js +80 -44
- package/dist/application/CommandShimReplay.js +136 -0
- package/dist/application/Doctor.js +4 -4
- package/dist/application/EvidenceBundleFiles.js +13 -4
- package/dist/application/MacHopper.js +175 -0
- package/dist/application/ProcessCaptureAuthority.js +25 -0
- package/dist/application/ProcessCaptureCapability.js +23 -0
- package/dist/application/ProcessCaptureError.js +5 -0
- package/dist/application/ProcessCaptureLifecycle.js +269 -0
- package/dist/application/ProcessCheckpoints.js +129 -0
- package/dist/application/ProcessCli.js +61 -0
- package/dist/application/ProcessEvidence.js +38 -0
- package/dist/application/ProcessHarness.js +252 -272
- package/dist/application/ProcessNormalization.js +10 -1
- package/dist/application/ProcessOwnership.js +39 -1
- package/dist/application/ProcessSampling.js +39 -23
- package/dist/application/Setup.js +104 -100
- package/dist/application/SetupSkill.js +44 -0
- package/dist/application/TerminalRenderer.js +92 -0
- package/dist/application/runtime.js +1 -1
- package/dist/artifacts/ArtifactProvider.js +18 -6
- package/dist/artifacts/ArtifactReader.js +3 -1
- package/dist/artifacts/AsarArtifactReader.js +8 -1
- package/dist/artifacts/DirectoryArtifactReader.js +1 -0
- package/dist/artifacts/MachOSliceArtifactReader.js +1 -0
- package/dist/artifacts/NativeDmgArtifactReader.js +151 -0
- package/dist/artifacts/ZipArtifactReader.js +1 -0
- package/dist/cli.js +60 -26
- package/dist/cliProcessCommands.js +19 -0
- package/dist/config.js +2 -0
- package/dist/contracts/artifactToolContracts.js +1 -0
- package/dist/contracts/investigationExamples.js +3 -3
- package/dist/contracts/processCaptureExample.js +50 -7
- package/dist/contracts/toolContracts.js +3 -2
- package/dist/domain/changedBehavior.js +2 -2
- package/dist/domain/errors.js +11 -3
- package/dist/domain/processCapture.js +99 -293
- package/dist/domain/processCaptureValidation.js +124 -0
- package/dist/domain/processComparison.js +176 -32
- package/dist/domain/processScenario.js +370 -0
- package/dist/domain/reconstructionVerification.js +3 -3
- package/dist/domain/runtimeVersion.js +8 -0
- package/dist/domain/staticRuntimeCorrelation.js +3 -2
- package/dist/identity.js +1 -0
- package/dist/server/registerProcessComparisonTool.js +28 -8
- package/dist/server/registerSessionTools.js +3 -25
- package/dist/server/sessionToolPolicies.js +1 -6
- package/install.sh +79 -164
- package/package.json +7 -4
- package/scripts/prepare-node-pty.mjs +35 -0
- 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
|
-
/**
|
|
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,
|
|
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 (
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
20
|
-
*
|
|
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 (
|
|
23
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
-
|
|
121
|
+
appliedActions.push("installed_skill");
|
|
66
122
|
const doctor = await host.doctor();
|
|
67
|
-
const activationRequired =
|
|
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
|
-
|
|
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
|
-
:
|
|
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 (
|
|
87
|
-
return "Install Node.js
|
|
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
|
|
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: () =>
|
|
113
|
-
|
|
114
|
-
|
|
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
|
]));
|