opencara 0.109.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
@@ -82,6 +82,12 @@ var AcpHistoryTurnSchema = z3.object({
82
82
  role: z3.enum(["user", "assistant"]),
83
83
  text: z3.string()
84
84
  });
85
+ var AcpImageInputSchema = z3.object({
86
+ /** Base64-encoded image bytes, without the `data:<mime>;base64,` prefix. */
87
+ data: z3.string(),
88
+ /** IANA image MIME type, e.g. `image/png`, `image/jpeg`, `image/webp`. */
89
+ mimeType: z3.string()
90
+ });
85
91
  var AcpPermissionModeSchema = z3.enum([
86
92
  "default",
87
93
  "acceptEdits",
@@ -94,7 +100,10 @@ var AcpSpecSchema = z3.object({
94
100
  history: z3.array(AcpHistoryTurnSchema).default([]),
95
101
  pageContextJson: z3.string().optional(),
96
102
  priorSessionId: z3.string().optional(),
97
- permissionMode: AcpPermissionModeSchema.optional()
103
+ permissionMode: AcpPermissionModeSchema.optional(),
104
+ instructionsFile: z3.string().optional(),
105
+ images: z3.array(AcpImageInputSchema).default([]),
106
+ model: z3.string().optional()
98
107
  });
99
108
  var AgentSpecSchema = z3.object({
100
109
  kind: z3.string(),
@@ -143,6 +152,8 @@ var PairingStatusResponseSchema = z4.union([
143
152
  var PairingConfirmRequestSchema = z4.object({
144
153
  device_name: z4.string().min(1)
145
154
  });
155
+ var HOST_PROTOCOL_VERSION = 1;
156
+ var WS_CLOSE_PROTOCOL_TOO_OLD = 4400;
146
157
  var SystemInfoSchema = z4.object({
147
158
  os: z4.string(),
148
159
  // os.platform()
@@ -172,6 +183,12 @@ var HelloMessageSchema = z4.object({
172
183
  type: z4.literal("hello"),
173
184
  platform: z4.string(),
174
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(),
175
192
  capabilities: z4.array(z4.string()).default([]),
176
193
  systemInfo: SystemInfoSchema.optional()
177
194
  });
@@ -203,12 +220,22 @@ var RunDoneSchema = z4.object({
203
220
  var HelloAckSchema = z4.object({
204
221
  type: z4.literal("hello-ack"),
205
222
  agentHostId: z4.string(),
206
- 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()
207
230
  });
208
231
  var CancelJobSchema = z4.object({
209
232
  type: z4.literal("cancel"),
210
233
  runId: z4.string(),
211
- 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")
212
239
  });
213
240
  var PingSchema = z4.object({ type: z4.literal("ping") });
214
241
  var PongSchema = z4.object({ type: z4.literal("pong") });
@@ -309,6 +336,13 @@ var DeviceToServerMessageSchema = z4.union([
309
336
  PongSchema,
310
337
  AgentCallRequestSchema
311
338
  ]);
339
+ var SERVER_TO_DEVICE_TYPES = /* @__PURE__ */ new Set([
340
+ "job",
341
+ "hello-ack",
342
+ "ping",
343
+ "agent-call-result",
344
+ "cancel"
345
+ ]);
312
346
  var HostRegisterRequestSchema = z4.object({
313
347
  hostId: z4.string(),
314
348
  hostName: z4.string(),
@@ -350,6 +384,9 @@ var IssueDetailSchema = IssueSummarySchema.extend({
350
384
  draftUpdatedAt: z5.string().datetime().nullable()
351
385
  });
352
386
 
387
+ // ../shared/dist/cron.js
388
+ var MAX_STEP_MINUTES = 366 * 24 * 60 + 24 * 60;
389
+
353
390
  // src/commands/register.ts
354
391
  var POLL_INTERVAL_MS = 2e3;
355
392
  async function register(opts = {}) {
@@ -422,6 +459,23 @@ function sleep(ms) {
422
459
 
423
460
  // src/transport/ws-client.ts
424
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
+ }
425
479
  var HEARTBEAT_MS = 3e4;
426
480
  var WsClient = class {
427
481
  constructor(opts) {
@@ -433,6 +487,8 @@ var WsClient = class {
433
487
  backoff;
434
488
  heartbeat = null;
435
489
  stopped = false;
490
+ /** Unknown frame types already warned about (once per type, not per frame). */
491
+ warnedUnknownTypes = /* @__PURE__ */ new Set();
436
492
  start() {
437
493
  this.connect();
438
494
  }
@@ -461,18 +517,28 @@ var WsClient = class {
461
517
  this.opts.onOpen?.();
462
518
  });
463
519
  ws.on("message", (raw) => {
464
- let parsed;
465
- try {
466
- parsed = ServerToDeviceMessageSchema.parse(JSON.parse(raw.toString()));
467
- } catch (err) {
468
- 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);
469
532
  return;
470
533
  }
471
- this.opts.onMessage(parsed);
534
+ this.opts.onMessage(frame.msg);
472
535
  });
473
536
  ws.on("close", (code, reasonBuf) => {
474
537
  const reason = reasonBuf.toString();
475
538
  if (this.heartbeat) clearInterval(this.heartbeat);
539
+ if (code === WS_CLOSE_PROTOCOL_TOO_OLD) {
540
+ this.stopped = true;
541
+ }
476
542
  this.opts.onClose?.(code, reason);
477
543
  if (!this.stopped) this.scheduleReconnect();
478
544
  });
@@ -546,6 +612,7 @@ var ACP_METHODS = {
546
612
  session_load: "session/load",
547
613
  session_prompt: "session/prompt",
548
614
  session_cancel: "session/cancel",
615
+ session_set_config_option: "session/set_config_option",
549
616
  // Client methods (agent → client) — handled minimally in this spike.
550
617
  session_update: "session/update",
551
618
  session_request_permission: "session/request_permission",
@@ -601,6 +668,15 @@ var AcpConnection = class {
601
668
  prompt(req) {
602
669
  return this.request(ACP_METHODS.session_prompt, req);
603
670
  }
671
+ /**
672
+ * Select a session config option (e.g. the model) advertised by the agent in
673
+ * the session/new response. pi-acp maps `configId: "model"` onto its model
674
+ * selector. Resolves on success; rejects with the agent's JSON-RPC error
675
+ * (e.g. "Model not found") — callers log and continue rather than fail the run.
676
+ */
677
+ setConfigOption(req) {
678
+ return this.request(ACP_METHODS.session_set_config_option, req);
679
+ }
604
680
  /** Notification (no reply). Used to cancel a turn mid-flight. */
605
681
  cancel(sessionId) {
606
682
  this.notify(ACP_METHODS.session_cancel, { sessionId });
@@ -765,6 +841,9 @@ var AcpClient = class {
765
841
  prompt(req) {
766
842
  return this.must().prompt(req);
767
843
  }
844
+ setConfigOption(req) {
845
+ return this.must().setConfigOption(req);
846
+ }
768
847
  cancel(sessionId) {
769
848
  this.must().cancel(sessionId);
770
849
  }
@@ -1152,21 +1231,35 @@ function runAcpJob(opts) {
1152
1231
  const cwd = spec.cwd ?? process.cwd();
1153
1232
  const mcpServers = [host.acpServerEntry()];
1154
1233
  const shimSupportsLoad = initResult.agentCapabilities?.loadSession === true;
1234
+ const instructionsExtra = acpSpec.instructionsFile ? { instructionsFile: acpSpec.instructionsFile } : {};
1155
1235
  let sessionId;
1156
1236
  let resumed = false;
1237
+ let configOptions;
1157
1238
  if (acpSpec.priorSessionId && shimSupportsLoad) {
1158
- await client.loadSession({
1239
+ const loaded = await client.loadSession({
1159
1240
  sessionId: acpSpec.priorSessionId,
1160
1241
  cwd,
1161
- mcpServers
1242
+ mcpServers,
1243
+ ...instructionsExtra
1162
1244
  });
1163
1245
  sessionId = acpSpec.priorSessionId;
1164
1246
  resumed = true;
1247
+ configOptions = loaded.configOptions;
1165
1248
  } else {
1166
- const session = await client.newSession({ cwd, mcpServers });
1249
+ const session = await client.newSession({ cwd, mcpServers, ...instructionsExtra });
1167
1250
  sessionId = session.sessionId;
1251
+ configOptions = session.configOptions;
1168
1252
  }
1169
1253
  activeSessionId = sessionId;
1254
+ if (acpSpec.model) {
1255
+ await selectAcpModel(
1256
+ client,
1257
+ sessionId,
1258
+ acpSpec.model,
1259
+ configOptions,
1260
+ handlers.onLog
1261
+ );
1262
+ }
1170
1263
  if (cancelRequested) {
1171
1264
  try {
1172
1265
  client.cancel(sessionId);
@@ -1232,7 +1325,68 @@ ${turns}`);
1232
1325
  parts.push(`# Current message
1233
1326
 
1234
1327
  ${acp.userPromptMd}`);
1235
- return [{ type: "text", text: parts.join("\n\n---\n\n") }];
1328
+ const blocks = [{ type: "text", text: parts.join("\n\n---\n\n") }];
1329
+ for (const img of acp.images ?? []) {
1330
+ blocks.push({ type: "image", data: img.data, mimeType: img.mimeType });
1331
+ }
1332
+ return blocks;
1333
+ }
1334
+ function matchModelValue(requested, values) {
1335
+ const want = requested.trim();
1336
+ if (!want) return void 0;
1337
+ const exact = values.find((v) => v === want);
1338
+ if (exact) return exact;
1339
+ const ci = values.find((v) => v.toLowerCase() === want.toLowerCase());
1340
+ if (ci) return ci;
1341
+ const suffix = `/${want.toLowerCase()}`;
1342
+ return values.find((v) => v.toLowerCase().endsWith(suffix));
1343
+ }
1344
+ async function selectAcpModel(client, sessionId, requested, configOptions, onLog) {
1345
+ const modelOption = configOptions?.find(
1346
+ (o) => o.id === "model" || o.category === "model"
1347
+ );
1348
+ if (!modelOption) {
1349
+ onLog(
1350
+ "stderr",
1351
+ `[acp] model "${requested}" requested but the agent advertised no model option; using its default
1352
+ `
1353
+ );
1354
+ return;
1355
+ }
1356
+ const values = (modelOption.options ?? []).map((o) => o.value);
1357
+ const target = matchModelValue(requested, values);
1358
+ if (!target) {
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
1371
+ `
1372
+ );
1373
+ }
1374
+ return;
1375
+ }
1376
+ if (modelOption.currentValue === target) return;
1377
+ try {
1378
+ await client.setConfigOption({
1379
+ sessionId,
1380
+ configId: modelOption.id,
1381
+ value: target
1382
+ });
1383
+ onLog("stderr", `[acp] selected model ${target}
1384
+ `);
1385
+ } catch (err) {
1386
+ const msg = err instanceof Error ? err.message : String(err);
1387
+ onLog("stderr", `[acp] model selection failed (${msg}); using the default
1388
+ `);
1389
+ }
1236
1390
  }
1237
1391
  function createUpdateTranslator(onLog) {
1238
1392
  let inThought = false;
@@ -1312,7 +1466,7 @@ function resolveLocalAcpAdapter(command, args) {
1312
1466
  }
1313
1467
 
1314
1468
  // src/commands/run.ts
1315
- var PKG_VERSION = "0.109.0";
1469
+ var PKG_VERSION = "0.112.1";
1316
1470
  var LOG_FLUSH_MS = 800;
1317
1471
  var MAX_CHUNK_SIZE = 4 * 1024;
1318
1472
  async function run(opts = {}) {
@@ -1332,6 +1486,7 @@ async function run(opts = {}) {
1332
1486
  type: "hello",
1333
1487
  platform: platform(),
1334
1488
  version: PKG_VERSION,
1489
+ protocolVersion: HOST_PROTOCOL_VERSION,
1335
1490
  // Advertise ACP transport support. Pre-v0.30 devices reported
1336
1491
  // "agent-call" (the fenced-stdout-block protocol); since the
1337
1492
  // legacy path was removed, this version reports "acp" so the
@@ -1342,6 +1497,14 @@ async function run(opts = {}) {
1342
1497
  },
1343
1498
  onMessage: (msg) => handleServerMessage(msg, client, cfg),
1344
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
+ }
1345
1508
  console.log(`[opencara] disconnected (code=${code} reason="${reason}")`);
1346
1509
  }
1347
1510
  });
@@ -1352,6 +1515,11 @@ var acpControllers = /* @__PURE__ */ new Map();
1352
1515
  function handleServerMessage(msg, client, _cfg) {
1353
1516
  if (msg.type === "hello-ack") {
1354
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
+ }
1355
1523
  return;
1356
1524
  }
1357
1525
  if (msg.type === "ping") return;
@@ -1599,7 +1767,7 @@ import {
1599
1767
  symlinkSync
1600
1768
  } from "node:fs";
1601
1769
  import { homedir as homedir2 } from "node:os";
1602
- import { join as join2, sep } from "node:path";
1770
+ import { dirname as dirname3, join as join2, sep } from "node:path";
1603
1771
  var OPENCARA_ROOT = join2(homedir2(), ".opencara");
1604
1772
  var WORK_ROOT = join2(OPENCARA_ROOT, "work");
1605
1773
  var SESSION_ROOT = join2(OPENCARA_ROOT, "sessions");
@@ -1662,17 +1830,24 @@ function worktreeCreate(args) {
1662
1830
  const cleanUrl = `https://github.com/${repo}.git`;
1663
1831
  mkdirSync2(sessionDir, { recursive: true });
1664
1832
  if (cacheDir) {
1833
+ const cacheLockPath = `${cacheDir}.lock`;
1834
+ mkdirSync2(dirname3(cacheLockPath), { recursive: true });
1665
1835
  if (existsSync4(join2(cacheDir, ".git"))) {
1666
- git(cacheDir, ["fetch", "--all", "--prune"], gitEnv);
1836
+ gitLocked(cacheDir, ["fetch", "--all", "--prune"], cacheLockPath, gitEnv);
1667
1837
  } else {
1668
1838
  mkdirSync2(cacheDir, { recursive: true });
1669
1839
  try {
1670
- git(
1840
+ gitLocked(
1671
1841
  cacheDir,
1672
1842
  ["-c", `credential.helper=${HELPER_SNIPPET}`, "clone", cleanUrl, "."],
1843
+ cacheLockPath,
1673
1844
  gitEnv
1674
1845
  );
1675
- git(cacheDir, ["config", "credential.helper", HELPER_SNIPPET]);
1846
+ gitLocked(
1847
+ cacheDir,
1848
+ ["config", "credential.helper", HELPER_SNIPPET],
1849
+ cacheLockPath
1850
+ );
1676
1851
  } catch (err) {
1677
1852
  try {
1678
1853
  if (!existsSync4(join2(cacheDir, ".git", "HEAD"))) {
@@ -1684,7 +1859,7 @@ function worktreeCreate(args) {
1684
1859
  }
1685
1860
  }
1686
1861
  if (useLfs) {
1687
- git(cacheDir, ["lfs", "fetch", "--all"], gitEnv);
1862
+ gitLocked(cacheDir, ["lfs", "fetch", "--all"], cacheLockPath, gitEnv);
1688
1863
  mkdirSync2(join2(cacheDir, ".git", "lfs", "objects"), { recursive: true });
1689
1864
  }
1690
1865
  }
@@ -1831,6 +2006,17 @@ function git(cwd, args, env) {
1831
2006
  env: env ?? process.env
1832
2007
  });
1833
2008
  }
2009
+ function gitLocked(cwd, args, lockPath, env) {
2010
+ execFileSync(
2011
+ "flock",
2012
+ ["--exclusive", lockPath, "git", ...args],
2013
+ {
2014
+ cwd,
2015
+ stdio: ["ignore", "ignore", "inherit"],
2016
+ env: env ?? process.env
2017
+ }
2018
+ );
2019
+ }
1834
2020
  function refExists(cwd, ref) {
1835
2021
  try {
1836
2022
  execFileSync("git", ["rev-parse", "--verify", "--quiet", ref], {
@@ -3,6 +3,8 @@
3
3
  // src/bin/claude-acp.ts
4
4
  import { spawn } from "node:child_process";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { readFileSync, realpathSync, statSync } from "node:fs";
7
+ import { isAbsolute, normalize, resolve as pathResolve, sep } from "node:path";
6
8
  import { stdin, stdout, stderr, exit } from "node:process";
7
9
 
8
10
  // src/acp/framing.ts
@@ -99,7 +101,176 @@ function buildClaudeMcpConfig(servers) {
99
101
  return JSON.stringify({ mcpServers });
100
102
  }
101
103
  var sessions = /* @__PURE__ */ new Map();
102
- async function runClaudeTurn(sessionId, state, promptText, permissionMode) {
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
+ }
156
+ var INSTRUCTIONS_FILE_MAX_BYTES = 64 * 1024;
157
+ function resolveInstructionsFile(cwd, relative) {
158
+ if (!relative || typeof relative !== "string" || relative.length === 0) {
159
+ return null;
160
+ }
161
+ if (!isAbsolute(cwd)) {
162
+ stderr.write(
163
+ `[claude-acp] instructionsFile skipped: cwd '${cwd}' is not absolute
164
+ `
165
+ );
166
+ return null;
167
+ }
168
+ if (isAbsolute(relative) || /^[A-Za-z]:[\\/]/.test(relative)) {
169
+ stderr.write(
170
+ `[claude-acp] instructionsFile skipped: '${relative}' must be repo-relative
171
+ `
172
+ );
173
+ return null;
174
+ }
175
+ if (relative.split(/[\\/]/).includes("..")) {
176
+ stderr.write(
177
+ `[claude-acp] instructionsFile skipped: '${relative}' contains '..'
178
+ `
179
+ );
180
+ return null;
181
+ }
182
+ const normalizedCwd = normalize(cwd);
183
+ const candidate = pathResolve(normalizedCwd, relative);
184
+ const cwdWithSep = normalizedCwd.endsWith(sep) ? normalizedCwd : `${normalizedCwd}${sep}`;
185
+ if (!candidate.startsWith(cwdWithSep) && candidate !== normalizedCwd) {
186
+ stderr.write(
187
+ `[claude-acp] instructionsFile skipped: '${candidate}' escapes cwd
188
+ `
189
+ );
190
+ return null;
191
+ }
192
+ let realCwd;
193
+ let realCandidate;
194
+ try {
195
+ realCwd = realpathSync(normalizedCwd);
196
+ } catch (err) {
197
+ stderr.write(
198
+ `[claude-acp] instructionsFile skipped: cwd '${normalizedCwd}' realpath failed: ${err instanceof Error ? err.message : String(err)}
199
+ `
200
+ );
201
+ return null;
202
+ }
203
+ try {
204
+ realCandidate = realpathSync(candidate);
205
+ } catch (err) {
206
+ stderr.write(
207
+ `[claude-acp] instructionsFile skipped: '${relative}' not found in cwd (${err instanceof Error ? err.message : String(err)})
208
+ `
209
+ );
210
+ return null;
211
+ }
212
+ const realCwdWithSep = realCwd.endsWith(sep) ? realCwd : `${realCwd}${sep}`;
213
+ if (!realCandidate.startsWith(realCwdWithSep) && realCandidate !== realCwd) {
214
+ stderr.write(
215
+ `[claude-acp] instructionsFile skipped: '${relative}' resolves to '${realCandidate}' which is outside cwd (symlink escape?)
216
+ `
217
+ );
218
+ return null;
219
+ }
220
+ let stat;
221
+ try {
222
+ stat = statSync(realCandidate);
223
+ } catch (err) {
224
+ stderr.write(
225
+ `[claude-acp] instructionsFile skipped: '${relative}' stat failed: ${err instanceof Error ? err.message : String(err)}
226
+ `
227
+ );
228
+ return null;
229
+ }
230
+ if (!stat.isFile()) {
231
+ stderr.write(
232
+ `[claude-acp] instructionsFile skipped: '${relative}' is not a regular file
233
+ `
234
+ );
235
+ return null;
236
+ }
237
+ if (stat.size > INSTRUCTIONS_FILE_MAX_BYTES) {
238
+ stderr.write(
239
+ `[claude-acp] instructionsFile skipped: '${relative}' is ${stat.size} bytes (> ${INSTRUCTIONS_FILE_MAX_BYTES} cap)
240
+ `
241
+ );
242
+ return null;
243
+ }
244
+ let content;
245
+ try {
246
+ content = readFileSync(realCandidate, "utf8");
247
+ } catch (err) {
248
+ stderr.write(
249
+ `[claude-acp] instructionsFile skipped: read failed for '${relative}': ${err instanceof Error ? err.message : String(err)}
250
+ `
251
+ );
252
+ return null;
253
+ }
254
+ return { path: realCandidate, content };
255
+ }
256
+ function buildStreamJsonInput(promptText, images) {
257
+ const content = [];
258
+ if (promptText.length > 0) {
259
+ content.push({ type: "text", text: promptText });
260
+ }
261
+ for (const img of images) {
262
+ content.push({
263
+ type: "image",
264
+ source: { type: "base64", media_type: img.mimeType, data: img.data }
265
+ });
266
+ }
267
+ return `${JSON.stringify({
268
+ type: "user",
269
+ message: { role: "user", content }
270
+ })}
271
+ `;
272
+ }
273
+ async function runClaudeTurn(sessionId, state, promptText, images, permissionMode) {
103
274
  return new Promise((resolve, reject) => {
104
275
  const idFlag = state.resume ? "--resume" : "--session-id";
105
276
  const args = [
@@ -114,6 +285,10 @@ async function runClaudeTurn(sessionId, state, promptText, permissionMode) {
114
285
  idFlag,
115
286
  sessionId
116
287
  ];
288
+ const useStreamJsonInput = images.length > 0;
289
+ if (useStreamJsonInput) {
290
+ args.push("--input-format", "stream-json");
291
+ }
117
292
  if (permissionMode && permissionMode !== "default") {
118
293
  args.push("--permission-mode", permissionMode);
119
294
  } else {
@@ -126,6 +301,19 @@ async function runClaudeTurn(sessionId, state, promptText, permissionMode) {
126
301
  "--strict-mcp-config"
127
302
  );
128
303
  }
304
+ const resolvedInstructions = resolveInstructionsFile(
305
+ state.cwd,
306
+ state.instructionsFile
307
+ );
308
+ if (resolvedInstructions) {
309
+ args.push("--bare", "--append-system-prompt", resolvedInstructions.content);
310
+ }
311
+ if (extraClaudeArgs.length > 0) {
312
+ args.push(...extraClaudeArgs);
313
+ }
314
+ if (state.modelOverride) {
315
+ args.push("--model", state.modelOverride);
316
+ }
129
317
  const child = spawn("claude", args, {
130
318
  cwd: state.cwd,
131
319
  env: process.env,
@@ -134,7 +322,9 @@ async function runClaudeTurn(sessionId, state, promptText, permissionMode) {
134
322
  state.activeChild = child;
135
323
  child.stdin.on("error", () => {
136
324
  });
137
- child.stdin.end(promptText);
325
+ child.stdin.end(
326
+ useStreamJsonInput ? buildStreamJsonInput(promptText, images) : promptText
327
+ );
138
328
  const decoder = new FrameDecoder();
139
329
  let resolved = false;
140
330
  let stopReason = "end_turn";
@@ -339,7 +529,9 @@ function handleInitialize(_params) {
339
529
  // on each turn (see SessionState.mcpServers / runClaudeTurn).
340
530
  loadSession: true,
341
531
  mcpCapabilities: {},
342
- promptCapabilities: { embeddedContext: false, image: false, audio: false }
532
+ // image: true since #142 image blocks are forwarded to claude via
533
+ // `--input-format stream-json`. embeddedContext / audio still unsupported.
534
+ promptCapabilities: { embeddedContext: false, image: true, audio: false }
343
535
  },
344
536
  authMethods: []
345
537
  };
@@ -347,24 +539,37 @@ function handleInitialize(_params) {
347
539
  function handleNewSession(params) {
348
540
  const sessionId = randomUUID();
349
541
  const mcpServers = normalizeMcpServers(params.mcpServers);
350
- sessions.set(sessionId, {
542
+ const instructionsFile = normalizeInstructionsFile(params.instructionsFile);
543
+ const state = {
351
544
  cwd: params.cwd ?? process.cwd(),
352
545
  resume: false,
353
- ...mcpServers.length > 0 ? { mcpServers } : {}
354
- });
355
- return { sessionId };
546
+ ...mcpServers.length > 0 ? { mcpServers } : {},
547
+ ...instructionsFile ? { instructionsFile } : {}
548
+ };
549
+ sessions.set(sessionId, state);
550
+ const configOptions = modelConfigOptions(state);
551
+ return { sessionId, ...configOptions ? { configOptions } : {} };
356
552
  }
357
553
  function handleLoadSession(params) {
358
554
  if (typeof params.sessionId !== "string" || params.sessionId.length === 0) {
359
555
  throw new Error("session/load: sessionId required");
360
556
  }
361
557
  const mcpServers = normalizeMcpServers(params.mcpServers);
362
- sessions.set(params.sessionId, {
558
+ const instructionsFile = normalizeInstructionsFile(params.instructionsFile);
559
+ const state = {
363
560
  cwd: params.cwd ?? process.cwd(),
364
561
  resume: true,
365
- ...mcpServers.length > 0 ? { mcpServers } : {}
366
- });
367
- return {};
562
+ ...mcpServers.length > 0 ? { mcpServers } : {},
563
+ ...instructionsFile ? { instructionsFile } : {}
564
+ };
565
+ sessions.set(params.sessionId, state);
566
+ const configOptions = modelConfigOptions(state);
567
+ return configOptions ? { configOptions } : {};
568
+ }
569
+ function normalizeInstructionsFile(raw) {
570
+ if (typeof raw !== "string") return void 0;
571
+ const trimmed = raw.trim();
572
+ return trimmed.length > 0 ? trimmed : void 0;
368
573
  }
369
574
  async function handlePrompt(params) {
370
575
  const state = sessions.get(params.sessionId);
@@ -372,13 +577,17 @@ async function handlePrompt(params) {
372
577
  throw new Error(`unknown sessionId: ${params.sessionId}`);
373
578
  }
374
579
  const promptText = params.prompt.filter((b) => b.type === "text").map((b) => typeof b.text === "string" ? b.text : "").join("\n\n");
375
- if (promptText.length === 0) {
376
- throw new Error("session/prompt: no text content blocks");
580
+ const images = params.prompt.filter(
581
+ (b) => b.type === "image" && typeof b.data === "string" && b.data.length > 0 && typeof b.mimeType === "string" && b.mimeType.length > 0
582
+ ).map((b) => ({ type: "image", data: b.data, mimeType: b.mimeType }));
583
+ if (promptText.length === 0 && images.length === 0) {
584
+ throw new Error("session/prompt: no text or image content blocks");
377
585
  }
378
586
  const result = await runClaudeTurn(
379
587
  params.sessionId,
380
588
  state,
381
589
  promptText,
590
+ images,
382
591
  params.permissionMode
383
592
  );
384
593
  state.resume = true;
@@ -400,6 +609,8 @@ function handleCancel(params) {
400
609
  }
401
610
  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;
402
611
  if (isMainModule) {
612
+ extraClaudeArgs = process.argv.slice(2);
613
+ Object.freeze(extraClaudeArgs);
403
614
  const decoder = new FrameDecoder();
404
615
  stdin.setEncoding("utf8");
405
616
  stdin.on("data", (chunk) => {
@@ -438,6 +649,14 @@ async function dispatch(msg) {
438
649
  case "session/load":
439
650
  reply(req.id, handleLoadSession(req.params));
440
651
  return;
652
+ case "session/set_config_option":
653
+ reply(
654
+ req.id,
655
+ handleSetConfigOption(
656
+ req.params
657
+ )
658
+ );
659
+ return;
441
660
  case "session/prompt": {
442
661
  const result = await handlePrompt(req.params);
443
662
  reply(req.id, result);
@@ -452,7 +671,7 @@ async function dispatch(msg) {
452
671
  }
453
672
  } catch (err) {
454
673
  const message = err instanceof Error ? err.message : String(err);
455
- 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:"));
456
675
  replyError(
457
676
  req.id,
458
677
  isParamsError ? JSON_RPC_ERROR_INVALID_PARAMS : JSON_RPC_ERROR_INTERNAL,
@@ -461,9 +680,16 @@ async function dispatch(msg) {
461
680
  }
462
681
  }
463
682
  export {
683
+ _setExtraClaudeArgsForTest,
684
+ buildStreamJsonInput,
685
+ extraClaudeArgs,
464
686
  handleInitialize,
465
687
  handleLoadSession,
466
688
  handleNewSession,
689
+ handleSetConfigOption,
690
+ modelConfigOptions,
691
+ parseModelFromArgs,
692
+ resolveInstructionsFile,
467
693
  sessions,
468
694
  translateClaudeEvent
469
695
  };
@@ -123,6 +123,12 @@ var AcpHistoryTurnSchema = z2.object({
123
123
  role: z2.enum(["user", "assistant"]),
124
124
  text: z2.string()
125
125
  });
126
+ var AcpImageInputSchema = z2.object({
127
+ /** Base64-encoded image bytes, without the `data:<mime>;base64,` prefix. */
128
+ data: z2.string(),
129
+ /** IANA image MIME type, e.g. `image/png`, `image/jpeg`, `image/webp`. */
130
+ mimeType: z2.string()
131
+ });
126
132
  var AcpPermissionModeSchema = z2.enum([
127
133
  "default",
128
134
  "acceptEdits",
@@ -135,7 +141,10 @@ var AcpSpecSchema = z2.object({
135
141
  history: z2.array(AcpHistoryTurnSchema).default([]),
136
142
  pageContextJson: z2.string().optional(),
137
143
  priorSessionId: z2.string().optional(),
138
- permissionMode: AcpPermissionModeSchema.optional()
144
+ permissionMode: AcpPermissionModeSchema.optional(),
145
+ instructionsFile: z2.string().optional(),
146
+ images: z2.array(AcpImageInputSchema).default([]),
147
+ model: z2.string().optional()
139
148
  });
140
149
  var AgentSpecSchema = z2.object({
141
150
  kind: z2.string(),
@@ -213,6 +222,12 @@ var HelloMessageSchema = z3.object({
213
222
  type: z3.literal("hello"),
214
223
  platform: z3.string(),
215
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(),
216
231
  capabilities: z3.array(z3.string()).default([]),
217
232
  systemInfo: SystemInfoSchema.optional()
218
233
  });
@@ -244,12 +259,22 @@ var RunDoneSchema = z3.object({
244
259
  var HelloAckSchema = z3.object({
245
260
  type: z3.literal("hello-ack"),
246
261
  agentHostId: z3.string(),
247
- 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()
248
269
  });
249
270
  var CancelJobSchema = z3.object({
250
271
  type: z3.literal("cancel"),
251
272
  runId: z3.string(),
252
- 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")
253
278
  });
254
279
  var PingSchema = z3.object({ type: z3.literal("ping") });
255
280
  var PongSchema = z3.object({ type: z3.literal("pong") });
@@ -391,6 +416,9 @@ var IssueDetailSchema = IssueSummarySchema.extend({
391
416
  draftUpdatedAt: z4.string().datetime().nullable()
392
417
  });
393
418
 
419
+ // ../shared/dist/cron.js
420
+ var MAX_STEP_MINUTES = 366 * 24 * 60 + 24 * 60;
421
+
394
422
  // src/mcp/tools.ts
395
423
  var issueBodySetShape = IssueBodySetCallSchema.omit({
396
424
  type: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.109.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"