@engineeros/connector 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,5 +1,17 @@
1
1
  # EngineerOS Connector
2
2
 
3
+ The connector uses an authenticated EngineerOS WebSocket for pairing, workspace identity, run lifecycle, and evidence. Coding work can run through any ACP v1-compatible agent over stdio. Codex CLI remains available as the default built-in adapter.
4
+
5
+ ## Pair an ACP coding agent
6
+
7
+ Run the command from the repository the agent should work in:
8
+
9
+ ```powershell
10
+ npx --yes @engineeros/connector@latest pair PAIRING-CODE --url http://localhost:8000 --workspace . --onboard --agent-name "My ACP agent" --agent-command my-agent --agent-args '["--acp"]'
11
+ ```
12
+
13
+ `--agent-command` must start an ACP v1 agent over NDJSON stdio. `--agent-args` is a JSON array so arguments are passed without invoking a shell. Omit both options to use the installed Codex CLI adapter.
14
+
3
15
  Connect a local Codex CLI workspace to EngineerOS through an outbound WebSocket.
4
16
 
5
17
  This package is only a connector. EngineerOS owns every prompt, Goal instruction, and
@@ -11,10 +11,11 @@ import {
11
11
  } from "../src/config.mjs";
12
12
  import {
13
13
  assessmentProgressMessage,
14
+ applyAcceptedChange,
14
15
  executeAssignment,
15
16
  executeConnectedPrompt,
16
17
  executeWorkspaceAssessment,
17
- inspectCodexCli,
18
+ inspectCodingAgent,
18
19
  stopProcess,
19
20
  workspaceSnapshot,
20
21
  } from "../src/runner.mjs";
