@engineeros/connector 0.15.3 → 0.16.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
@@ -47,7 +47,7 @@ npx --yes @engineeros/connector@latest pair PAIRING-CODE --url https://your-engi
47
47
 
48
48
  The connector uploads a bounded ZIP snapshot for a safe file inventory, then stays online for deep assessments, rescans, and Goal Runs. Inventory never executes repository code. It excludes known secrets, dependency directories, build output, compiled binaries, files larger than 5 MB, agent-tool caches, Git metadata, and connector state before upload.
49
49
 
50
- From **Project steering -> Workspace**, run the workspace assessment to use the connected agent subscription already authenticated on that computer. Select any combination of fifteen brownfield assessment domains, collectively covering 133 baseline checks from product and architecture through security, privacy, project-specific compliance, delivery, reliability, governance, and modernization planning. Every domain uses a detailed structured-Markdown contract. Connector `0.15.3` starts assessments with three parallel workers and accepts the run's UI-selected limit from three through eight for every coding-agent provider. It atomically spools each completed Markdown report under `.engineeros/assessments/<run-id>` before delivery. ACP agents such as OpenCode use isolated stage sessions within one persistent coding-agent process. The connector owns the worker pool and sends one aggregate heartbeat containing every active worker's phase, progress, last activity, and event count; report content is no longer streamed over the WebSocket or written into backend assessment JSON. The backend validates stage checkpoints so dependency fan-out remains authoritative, while the connector retains the reports and reuses them after reconnect without rerunning completed agents. Capability detail and project-specific compliance controls fan out after their catalogs. Final synthesis reads the connector-local Markdown files, then one completed bundle is revalidated, persisted, and published as canonical artifacts. The connector removes its spool only after EngineerOS acknowledges the complete bundle. Compliance findings describe repository-verifiable engineering readiness and missing external context, never legal compliance or certification. The connector prints each worker's safe activity and elapsed time every 30 seconds, stops a worker that produces no agent activity for ten minutes instead of waiting indefinitely, and applies separate bounded structure, semantic, and corrected-response formatting repairs before failing a stage. Assessment cannot modify tracked workspace source.
50
+ From **Project steering -> Workspace**, run the workspace assessment to use the connected agent subscription already authenticated on that computer. Select any combination of fifteen brownfield assessment domains, collectively covering 133 baseline checks from product and architecture through security, privacy, project-specific compliance, delivery, reliability, governance, and modernization planning. Every domain uses a detailed structured-Markdown contract. Connector `0.16.0` starts assessments with three parallel workers and accepts the run's UI-selected limit from three through eight for every coding-agent provider. It atomically spools each completed Markdown report and each active stage's resumable agent-session checkpoint under `.engineeros/assessments/<run-id>`. ACP agents such as OpenCode use isolated stage sessions within one persistent coding-agent process. The connector owns the worker pool and sends one aggregate heartbeat containing every active worker's phase, progress, last activity, and event count; report content is no longer streamed over the WebSocket or written into backend assessment JSON. The backend validates stage checkpoints so dependency fan-out remains authoritative, while the connector retains completed reports and reloads interrupted ACP or Codex sessions after reconnect when the coding agent supports session restoration. Capability detail and project-specific compliance controls fan out after their catalogs. Final synthesis reads the connector-local Markdown files, then one completed bundle is revalidated, persisted, and published as canonical artifacts. The connector removes its spool only after EngineerOS acknowledges the complete bundle. Compliance findings describe repository-verifiable engineering readiness and missing external context, never legal compliance or certification. The connector prints each worker's safe activity and elapsed time every 30 seconds, stops a worker that produces no agent activity for ten minutes instead of waiting indefinitely, and applies separate bounded structure, semantic, and corrected-response formatting repairs before failing a stage. Assessment cannot modify tracked workspace source.
51
51
 
52
52
  Every backend `422` content-validation response is returned to the stage agent with the exact failed item. Distinct validation failures continue through correction and resubmission until the complete stage is accepted. Three repetitions of the same unresolved validation error stop the automatic loop while preserving the latest local report for retry. Authentication, cancellation, source drift, and transport failures remain operational errors rather than agent-correction prompts.
53
53
 
