@engineeros/connector 0.4.1 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -36,3 +36,14 @@ Credentials are stored per workspace under `~/.engineeros/connectors` with owner
36
36
  Keep the connector online to receive Goals assigned from EngineerOS. Each Goal runs in an isolated worktree below `~/.engineeros/runs`. Cancellation stops Codex. The connector returns changed paths, a bounded diff, and the exact repository ZIP; a human still performs independent attestation.
37
37
 
38
38
  Requirements: Node.js 22 or newer, Git, and an authenticated Codex CLI (`codex login`).
39
+
40
+ ## Codex CLI compatibility
41
+
42
+ The connector prints the exact Codex CLI version it will use before connecting. If the
43
+ configured model requires a newer CLI, update Codex and restart the connector:
44
+
45
+ ```sh
46
+ npm install -g @openai/codex@latest
47
+ codex --version
48
+ npx @engineeros/connector start --workspace .
49
+ ```
@@ -10,9 +10,11 @@ import {
10
10
  workspaceUrl,
11
11
  } from "../src/config.mjs";
12
12
  import {
13
+ assessmentProgressMessage,
13
14
  executeAssignment,
14
15
  executeConnectedPrompt,
15
16
  executeWorkspaceAssessment,
17
+ inspectCodexCli,
16
18
  stopProcess,
17
19
  workspaceSnapshot,
18
20
  } from "../src/runner.mjs";
@@ -73,6 +75,17 @@ if (command === "pair") {
73
75
  fail("Use `engineeros-connector pair`, `start`, or `status`.");
74
76
  }
75
77
 
78
+ let codexCli;
79
+ try {
80
+ codexCli = await inspectCodexCli(config.workspace);
81
+ } catch (error) {
82
+ fail(error instanceof Error ? error.message : String(error));
83
+ }
84
+ console.log(`Using ${codexCli.version}.`);
85
+ if (firstMessage.type === "pair") {
86
+ firstMessage.capabilities.codex_cli_version = codexCli.version;
87
+ }
88
+
76
89
  let stopped = false;
77
90
  let active = null;
78
91
  const available = [];
