opencara 0.105.0 → 0.105.2

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/dist/bin.js CHANGED
@@ -13,6 +13,7 @@ import {
13
13
  uptime
14
14
  } from "node:os";
15
15
  import { statfsSync } from "node:fs";
16
+ import { spawn as spawn3 } from "node:child_process";
16
17
 
17
18
  // src/config/store.ts
18
19
  import { readFileSync, writeFileSync, mkdirSync, existsSync, unlinkSync } from "node:fs";
@@ -85,7 +86,8 @@ var AcpSpecSchema = z3.object({
85
86
  systemPromptMd: z3.string(),
86
87
  userPromptMd: z3.string(),
87
88
  history: z3.array(AcpHistoryTurnSchema).default([]),
88
- pageContextJson: z3.string().optional()
89
+ pageContextJson: z3.string().optional(),
90
+ priorSessionId: z3.string().optional()
89
91
  });
90
92
  var AgentSpecSchema = z3.object({
91
93
  kind: z3.string(),
@@ -184,7 +186,12 @@ var RunDoneSchema = z4.object({
184
186
  runId: z4.string(),
185
187
  status: z4.enum(["succeeded", "failed", "cancelled"]),
186
188
  exitCode: z4.number().int().nullable().optional(),
187
- errorMessage: z4.string().optional()
189
+ errorMessage: z4.string().optional(),
190
+ /** ACP session id the agent ran under (fresh from session/new, or
191
+ * echoed from session/load). The orchestrator persists this per
192
+ * (repo, branch) so the next iteration can resume via session/load.
193
+ * Null/absent for non-ACP runs (worktree-allocate, write-session). */
194
+ acpSessionId: z4.string().nullable().optional()
188
195
  });
189
196
  var HelloAckSchema = z4.object({
190
197
  type: z4.literal("hello-ack"),
@@ -561,6 +568,9 @@ var AcpConnection = class {
561
568
  newSession(req) {
562
569
  return this.request(ACP_METHODS.session_new, req);
563
570
  }
571
+ loadSession(req) {
572
+ return this.request(ACP_METHODS.session_load, req);
573
+ }
564
574
  prompt(req) {
565
575
  return this.request(ACP_METHODS.session_prompt, req);
566
576
  }
@@ -722,6 +732,9 @@ var AcpClient = class {
722
732
  newSession(req) {
723
733
  return this.must().newSession(req);
724
734
  }
735
+ loadSession(req) {
736
+ return this.must().loadSession(req);
737
+ }
725
738
  prompt(req) {
726
739
  return this.must().prompt(req);
727
740
  }
@@ -1065,26 +1078,35 @@ function runAcpJob(opts) {
1065
1078
  }
1066
1079
  };
1067
1080
  const promise = (async () => {
1068
- let result = { exitCode: 1, stopReason: "uninitialized" };
1081
+ let result = { exitCode: 1, stopReason: "uninitialized", sessionId: "" };
1069
1082
  try {
1070
1083
  await host.start();
1071
1084
  client.start();
1072
- await client.initialize({
1085
+ const initResult = await client.initialize({
1073
1086
  protocolVersion: ACP_PROTOCOL_VERSION,
1074
1087
  clientCapabilities: {}
1075
1088
  });
1076
- const session = await client.newSession({
1077
- cwd: spec.cwd ?? process.cwd(),
1078
- mcpServers: [host.acpServerEntry()]
1079
- });
1089
+ const cwd = spec.cwd ?? process.cwd();
1090
+ const mcpServers = [host.acpServerEntry()];
1091
+ const shimSupportsLoad = initResult.agentCapabilities?.loadSession === true;
1092
+ let sessionId;
1093
+ if (acpSpec.priorSessionId && shimSupportsLoad) {
1094
+ await client.loadSession({
1095
+ sessionId: acpSpec.priorSessionId,
1096
+ cwd,
1097
+ mcpServers
1098
+ });
1099
+ sessionId = acpSpec.priorSessionId;
1100
+ } else {
1101
+ const session = await client.newSession({ cwd, mcpServers });
1102
+ sessionId = session.sessionId;
1103
+ }
1080
1104
  const prompt = buildPromptContent(acpSpec);
1081
- const promptResult = await client.prompt({
1082
- sessionId: session.sessionId,
1083
- prompt
1084
- });
1105
+ const promptResult = await client.prompt({ sessionId, prompt });
1085
1106
  result = {
1086
1107
  exitCode: promptResult.stopReason === "end_turn" ? 0 : 1,
1087
- stopReason: promptResult.stopReason
1108
+ stopReason: promptResult.stopReason,
1109
+ sessionId
1088
1110
  };
1089
1111
  return result;
1090
1112
  } finally {
@@ -1184,7 +1206,7 @@ function resolveLocalAcpAdapter(command, args) {
1184
1206
  }
1185
1207
 
1186
1208
  // src/commands/run.ts
1187
- var PKG_VERSION = "0.105.0";
1209
+ var PKG_VERSION = "0.105.2";
1188
1210
  var LOG_FLUSH_MS = 800;
1189
1211
  var MAX_CHUNK_SIZE = 4 * 1024;
1190
1212
  async function run(opts = {}) {
@@ -1260,6 +1282,11 @@ async function executeJob(job, client) {
1260
1282
  if (flushTimer) return;
1261
1283
  flushTimer = setTimeout(flush, LOG_FLUSH_MS);
1262
1284
  };
1285
+ if (isInternalCommand(job.spec)) {
1286
+ flush();
1287
+ await runInternalCommand(job, client);
1288
+ return;
1289
+ }
1263
1290
  if (!job.spec.acp) {
1264
1291
  flush();
1265
1292
  const message = `legacy stdin-JSON dispatch removed in v0.30 \u2014 orchestrator must send spec.acp. Got command: ${job.spec.command}.`;
@@ -1286,7 +1313,8 @@ async function executeJob(job, client) {
1286
1313
  type: "done",
1287
1314
  runId,
1288
1315
  status: result.exitCode === 0 ? "succeeded" : "failed",
1289
- exitCode: result.exitCode
1316
+ exitCode: result.exitCode,
1317
+ acpSessionId: result.sessionId || null
1290
1318
  });
1291
1319
  console.log(
1292
1320
  `[opencara] job ${runId.slice(-8)} (acp) \u2192 ${result.stopReason} exit=${result.exitCode}`
@@ -1300,6 +1328,63 @@ async function executeJob(job, client) {
1300
1328
  acpControllers.delete(runId);
1301
1329
  }
1302
1330
  }
1331
+ function isInternalCommand(spec) {
1332
+ return spec.command === "opencara" && Array.isArray(spec.args) && spec.args[0] === "internal";
1333
+ }
1334
+ async function runInternalCommand(job, client, opts = {}) {
1335
+ const runId = job.run.id;
1336
+ const args = job.spec.args ?? [];
1337
+ const binPath = opts.binPath ?? process.argv[1];
1338
+ const nodePath = opts.nodePath ?? process.execPath;
1339
+ if (!binPath) {
1340
+ client.send({
1341
+ type: "done",
1342
+ runId,
1343
+ status: "failed",
1344
+ errorMessage: "internal: process.argv[1] missing \u2014 cannot re-invoke bin"
1345
+ });
1346
+ return;
1347
+ }
1348
+ let seq = 0;
1349
+ const emit = (stream, chunk) => {
1350
+ let remaining = chunk;
1351
+ while (remaining.length > 0) {
1352
+ const take = remaining.slice(0, MAX_CHUNK_SIZE);
1353
+ client.send({ type: "log", runId, seq: seq++, stream, chunk: take });
1354
+ remaining = remaining.slice(MAX_CHUNK_SIZE);
1355
+ }
1356
+ };
1357
+ const child = spawn3(nodePath, [binPath, ...args], {
1358
+ cwd: job.spec.cwd,
1359
+ env: job.spec.env ? { ...process.env, ...job.spec.env } : process.env,
1360
+ stdio: ["ignore", "pipe", "pipe"]
1361
+ });
1362
+ child.stdout.setEncoding("utf8");
1363
+ child.stdout.on("data", (c) => emit("stdout", c));
1364
+ child.stderr.setEncoding("utf8");
1365
+ child.stderr.on("data", (c) => emit("stderr", c));
1366
+ const { exitCode, errorMessage } = await new Promise((resolve) => {
1367
+ let done = false;
1368
+ child.on("error", (err) => {
1369
+ if (done) return;
1370
+ done = true;
1371
+ resolve({ exitCode: 1, errorMessage: `internal spawn error: ${err.message}` });
1372
+ });
1373
+ child.on("close", (code, signal) => {
1374
+ if (done) return;
1375
+ done = true;
1376
+ resolve({ exitCode: code ?? (signal ? 1 : 0) });
1377
+ });
1378
+ });
1379
+ client.send({
1380
+ type: "done",
1381
+ runId,
1382
+ status: exitCode === 0 ? "succeeded" : "failed",
1383
+ exitCode,
1384
+ ...errorMessage ? { errorMessage } : {}
1385
+ });
1386
+ console.log(`[opencara] job ${runId.slice(-8)} (internal) \u2192 exit=${exitCode}`);
1387
+ }
1303
1388
  function collectSystemInfo() {
1304
1389
  try {
1305
1390
  const cpuList = cpus();
@@ -74,23 +74,25 @@ async function runClaudeTurn(sessionId, state, promptText) {
74
74
  "--include-partial-messages",
75
75
  "--verbose",
76
76
  "--session-id",
77
- state.claudeSessionId,
77
+ sessionId,
78
78
  // Headless: no human in the loop to approve tool use. Matches the
79
79
  // legacy `claudeAdapter` posture in agents/kinds.ts.
80
- "--dangerously-skip-permissions",
81
- promptText
80
+ "--dangerously-skip-permissions"
82
81
  ];
83
82
  const child = spawn("claude", args, {
84
83
  cwd: state.cwd,
85
84
  env: process.env,
86
- stdio: ["ignore", "pipe", "pipe"]
85
+ stdio: ["pipe", "pipe", "pipe"]
86
+ });
87
+ child.stdin.on("error", () => {
87
88
  });
88
- const decoder2 = new FrameDecoder();
89
+ child.stdin.end(promptText);
90
+ const decoder = new FrameDecoder();
89
91
  let resolved = false;
90
92
  let stopReason = "end_turn";
91
93
  child.stdout.setEncoding("utf8");
92
94
  child.stdout.on("data", (chunk) => {
93
- const { messages, malformed } = decoder2.feed(chunk);
95
+ const { messages, malformed } = decoder.feed(chunk);
94
96
  for (const line of malformed) {
95
97
  stderr.write(`[claude-acp] malformed: ${line}
96
98
  `);
@@ -189,10 +191,12 @@ function handleInitialize(_params) {
189
191
  version: "0.0.1"
190
192
  },
191
193
  agentCapabilities: {
192
- // No session resume yet (follow-up). MCP via stdio works because
193
- // the `claude` CLI itself supports `mcpServers` in settings.json
194
- // but we don't propagate ACP's mcpServers config in this MVP.
195
- loadSession: false,
194
+ // Session resume works by passing the ACP sessionId back as
195
+ // `claude --session-id <uuid>` on the next prompt — Claude CLI
196
+ // replays its own JSONL internally. MCP via stdio is not yet
197
+ // propagated from ACP's mcpServers config (the `claude` CLI uses
198
+ // settings.json for that today; bridging is a separate change).
199
+ loadSession: true,
196
200
  mcpCapabilities: {},
197
201
  promptCapabilities: { embeddedContext: false, image: false, audio: false }
198
202
  },
@@ -201,12 +205,16 @@ function handleInitialize(_params) {
201
205
  }
202
206
  function handleNewSession(params) {
203
207
  const sessionId = randomUUID();
204
- sessions.set(sessionId, {
205
- claudeSessionId: randomUUID(),
206
- cwd: params.cwd ?? process.cwd()
207
- });
208
+ sessions.set(sessionId, { cwd: params.cwd ?? process.cwd() });
208
209
  return { sessionId };
209
210
  }
211
+ function handleLoadSession(params) {
212
+ if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
213
+ throw new Error("session/load: sessionId required");
214
+ }
215
+ sessions.set(params.sessionId, { cwd: params.cwd ?? process.cwd() });
216
+ return {};
217
+ }
210
218
  async function handlePrompt(params) {
211
219
  const state = sessions.get(params.sessionId);
212
220
  if (!state) {
@@ -219,19 +227,22 @@ async function handlePrompt(params) {
219
227
  const result = await runClaudeTurn(params.sessionId, state, promptText);
220
228
  return { stopReason: result.stopReason };
221
229
  }
222
- var decoder = new FrameDecoder();
223
- stdin.setEncoding("utf8");
224
- stdin.on("data", (chunk) => {
225
- const { messages, malformed } = decoder.feed(chunk);
226
- for (const line of malformed) {
227
- stderr.write(`[claude-acp] malformed inbound: ${line}
230
+ var isMainModule = import.meta.url === `file://${process.argv[1]}` || process.argv[1]?.endsWith("claude-acp.ts") === true || process.argv[1]?.endsWith("claude-acp.js") === true;
231
+ if (isMainModule) {
232
+ const decoder = new FrameDecoder();
233
+ stdin.setEncoding("utf8");
234
+ stdin.on("data", (chunk) => {
235
+ const { messages, malformed } = decoder.feed(chunk);
236
+ for (const line of malformed) {
237
+ stderr.write(`[claude-acp] malformed inbound: ${line}
228
238
  `);
229
- }
230
- for (const msg of messages) void dispatch(msg);
231
- });
232
- stdin.on("end", () => {
233
- exit(0);
234
- });
239
+ }
240
+ for (const msg of messages) void dispatch(msg);
241
+ });
242
+ stdin.on("end", () => {
243
+ exit(0);
244
+ });
245
+ }
235
246
  async function dispatch(msg) {
236
247
  if (!("id" in msg) || msg.id == null) return;
237
248
  if ("result" in msg || "error" in msg) return;
@@ -244,6 +255,9 @@ async function dispatch(msg) {
244
255
  case "session/new":
245
256
  reply(req.id, handleNewSession(req.params));
246
257
  return;
258
+ case "session/load":
259
+ reply(req.id, handleLoadSession(req.params));
260
+ return;
247
261
  case "session/prompt": {
248
262
  const result = await handlePrompt(req.params);
249
263
  reply(req.id, result);
@@ -258,10 +272,17 @@ async function dispatch(msg) {
258
272
  }
259
273
  } catch (err) {
260
274
  const message = err instanceof Error ? err.message : String(err);
275
+ const isParamsError = err instanceof Error && (message.startsWith("session/prompt:") || message.startsWith("session/load:"));
261
276
  replyError(
262
277
  req.id,
263
- err instanceof Error && message.startsWith("session/prompt:") ? JSON_RPC_ERROR_INVALID_PARAMS : JSON_RPC_ERROR_INTERNAL,
278
+ isParamsError ? JSON_RPC_ERROR_INVALID_PARAMS : JSON_RPC_ERROR_INTERNAL,
264
279
  message
265
280
  );
266
281
  }
267
282
  }
283
+ export {
284
+ handleInitialize,
285
+ handleLoadSession,
286
+ handleNewSession,
287
+ sessions
288
+ };
@@ -127,7 +127,8 @@ var AcpSpecSchema = z2.object({
127
127
  systemPromptMd: z2.string(),
128
128
  userPromptMd: z2.string(),
129
129
  history: z2.array(AcpHistoryTurnSchema).default([]),
130
- pageContextJson: z2.string().optional()
130
+ pageContextJson: z2.string().optional(),
131
+ priorSessionId: z2.string().optional()
131
132
  });
132
133
  var AgentSpecSchema = z2.object({
133
134
  kind: z2.string(),
@@ -226,7 +227,12 @@ var RunDoneSchema = z3.object({
226
227
  runId: z3.string(),
227
228
  status: z3.enum(["succeeded", "failed", "cancelled"]),
228
229
  exitCode: z3.number().int().nullable().optional(),
229
- errorMessage: z3.string().optional()
230
+ errorMessage: z3.string().optional(),
231
+ /** ACP session id the agent ran under (fresh from session/new, or
232
+ * echoed from session/load). The orchestrator persists this per
233
+ * (repo, branch) so the next iteration can resume via session/load.
234
+ * Null/absent for non-ACP runs (worktree-allocate, write-session). */
235
+ acpSessionId: z3.string().nullable().optional()
230
236
  });
231
237
  var HelloAckSchema = z3.object({
232
238
  type: z3.literal("hello-ack"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.105.0",
3
+ "version": "0.105.2",
4
4
  "description": "OpenCara agent-host CLI: register a machine as an agent host and run dispatched agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -38,7 +38,7 @@
38
38
  "dev": "tsx watch src/bin.ts",
39
39
  "start": "node dist/bin.js",
40
40
  "typecheck": "tsc -b",
41
- "test": "node --import tsx --test --test-reporter=spec src/acp/__tests__/*.test.ts src/mcp/__tests__/*.test.ts src/runner/__tests__/*.test.ts",
41
+ "test": "node --import tsx --test --test-reporter=spec src/acp/__tests__/*.test.ts src/mcp/__tests__/*.test.ts src/runner/__tests__/*.test.ts src/bin/__tests__/*.test.ts src/commands/__tests__/*.test.ts",
42
42
  "acp:spike": "tsx src/acp/spike.ts",
43
43
  "mcp:smoke": "tsx src/mcp/smoke.ts",
44
44
  "clean": "rm -rf dist *.tsbuildinfo"