@@ -36,9 +36,11 @@ import {
36
36
  import {
37
37
  assessmentCheckpointPayload,
38
38
  assessmentCompletionBundle,
39
- clearAssessmentSpool,
40
- loadAssessmentStageResult,
41
- persistAssessmentStageResult,
39
+ clearAssessmentSpool,
40
+ loadAssessmentStageSession,
41
+ loadAssessmentStageResult,
42
+ persistAssessmentStageResult,
43
+ persistAssessmentStageSession,
42
44
  prepareLocalAssessmentAssignment,
43
45
  removeAssessmentStageResult,
44
46
  } from "../src/assessment-spool.mjs";
@@ -50,11 +52,12 @@ import {
50
52
  registeredAgents,
51
53
  } from "../src/agent-registry.mjs";
52
54
  import {
53
- describeRejectedResponse,
54
- describeWebSocketError,
55
- protocolFailureMessage,
56
- startConnectionWatchdog,
57
- } from "../src/connection.mjs";
55
+ describeRejectedResponse,
56
+ describeWebSocketError,
57
+ protocolFailureMessage,
58
+ sendConnectionMessage,
59
+ startConnectionWatchdog,
60
+ } from "../src/connection.mjs";
58
61
  import { advertisedCapabilities } from "../src/capabilities.mjs";
59
62
  import { parseConnectorArgs } from "../src/cli-args.mjs";
60
63
  import { runMcpServer } from "../src/mcp-server.mjs";
