opencara 0.108.2 → 0.110.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/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(),
@@ -350,6 +359,9 @@ var IssueDetailSchema = IssueSummarySchema.extend({
350
359
  draftUpdatedAt: z5.string().datetime().nullable()
351
360
  });
352
361
 
362
+ // ../shared/dist/cron.js
363
+ var MAX_STEP_MINUTES = 366 * 24 * 60 + 24 * 60;
364
+
353
365
  // src/commands/register.ts
354
366
  var POLL_INTERVAL_MS = 2e3;
355
367
  async function register(opts = {}) {
@@ -546,6 +558,7 @@ var ACP_METHODS = {
546
558
  session_load: "session/load",
547
559
  session_prompt: "session/prompt",
548
560
  session_cancel: "session/cancel",
561
+ session_set_config_option: "session/set_config_option",
549
562
  // Client methods (agent → client) — handled minimally in this spike.
550
563
  session_update: "session/update",
551
564
  session_request_permission: "session/request_permission",
@@ -601,6 +614,15 @@ var AcpConnection = class {
601
614
  prompt(req) {
602
615
  return this.request(ACP_METHODS.session_prompt, req);
603
616
  }
617
+ /**
618
+ * Select a session config option (e.g. the model) advertised by the agent in
619
+ * the session/new response. pi-acp maps `configId: "model"` onto its model
620
+ * selector. Resolves on success; rejects with the agent's JSON-RPC error
621
+ * (e.g. "Model not found") — callers log and continue rather than fail the run.
622
+ */
623
+ setConfigOption(req) {
624
+ return this.request(ACP_METHODS.session_set_config_option, req);
625
+ }
604
626
  /** Notification (no reply). Used to cancel a turn mid-flight. */
605
627
  cancel(sessionId) {
606
628
  this.notify(ACP_METHODS.session_cancel, { sessionId });
@@ -765,6 +787,9 @@ var AcpClient = class {
765
787
  prompt(req) {
766
788
  return this.must().prompt(req);
767
789
  }
790
+ setConfigOption(req) {
791
+ return this.must().setConfigOption(req);
792
+ }
768
793
  cancel(sessionId) {
769
794
  this.must().cancel(sessionId);
770
795
  }
@@ -1152,21 +1177,35 @@ function runAcpJob(opts) {
1152
1177
  const cwd = spec.cwd ?? process.cwd();
1153
1178
  const mcpServers = [host.acpServerEntry()];
1154
1179
  const shimSupportsLoad = initResult.agentCapabilities?.loadSession === true;
1180
+ const instructionsExtra = acpSpec.instructionsFile ? { instructionsFile: acpSpec.instructionsFile } : {};
1155
1181
  let sessionId;
1156
1182
  let resumed = false;
1183
+ let configOptions;
1157
1184
  if (acpSpec.priorSessionId && shimSupportsLoad) {
1158
- await client.loadSession({
1185
+ const loaded = await client.loadSession({
1159
1186
  sessionId: acpSpec.priorSessionId,
1160
1187
  cwd,
1161
- mcpServers
1188
+ mcpServers,
1189
+ ...instructionsExtra
1162
1190
  });
1163
1191
  sessionId = acpSpec.priorSessionId;
1164
1192
  resumed = true;
1193
+ configOptions = loaded.configOptions;
1165
1194
  } else {
1166
- const session = await client.newSession({ cwd, mcpServers });
1195
+ const session = await client.newSession({ cwd, mcpServers, ...instructionsExtra });
1167
1196
  sessionId = session.sessionId;
1197
+ configOptions = session.configOptions;
1168
1198
  }
1169
1199
  activeSessionId = sessionId;
1200
+ if (acpSpec.model) {
1201
+ await selectAcpModel(
1202
+ client,
1203
+ sessionId,
1204
+ acpSpec.model,
1205
+ configOptions,
1206
+ handlers.onLog
1207
+ );
1208
+ }
1170
1209
  if (cancelRequested) {
1171
1210
  try {
1172
1211
  client.cancel(sessionId);
@@ -1232,7 +1271,58 @@ ${turns}`);
1232
1271
  parts.push(`# Current message
1233
1272
 
1234
1273
  ${acp.userPromptMd}`);
1235
- return [{ type: "text", text: parts.join("\n\n---\n\n") }];
1274
+ const blocks = [{ type: "text", text: parts.join("\n\n---\n\n") }];
1275
+ for (const img of acp.images ?? []) {
1276
+ blocks.push({ type: "image", data: img.data, mimeType: img.mimeType });
1277
+ }
1278
+ return blocks;
1279
+ }
1280
+ function matchModelValue(requested, values) {
1281
+ const want = requested.trim();
1282
+ if (!want) return void 0;
1283
+ const exact = values.find((v) => v === want);
1284
+ if (exact) return exact;
1285
+ const ci = values.find((v) => v.toLowerCase() === want.toLowerCase());
1286
+ if (ci) return ci;
1287
+ const suffix = `/${want.toLowerCase()}`;
1288
+ return values.find((v) => v.toLowerCase().endsWith(suffix));
1289
+ }
1290
+ async function selectAcpModel(client, sessionId, requested, configOptions, onLog) {
1291
+ const modelOption = configOptions?.find(
1292
+ (o) => o.id === "model" || o.category === "model"
1293
+ );
1294
+ if (!modelOption) {
1295
+ onLog(
1296
+ "stderr",
1297
+ `[acp] model "${requested}" requested but the agent advertised no model option; using its default
1298
+ `
1299
+ );
1300
+ return;
1301
+ }
1302
+ const values = (modelOption.options ?? []).map((o) => o.value);
1303
+ const target = matchModelValue(requested, values);
1304
+ if (!target) {
1305
+ onLog(
1306
+ "stderr",
1307
+ `[acp] model "${requested}" not among available models [${values.join(", ")}]; using the default
1308
+ `
1309
+ );
1310
+ return;
1311
+ }
1312
+ if (modelOption.currentValue === target) return;
1313
+ try {
1314
+ await client.setConfigOption({
1315
+ sessionId,
1316
+ configId: modelOption.id,
1317
+ value: target
1318
+ });
1319
+ onLog("stderr", `[acp] selected model ${target}
1320
+ `);
1321
+ } catch (err) {
1322
+ const msg = err instanceof Error ? err.message : String(err);
1323
+ onLog("stderr", `[acp] model selection failed (${msg}); using the default
1324
+ `);
1325
+ }
1236
1326
  }
1237
1327
  function createUpdateTranslator(onLog) {
1238
1328
  let inThought = false;
@@ -1312,7 +1402,7 @@ function resolveLocalAcpAdapter(command, args) {
1312
1402
  }
1313
1403
 
1314
1404
  // src/commands/run.ts
1315
- var PKG_VERSION = "0.108.2";
1405
+ var PKG_VERSION = "0.110.0";
1316
1406
  var LOG_FLUSH_MS = 800;
1317
1407
  var MAX_CHUNK_SIZE = 4 * 1024;
1318
1408
  async function run(opts = {}) {
@@ -1599,7 +1689,7 @@ import {
1599
1689
  symlinkSync
1600
1690
  } from "node:fs";
1601
1691
  import { homedir as homedir2 } from "node:os";
1602
- import { join as join2, sep } from "node:path";
1692
+ import { dirname as dirname3, join as join2, sep } from "node:path";
1603
1693
  var OPENCARA_ROOT = join2(homedir2(), ".opencara");
1604
1694
  var WORK_ROOT = join2(OPENCARA_ROOT, "work");
1605
1695
  var SESSION_ROOT = join2(OPENCARA_ROOT, "sessions");
@@ -1662,17 +1752,24 @@ function worktreeCreate(args) {
1662
1752
  const cleanUrl = `https://github.com/${repo}.git`;
1663
1753
  mkdirSync2(sessionDir, { recursive: true });
1664
1754
  if (cacheDir) {
1755
+ const cacheLockPath = `${cacheDir}.lock`;
1756
+ mkdirSync2(dirname3(cacheLockPath), { recursive: true });
1665
1757
  if (existsSync4(join2(cacheDir, ".git"))) {
1666
- git(cacheDir, ["fetch", "--all", "--prune"], gitEnv);
1758
+ gitLocked(cacheDir, ["fetch", "--all", "--prune"], cacheLockPath, gitEnv);
1667
1759
  } else {
1668
1760
  mkdirSync2(cacheDir, { recursive: true });
1669
1761
  try {
1670
- git(
1762
+ gitLocked(
1671
1763
  cacheDir,
1672
1764
  ["-c", `credential.helper=${HELPER_SNIPPET}`, "clone", cleanUrl, "."],
1765
+ cacheLockPath,
1673
1766
  gitEnv
1674
1767
  );
1675
- git(cacheDir, ["config", "credential.helper", HELPER_SNIPPET]);
1768
+ gitLocked(
1769
+ cacheDir,
1770
+ ["config", "credential.helper", HELPER_SNIPPET],
1771
+ cacheLockPath
1772
+ );
1676
1773
  } catch (err) {
1677
1774
  try {
1678
1775
  if (!existsSync4(join2(cacheDir, ".git", "HEAD"))) {
@@ -1684,7 +1781,7 @@ function worktreeCreate(args) {
1684
1781
  }
1685
1782
  }
1686
1783
  if (useLfs) {
1687
- git(cacheDir, ["lfs", "fetch", "--all"], gitEnv);
1784
+ gitLocked(cacheDir, ["lfs", "fetch", "--all"], cacheLockPath, gitEnv);
1688
1785
  mkdirSync2(join2(cacheDir, ".git", "lfs", "objects"), { recursive: true });
1689
1786
  }
1690
1787
  }
@@ -1831,6 +1928,17 @@ function git(cwd, args, env) {
1831
1928
  env: env ?? process.env
1832
1929
  });
1833
1930
  }
1931
+ function gitLocked(cwd, args, lockPath, env) {
1932
+ execFileSync(
1933
+ "flock",
1934
+ ["--exclusive", lockPath, "git", ...args],
1935
+ {
1936
+ cwd,
1937
+ stdio: ["ignore", "ignore", "inherit"],
1938
+ env: env ?? process.env
1939
+ }
1940
+ );
1941
+ }
1834
1942
  function refExists(cwd, ref) {
1835
1943
  try {
1836
1944
  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,125 @@ 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
+ var INSTRUCTIONS_FILE_MAX_BYTES = 64 * 1024;
106
+ function resolveInstructionsFile(cwd, relative) {
107
+ if (!relative || typeof relative !== "string" || relative.length === 0) {
108
+ return null;
109
+ }
110
+ if (!isAbsolute(cwd)) {
111
+ stderr.write(
112
+ `[claude-acp] instructionsFile skipped: cwd '${cwd}' is not absolute
113
+ `
114
+ );
115
+ return null;
116
+ }
117
+ if (isAbsolute(relative) || /^[A-Za-z]:[\\/]/.test(relative)) {
118
+ stderr.write(
119
+ `[claude-acp] instructionsFile skipped: '${relative}' must be repo-relative
120
+ `
121
+ );
122
+ return null;
123
+ }
124
+ if (relative.split(/[\\/]/).includes("..")) {
125
+ stderr.write(
126
+ `[claude-acp] instructionsFile skipped: '${relative}' contains '..'
127
+ `
128
+ );
129
+ return null;
130
+ }
131
+ const normalizedCwd = normalize(cwd);
132
+ const candidate = pathResolve(normalizedCwd, relative);
133
+ const cwdWithSep = normalizedCwd.endsWith(sep) ? normalizedCwd : `${normalizedCwd}${sep}`;
134
+ if (!candidate.startsWith(cwdWithSep) && candidate !== normalizedCwd) {
135
+ stderr.write(
136
+ `[claude-acp] instructionsFile skipped: '${candidate}' escapes cwd
137
+ `
138
+ );
139
+ return null;
140
+ }
141
+ let realCwd;
142
+ let realCandidate;
143
+ try {
144
+ realCwd = realpathSync(normalizedCwd);
145
+ } catch (err) {
146
+ stderr.write(
147
+ `[claude-acp] instructionsFile skipped: cwd '${normalizedCwd}' realpath failed: ${err instanceof Error ? err.message : String(err)}
148
+ `
149
+ );
150
+ return null;
151
+ }
152
+ try {
153
+ realCandidate = realpathSync(candidate);
154
+ } catch (err) {
155
+ stderr.write(
156
+ `[claude-acp] instructionsFile skipped: '${relative}' not found in cwd (${err instanceof Error ? err.message : String(err)})
157
+ `
158
+ );
159
+ return null;
160
+ }
161
+ const realCwdWithSep = realCwd.endsWith(sep) ? realCwd : `${realCwd}${sep}`;
162
+ if (!realCandidate.startsWith(realCwdWithSep) && realCandidate !== realCwd) {
163
+ stderr.write(
164
+ `[claude-acp] instructionsFile skipped: '${relative}' resolves to '${realCandidate}' which is outside cwd (symlink escape?)
165
+ `
166
+ );
167
+ return null;
168
+ }
169
+ let stat;
170
+ try {
171
+ stat = statSync(realCandidate);
172
+ } catch (err) {
173
+ stderr.write(
174
+ `[claude-acp] instructionsFile skipped: '${relative}' stat failed: ${err instanceof Error ? err.message : String(err)}
175
+ `
176
+ );
177
+ return null;
178
+ }
179
+ if (!stat.isFile()) {
180
+ stderr.write(
181
+ `[claude-acp] instructionsFile skipped: '${relative}' is not a regular file
182
+ `
183
+ );
184
+ return null;
185
+ }
186
+ if (stat.size > INSTRUCTIONS_FILE_MAX_BYTES) {
187
+ stderr.write(
188
+ `[claude-acp] instructionsFile skipped: '${relative}' is ${stat.size} bytes (> ${INSTRUCTIONS_FILE_MAX_BYTES} cap)
189
+ `
190
+ );
191
+ return null;
192
+ }
193
+ let content;
194
+ try {
195
+ content = readFileSync(realCandidate, "utf8");
196
+ } catch (err) {
197
+ stderr.write(
198
+ `[claude-acp] instructionsFile skipped: read failed for '${relative}': ${err instanceof Error ? err.message : String(err)}
199
+ `
200
+ );
201
+ return null;
202
+ }
203
+ return { path: realCandidate, content };
204
+ }
205
+ function buildStreamJsonInput(promptText, images) {
206
+ const content = [];
207
+ if (promptText.length > 0) {
208
+ content.push({ type: "text", text: promptText });
209
+ }
210
+ for (const img of images) {
211
+ content.push({
212
+ type: "image",
213
+ source: { type: "base64", media_type: img.mimeType, data: img.data }
214
+ });
215
+ }
216
+ return `${JSON.stringify({
217
+ type: "user",
218
+ message: { role: "user", content }
219
+ })}
220
+ `;
221
+ }
222
+ async function runClaudeTurn(sessionId, state, promptText, images, permissionMode) {
103
223
  return new Promise((resolve, reject) => {
104
224
  const idFlag = state.resume ? "--resume" : "--session-id";
105
225
  const args = [
@@ -114,6 +234,10 @@ async function runClaudeTurn(sessionId, state, promptText, permissionMode) {
114
234
  idFlag,
115
235
  sessionId
116
236
  ];
237
+ const useStreamJsonInput = images.length > 0;
238
+ if (useStreamJsonInput) {
239
+ args.push("--input-format", "stream-json");
240
+ }
117
241
  if (permissionMode && permissionMode !== "default") {
118
242
  args.push("--permission-mode", permissionMode);
119
243
  } else {
@@ -126,6 +250,16 @@ async function runClaudeTurn(sessionId, state, promptText, permissionMode) {
126
250
  "--strict-mcp-config"
127
251
  );
128
252
  }
253
+ const resolvedInstructions = resolveInstructionsFile(
254
+ state.cwd,
255
+ state.instructionsFile
256
+ );
257
+ if (resolvedInstructions) {
258
+ args.push("--bare", "--append-system-prompt", resolvedInstructions.content);
259
+ }
260
+ if (extraClaudeArgs.length > 0) {
261
+ args.push(...extraClaudeArgs);
262
+ }
129
263
  const child = spawn("claude", args, {
130
264
  cwd: state.cwd,
131
265
  env: process.env,
@@ -134,7 +268,9 @@ async function runClaudeTurn(sessionId, state, promptText, permissionMode) {
134
268
  state.activeChild = child;
135
269
  child.stdin.on("error", () => {
136
270
  });
137
- child.stdin.end(promptText);
271
+ child.stdin.end(
272
+ useStreamJsonInput ? buildStreamJsonInput(promptText, images) : promptText
273
+ );
138
274
  const decoder = new FrameDecoder();
139
275
  let resolved = false;
140
276
  let stopReason = "end_turn";
@@ -182,27 +318,43 @@ async function runClaudeTurn(sessionId, state, promptText, permissionMode) {
182
318
  });
183
319
  });
184
320
  }
185
- function handleClaudeEvent(sessionId, raw, done) {
186
- if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return;
321
+ function translateClaudeEvent(sessionId, raw) {
322
+ const out = [];
323
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
324
+ return { notifications: out };
325
+ }
187
326
  const msg = raw;
188
327
  const type = typeof msg["type"] === "string" ? msg["type"] : "";
189
328
  if (type === "assistant") {
190
- return;
329
+ const message = msg["message"];
330
+ if (message && typeof message === "object" && !Array.isArray(message)) {
331
+ const content = message.content;
332
+ if (Array.isArray(content)) {
333
+ for (const block of content) {
334
+ const translated = translateAssistantBlock(sessionId, block);
335
+ if (translated) out.push(...translated);
336
+ }
337
+ }
338
+ }
339
+ return { notifications: out };
191
340
  }
192
341
  if (type === "stream_event") {
193
342
  const event = msg["event"];
194
- if (event?.type !== "content_block_delta") return;
195
- if (event.delta?.type !== "text_delta") return;
343
+ if (event?.type !== "content_block_delta") return { notifications: out };
344
+ if (event.delta?.type !== "text_delta") return { notifications: out };
196
345
  const text = typeof event.delta.text === "string" ? event.delta.text : "";
197
- if (text.length === 0) return;
198
- notify("session/update", {
199
- sessionId,
200
- update: {
201
- sessionUpdate: "agent_message_chunk",
202
- content: { type: "text", text }
346
+ if (text.length === 0) return { notifications: out };
347
+ out.push({
348
+ method: "session/update",
349
+ params: {
350
+ sessionId,
351
+ update: {
352
+ sessionUpdate: "agent_message_chunk",
353
+ content: { type: "text", text }
354
+ }
203
355
  }
204
356
  });
205
- return;
357
+ return { notifications: out };
206
358
  }
207
359
  if (type === "result") {
208
360
  const subtype = typeof msg["subtype"] === "string" ? msg["subtype"] : "";
@@ -210,30 +362,102 @@ function handleClaudeEvent(sessionId, raw, done) {
210
362
  if (isError) {
211
363
  const resultText = typeof msg["result"] === "string" ? msg["result"] : "";
212
364
  if (resultText.length > 0) {
213
- notify("session/update", {
214
- sessionId,
215
- update: {
216
- sessionUpdate: "agent_message_chunk",
217
- content: { type: "text", text: `
365
+ out.push({
366
+ method: "session/update",
367
+ params: {
368
+ sessionId,
369
+ update: {
370
+ sessionUpdate: "agent_message_chunk",
371
+ content: { type: "text", text: `
218
372
 
219
373
  [claude error: ${resultText}]` }
374
+ }
220
375
  }
221
376
  });
222
377
  }
223
- done("refusal");
224
- return;
378
+ return { notifications: out, stopReason: "refusal" };
225
379
  }
226
380
  if (subtype === "error_max_turns") {
227
- done("max_turn_requests");
228
- return;
381
+ return { notifications: out, stopReason: "max_turn_requests" };
229
382
  }
230
383
  if (subtype === "error_max_tokens") {
231
- done("max_tokens");
232
- return;
384
+ return { notifications: out, stopReason: "max_tokens" };
233
385
  }
234
- done("end_turn");
235
- return;
386
+ return { notifications: out, stopReason: "end_turn" };
387
+ }
388
+ return { notifications: out };
389
+ }
390
+ function translateAssistantBlock(sessionId, block) {
391
+ if (!block || typeof block !== "object" || Array.isArray(block)) return null;
392
+ const b = block;
393
+ if (b["type"] !== "tool_use") return null;
394
+ if (b["name"] !== "AskUserQuestion") return null;
395
+ const input = b["input"];
396
+ if (!input || typeof input !== "object" || Array.isArray(input)) return null;
397
+ const questions = input.questions;
398
+ if (!Array.isArray(questions)) return null;
399
+ const chunks = [];
400
+ for (const q of questions) {
401
+ const rendered = renderAskUserQuestionItem(q);
402
+ if (rendered) chunks.push(rendered);
403
+ }
404
+ if (chunks.length === 0) return null;
405
+ const text = `
406
+
407
+ ${chunks.join("\n\n")}
408
+ `;
409
+ return [
410
+ {
411
+ method: "session/update",
412
+ params: {
413
+ sessionId,
414
+ update: {
415
+ sessionUpdate: "agent_message_chunk",
416
+ content: { type: "text", text }
417
+ }
418
+ }
419
+ }
420
+ ];
421
+ }
422
+ function renderAskUserQuestionItem(raw) {
423
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
424
+ const q = raw;
425
+ const question = typeof q["question"] === "string" ? q["question"].trim() : "";
426
+ if (!question) return null;
427
+ const header = typeof q["header"] === "string" ? q["header"].trim() : "";
428
+ const multiSelect = q["multiSelect"] === true;
429
+ const rawOptions = Array.isArray(q["options"]) ? q["options"] : [];
430
+ const options = [];
431
+ for (const opt of rawOptions) {
432
+ if (!opt || typeof opt !== "object" || Array.isArray(opt)) continue;
433
+ const o = opt;
434
+ const label = typeof o["label"] === "string" ? o["label"].trim() : "";
435
+ if (!label) continue;
436
+ const value = header ? `${header}: ${label}` : label;
437
+ options.push({ label, value });
236
438
  }
439
+ if (options.length === 0) return null;
440
+ const promptParts = [`**${question}**`];
441
+ if (multiSelect) {
442
+ promptParts.push(
443
+ "_(Multiple answers expected \u2014 click one, then type any others.)_"
444
+ );
445
+ } else {
446
+ promptParts.push(
447
+ "_(Pick an option, or type your own answer below.)_"
448
+ );
449
+ }
450
+ const payload = {
451
+ type: "options",
452
+ text: promptParts.join(" "),
453
+ options
454
+ };
455
+ return "```json\n" + JSON.stringify(payload, null, 2) + "\n```";
456
+ }
457
+ function handleClaudeEvent(sessionId, raw, done) {
458
+ const { notifications, stopReason } = translateClaudeEvent(sessionId, raw);
459
+ for (const n of notifications) notify(n.method, n.params);
460
+ if (stopReason) done(stopReason);
237
461
  }
238
462
  function handleInitialize(_params) {
239
463
  return {
@@ -251,7 +475,9 @@ function handleInitialize(_params) {
251
475
  // on each turn (see SessionState.mcpServers / runClaudeTurn).
252
476
  loadSession: true,
253
477
  mcpCapabilities: {},
254
- promptCapabilities: { embeddedContext: false, image: false, audio: false }
478
+ // image: true since #142 image blocks are forwarded to claude via
479
+ // `--input-format stream-json`. embeddedContext / audio still unsupported.
480
+ promptCapabilities: { embeddedContext: false, image: true, audio: false }
255
481
  },
256
482
  authMethods: []
257
483
  };
@@ -259,10 +485,12 @@ function handleInitialize(_params) {
259
485
  function handleNewSession(params) {
260
486
  const sessionId = randomUUID();
261
487
  const mcpServers = normalizeMcpServers(params.mcpServers);
488
+ const instructionsFile = normalizeInstructionsFile(params.instructionsFile);
262
489
  sessions.set(sessionId, {
263
490
  cwd: params.cwd ?? process.cwd(),
264
491
  resume: false,
265
- ...mcpServers.length > 0 ? { mcpServers } : {}
492
+ ...mcpServers.length > 0 ? { mcpServers } : {},
493
+ ...instructionsFile ? { instructionsFile } : {}
266
494
  });
267
495
  return { sessionId };
268
496
  }
@@ -271,26 +499,37 @@ function handleLoadSession(params) {
271
499
  throw new Error("session/load: sessionId required");
272
500
  }
273
501
  const mcpServers = normalizeMcpServers(params.mcpServers);
502
+ const instructionsFile = normalizeInstructionsFile(params.instructionsFile);
274
503
  sessions.set(params.sessionId, {
275
504
  cwd: params.cwd ?? process.cwd(),
276
505
  resume: true,
277
- ...mcpServers.length > 0 ? { mcpServers } : {}
506
+ ...mcpServers.length > 0 ? { mcpServers } : {},
507
+ ...instructionsFile ? { instructionsFile } : {}
278
508
  });
279
509
  return {};
280
510
  }
511
+ function normalizeInstructionsFile(raw) {
512
+ if (typeof raw !== "string") return void 0;
513
+ const trimmed = raw.trim();
514
+ return trimmed.length > 0 ? trimmed : void 0;
515
+ }
281
516
  async function handlePrompt(params) {
282
517
  const state = sessions.get(params.sessionId);
283
518
  if (!state) {
284
519
  throw new Error(`unknown sessionId: ${params.sessionId}`);
285
520
  }
286
521
  const promptText = params.prompt.filter((b) => b.type === "text").map((b) => typeof b.text === "string" ? b.text : "").join("\n\n");
287
- if (promptText.length === 0) {
288
- throw new Error("session/prompt: no text content blocks");
522
+ const images = params.prompt.filter(
523
+ (b) => b.type === "image" && typeof b.data === "string" && b.data.length > 0 && typeof b.mimeType === "string" && b.mimeType.length > 0
524
+ ).map((b) => ({ type: "image", data: b.data, mimeType: b.mimeType }));
525
+ if (promptText.length === 0 && images.length === 0) {
526
+ throw new Error("session/prompt: no text or image content blocks");
289
527
  }
290
528
  const result = await runClaudeTurn(
291
529
  params.sessionId,
292
530
  state,
293
531
  promptText,
532
+ images,
294
533
  params.permissionMode
295
534
  );
296
535
  state.resume = true;
@@ -312,6 +551,8 @@ function handleCancel(params) {
312
551
  }
313
552
  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;
314
553
  if (isMainModule) {
554
+ extraClaudeArgs = process.argv.slice(2);
555
+ Object.freeze(extraClaudeArgs);
315
556
  const decoder = new FrameDecoder();
316
557
  stdin.setEncoding("utf8");
317
558
  stdin.on("data", (chunk) => {
@@ -373,8 +614,12 @@ async function dispatch(msg) {
373
614
  }
374
615
  }
375
616
  export {
617
+ buildStreamJsonInput,
618
+ extraClaudeArgs,
376
619
  handleInitialize,
377
620
  handleLoadSession,
378
621
  handleNewSession,
379
- sessions
622
+ resolveInstructionsFile,
623
+ sessions,
624
+ translateClaudeEvent
380
625
  };
@@ -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(),
@@ -391,6 +400,9 @@ var IssueDetailSchema = IssueSummarySchema.extend({
391
400
  draftUpdatedAt: z4.string().datetime().nullable()
392
401
  });
393
402
 
403
+ // ../shared/dist/cron.js
404
+ var MAX_STEP_MINUTES = 366 * 24 * 60 + 24 * 60;
405
+
394
406
  // src/mcp/tools.ts
395
407
  var issueBodySetShape = IssueBodySetCallSchema.omit({
396
408
  type: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "opencara",
3
- "version": "0.108.2",
3
+ "version": "0.110.0",
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": {