@@ -134,7 +147,9 @@ async function connect() {
134
147
  if (message.type === "workspace.assessment") {
135
148
  if (
136
149
  active?.runId !== message.assessment_id &&
137
- !assessments.some((candidate) => candidate.assessment_id === message.assessment_id)
150
+ !assessments.some(
151
+ (candidate) => candidate.assessment_id === message.assessment_id,
152
+ )
138
153
  ) {
139
154
  assessments.push(message);
140
155
  }
@@ -314,7 +329,9 @@ function pump() {
314
329
 
315
330
  async function executePrompt(assignment) {
316
331
  const promptId = assignment.prompt_id;
317
- console.log(`Answering ${assignment.purpose || "project"} prompt with Codex CLI.`);
332
+ console.log(
333
+ `Answering ${assignment.purpose || "project"} prompt with Codex CLI.`,
334
+ );
318
335
  try {
319
336
  const result = await executeConnectedPrompt(assignment, config, {
320
337
  onProcess: (child) => {
@@ -353,8 +370,12 @@ async function executeAssessment(assignment) {
353
370
  const assessmentId = assignment.assessment_id;
354
371
  console.log(`Assessing workspace with Codex CLI (${assessmentId}).`);
355
372
  let progress = 10;
356
- const reportProgress = (message) => {
357
- if (socket.readyState !== WebSocket.OPEN || active?.runId !== assessmentId) return;
373
+ const reportedMilestones = new Set();
374
+ const reportProgress = (message, { milestone = true } = {}) => {
375
+ if (socket.readyState !== WebSocket.OPEN || active?.runId !== assessmentId)
376
+ return;
377
+ if (milestone && reportedMilestones.has(message)) return;
378
+ if (milestone) reportedMilestones.add(message);
358
379
  socket.send(
359
380
  JSON.stringify({
360
381
  type: "workspace.assessment.progress",
@@ -364,20 +385,23 @@ async function executeAssessment(assignment) {
364
385
  }),
365
386
  );
366
387
  };
367
- reportProgress("Codex is inspecting the workspace");
388
+ reportProgress("Starting read-only repository assessment");
368
389
  const heartbeat = setInterval(() => {
369
390
  progress = Math.min(90, progress + 5);
370
- reportProgress("Codex is building the evidence-backed assessment");
391
+ reportProgress("Assessment in progress", { milestone: false });
371
392
  }, 15_000);
372
393
  try {
394
+ if (assignment.assessment_mode === "incremental") {
395
+ progress = 15;
396
+ reportProgress("Calculating changed files and affected evidence");
397
+ }
373
398
  const result = await executeWorkspaceAssessment(assignment, config, {
374
399
  onProcess: (child) => {
375
400
  if (active?.runId === assessmentId) active.child = child;
376
401
  },
377
402
  onEvent: (event) => {
378
- const message =
379
- event.message || event.item?.text || event.type || "Codex is assessing the workspace";
380
- reportProgress(message);
403
+ const message = assessmentProgressMessage(event);
404
+ if (message) reportProgress(message);
381
405
  },
382
406
  });
383
407
  const response = await fetch(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engineeros/connector",
3
- "version": "0.4.1",
3
+ "version": "0.4.6",
4
4
  "description": "Connect a local Codex CLI workspace to EngineerOS over an outbound WebSocket.",
5
5
  "private": false,
6
6
  "type": "module",
package/src/runner.mjs CHANGED
@@ -1,6 +1,13 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { spawn } from "node:child_process";
3
- import { lstat, mkdir, readFile, readdir, stat, writeFile } from "node:fs/promises";
3
+ import {
4
+ lstat,
5
+ mkdir,
6
+ readFile,
7
+ readdir,
8
+ stat,
9
+ writeFile,
10
+ } from "node:fs/promises";
4
11
  import os from "node:os";
5
12
  import path from "node:path";
6
13
  import { promisify } from "node:util";
@@ -114,17 +121,47 @@ export async function executeAssignment(assignment, config, callbacks) {
114
121
  };
115
122
  }
116
123
 
117
- export async function executeWorkspaceAssessment(assignment, config, callbacks) {
124
+ export async function executeWorkspaceAssessment(
125
+ assignment,
126
+ config,
127
+ callbacks,
128
+ ) {
118
129
  const execution = connectorExecution(assignment);
119
- const revision = await run(
130
+ const startingRevision = await run(
120
131
  "git",
121
132
  ["rev-parse", "HEAD"],
122
133
  config.workspace,
123
134
  { allowFailure: true },
124
135
  );
136
+ const startingHead =
137
+ startingRevision.code === 0
138
+ ? startingRevision.stdout.trim().slice(0, 128)
139
+ : null;
140
+ if (
141
+ assignment.target_head_revision &&
142
+ startingHead !== assignment.target_head_revision
143
+ ) {
144
+ throw new Error(
145
+ "This workspace is no longer at the commit inventoried by EngineerOS. Refresh the workspace inventory before assessing it.",
146
+ );
147
+ }
148
+ const changeImpact =
149
+ assignment.assessment_mode === "incremental"
150
+ ? await workspaceChangeImpact(
151
+ config.workspace,
152
+ assignment.base_head_revision,
153
+ assignment.target_head_revision,
154
+ )
155
+ : { changedFiles: [], markdown: null };
156
+ const prompt = changeImpact.markdown
157
+ ? execution.prompt.replace(
158
+ "<!-- ENGINEEROS_CHANGE_IMPACT -->",
159
+ changeImpact.markdown,
160
+ )
161
+ : execution.prompt;
125
162
  const controller = launchCodexProcess(
126
163
  config.workspace,
127
- execution.prompt,
164
+ prompt,
128
165
  execution.sandboxMode,
129
166
  callbacks,
130
167
  );
@@ -132,13 +169,115 @@ export async function executeWorkspaceAssessment(assignment, config, callbacks)
132
169
  const completed = await controller.completed;
133
170
  const report = completed.finalMessage.trim();
134
171
  if (!report) throw new Error("Codex completed without returning a response.");
172
+ const endingRevision = await run(
173
+ "git",
174
+ ["rev-parse", "HEAD"],
175
+ config.workspace,
176
+ { allowFailure: true },
177
+ );
178
+ const endingHead =
179
+ endingRevision.code === 0
180
+ ? endingRevision.stdout.trim().slice(0, 128)
181
+ : null;
182
+ if (startingHead !== endingHead) {
183
+ throw new Error(
184
+ "The Git commit changed during assessment. Refresh the workspace inventory and assess the new commit.",
185
+ );
186
+ }
135
187
  return {
136
188
  report_markdown: report,
137
- observed_head_revision:
138
- revision.code === 0 ? revision.stdout.trim().slice(0, 128) : null,
189
+ observed_head_revision: endingHead,
190
+ changed_files: changeImpact.changedFiles,
191
+ change_impact_markdown: changeImpact.markdown,
139
192
  };
140
193
  }
141
194
 
195
+ export async function workspaceChangeImpact(workspace, baseRevision, targetRevision) {
196
+ if (!baseRevision || !targetRevision) {
197
+ throw new Error(
198
+ "Incremental assessment requires both the previously assessed and current Git commits. Run a full assessment instead.",
199
+ );
200
+ }
201
+ const range = `${baseRevision}..${targetRevision}`;
202
+ const names = await run(
203
+ "git",
204
+ ["diff", "--name-status", "--find-renames", range],
205
+ workspace,
206
+ { allowFailure: true },
207
+ );
208
+ if (names.code !== 0) {
209
+ throw new Error(
210
+ "Codex could not compare the assessed and current commits. Fetch the missing Git history or run a full assessment.",
211
+ );
212
+ }
213
+ const committedFiles = await run(
214
+ "git",
215
+ ["-c", "core.quotepath=false", "diff", "--name-only", range],
216
+ workspace,
217
+ { allowFailure: true },
218
+ );
219
+ const workingFiles = await run(
220
+ "git",
221
+ ["-c", "core.quotepath=false", "ls-files", "--others", "--modified", "--deleted", "--exclude-standard"],
222
+ workspace,
223
+ { allowFailure: true },
224
+ );
225
+ const status = await run("git", ["status", "--short"], workspace, {
226
+ allowFailure: true,
227
+ });
228
+ const stat = await run(
229
+ "git",
230
+ ["diff", "--stat", "--compact-summary", range],
231
+ workspace,
232
+ { allowFailure: true },
233
+ );
234
+ const allChangedFiles = [...new Set([
235
+ ...pathLines(committedFiles.stdout),
236
+ ...pathLines(workingFiles.stdout),
237
+ ])];
238
+ if (allChangedFiles.length > 500) {
239
+ throw new Error(
240
+ `This change affects ${allChangedFiles.length} paths, above the 500-path incremental limit. Run a full reassessment instead.`,
241
+ );
242
+ }
243
+ const changedFiles = allChangedFiles;
244
+ if (!changedFiles.length) {
245
+ throw new Error(
246
+ "The inventoried repository changed but Git reports no assessable paths. Refresh inventory or run a full assessment.",
247
+ );
248
+ }
249
+ const lines = [
250
+ `Comparison: \`${range}\``,
251
+ `Changed paths: ${changedFiles.length}`,
252
+ "",
253
+ "### Name status",
254
+ "",
255
+ "```text",
256
+ boundedText(names.stdout, 12_000),
257
+ "```",
258
+ ];
259
+ if (status.stdout.trim()) {
260
+ lines.push("", "### Working tree", "", "```text", boundedText(status.stdout, 6_000), "```");
261
+ }
262
+ if (stat.stdout.trim()) {
263
+ lines.push("", "### Diff summary", "", "```text", boundedText(stat.stdout, 6_000), "```");
264
+ }
265
+ return { changedFiles, markdown: lines.join("\n") };
266
+ }
267
+
268
+ function pathLines(output) {
269
+ return String(output || "")
270
+ .split(/\r?\n/)
271
+ .map((line) => line.trim())
272
+ .filter(Boolean)
273
+ .map((line) => line.replace(/^"|"$/g, ""));
274
+ }
275
+
276
+ function boundedText(value, maximum) {
277
+ const text = String(value || "").trim();
278
+ return text.length <= maximum ? text : `${text.slice(0, maximum)}\n... truncated by EngineerOS`;
279
+ }
280
+
142
281
  export async function executeConnectedPrompt(assignment, config, callbacks) {
143
282
  const execution = connectorExecution(assignment);
144
283
  const controller = launchCodexProcess(
@@ -156,6 +295,83 @@ export async function executeConnectedPrompt(assignment, config, callbacks) {
156
295
  return { content, model: "codex-cli" };
157
296
  }
158
297
 
298
+ export async function inspectCodexCli(workspace = process.cwd()) {
299
+ const command =
300
+ process.env.CODEX_BIN ||
301
+ (process.platform === "win32" ? "codex.cmd" : "codex");
302
+ const result = await runCodexCommand(command, ["--version"], workspace);
303
+ if (result.code !== 0) {
304
+ throw new Error(
305
+ "Codex CLI is unavailable. Install it with `npm install -g @openai/codex@latest`, run `codex login`, then restart this connector.",
306
+ );
307
+ }
308
+ const version = result.stdout.trim().slice(0, 100);
309
+ if (!version) {
310
+ throw new Error(
311
+ "Codex CLI returned no version. Reinstall @openai/codex, then restart this connector.",
312
+ );
313
+ }
314
+ return { command, version };
315
+ }
316
+
317
+ export function codexFailureMessage(output, code) {
318
+ if (/requires a newer version of Codex/i.test(output)) {
319
+ return (
320
+ "The configured model requires a newer Codex CLI. " +
321
+ "Run `npm install -g @openai/codex@latest`, verify with `codex --version`, " +
322
+ "then restart the EngineerOS connector and retry the assessment."
323
+ );
324
+ }
325
+ if (/not logged in|login required|authentication required/i.test(output)) {
326
+ return "Codex CLI is not authenticated. Run `codex login`, then restart the EngineerOS connector.";
327
+ }
328
+ return `Codex exited with code ${code}. ${output.slice(-1_000)}`;
329
+ }
330
+
331
+ export function assessmentProgressMessage(event) {
332
+ if (!event || typeof event !== "object") return null;
333
+ if (event.type === "turn.started")
334
+ return "Reviewing repository structure and current Git state";
335
+ if (event.type === "item.started") {
336
+ if (event.item?.type === "command_execution") {
337
+ return assessmentCommandMilestone(event.item.command);
338
+ }
339
+ if (event.item?.type === "mcp_tool_call")
340
+ return "Tracing architecture and code relationships";
341
+ if (event.item?.type === "web_search")
342
+ return "Checking an external technical reference";
343
+ return null;
344
+ }
345
+ if (event.type === "item.completed" && event.item?.type === "agent_message") {
346
+ return "Synthesizing findings and highest-return actions";
347
+ }
348
+ return null;
349
+ }
350
+
351
+ function assessmentCommandMilestone(command) {
352
+ const value = Array.isArray(command)
353
+ ? command.join(" ")
354
+ : String(command || "");
355
+ const normalized = value.replace(/\s+/g, " ").trim().toLowerCase();
356
+ if (!normalized) return "Inspecting repository evidence";
357
+ if (/\bgit\s+(status|log|diff|show|rev-parse)\b/.test(normalized)) {
358
+ return "Comparing Git history and workspace changes";
359
+ }
360
+ if (
361
+ /\b(test|pytest|vitest|jest|ruff|eslint|tsc|build|lint)\b/.test(normalized)
362
+ ) {
363
+ return "Checking verification and delivery signals";
364
+ }
365
+ if (
366
+ /\b(audit|dependency|dependencies|lockfile|package-lock|pnpm-lock|requirements)\b/.test(
367
+ normalized,
368
+ )
369
+ ) {
370
+ return "Reviewing dependencies and security signals";
371
+ }
372
+ return "Tracing architecture and code relationships";
373
+ }
374
+
159
375
  export function connectorExecution(assignment) {
160
376
  const prompt = assignment?.prompt_markdown;
161
377
  if (typeof prompt !== "string" || !prompt.trim()) {
@@ -292,15 +508,7 @@ function launchCodexProcess(workspace, prompt, sandbox, callbacks) {
292
508
  const command =
293
509
  process.env.CODEX_BIN ||
294
510
  (process.platform === "win32" ? "codex.cmd" : "codex");
295
- const args = [
296
- "exec",
297
- "--json",
298
- "--sandbox",
299
- sandbox,
300
- "-C",
301
- workspace,
302
- "-",
303
- ];
511
+ const args = ["exec", "--json", "--sandbox", sandbox, "-C", workspace, "-"];
304
512
  const child = spawn(command, args, {
305
513
  cwd: workspace,
306
514
  env: process.env,
@@ -349,15 +557,31 @@ function launchCodexProcess(workspace, prompt, sandbox, callbacks) {
349
557
  child.once("error", reject);
350
558
  child.once("close", (code) => {
351
559
  if (code === 0) resolve({ output: output.slice(-20_000), finalMessage });
352
- else
353
- reject(
354
- new Error(`Codex exited with code ${code}. ${output.slice(-1_000)}`),
355
- );
560
+ else reject(new Error(codexFailureMessage(output, code)));
356
561
  });
357
562
  });
358
563
  return { child, completed };
359
564
  }
360
565
 
566
+ function runCodexCommand(command, args, cwd) {
567
+ return new Promise((resolve, reject) => {
568
+ const child = spawn(command, args, {
569
+ cwd,
570
+ env: process.env,
571
+ shell: process.platform === "win32",
572
+ windowsHide: true,
573
+ });
574
+ let stdout = "";
575
+ let stderr = "";
576
+ child.stdout.setEncoding("utf8");
577
+ child.stderr.setEncoding("utf8");
578
+ child.stdout.on("data", (chunk) => (stdout += chunk));
579
+ child.stderr.on("data", (chunk) => (stderr += chunk));
580
+ child.once("error", reject);
581
+ child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
582
+ });
583
+ }
584
+
361
585
  async function changedFilePaths(workspace) {
362
586
  const tracked = await run(
363
587
  "git",
@@ -452,7 +676,12 @@ async function workspaceFiles(root, current = root) {
452
676
 
453
677
  function isShareablePath(relative) {
454
678
  const normalized = normalizePath(relative);
455
- if (!normalized || normalized === ".." || normalized.startsWith("../") || path.isAbsolute(normalized)) {
679
+ if (
680
+ !normalized ||
681
+ normalized === ".." ||
682
+ normalized.startsWith("../") ||
683
+ path.isAbsolute(normalized)
684
+ ) {
456
685
  return false;
457
686
  }
458
687
  const parts = normalized.split("/");
@@ -482,7 +711,9 @@ function isShareablePath(relative) {
482
711
 
483
712
  function isCodeBearing(relative) {
484
713
  const name = path.posix.basename(relative).toLowerCase();
485
- return CODE_MARKERS.has(name) || CODE_EXTENSIONS.has(path.posix.extname(name));
714
+ return (
715
+ CODE_MARKERS.has(name) || CODE_EXTENSIONS.has(path.posix.extname(name))
716
+ );
486
717
  }
487
718
 
488
719
  async function worktreeBase(workspace, requested) {