@@ -250,25 +253,47 @@ process.on("SIGINT", async () => {
250
253
 
251
254
  await connect();
252
255
 
253
- async function connect() {
254
- console.log(`Connecting ${config.name} to ${config.server_url}`);
255
- connectionRejected = false;
256
- lastConnectionError = undefined;
257
- socket = new WebSocket(config.server_url);
258
- clearConnectionWatchdog = startConnectionWatchdog(socket, config.server_url, {
259
- onTimeout: (message) => {
260
- lastConnectionError = message;
261
- console.error(message);
262
- scheduleReconnect();
263
- },
264
- });
265
- socket.addEventListener("open", () =>
266
- socket.send(JSON.stringify(firstMessage)),
267
- );
268
- socket.addEventListener("message", async (event) => {
269
- const message = JSON.parse(String(event.data));
270
- if (message.type === "paired") {
271
- clearConnectionWatchdog?.();
256
+ async function connect() {
257
+ console.log(`Connecting ${config.name} to ${config.server_url}`);
258
+ connectionRejected = false;
259
+ lastConnectionError = undefined;
260
+ const connection = new WebSocket(config.server_url);
261
+ socket = connection;
262
+ const clearWatchdog = startConnectionWatchdog(connection, config.server_url, {
263
+ onTimeout: (message) => {
264
+ if (socket !== connection) return;
265
+ lastConnectionError = message;
266
+ console.error(message);
267
+ scheduleReconnect();
268
+ },
269
+ });
270
+ clearConnectionWatchdog = clearWatchdog;
271
+ connection.addEventListener("open", () => {
272
+ if (socket !== connection) {
273
+ connection.close();
274
+ return;
275
+ }
276
+ try {
277
+ if (sendConnectionMessage(socket, connection, firstMessage)) return;
278
+ throw new Error("The WebSocket was not open when authentication started.");
279
+ } catch (error) {
280
+ lastConnectionError = protocolFailureMessage(error);
281
+ console.error(
282
+ `Could not authenticate the WebSocket connection: ${lastConnectionError}`,
283
+ );
284
+ try {
285
+ connection.close();
286
+ } catch {
287
+ // The reconnect timer below owns recovery.
288
+ }
289
+ scheduleReconnect();
290
+ }
291
+ });
292
+ connection.addEventListener("message", async (event) => {
293
+ if (socket !== connection) return;
294
+ const message = JSON.parse(String(event.data));
295
+ if (message.type === "paired") {
296
+ clearWatchdog();
272
297
  const reusedConnector =
273
298
  existingConfig?.connector_id === message.connector.id;
274
299
  config = mergePairedConfig(
@@ -294,8 +319,8 @@ async function connect() {
294
319
  if (config.onboarding_pending) void submitWorkspaceSnapshot();
295
320
  return;
296
321
  }
297
- if (message.type === "authenticated") {
298
- clearConnectionWatchdog?.();
322
+ if (message.type === "authenticated") {
323
+ clearWatchdog();
299
324
  console.log("Connected and waiting for EngineerOS runs.");
300
325
  reconnectDelay = 1_000;
301
326
  startPings();
@@ -345,10 +370,10 @@ async function connect() {
345
370
  await stopProcess(active.child);
346
371
  return;
347
372
  }
348
- if (message.type === "connector.revoked") {
349
- stopped = true;
350
- console.error("This connector was revoked in EngineerOS.");
351
- socket.close();
373
+ if (message.type === "connector.revoked") {
374
+ stopped = true;
375
+ console.error("This connector was revoked in EngineerOS.");
376
+ connection.close();
352
377
  return;
353
378
  }
354
379
  if (message.type === "run.error") {
@@ -364,9 +389,10 @@ async function connect() {
364
389
  console.error(`EngineerOS: ${message.message}`);
365
390
  }
366
391
  });
367
- socket.addEventListener("close", (event) => {
368
- clearConnectionWatchdog?.();
369
- clearInterval(pingTimer);
392
+ connection.addEventListener("close", (event) => {
393
+ clearWatchdog();
394
+ if (socket !== connection) return;
395
+ clearInterval(pingTimer);
370
396
  for (const promptState of activePrompts.values())
371
397
  void cancelPrompt(promptState);
372
398
  for (const assessmentState of activeAssessments.values()) {
@@ -382,8 +408,9 @@ async function connect() {
382
408
  if (stopped) return;
383
409
  scheduleReconnect();
384
410
  });
385
- socket.addEventListener("error", (event) => {
386
- lastConnectionError = describeWebSocketError(event);
411
+ connection.addEventListener("error", (event) => {
412
+ if (socket !== connection) return;
413
+ lastConnectionError = describeWebSocketError(event);
387
414
  console.error(
388
415
  `WebSocket connection to ${config.server_url} failed: ${lastConnectionError}`,
389
416
  );
@@ -724,7 +751,16 @@ async function executeAssessment(assignment, assessmentState) {
724
751
  );
725
752
  }
726
753
  }, 15_000);
727
- const assessmentAgentCallbacks = {
754
+ const assessmentAgentCallbacks = {
755
+ onSession: async (sessionId) => {
756
+ if (!isActiveAssessment()) return;
757
+ await persistAssessmentStageSession(
758
+ config.workspace,
759
+ assessmentId,
760
+ stage,
761
+ sessionId,
762
+ );
763
+ },
728
764
  onController: (controller) => {
729
765
  if (isActiveAssessment()) assessmentState.controller = controller;
730
766
  },
@@ -773,20 +809,29 @@ async function executeAssessment(assignment, assessmentState) {
773
809
  config.workspace,
774
810
  assignment,
775
811
  );
776
- let result = await loadAssessmentStageResult(
812
+ let result = await loadAssessmentStageResult(
777
813
  config.workspace,
778
814
  assessmentId,
779
815
  stage,
780
816
  );
781
- if (result) {
817
+ if (result) {
782
818
  attachStoredCorrection(result, preparedAssignment);
783
819
  reportProgress("Recovered completed stage report from connector storage");
784
- } else {
785
- result = await executeWorkspaceAssessment(
786
- preparedAssignment,
787
- config,
788
- assessmentAgentCallbacks,
789
- );
820
+ } else {
821
+ const previousSessionId = await loadAssessmentStageSession(
822
+ config.workspace,
823
+ assessmentId,
824
+ stage,
825
+ );
826
+ if (previousSessionId) {
827
+ reportProgress("Restoring interrupted agent session");
828
+ }
829
+ result = await executeWorkspaceAssessment(
830
+ preparedAssignment,
831
+ config,
832
+ assessmentAgentCallbacks,
833
+ { previousSessionId },
834
+ );
790
835
  await persistAssessmentStageResult(
791
836
  config.workspace,
792
837
  assessmentId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@engineeros/connector",
3
- "version": "0.15.3",
3
+ "version": "0.16.0",
4
4
  "description": "Connect a local coding agent to EngineerOS, using ACP when supported.",
5
5
  "private": false,
6
6
  "type": "module",
@@ -201,19 +201,20 @@ class AcpRuntime {
201
201
  this.clearIdleTimer();
202
202
  const initialized = await this.ready;
203
203
  let session = this.sessions.get(sessionKey);
204
- if (!session) {
205
- session = await this.openSession(
204
+ if (!session) {
205
+ session = await this.openSession(
206
206
  sessionKey,
207
207
  previousSessionId,
208
208
  sandbox,
209
209
  profile,
210
210
  );
211
- callbacks.onEvent?.({
212
- type: "agent.connected",
213
- message: `Agent connected with protocol ${initialized.protocolVersion}`,
214
- });
215
- }
216
- if (this.turns.has(session.sessionId)) {
211
+ callbacks.onEvent?.({
212
+ type: "agent.connected",
213
+ message: `Agent connected with protocol ${initialized.protocolVersion}`,
214
+ });
215
+ }
216
+ await callbacks.onSession?.(session.sessionId);
217
+ if (this.turns.has(session.sessionId)) {
217
218
  throw new Error(
218
219
  "The agent is already answering another prompt in this session.",
219
220
  );
@@ -53,7 +53,7 @@ export async function persistAssessmentStageResult(
53
53
  return { ...metadata, report_markdown: report };
54
54
  }
55
55
 
56
- export async function loadAssessmentStageResult(
56
+ export async function loadAssessmentStageResult(
57
57
  workspace,
58
58
  assessmentId,
59
59
  stage,
@@ -89,7 +89,58 @@ export async function loadAssessmentStageResult(
89
89
  if (error?.code === "ENOENT") return null;
90
90
  throw error;
91
91
  }
92
- }
92
+ }
93
+
94
+ export async function persistAssessmentStageSession(
95
+ workspace,
96
+ assessmentId,
97
+ stage,
98
+ sessionId,
99
+ ) {
100
+ const normalizedSessionId = String(sessionId || "").trim();
101
+ if (!normalizedSessionId) {
102
+ throw new Error(`Assessment stage '${stage}' returned an empty session id.`);
103
+ }
104
+ const directory = assessmentRunDirectory(workspace, assessmentId);
105
+ await mkdir(directory, { recursive: true, mode: 0o700 });
106
+ const checkpoint = {
107
+ stage,
108
+ agent_session_id: normalizedSessionId,
109
+ };
110
+ await atomicWrite(
111
+ assessmentStageSessionPath(directory, stage),
112
+ `${JSON.stringify(checkpoint, null, 2)}\n`,
113
+ );
114
+ return checkpoint;
115
+ }
116
+
117
+ export async function loadAssessmentStageSession(
118
+ workspace,
119
+ assessmentId,
120
+ stage,
121
+ ) {
122
+ const directory = assessmentRunDirectory(workspace, assessmentId);
123
+ try {
124
+ const checkpoint = JSON.parse(
125
+ await readFile(assessmentStageSessionPath(directory, stage), "utf8"),
126
+ );
127
+ if (checkpoint.stage !== stage) {
128
+ throw new Error(
129
+ `Assessment session checkpoint does not match stage '${stage}'.`,
130
+ );
131
+ }
132
+ const sessionId = String(checkpoint.agent_session_id || "").trim();
133
+ if (!sessionId) {
134
+ throw new Error(
135
+ `Assessment session checkpoint for '${stage}' has no session id.`,
136
+ );
137
+ }
138
+ return sessionId;
139
+ } catch (error) {
140
+ if (error?.code === "ENOENT") return null;
141
+ throw error;
142
+ }
143
+ }
93
144
 
94
145
  export async function removeAssessmentStageResult(
95
146
  workspace,
@@ -226,14 +277,18 @@ function validateLocalAssessmentResult(assignment, result) {
226
277
  }
227
278
  }
228
279
 
229
- function assessmentStageBasename(stage) {
280
+ function assessmentStageBasename(stage) {
230
281
  const slug =
231
282
  String(stage)
232
283
  .replace(/[^a-zA-Z0-9_-]+/g, "-")
233
284
  .replace(/^-+|-+$/g, "")
234
285
  .slice(0, 64) || "stage";
235
286
  return `${slug}-${sha256(String(stage)).slice(0, 12)}`;
236
- }
287
+ }
288
+
289
+ function assessmentStageSessionPath(directory, stage) {
290
+ return path.join(directory, `${assessmentStageBasename(stage)}.session.json`);
291
+ }
237
292
 
238
293
  function sha256(value) {
239
294
  return createHash("sha256").update(value).digest("hex");
@@ -217,8 +217,9 @@ export function launchCodexAppServer({
217
217
  model: profile.model || null,
218
218
  ephemeral: false,
219
219
  });
220
- threadId = threadResult.thread.id;
221
- callbacks.onEvent?.({ type: "thread.started", thread_id: threadId });
220
+ threadId = threadResult.thread.id;
221
+ await callbacks.onSession?.(threadId);
222
+ callbacks.onEvent?.({ type: "thread.started", thread_id: threadId });
222
223
  const turnResult = await request("turn/start", {
223
224
  threadId,
224
225
  input: [{ type: "text", text: prompt }],
@@ -1,13 +1,19 @@
1
1
  const PROTOCOL_FAILURE_MESSAGE_MAX_LENGTH = 2_000;
2
2
  const TRUNCATED_FAILURE_SUFFIX = "\n… [truncated by EngineerOS connector]";
3
3
 
4
- export function describeWebSocketError(event) {
4
+ export function describeWebSocketError(event) {
5
5
  return (
6
6
  event?.error?.message ||
7
7
  event?.message ||
8
8
  "The WebSocket connection failed without providing an error detail."
9
9
  );
10
- }
10
+ }
11
+
12
+ export function sendConnectionMessage(activeSocket, connection, message) {
13
+ if (activeSocket !== connection || connection.readyState !== 1) return false;
14
+ connection.send(JSON.stringify(message));
15
+ return true;
16
+ }
11
17
 
12
18
  export function protocolFailureMessage(error) {
13
19
  const raw = error instanceof Error ? error.message : String(error ?? "");
package/src/runner.mjs CHANGED
@@ -361,11 +361,12 @@ export function parseVerificationReport(
361
361
  });
362
362
  }
363
363
 
364
- export async function executeWorkspaceAssessment(
365
- assignment,
366
- config,
367
- callbacks,
368
- ) {
364
+ export async function executeWorkspaceAssessment(
365
+ assignment,
366
+ config,
367
+ callbacks,
368
+ { previousSessionId } = {},
369
+ ) {
369
370
  const execution = workspaceAssessmentExecution(assignment);
370
371
  const assessmentAcpContext =
371
372
  config.agent_protocol === "acp"
@@ -421,7 +422,7 @@ export async function executeWorkspaceAssessment(
421
422
  changeImpact.markdown,
422
423
  )
423
424
  : execution.prompt;
424
- const controller = launchAssessmentAgent(prompt);
425
+ const controller = launchAssessmentAgent(prompt, previousSessionId);
425
426
  let completed = await controller.completed;
426
427
  const recovered = await recoverAssessmentStageOutput({
427
428
  completed,
@@ -1674,8 +1675,10 @@ function launchCodexProcess(
1674
1675
  child.stdin.end(prompt);
1675
1676
  let output = "";
1676
1677
  let buffer = "";
1677
- let finalMessage = "";
1678
- let sessionId = previousSessionId || "";
1678
+ let finalMessage = "";
1679
+ let sessionId = previousSessionId || "";
1680
+ let sessionCheckpoint = Promise.resolve();
1681
+ let sessionCheckpointError;
1679
1682
  child.stdout.setEncoding("utf8");
1680
1683
  child.stdout.on("data", (chunk) => {
1681
1684
  output += chunk;
@@ -1686,12 +1689,17 @@ function launchCodexProcess(
1686
1689
  if (!line.trim()) continue;
1687
1690
  try {
1688
1691
  const event = JSON.parse(line);
1689
- if (
1690
- event.type === "thread.started" &&
1691
- typeof event.thread_id === "string"
1692
- ) {
1693
- sessionId = event.thread_id;
1694
- }
1692
+ if (
1693
+ event.type === "thread.started" &&
1694
+ typeof event.thread_id === "string"
1695
+ ) {
1696
+ sessionId = event.thread_id;
1697
+ sessionCheckpoint = Promise.resolve()
1698
+ .then(() => callbacks.onSession?.(sessionId))
1699
+ .catch((error) => {
1700
+ sessionCheckpointError = error;
1701
+ });
1702
+ }
1695
1703
  if (
1696
1704
  event.type === "item.completed" &&
1697
1705
  event.item?.type === "agent_message" &&
@@ -1718,17 +1726,20 @@ function launchCodexProcess(
1718
1726
  });
1719
1727
  const completed = new Promise((resolve, reject) => {
1720
1728
  child.once("error", reject);
1721
- child.once("close", (code) => {
1722
- if (code === 0 && sessionId)
1723
- resolve({ output: output.slice(-20_000), finalMessage, sessionId });
1724
- else if (code === 0)
1725
- reject(
1726
- new Error(
1727
- "Agent completed without announcing a resumable session id.",
1728
- ),
1729
- );
1730
- else reject(new Error(codexFailureMessage(output, code)));
1731
- });
1729
+ child.once("close", (code) => {
1730
+ void sessionCheckpoint.then(() => {
1731
+ if (sessionCheckpointError) reject(sessionCheckpointError);
1732
+ else if (code === 0 && sessionId)
1733
+ resolve({ output: output.slice(-20_000), finalMessage, sessionId });
1734
+ else if (code === 0)
1735
+ reject(
1736
+ new Error(
1737
+ "Agent completed without announcing a resumable session id.",
1738
+ ),
1739
+ );
1740
+ else reject(new Error(codexFailureMessage(output, code)));
1741
+ });
1742
+ });
1732
1743
  });
1733
1744
  return { child, completed };
1734
1745
  }