@@ -46,6 +47,10 @@ if (command === "pair") {
46
47
  workspace: path.resolve(flags.workspace || process.cwd()),
47
48
  onboard: flags.onboard === true,
48
49
  onboarding_pending: flags.onboard === true,
50
+ agent_protocol: flags["agent-command"] ? "acp" : "codex",
51
+ agent_command: flags["agent-command"] || null,
52
+ agent_args: parseAgentArgs(flags["agent-args"]),
53
+ agent_name: flags["agent-name"] || null,
49
54
  name:
50
55
  flags.name ||
51
56
  `${os.hostname()} - ${path.basename(path.resolve(flags.workspace || process.cwd()))}`,
@@ -55,7 +60,9 @@ if (command === "pair") {
55
60
  pairing_code: pairingCode,
56
61
  name: config.name,
57
62
  capabilities: {
58
- codex_cli: true,
63
+ agent_protocols: flags["agent-command"] ? ["acp"] : ["codex"],
64
+ coding_agent: true,
65
+ codex_cli: !flags["agent-command"],
59
66
  platform: process.platform,
60
67
  workspace_name: path.basename(config.workspace),
61
68
  },
@@ -75,15 +82,16 @@ if (command === "pair") {
75
82
  fail("Use `engineeros-connector pair`, `start`, or `status`.");
76
83
  }
77
84
 
78
- let codexCli;
85
+ let codingAgent;
79
86
  try {
80
- codexCli = await inspectCodexCli(config.workspace);
87
+ codingAgent = await inspectCodingAgent(config, config.workspace);
81
88
  } catch (error) {
82
89
  fail(error instanceof Error ? error.message : String(error));
83
90
  }
84
- console.log(`Using ${codexCli.version}.`);
91
+ console.log(`Using ${codingAgent.name} through ${codingAgent.protocol} (${codingAgent.version}).`);
85
92
  if (firstMessage.type === "pair") {
86
- firstMessage.capabilities.codex_cli_version = codexCli.version;
93
+ firstMessage.capabilities.agent_name = codingAgent.name;
94
+ firstMessage.capabilities.agent_version = codingAgent.version;
87
95
  }
88
96
 
89
97
  let stopped = false;
@@ -318,7 +326,7 @@ function pump() {
318
326
  JSON.stringify({
319
327
  type: "run.claim",
320
328
  run_id: runId,
321
- runner_name: "Codex CLI",
329
+ runner_name: codingAgent.name,
322
330
  metadata: {
323
331
  hostname: os.hostname(),
324
332
  workspace_name: path.basename(config.workspace),
@@ -330,7 +338,7 @@ function pump() {
330
338
  async function executePrompt(assignment) {
331
339
  const promptId = assignment.prompt_id;
332
340
  console.log(
333
- `Answering ${assignment.purpose || "project"} prompt with Codex CLI.`,
341
+ `Answering ${assignment.purpose || "project"} prompt with ${codingAgent.name}.`,
334
342
  );
335
343
  try {
336
344
  const result = await executeConnectedPrompt(assignment, config, {
@@ -368,7 +376,7 @@ async function executePrompt(assignment) {
368
376
 
369
377
  async function executeAssessment(assignment) {
370
378
  const assessmentId = assignment.assessment_id;
371
- console.log(`Assessing workspace with Codex CLI (${assessmentId}).`);
379
+ console.log(`Assessing workspace with ${codingAgent.name} (${assessmentId}).`);
372
380
  let progress = 10;
373
381
  const reportedMilestones = new Set();
374
382
  const reportProgress = (message, { milestone = true } = {}) => {
@@ -420,7 +428,7 @@ async function executeAssessment(assignment) {
420
428
  `EngineerOS rejected the assessment (${response.status}): ${await response.text()}`,
421
429
  );
422
430
  }
423
- console.log("Codex workspace assessment is current in EngineerOS.");
431
+ console.log(`${codingAgent.name} workspace assessment is current in EngineerOS.`);
424
432
  } catch (error) {
425
433
  if (socket.readyState === WebSocket.OPEN) {
426
434
  socket.send(
@@ -441,7 +449,7 @@ async function executeAssessment(assignment) {
441
449
 
442
450
  async function execute(assignment) {
443
451
  const runId = assignment.run_id;
444
- console.log(`Running Goal ${runId} with Codex CLI.`);
452
+ console.log(`Running Goal ${runId} with ${codingAgent.name}.`);
445
453
  let progress = 10;
446
454
  const heartbeat = setInterval(() => {
447
455
  if (socket.readyState === WebSocket.OPEN && active?.runId === runId) {
@@ -451,7 +459,7 @@ async function execute(assignment) {
451
459
  type: "run.progress",
452
460
  run_id: runId,
453
461
  progress_percent: progress,
454
- message: "Codex is working",
462
+ message: `${codingAgent.name} is working`,
455
463
  }),
456
464
  );
457
465
  }
@@ -463,7 +471,7 @@ async function execute(assignment) {
463
471
  },
464
472
  onEvent: (event) => {
465
473
  const message =
466
- event.message || event.item?.text || event.type || "Codex is working";
474
+ event.message || event.item?.text || event.type || `${codingAgent.name} is working`;
467
475
  if (socket.readyState === WebSocket.OPEN) {
468
476
  socket.send(
469
477
  JSON.stringify({
@@ -492,7 +500,17 @@ async function execute(assignment) {
492
500
  throw new Error(
493
501
  `EngineerOS rejected the result (${response.status}): ${await response.text()}`,
494
502
  );
495
- console.log(`Run submitted. Review workspace: ${result.run_workspace}`);
503
+ const integration = await applyAcceptedChange(
504
+ config.workspace,
505
+ assignment.base_revision,
506
+ result.head_revision,
507
+ );
508
+ if (integration.applied) {
509
+ console.log(`Run accepted and applied to the connected repository at ${integration.revision}.`);
510
+ } else {
511
+ console.warn(`Run accepted but not applied locally. ${integration.reason}`);
512
+ console.warn(`The verified run workspace remains at ${result.run_workspace}.`);
513
+ }
496
514
  } catch (error) {
497
515
  if (!active?.cancelled && socket.readyState === WebSocket.OPEN) {
498
516
  socket.send(
@@ -525,6 +543,19 @@ function parseArgs(args) {
525
543
  return { command, positional, flags };
526
544
  }
527
545
 
546
+ function parseAgentArgs(value) {
547
+ if (!value) return [];
548
+ try {
549
+ const parsed = JSON.parse(value);
550
+ if (!Array.isArray(parsed) || parsed.some((item) => typeof item !== "string")) {
551
+ throw new Error();
552
+ }
553
+ return parsed;
554
+ } catch {
555
+ fail('--agent-args must be a JSON array, for example: --agent-args "[\\"acp\\"]"');
556
+ }
557
+ }
558
+
528
559
  function fail(message) {
529
560
  console.error(message);
530
561
  process.exit(1);
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@engineeros/connector",
3
- "version": "0.5.0",
4
- "description": "Connect a local Codex CLI workspace to EngineerOS over an outbound WebSocket.",
3
+ "version": "0.6.0",
4
+ "description": "Connect a local coding agent to EngineerOS, using ACP when supported.",
5
5
  "private": false,
6
6
  "type": "module",
7
7
  "license": "UNLICENSED",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "scripts": {
17
17
  "start": "node ./bin/engineeros-connector.mjs",
18
- "test": "node --test",
18
+ "test": "node --test --test-concurrency=1",
19
19
  "type-check": "node --check ./bin/engineeros-connector.mjs && node --check ./src/runner.mjs"
20
20
  },
21
21
  "engines": {
@@ -31,5 +31,8 @@
31
31
  },
32
32
  "publishConfig": {
33
33
  "access": "public"
34
+ },
35
+ "dependencies": {
36
+ "@agentclientprotocol/sdk": "1.3.0"
34
37
  }
35
38
  }
@@ -0,0 +1,105 @@
1
+ import { spawn } from "node:child_process";
2
+ import { Readable, Writable } from "node:stream";
3
+ import * as acp from "@agentclientprotocol/sdk";
4
+
5
+ function permissionOutcome(options = []) {
6
+ const selected =
7
+ options.find((option) => option.kind === "allow_once") ??
8
+ options.find((option) => option.kind === "allow_always");
9
+ return selected
10
+ ? { outcome: { outcome: "selected", optionId: selected.optionId } }
11
+ : { outcome: { outcome: "cancelled" } };
12
+ }
13
+
14
+ export function launchAcpAgent(workspace, prompt, config, callbacks = {}) {
15
+ if (!config.agent_command) {
16
+ throw new Error(
17
+ "No ACP coding agent is configured. Pair again with --agent-command and optional --agent-args JSON.",
18
+ );
19
+ }
20
+ const child = spawn(
21
+ config.agent_command,
22
+ Array.isArray(config.agent_args) ? config.agent_args : [],
23
+ {
24
+ cwd: workspace,
25
+ env: process.env,
26
+ shell: false,
27
+ stdio: ["pipe", "pipe", "pipe"],
28
+ },
29
+ );
30
+ let stderr = "";
31
+ child.stderr.setEncoding("utf8");
32
+ child.stderr.on("data", (chunk) => {
33
+ stderr += chunk;
34
+ callbacks.onEvent?.({
35
+ type: "agent.stderr",
36
+ message: String(chunk).trim().slice(0, 500),
37
+ });
38
+ });
39
+
40
+ const stream = acp.ndJsonStream(
41
+ Writable.toWeb(child.stdin),
42
+ Readable.toWeb(child.stdout),
43
+ );
44
+ let finalMessage = "";
45
+ const completed = acp
46
+ .client({ name: "EngineerOS" })
47
+ .onRequest(acp.methods.client.session.requestPermission, (ctx) =>
48
+ permissionOutcome(ctx.params.options),
49
+ )
50
+ .connectWith(stream, async (ctx) => {
51
+ const initialized = await ctx.request(acp.methods.agent.initialize, {
52
+ protocolVersion: acp.PROTOCOL_VERSION,
53
+ clientCapabilities: {},
54
+ });
55
+ callbacks.onEvent?.({
56
+ type: "agent.connected",
57
+ message: `ACP agent connected with protocol ${initialized.protocolVersion}`,
58
+ });
59
+ return ctx.buildSession(workspace).withSession(async (session) => {
60
+ child.engineerOsCancel = () =>
61
+ ctx.notify(acp.methods.agent.session.cancel, {
62
+ sessionId: session.sessionId,
63
+ });
64
+ void session.prompt(prompt);
65
+ for (;;) {
66
+ const message = await session.nextUpdate();
67
+ if (message.kind === "stop") {
68
+ if (message.stopReason === "error") {
69
+ throw new Error("The ACP agent ended the task with an error.");
70
+ }
71
+ return { finalMessage, model: "acp-agent" };
72
+ }
73
+ const update = message.update;
74
+ if (
75
+ update.sessionUpdate === "agent_message_chunk" &&
76
+ update.content?.type === "text"
77
+ ) {
78
+ finalMessage += update.content.text;
79
+ }
80
+ callbacks.onEvent?.({
81
+ type: `acp.${update.sessionUpdate}`,
82
+ message:
83
+ update.title ??
84
+ update.content?.text ??
85
+ update.status ??
86
+ update.sessionUpdate,
87
+ });
88
+ }
89
+ });
90
+ })
91
+ .catch((error) => {
92
+ const detail = stderr.trim().slice(-1_000);
93
+ throw new Error(
94
+ `ACP agent failed: ${error instanceof Error ? error.message : String(error)}${detail ? ` ${detail}` : ""}`,
95
+ );
96
+ })
97
+ .finally(() => {
98
+ child.kill("SIGKILL");
99
+ child.stdin.destroy();
100
+ child.stdout.destroy();
101
+ child.stderr.destroy();
102
+ child.unref();
103
+ });
104
+ return { child, completed };
105
+ }
package/src/runner.mjs CHANGED
@@ -11,6 +11,7 @@ import os from "node:os";
11
11
  import path from "node:path";
12
12
  import { promisify } from "node:util";
13
13
  import { deflateRaw } from "node:zlib";
14
+ import { launchAcpAgent } from "./acp-client.mjs";
14
15
 
15
16
  const deflate = promisify(deflateRaw);
16
17
  const MAX_ARCHIVE_BYTES = 25 * 1024 * 1024;
@@ -94,10 +95,11 @@ export async function executeAssignment(assignment, config, callbacks) {
94
95
  assignment.run_id,
95
96
  assignment.base_revision,
96
97
  );
97
- const controller = launchCodexProcess(
98
+ const controller = launchAgentProcess(
98
99
  runWorkspace,
99
100
  execution.prompt,
100
101
  execution.sandboxMode,
102
+ config,
101
103
  callbacks,
102
104
  );
103
105
  callbacks.onProcess?.(controller.child);
@@ -110,10 +112,11 @@ export async function executeAssignment(assignment, config, callbacks) {
110
112
  assignment.base_revision,
111
113
  assignment.run_id,
112
114
  );
113
- const verifier = launchCodexProcess(
115
+ const verifier = launchAgentProcess(
114
116
  runWorkspace,
115
117
  verificationPrompt(assignment, committed.revision, committed.changedFiles),
116
118
  "read-only",
119
+ config,
117
120
  callbacks,
118
121
  );
119
122
  callbacks.onProcess?.(verifier.child);
@@ -135,6 +138,50 @@ export async function executeAssignment(assignment, config, callbacks) {
135
138
  };
136
139
  }
137
140
 
141
+ export async function applyAcceptedChange(workspace, baseRevision, headRevision) {
142
+ const current = await run("git", ["rev-parse", "HEAD"], workspace, {
143
+ allowFailure: true,
144
+ });
145
+ if (current.code !== 0) {
146
+ return { applied: false, reason: "The connected workspace is not a Git repository." };
147
+ }
148
+ const currentHead = current.stdout.trim();
149
+ const alreadyApplied = await run(
150
+ "git",
151
+ ["merge-base", "--is-ancestor", headRevision, currentHead],
152
+ workspace,
153
+ { allowFailure: true },
154
+ );
155
+ if (alreadyApplied.code === 0) return { applied: true, revision: currentHead };
156
+ const status = await run("git", ["status", "--porcelain"], workspace);
157
+ if (status.stdout.trim()) {
158
+ return {
159
+ applied: false,
160
+ reason: `The connected workspace has uncommitted changes. Apply accepted commit ${headRevision} after preserving them.`,
161
+ };
162
+ }
163
+ if (currentHead !== baseRevision) {
164
+ return {
165
+ applied: false,
166
+ reason: `The connected branch moved from frozen base ${baseRevision} to ${currentHead}. Apply accepted commit ${headRevision} with git cherry-pick.`,
167
+ };
168
+ }
169
+ const applied = await run("git", ["cherry-pick", headRevision], workspace, {
170
+ allowFailure: true,
171
+ });
172
+ if (applied.code !== 0) {
173
+ await run("git", ["cherry-pick", "--abort"], workspace, {
174
+ allowFailure: true,
175
+ });
176
+ return {
177
+ applied: false,
178
+ reason: `The accepted commit ${headRevision} could not be applied cleanly. Apply it manually with git cherry-pick.`,
179
+ };
180
+ }
181
+ const integrated = await run("git", ["rev-parse", "HEAD"], workspace);
182
+ return { applied: true, revision: integrated.stdout.trim() };
183
+ }
184
+
138
185
  async function commitRunChange(workspace, baseRevision, runId) {
139
186
  await run("git", ["add", "-A"], workspace);
140
187
  await run(
@@ -254,10 +301,11 @@ export async function executeWorkspaceAssessment(
254
301
  changeImpact.markdown,
255
302
  )
256
303
  : execution.prompt;
257
- const controller = launchCodexProcess(
304
+ const controller = launchAgentProcess(
258
305
  config.workspace,
259
306
  prompt,
260
307
  execution.sandboxMode,
308
+ config,
261
309
  callbacks,
262
310
  );
263
311
  callbacks.onProcess?.(controller.child);
@@ -375,10 +423,11 @@ function boundedText(value, maximum) {
375
423
 
376
424
  export async function executeConnectedPrompt(assignment, config, callbacks) {
377
425
  const execution = connectorExecution(assignment);
378
- const controller = launchCodexProcess(
426
+ const controller = launchAgentProcess(
379
427
  config.workspace,
380
428
  execution.prompt,
381
429
  execution.sandboxMode,
430
+ config,
382
431
  callbacks,
383
432
  );
384
433
  callbacks.onProcess?.(controller.child);
@@ -387,7 +436,22 @@ export async function executeConnectedPrompt(assignment, config, callbacks) {
387
436
  if (!content) {
388
437
  throw new Error("Codex completed without returning a response.");
389
438
  }
390
- return { content, model: "codex-cli" };
439
+ return { content, model: completed.model ?? config.agent_protocol ?? "coding-agent" };
440
+ }
441
+
442
+ export async function inspectCodingAgent(config, workspace = process.cwd()) {
443
+ if (config.agent_protocol === "acp") {
444
+ if (!config.agent_command) {
445
+ throw new Error("ACP requires --agent-command when pairing the connector.");
446
+ }
447
+ return {
448
+ protocol: "acp",
449
+ name: config.agent_name || path.basename(config.agent_command),
450
+ version: "ACP v1",
451
+ };
452
+ }
453
+ const codex = await inspectCodexCli(workspace);
454
+ return { protocol: "codex", name: "Codex CLI", version: codex.version };
391
455
  }
392
456
 
393
457
  export async function inspectCodexCli(workspace = process.cwd()) {
@@ -481,6 +545,7 @@ export function connectorExecution(assignment) {
481
545
 
482
546
  export async function stopProcess(child) {
483
547
  if (!child || child.exitCode !== null) return;
548
+ await child.engineerOsCancel?.();
484
549
  if (process.platform === "win32") {
485
550
  await run(
486
551
  "taskkill",
@@ -658,6 +723,13 @@ function launchCodexProcess(workspace, prompt, sandbox, callbacks) {
658
723
  return { child, completed };
659
724
  }
660
725
 
726
+ function launchAgentProcess(workspace, prompt, sandbox, config, callbacks) {
727
+ if (config.agent_protocol === "acp") {
728
+ return launchAcpAgent(workspace, prompt, config, callbacks);
729
+ }
730
+ return launchCodexProcess(workspace, prompt, sandbox, callbacks);
731
+ }
732
+
661
733
  function runCodexCommand(command, args, cwd) {
662
734
  return new Promise((resolve, reject) => {
663
735
  const child = spawn(command, args, {