opencara 0.110.0 → 0.112.1

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
@@ -152,6 +152,8 @@ var PairingStatusResponseSchema = z4.union([
152
152
  var PairingConfirmRequestSchema = z4.object({
153
153
  device_name: z4.string().min(1)
154
154
  });
155
+ var HOST_PROTOCOL_VERSION = 1;
156
+ var WS_CLOSE_PROTOCOL_TOO_OLD = 4400;
155
157
  var SystemInfoSchema = z4.object({
156
158
  os: z4.string(),
157
159
  // os.platform()
@@ -181,6 +183,12 @@ var HelloMessageSchema = z4.object({
181
183
  type: z4.literal("hello"),
182
184
  platform: z4.string(),
183
185
  version: z4.string(),
186
+ /**
187
+ * HOST_PROTOCOL_VERSION the device speaks. Absent from CLIs published
188
+ * before versioning existed — the server treats absent as 0 and gates
189
+ * on MIN_HOST_PROTOCOL_VERSION.
190
+ */
191
+ protocolVersion: z4.number().int().nonnegative().optional(),
184
192
  capabilities: z4.array(z4.string()).default([]),
185
193
  systemInfo: SystemInfoSchema.optional()
186
194
  });
@@ -212,12 +220,22 @@ var RunDoneSchema = z4.object({
212
220
  var HelloAckSchema = z4.object({
213
221
  type: z4.literal("hello-ack"),
214
222
  agentHostId: z4.string(),
215
- deviceName: z4.string()
223
+ deviceName: z4.string(),
224
+ /**
225
+ * Server's HOST_PROTOCOL_VERSION. Optional so old servers (which don't
226
+ * send it) still satisfy new clients' parse; clients use it only to log
227
+ * a version-skew warning, never to gate.
228
+ */
229
+ protocolVersion: z4.number().int().nonnegative().optional()
216
230
  });
217
231
  var CancelJobSchema = z4.object({
218
232
  type: z4.literal("cancel"),
219
233
  runId: z4.string(),
220
- reason: z4.enum(["user_stopped", "wave_cancelled"])
234
+ // `.catch()` — not a bare enum — so a future server that grows a new
235
+ // reason (e.g. "timeout") degrades on THIS client to "user_stopped"
236
+ // instead of failing the whole frame's parse and silently dropping the
237
+ // cancel, which left the job unkillable on pre-versioning CLIs.
238
+ reason: z4.enum(["user_stopped", "wave_cancelled"]).catch("user_stopped")
221
239
  });
222
240
  var PingSchema = z4.object({ type: z4.literal("ping") });
223
241
  var PongSchema = z4.object({ type: z4.literal("pong") });
@@ -318,6 +336,13 @@ var DeviceToServerMessageSchema = z4.union([
318
336
  PongSchema,
319
337
  AgentCallRequestSchema
320
338
  ]);
339
+ var SERVER_TO_DEVICE_TYPES = /* @__PURE__ */ new Set([
340
+ "job",
341
+ "hello-ack",
342
+ "ping",
343
+ "agent-call-result",
344
+ "cancel"
345
+ ]);
321
346
  var HostRegisterRequestSchema = z4.object({
322
347
  hostId: z4.string(),
323
348
  hostName: z4.string(),
@@ -434,6 +459,23 @@ function sleep(ms) {
434
459
 
435
460
  // src/transport/ws-client.ts
436
461
  import WebSocket from "ws";
462
+ function classifyServerFrame(raw) {
463
+ let json;
464
+ try {
465
+ json = JSON.parse(raw);
466
+ } catch {
467
+ return { kind: "malformed", error: "not JSON" };
468
+ }
469
+ const type = typeof json === "object" && json !== null && "type" in json ? json.type : void 0;
470
+ if (typeof type === "string" && !SERVER_TO_DEVICE_TYPES.has(type)) {
471
+ return { kind: "unknown-type", type };
472
+ }
473
+ const parsed = ServerToDeviceMessageSchema.safeParse(json);
474
+ if (!parsed.success) {
475
+ return { kind: "malformed", error: parsed.error.message };
476
+ }
477
+ return { kind: "ok", msg: parsed.data };
478
+ }
437
479
  var HEARTBEAT_MS = 3e4;
438
480
  var WsClient = class {
439
481
  constructor(opts) {
@@ -445,6 +487,8 @@ var WsClient = class {
445
487
  backoff;
446
488
  heartbeat = null;
447
489
  stopped = false;
490
+ /** Unknown frame types already warned about (once per type, not per frame). */
491
+ warnedUnknownTypes = /* @__PURE__ */ new Set();
448
492
  start() {
449
493
  this.connect();
450
494
  }
@@ -473,18 +517,28 @@ var WsClient = class {
473
517
  this.opts.onOpen?.();
474
518
  });
475
519
  ws.on("message", (raw) => {
476
- let parsed;
477
- try {
478
- parsed = ServerToDeviceMessageSchema.parse(JSON.parse(raw.toString()));
479
- } catch (err) {
480
- console.error("[ws] invalid frame", err);
520
+ const frame = classifyServerFrame(raw.toString());
521
+ if (frame.kind === "unknown-type") {
522
+ if (!this.warnedUnknownTypes.has(frame.type)) {
523
+ this.warnedUnknownTypes.add(frame.type);
524
+ console.warn(
525
+ `[ws] ignoring unknown frame type "${frame.type}" \u2014 the server is likely newer than this CLI; consider \`npm i -g opencara\``
526
+ );
527
+ }
528
+ return;
529
+ }
530
+ if (frame.kind === "malformed") {
531
+ console.error("[ws] invalid frame:", frame.error);
481
532
  return;
482
533
  }
483
- this.opts.onMessage(parsed);
534
+ this.opts.onMessage(frame.msg);
484
535
  });
485
536
  ws.on("close", (code, reasonBuf) => {
486
537
  const reason = reasonBuf.toString();
487
538
  if (this.heartbeat) clearInterval(this.heartbeat);
539
+ if (code === WS_CLOSE_PROTOCOL_TOO_OLD) {
540
+ this.stopped = true;
541
+ }
488
542
  this.opts.onClose?.(code, reason);
489
543
  if (!this.stopped) this.scheduleReconnect();
490
544
  });
@@ -1302,11 +1356,21 @@ async function selectAcpModel(client, sessionId, requested, configOptions, onLog
1302
1356
  const values = (modelOption.options ?? []).map((o) => o.value);
1303
1357
  const target = matchModelValue(requested, values);
1304
1358
  if (!target) {
1305
- onLog(
1306
- "stderr",
1307
- `[acp] model "${requested}" not among available models [${values.join(", ")}]; using the default
1359
+ try {
1360
+ await client.setConfigOption({
1361
+ sessionId,
1362
+ configId: modelOption.id,
1363
+ value: requested.trim()
1364
+ });
1365
+ onLog("stderr", `[acp] selected model ${requested.trim()} (freeform)
1366
+ `);
1367
+ } catch {
1368
+ onLog(
1369
+ "stderr",
1370
+ `[acp] model "${requested}" not among available models [${values.join(", ")}]; using the default
1308
1371
  `
1309
- );
1372
+ );
1373
+ }
1310
1374
  return;
1311
1375
  }
1312
1376
  if (modelOption.currentValue === target) return;
@@ -1402,7 +1466,7 @@ function resolveLocalAcpAdapter(command, args) {
1402
1466
  }
1403
1467
 
1404
1468
  // src/commands/run.ts
1405
- var PKG_VERSION = "0.110.0";
1469
+ var PKG_VERSION = "0.112.1";
1406
1470
  var LOG_FLUSH_MS = 800;
1407
1471
  var MAX_CHUNK_SIZE = 4 * 1024;
1408
1472
  async function run(opts = {}) {
@@ -1422,6 +1486,7 @@ async function run(opts = {}) {
1422
1486
  type: "hello",
1423
1487
  platform: platform(),
1424
1488
  version: PKG_VERSION,
1489
+ protocolVersion: HOST_PROTOCOL_VERSION,
1425
1490
  // Advertise ACP transport support. Pre-v0.30 devices reported
1426
1491
  // "agent-call" (the fenced-stdout-block protocol); since the
1427
1492
  // legacy path was removed, this version reports "acp" so the
@@ -1432,6 +1497,14 @@ async function run(opts = {}) {
1432
1497
  },
1433
1498
  onMessage: (msg) => handleServerMessage(msg, client, cfg),
1434
1499
  onClose: (code, reason) => {
1500
+ if (code === WS_CLOSE_PROTOCOL_TOO_OLD) {
1501
+ console.error(
1502
+ `[opencara] this CLI's protocol is too old for the orchestrator: ${reason}`
1503
+ );
1504
+ console.error("[opencara] upgrade with: npm i -g opencara");
1505
+ process.exitCode = 1;
1506
+ return;
1507
+ }
1435
1508
  console.log(`[opencara] disconnected (code=${code} reason="${reason}")`);
1436
1509
  }
1437
1510
  });
@@ -1442,6 +1515,11 @@ var acpControllers = /* @__PURE__ */ new Map();
1442
1515
  function handleServerMessage(msg, client, _cfg) {
1443
1516
  if (msg.type === "hello-ack") {
1444
1517
  console.log(`[opencara] acked as ${msg.deviceName} (${msg.agentHostId})`);
1518
+ if (msg.protocolVersion !== void 0 && msg.protocolVersion !== HOST_PROTOCOL_VERSION) {
1519
+ console.warn(
1520
+ `[opencara] protocol skew: server v${msg.protocolVersion}, CLI v${HOST_PROTOCOL_VERSION}` + (msg.protocolVersion > HOST_PROTOCOL_VERSION ? " \u2014 consider `npm i -g opencara`" : "")
1521
+ );
1522
+ }
1445
1523
  return;
1446
1524
  }
1447
1525
  if (msg.type === "ping") return;
@@ -102,6 +102,57 @@ function buildClaudeMcpConfig(servers) {
102
102
  }
103
103
  var sessions = /* @__PURE__ */ new Map();
104
104
  var extraClaudeArgs = [];
105
+ function _setExtraClaudeArgsForTest(args) {
106
+ extraClaudeArgs = args;
107
+ }
108
+ function parseModelFromArgs(args) {
109
+ let model;
110
+ for (let i = 0; i < args.length; i++) {
111
+ const a = args[i];
112
+ const eq = /^(?:--model|-m)=(.*)$/.exec(a);
113
+ if (eq && eq[1]) {
114
+ model = eq[1];
115
+ continue;
116
+ }
117
+ if ((a === "--model" || a === "-m") && args[i + 1] !== void 0) {
118
+ model = args[i + 1];
119
+ i++;
120
+ }
121
+ }
122
+ return model;
123
+ }
124
+ function modelConfigOptions(state) {
125
+ const effective = state.modelOverride ?? parseModelFromArgs(extraClaudeArgs);
126
+ if (!effective) return void 0;
127
+ return [
128
+ {
129
+ type: "select",
130
+ id: "model",
131
+ category: "model",
132
+ name: "Model",
133
+ currentValue: effective,
134
+ options: [{ value: effective }]
135
+ }
136
+ ];
137
+ }
138
+ function handleSetConfigOption(params) {
139
+ const sessionId = typeof params.sessionId === "string" ? params.sessionId : "";
140
+ const state = sessions.get(sessionId);
141
+ if (!state) {
142
+ throw new Error(`session/set_config_option: unknown session '${sessionId}'`);
143
+ }
144
+ if (params.configId !== "model") {
145
+ throw new Error(
146
+ `session/set_config_option: unknown configId '${String(params.configId)}'`
147
+ );
148
+ }
149
+ const value = typeof params.value === "string" ? params.value.trim() : "";
150
+ if (!value) {
151
+ throw new Error("session/set_config_option: value must be a non-empty string");
152
+ }
153
+ state.modelOverride = value;
154
+ return {};
155
+ }
105
156
  var INSTRUCTIONS_FILE_MAX_BYTES = 64 * 1024;
106
157
  function resolveInstructionsFile(cwd, relative) {
107
158
  if (!relative || typeof relative !== "string" || relative.length === 0) {
@@ -260,6 +311,9 @@ async function runClaudeTurn(sessionId, state, promptText, images, permissionMod
260
311
  if (extraClaudeArgs.length > 0) {
261
312
  args.push(...extraClaudeArgs);
262
313
  }
314
+ if (state.modelOverride) {
315
+ args.push("--model", state.modelOverride);
316
+ }
263
317
  const child = spawn("claude", args, {
264
318
  cwd: state.cwd,
265
319
  env: process.env,
@@ -486,13 +540,15 @@ function handleNewSession(params) {
486
540
  const sessionId = randomUUID();
487
541
  const mcpServers = normalizeMcpServers(params.mcpServers);
488
542
  const instructionsFile = normalizeInstructionsFile(params.instructionsFile);
489
- sessions.set(sessionId, {
543
+ const state = {
490
544
  cwd: params.cwd ?? process.cwd(),
491
545
  resume: false,
492
546
  ...mcpServers.length > 0 ? { mcpServers } : {},
493
547
  ...instructionsFile ? { instructionsFile } : {}
494
- });
495
- return { sessionId };
548
+ };
549
+ sessions.set(sessionId, state);
550
+ const configOptions = modelConfigOptions(state);
551
+ return { sessionId, ...configOptions ? { configOptions } : {} };
496
552
  }
497
553
  function handleLoadSession(params) {
498
554
  if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
@@ -500,13 +556,15 @@ function handleLoadSession(params) {
500
556
  }
501
557
  const mcpServers = normalizeMcpServers(params.mcpServers);
502
558
  const instructionsFile = normalizeInstructionsFile(params.instructionsFile);
503
- sessions.set(params.sessionId, {
559
+ const state = {
504
560
  cwd: params.cwd ?? process.cwd(),
505
561
  resume: true,
506
562
  ...mcpServers.length > 0 ? { mcpServers } : {},
507
563
  ...instructionsFile ? { instructionsFile } : {}
508
- });
509
- return {};
564
+ };
565
+ sessions.set(params.sessionId, state);
566
+ const configOptions = modelConfigOptions(state);
567
+ return configOptions ? { configOptions } : {};
510
568
  }
511
569
  function normalizeInstructionsFile(raw) {
512
570
  if (typeof raw !== "string") return void 0;
@@ -591,6 +649,14 @@ async function dispatch(msg) {
591
649
  case "session/load":
592
650
  reply(req.id, handleLoadSession(req.params));
593
651
  return;
652
+ case "session/set_config_option":
653
+ reply(
654
+ req.id,
655
+ handleSetConfigOption(
656
+ req.params
657
+ )
658
+ );
659
+ return;
594
660
  case "session/prompt": {
595
661
  const result = await handlePrompt(req.params);
596
662
  reply(req.id, result);
@@ -605,7 +671,7 @@ async function dispatch(msg) {
605
671
  }
606
672
  } catch (err) {
607
673
  const message = err instanceof Error ? err.message : String(err);
608
- const isParamsError = err instanceof Error && (message.startsWith("session/prompt:") || message.startsWith("session/load:"));
674
+ const isParamsError = err instanceof Error && (message.startsWith("session/prompt:") || message.startsWith("session/load:") || message.startsWith("session/set_config_option:"));
609
675
  replyError(
610
676
  req.id,
611
677
  isParamsError ? JSON_RPC_ERROR_INVALID_PARAMS : JSON_RPC_ERROR_INTERNAL,
@@ -614,11 +680,15 @@ async function dispatch(msg) {
614
680
  }
615
681
  }
616
682
  export {
683
+ _setExtraClaudeArgsForTest,
617
684
  buildStreamJsonInput,
618
685
  extraClaudeArgs,
619
686
  handleInitialize,
620
687
  handleLoadSession,
621
688
  handleNewSession,
689
+ handleSetConfigOption,
690
+ modelConfigOptions,
691
+ parseModelFromArgs,
622
692
  resolveInstructionsFile,
623
693
  sessions,
624
694
  translateClaudeEvent
@@ -222,6 +222,12 @@ var HelloMessageSchema = z3.object({
222
222
  type: z3.literal("hello"),
223
223
  platform: z3.string(),
224
224
  version: z3.string(),
225
+ /**
226
+ * HOST_PROTOCOL_VERSION the device speaks. Absent from CLIs published
227
+ * before versioning existed — the server treats absent as 0 and gates
228
+ * on MIN_HOST_PROTOCOL_VERSION.
229
+ */
230
+ protocolVersion: z3.number().int().nonnegative().optional(),
225
231
  capabilities: z3.array(z3.string()).default([]),
226
232
  systemInfo: SystemInfoSchema.optional()
227
233
  });
@@ -253,12 +259,22 @@ var RunDoneSchema = z3.object({
253
259
  var HelloAckSchema = z3.object({
254
260
  type: z3.literal("hello-ack"),
255
261
  agentHostId: z3.string(),
256
- deviceName: z3.string()
262
+ deviceName: z3.string(),
263
+ /**
264
+ * Server's HOST_PROTOCOL_VERSION. Optional so old servers (which don't
265
+ * send it) still satisfy new clients' parse; clients use it only to log
266
+ * a version-skew warning, never to gate.
267
+ */
268
+ protocolVersion: z3.number().int().nonnegative().optional()
257
269
  });
258
270
  var CancelJobSchema = z3.object({
259
271
  type: z3.literal("cancel"),
260
272
  runId: z3.string(),
261
- reason: z3.enum(["user_stopped", "wave_cancelled"])
273
+ // `.catch()` — not a bare enum — so a future server that grows a new
274
+ // reason (e.g. "timeout") degrades on THIS client to "user_stopped"
275
+ // instead of failing the whole frame's parse and silently dropping the
276
+ // cancel, which left the job unkillable on pre-versioning CLIs.
277
+ reason: z3.enum(["user_stopped", "wave_cancelled"]).catch("user_stopped")
262
278
  });
263
279
  var PingSchema = z3.object({ type: z3.literal("ping") });
264
280
  var PongSchema = z3.object({ type: z3.literal("pong") });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.110.0",
3
+ "version": "0.112.1",
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 src/bin/__tests__/*.test.ts src/commands/__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 src/transport/__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"