opencara 0.102.0 → 0.104.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
@@ -77,12 +77,28 @@ var AgentRunStatusSchema = z3.enum([
77
77
  "failed",
78
78
  "cancelled"
79
79
  ]);
80
+ var AcpHistoryTurnSchema = z3.object({
81
+ role: z3.enum(["user", "assistant"]),
82
+ text: z3.string()
83
+ });
84
+ var AcpSpecSchema = z3.object({
85
+ systemPromptMd: z3.string(),
86
+ userPromptMd: z3.string(),
87
+ history: z3.array(AcpHistoryTurnSchema).default([]),
88
+ pageContextJson: z3.string().optional()
89
+ });
80
90
  var AgentSpecSchema = z3.object({
81
91
  kind: z3.string(),
82
92
  command: z3.string(),
83
93
  args: z3.array(z3.string()).default([]),
84
94
  env: z3.record(z3.string()).default({}),
85
- cwd: z3.string().optional()
95
+ cwd: z3.string().optional(),
96
+ /**
97
+ * Present iff this run goes through the ACP+MCP path. Mutually exclusive
98
+ * with the legacy stdin-JSON envelope — when set, `JobAssignment.stdinJson`
99
+ * is ignored by the device runner.
100
+ */
101
+ acp: AcpSpecSchema.optional()
86
102
  });
87
103
  var AgentRunSchema = z3.object({
88
104
  id: z3.string(),
@@ -207,17 +223,58 @@ var AgentCallSchema = z4.discriminatedUnion("kind", [
207
223
  FlowNodeConfigSetCallSchema,
208
224
  TemplateNodeConfigSetCallSchema
209
225
  ]);
226
+ var AgentCallRequestEnvelope = {
227
+ type: z4.literal("agent-call-request"),
228
+ runId: z4.string(),
229
+ callId: z4.string()
230
+ };
231
+ var IssueBodySetCallRequestSchema = z4.object({
232
+ ...AgentCallRequestEnvelope,
233
+ kind: z4.literal("issue.body.set"),
234
+ issueNumber: z4.number().int(),
235
+ bodyMd: z4.string()
236
+ });
237
+ var FlowNodeConfigSetCallRequestSchema = z4.object({
238
+ ...AgentCallRequestEnvelope,
239
+ kind: z4.literal("flow.node.config.set"),
240
+ flowSlug: z4.string().min(1),
241
+ nodeId: z4.string().min(1),
242
+ config: z4.record(z4.string(), z4.unknown())
243
+ });
244
+ var TemplateNodeConfigSetCallRequestSchema = z4.object({
245
+ ...AgentCallRequestEnvelope,
246
+ kind: z4.literal("template.node.config.set"),
247
+ templateSlug: z4.string().min(1),
248
+ nodeId: z4.string().min(1),
249
+ config: z4.record(z4.string(), z4.unknown())
250
+ });
251
+ var AgentCallRequestSchema = z4.discriminatedUnion("kind", [
252
+ IssueBodySetCallRequestSchema,
253
+ FlowNodeConfigSetCallRequestSchema,
254
+ TemplateNodeConfigSetCallRequestSchema
255
+ ]);
256
+ var AgentCallResultSchema = z4.object({
257
+ type: z4.literal("agent-call-result"),
258
+ runId: z4.string(),
259
+ callId: z4.string(),
260
+ result: z4.union([
261
+ z4.object({ ok: z4.literal(true) }),
262
+ z4.object({ ok: z4.literal(false), reason: z4.string() })
263
+ ])
264
+ });
210
265
  var ServerToDeviceMessageSchema = z4.discriminatedUnion("type", [
211
266
  JobAssignmentSchema,
212
267
  HelloAckSchema,
213
- PingSchema
268
+ PingSchema,
269
+ AgentCallResultSchema
214
270
  ]);
215
271
  var DeviceToServerMessageSchema = z4.union([
216
272
  HelloMessageSchema,
217
273
  LogFrameSchema,
218
274
  RunDoneSchema,
219
275
  PongSchema,
220
- AgentCallSchema
276
+ AgentCallSchema,
277
+ AgentCallRequestSchema
221
278
  ]);
222
279
  var HostRegisterRequestSchema = z4.object({
223
280
  hostId: z4.string(),
@@ -399,10 +456,741 @@ var WsClient = class {
399
456
  };
400
457
 
401
458
  // src/runner/spawn.ts
459
+ import { spawn as spawn3 } from "node:child_process";
460
+
461
+ // src/runner/acpRunner.ts
462
+ import { existsSync as existsSync3 } from "node:fs";
463
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
464
+ import { dirname as dirname2, resolve as pathResolve3 } from "node:path";
465
+
466
+ // src/acp/client.ts
402
467
  import { spawn as spawn2 } from "node:child_process";
468
+ import { EventEmitter } from "node:events";
469
+
470
+ // src/acp/framing.ts
471
+ function encodeFrame(msg) {
472
+ return JSON.stringify(msg) + "\n";
473
+ }
474
+ var FrameDecoder = class {
475
+ buffer = "";
476
+ /** Feed a chunk. Returns parsed messages and any malformed lines. */
477
+ feed(chunk) {
478
+ this.buffer += chunk;
479
+ const messages = [];
480
+ const malformed = [];
481
+ const lines = this.buffer.split("\n");
482
+ this.buffer = lines.pop() ?? "";
483
+ for (const line of lines) {
484
+ const trimmed = line.trim();
485
+ if (trimmed.length === 0) continue;
486
+ try {
487
+ const parsed = JSON.parse(trimmed);
488
+ if (isPlainObject(parsed)) {
489
+ messages.push(parsed);
490
+ } else {
491
+ malformed.push(trimmed);
492
+ }
493
+ } catch {
494
+ malformed.push(trimmed);
495
+ }
496
+ }
497
+ return { messages, malformed };
498
+ }
499
+ /** Whatever's still buffered (a partial line awaiting more data). */
500
+ remainder() {
501
+ return this.buffer;
502
+ }
503
+ };
504
+ function isPlainObject(v) {
505
+ return typeof v === "object" && v !== null && !Array.isArray(v);
506
+ }
507
+
508
+ // src/acp/jsonrpc.ts
509
+ var JSON_RPC_ERROR_METHOD_NOT_FOUND = -32601;
510
+
511
+ // src/acp/types.ts
512
+ var ACP_METHODS = {
513
+ // Agent methods (client → agent).
514
+ initialize: "initialize",
515
+ session_new: "session/new",
516
+ session_load: "session/load",
517
+ session_prompt: "session/prompt",
518
+ session_cancel: "session/cancel",
519
+ // Client methods (agent → client) — handled minimally in this spike.
520
+ session_update: "session/update",
521
+ session_request_permission: "session/request_permission",
522
+ fs_read_text_file: "fs/read_text_file",
523
+ fs_write_text_file: "fs/write_text_file"
524
+ };
525
+ var ACP_PROTOCOL_VERSION = 1;
526
+ function isMessageChunk(u) {
527
+ return u.sessionUpdate === "user_message_chunk" || u.sessionUpdate === "agent_message_chunk" || u.sessionUpdate === "agent_thought_chunk";
528
+ }
529
+ function isToolCallStart(u) {
530
+ return u.sessionUpdate === "tool_call";
531
+ }
532
+ function isToolCallProgress(u) {
533
+ return u.sessionUpdate === "tool_call_update";
534
+ }
535
+
536
+ // src/acp/client.ts
537
+ var AcpConnection = class {
538
+ constructor(out, trace) {
539
+ this.out = out;
540
+ this.trace = trace;
541
+ }
542
+ out;
543
+ trace;
544
+ decoder = new FrameDecoder();
545
+ pending = /* @__PURE__ */ new Map();
546
+ emitter = new EventEmitter();
547
+ nextId = 1;
548
+ closed = false;
549
+ // ─── public events ───────────────────────────────────────────────
550
+ onSessionUpdate(fn) {
551
+ this.emitter.on("sessionUpdate", fn);
552
+ }
553
+ /** Called for EVERY frame that crosses the wire — both directions. */
554
+ onFrame(fn) {
555
+ this.emitter.on("frame", fn);
556
+ }
557
+ /** Malformed lines (parse errors). The spike harness logs these. */
558
+ onMalformed(fn) {
559
+ this.emitter.on("malformed", fn);
560
+ }
561
+ // ─── public methods ──────────────────────────────────────────────
562
+ initialize(req) {
563
+ return this.request(ACP_METHODS.initialize, req);
564
+ }
565
+ newSession(req) {
566
+ return this.request(ACP_METHODS.session_new, req);
567
+ }
568
+ prompt(req) {
569
+ return this.request(ACP_METHODS.session_prompt, req);
570
+ }
571
+ /** Notification (no reply). Used to cancel a turn mid-flight. */
572
+ cancel(sessionId) {
573
+ this.notify(ACP_METHODS.session_cancel, { sessionId });
574
+ }
575
+ // ─── ingest path (public so the wrapper can pump bytes in) ───────
576
+ feed(chunk) {
577
+ if (this.closed) return;
578
+ const { messages, malformed } = this.decoder.feed(chunk);
579
+ for (const line of malformed) this.emitter.emit("malformed", line);
580
+ for (const msg of messages) this.dispatch(msg);
581
+ }
582
+ /**
583
+ * Reject all pending requests. Called when the underlying transport closes
584
+ * (child process exit, stream error, manual disposal). Safe to call twice.
585
+ *
586
+ * User listeners are deliberately NOT cleared. Diagnostic events (frame
587
+ * trace, malformed lines) can still fire during the close sequence, and
588
+ * GC reclaims them when the consumer drops the connection.
589
+ */
590
+ shutdown(reason) {
591
+ if (this.closed) return;
592
+ this.closed = true;
593
+ const err = new Error(`acp connection closed: ${reason}`);
594
+ for (const p of this.pending.values()) p.reject(err);
595
+ this.pending.clear();
596
+ }
597
+ // ─── internals ───────────────────────────────────────────────────
598
+ request(method, params) {
599
+ if (this.closed) return Promise.reject(new Error("acp connection is closed"));
600
+ const id = this.nextId++;
601
+ const msg = { jsonrpc: "2.0", id, method, params };
602
+ return new Promise((resolve, reject) => {
603
+ this.pending.set(id, { resolve, reject, method });
604
+ this.send(msg);
605
+ });
606
+ }
607
+ notify(method, params) {
608
+ if (this.closed) return;
609
+ this.send({ jsonrpc: "2.0", method, params });
610
+ }
611
+ send(msg) {
612
+ if (this.trace) this.emitter.emit("frame", "out", msg);
613
+ this.out.write(encodeFrame(msg));
614
+ }
615
+ dispatch(msg) {
616
+ if (this.trace) this.emitter.emit("frame", "in", msg);
617
+ if ("result" in msg || "error" in msg) {
618
+ if (msg.id == null) return;
619
+ const pending = this.pending.get(msg.id);
620
+ if (!pending) return;
621
+ this.pending.delete(msg.id);
622
+ if ("error" in msg) {
623
+ pending.reject(
624
+ new Error(`${pending.method}: ${msg.error.message} (${msg.error.code})`)
625
+ );
626
+ } else {
627
+ pending.resolve(msg.result);
628
+ }
629
+ return;
630
+ }
631
+ if (!("id" in msg) || msg.id == null) {
632
+ if ("method" in msg && msg.method === ACP_METHODS.session_update) {
633
+ this.emitter.emit("sessionUpdate", msg.params);
634
+ }
635
+ return;
636
+ }
637
+ if (msg.method === ACP_METHODS.session_request_permission) {
638
+ this.send(buildAutoAllowResponse(msg.id, msg.params));
639
+ return;
640
+ }
641
+ const reply = {
642
+ jsonrpc: "2.0",
643
+ id: msg.id,
644
+ error: {
645
+ code: JSON_RPC_ERROR_METHOD_NOT_FOUND,
646
+ message: `method not implemented in spike client: ${msg.method}`
647
+ }
648
+ };
649
+ this.send(reply);
650
+ }
651
+ };
652
+ function buildAutoAllowResponse(id, params) {
653
+ const options = params && typeof params === "object" && "options" in params ? params.options : [];
654
+ const allow = options.find((o) => o?.kind === "allow_once") ?? options.find((o) => o?.kind === "allow_always");
655
+ if (allow?.optionId) {
656
+ return {
657
+ jsonrpc: "2.0",
658
+ id,
659
+ result: { outcome: { outcome: "selected", optionId: allow.optionId } }
660
+ };
661
+ }
662
+ return {
663
+ jsonrpc: "2.0",
664
+ id,
665
+ result: { outcome: { outcome: "cancelled" } }
666
+ };
667
+ }
668
+ var AcpClient = class {
669
+ constructor(opts) {
670
+ this.opts = opts;
671
+ }
672
+ opts;
673
+ child = null;
674
+ connection = null;
675
+ exitPromise = null;
676
+ stderrListeners = /* @__PURE__ */ new Set();
677
+ // Pre-start listener queues. Registering listeners before `start()` is the
678
+ // natural order in the spike (and any consumer that wants to capture the
679
+ // very first frame). We hold them here and attach to the live connection
680
+ // inside `start()`. Once started, registrations bypass the queue and go
681
+ // straight to the connection.
682
+ preStartSessionUpdate = [];
683
+ preStartFrame = [];
684
+ preStartMalformed = [];
685
+ start() {
686
+ if (this.child) return;
687
+ const env = { ...process.env, ...this.opts.env ?? {} };
688
+ const child = spawn2(this.opts.command, this.opts.args ?? [], {
689
+ env,
690
+ cwd: this.opts.cwd,
691
+ stdio: ["pipe", "pipe", "pipe"]
692
+ });
693
+ child.stdout.setEncoding("utf8");
694
+ child.stderr.setEncoding("utf8");
695
+ const conn = new AcpConnection(
696
+ { write: (chunk) => child.stdin.write(chunk) },
697
+ this.opts.trace ?? false
698
+ );
699
+ for (const fn of this.preStartSessionUpdate) conn.onSessionUpdate(fn);
700
+ for (const fn of this.preStartFrame) conn.onFrame(fn);
701
+ for (const fn of this.preStartMalformed) conn.onMalformed(fn);
702
+ this.preStartSessionUpdate.length = 0;
703
+ this.preStartFrame.length = 0;
704
+ this.preStartMalformed.length = 0;
705
+ child.stdout.on("data", (chunk) => conn.feed(chunk));
706
+ child.stderr.on("data", (chunk) => {
707
+ for (const fn of this.stderrListeners) fn(chunk);
708
+ });
709
+ this.exitPromise = new Promise((resolve) => {
710
+ child.on("close", (code, signal) => {
711
+ conn.shutdown(`child exited code=${code} signal=${signal ?? "none"}`);
712
+ resolve({ code, signal });
713
+ });
714
+ child.on("error", (err) => {
715
+ conn.shutdown(`child error: ${err.message}`);
716
+ resolve({ code: null, signal: null });
717
+ });
718
+ });
719
+ this.child = child;
720
+ this.connection = conn;
721
+ }
722
+ // ─── delegations to the connection ───────────────────────────────
723
+ initialize(req = { protocolVersion: ACP_PROTOCOL_VERSION }) {
724
+ return this.must().initialize(req);
725
+ }
726
+ newSession(req) {
727
+ return this.must().newSession(req);
728
+ }
729
+ prompt(req) {
730
+ return this.must().prompt(req);
731
+ }
732
+ cancel(sessionId) {
733
+ this.must().cancel(sessionId);
734
+ }
735
+ onSessionUpdate(fn) {
736
+ if (this.connection) this.connection.onSessionUpdate(fn);
737
+ else this.preStartSessionUpdate.push(fn);
738
+ }
739
+ onFrame(fn) {
740
+ if (this.connection) this.connection.onFrame(fn);
741
+ else this.preStartFrame.push(fn);
742
+ }
743
+ onMalformed(fn) {
744
+ if (this.connection) this.connection.onMalformed(fn);
745
+ else this.preStartMalformed.push(fn);
746
+ }
747
+ onStderr(fn) {
748
+ this.stderrListeners.add(fn);
749
+ }
750
+ /**
751
+ * Close stdin and wait for the child to exit. If the child is still
752
+ * running after `graceMs`, send SIGTERM; if it's still alive after
753
+ * `2 * graceMs`, send SIGKILL. Always resolves.
754
+ */
755
+ async close(graceMs = 2e3) {
756
+ const child = this.child;
757
+ if (!child || !this.exitPromise) return { code: null, signal: null };
758
+ try {
759
+ child.stdin.end();
760
+ } catch {
761
+ }
762
+ const term = setTimeout(() => {
763
+ try {
764
+ child.kill("SIGTERM");
765
+ } catch {
766
+ }
767
+ }, graceMs);
768
+ const kill = setTimeout(() => {
769
+ try {
770
+ child.kill("SIGKILL");
771
+ } catch {
772
+ }
773
+ }, graceMs * 2);
774
+ try {
775
+ return await this.exitPromise;
776
+ } finally {
777
+ clearTimeout(term);
778
+ clearTimeout(kill);
779
+ }
780
+ }
781
+ must() {
782
+ if (!this.connection) throw new Error("acp client not started \u2014 call start() first");
783
+ return this.connection;
784
+ }
785
+ };
786
+
787
+ // src/mcp/host.ts
788
+ import { existsSync as existsSync2 } from "node:fs";
789
+ import { fileURLToPath } from "node:url";
790
+ import { dirname, resolve as pathResolve2 } from "node:path";
791
+
792
+ // src/mcp/ipc.ts
793
+ import { createServer, createConnection } from "node:net";
794
+ import { unlink } from "node:fs/promises";
795
+ import { tmpdir } from "node:os";
796
+ import { resolve as pathResolve } from "node:path";
797
+ var IPC_SOCKET_ENV = "OPENCARA_MCP_IPC_SOCKET";
798
+ function defaultIpcSocketPath(runId) {
799
+ return pathResolve(tmpdir(), `opencara-mcp-${runId}.sock`);
800
+ }
801
+ function encode(frame) {
802
+ return JSON.stringify(frame) + "\n";
803
+ }
804
+ function decode(buffered, chunk) {
805
+ const buf = buffered + chunk;
806
+ const lines = buf.split("\n");
807
+ const remainder = lines.pop() ?? "";
808
+ const frames = [];
809
+ const malformed = [];
810
+ for (const line of lines) {
811
+ const t = line.trim();
812
+ if (t.length === 0) continue;
813
+ try {
814
+ const parsed = JSON.parse(t);
815
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed) && "type" in parsed && (parsed.type === "tool-call" || parsed.type === "tool-result")) {
816
+ frames.push(parsed);
817
+ } else {
818
+ malformed.push(t);
819
+ }
820
+ } catch {
821
+ malformed.push(t);
822
+ }
823
+ }
824
+ return { frames, malformed, buffered: remainder };
825
+ }
826
+ var IpcServer = class {
827
+ constructor(opts) {
828
+ this.opts = opts;
829
+ }
830
+ opts;
831
+ server = null;
832
+ connection = null;
833
+ async start() {
834
+ await unlink(this.opts.socketPath).catch(() => void 0);
835
+ await new Promise((resolve, reject) => {
836
+ const server = createServer((socket) => {
837
+ if (this.connection) {
838
+ socket.destroy();
839
+ return;
840
+ }
841
+ this.connection = socket;
842
+ this.opts.onConnect?.();
843
+ let buffered = "";
844
+ socket.setEncoding("utf8");
845
+ socket.on("data", (chunk) => {
846
+ const d = decode(buffered, chunk);
847
+ buffered = d.buffered;
848
+ for (const frame of d.frames) {
849
+ if (frame.type === "tool-call") {
850
+ void this.handleCall(frame, socket);
851
+ }
852
+ }
853
+ });
854
+ socket.on("close", () => {
855
+ if (this.connection === socket) this.connection = null;
856
+ this.opts.onDisconnect?.();
857
+ });
858
+ socket.on("error", () => {
859
+ });
860
+ });
861
+ server.on("error", reject);
862
+ server.listen(this.opts.socketPath, () => {
863
+ this.server = server;
864
+ resolve();
865
+ });
866
+ });
867
+ }
868
+ async handleCall(call, socket) {
869
+ let result;
870
+ try {
871
+ result = await this.opts.handler(call);
872
+ } catch (err) {
873
+ const reason = err instanceof Error ? err.message : String(err);
874
+ result = { ok: false, reason: `transport error: ${reason}` };
875
+ }
876
+ if (socket.writable) {
877
+ socket.write(encode({ type: "tool-result", callId: call.callId, result }));
878
+ }
879
+ }
880
+ async stop() {
881
+ if (this.connection) {
882
+ this.connection.destroy();
883
+ this.connection = null;
884
+ }
885
+ if (this.server) {
886
+ await new Promise((resolve) => this.server.close(() => resolve()));
887
+ this.server = null;
888
+ }
889
+ await unlink(this.opts.socketPath).catch(() => void 0);
890
+ }
891
+ };
892
+
893
+ // src/mcp/host.ts
894
+ function defaultMcpInvocation() {
895
+ const here = dirname(fileURLToPath(import.meta.url));
896
+ const sourceBin = pathResolve2(here, "..", "bin", "opencara-mcp.ts");
897
+ if (existsSync2(sourceBin)) {
898
+ return { command: "tsx", args: [sourceBin] };
899
+ }
900
+ const distBin = pathResolve2(here, "opencara-mcp.js");
901
+ if (existsSync2(distBin)) {
902
+ return { command: "node", args: [distBin] };
903
+ }
904
+ return { command: "opencara-mcp", args: [] };
905
+ }
906
+ var McpHost = class {
907
+ server;
908
+ socketPath;
909
+ mcpCommand;
910
+ mcpArgs;
911
+ started = false;
912
+ constructor(opts) {
913
+ this.socketPath = opts.socketPath ?? defaultIpcSocketPath(opts.runId);
914
+ const inv = opts.mcpCommand ? { command: opts.mcpCommand, args: opts.mcpArgs ?? [] } : defaultMcpInvocation();
915
+ this.mcpCommand = inv.command;
916
+ this.mcpArgs = inv.args;
917
+ this.server = new IpcServer({
918
+ socketPath: this.socketPath,
919
+ handler: async (call) => {
920
+ return opts.router.call(call.kind, call.args);
921
+ }
922
+ });
923
+ }
924
+ async start() {
925
+ if (this.started) return;
926
+ await this.server.start();
927
+ this.started = true;
928
+ }
929
+ /**
930
+ * The shape the ACP client should put in `session/new`'s `mcpServers`
931
+ * array. Agent spawns this command; opencara-mcp reads
932
+ * `OPENCARA_MCP_IPC_SOCKET` and dials back to our IPC server.
933
+ */
934
+ acpServerEntry() {
935
+ return {
936
+ type: "stdio",
937
+ name: "opencara",
938
+ command: this.mcpCommand,
939
+ args: [...this.mcpArgs],
940
+ env: [{ name: IPC_SOCKET_ENV, value: this.socketPath }]
941
+ };
942
+ }
943
+ async stop() {
944
+ if (!this.started) return;
945
+ await this.server.stop();
946
+ this.started = false;
947
+ }
948
+ };
949
+
950
+ // src/mcp/wsBridge.ts
951
+ var WsAgentCallBridge = class {
952
+ constructor(opts) {
953
+ this.opts = opts;
954
+ }
955
+ opts;
956
+ pending = /* @__PURE__ */ new Map();
957
+ nextCallId = 1;
958
+ /**
959
+ * Pass to McpHost as the tool-call router. Each invocation mints a
960
+ * callId, sends an `agent-call-request`, and parks the resolver until
961
+ * `onResult` finds a match.
962
+ */
963
+ router = {
964
+ call: (kind, args) => this.dispatch(kind, args)
965
+ };
966
+ /**
967
+ * Forward an inbound `agent-call-result` frame from the WS layer.
968
+ * Resolves the matching pending call. Stale callIds (the run already
969
+ * ended, or someone replied twice) are dropped silently.
970
+ */
971
+ onResult(msg) {
972
+ if (msg.runId !== this.opts.runId) return;
973
+ const p = this.pending.get(msg.callId);
974
+ if (!p) return;
975
+ this.pending.delete(msg.callId);
976
+ p.resolve(msg.result);
977
+ }
978
+ /**
979
+ * Reject every in-flight tool call. Call when the run ends or the WS
980
+ * disconnects so opencara-mcp doesn't hang forever waiting on a result
981
+ * that will never arrive.
982
+ */
983
+ shutdown(reason) {
984
+ if (this.pending.size === 0) return;
985
+ const err = new Error(`bridge closed: ${reason}`);
986
+ for (const p of this.pending.values()) p.reject(err);
987
+ this.pending.clear();
988
+ }
989
+ dispatch(kind, args) {
990
+ const callId = `bc${this.nextCallId++}`;
991
+ const req = buildAgentCallRequest(this.opts.runId, callId, kind, args);
992
+ if (!req) {
993
+ return Promise.resolve({
994
+ ok: false,
995
+ reason: `unknown agent-call kind: ${kind}`
996
+ });
997
+ }
998
+ return new Promise((resolve, reject) => {
999
+ this.pending.set(callId, { resolve, reject });
1000
+ try {
1001
+ this.opts.sendRequest(req);
1002
+ } catch (err) {
1003
+ this.pending.delete(callId);
1004
+ reject(err instanceof Error ? err : new Error(String(err)));
1005
+ }
1006
+ });
1007
+ }
1008
+ };
1009
+ function buildAgentCallRequest(runId, callId, kind, args) {
1010
+ switch (kind) {
1011
+ case "issue.body.set":
1012
+ return {
1013
+ type: "agent-call-request",
1014
+ runId,
1015
+ callId,
1016
+ kind: "issue.body.set",
1017
+ issueNumber: Number(args["issueNumber"]),
1018
+ bodyMd: String(args["bodyMd"] ?? "")
1019
+ };
1020
+ case "flow.node.config.set":
1021
+ return {
1022
+ type: "agent-call-request",
1023
+ runId,
1024
+ callId,
1025
+ kind: "flow.node.config.set",
1026
+ flowSlug: String(args["flowSlug"] ?? ""),
1027
+ nodeId: String(args["nodeId"] ?? ""),
1028
+ config: args["config"] ?? {}
1029
+ };
1030
+ case "template.node.config.set":
1031
+ return {
1032
+ type: "agent-call-request",
1033
+ runId,
1034
+ callId,
1035
+ kind: "template.node.config.set",
1036
+ templateSlug: String(args["templateSlug"] ?? ""),
1037
+ nodeId: String(args["nodeId"] ?? ""),
1038
+ config: args["config"] ?? {}
1039
+ };
1040
+ default:
1041
+ return null;
1042
+ }
1043
+ }
1044
+
1045
+ // src/runner/acpRunner.ts
1046
+ function runAcpJob(opts) {
1047
+ const { runId, spec, handlers } = opts;
1048
+ if (!spec.acp) {
1049
+ throw new Error("runAcpJob: spec.acp is required");
1050
+ }
1051
+ const acpSpec = spec.acp;
1052
+ const bridge = new WsAgentCallBridge({
1053
+ runId,
1054
+ sendRequest: handlers.sendAgentCall
1055
+ });
1056
+ const host = new McpHost({ runId, router: bridge.router });
1057
+ const resolved = resolveLocalAcpAdapter(spec.command, spec.args);
1058
+ const client = new AcpClient({
1059
+ command: resolved.command,
1060
+ args: resolved.args,
1061
+ env: spec.env,
1062
+ cwd: spec.cwd
1063
+ });
1064
+ client.onSessionUpdate((p) => translateUpdate(p.update, handlers.onLog));
1065
+ client.onStderr((chunk) => handlers.onLog("stderr", chunk));
1066
+ const controller = {
1067
+ onAgentCallResult(msg) {
1068
+ bridge.onResult(msg);
1069
+ }
1070
+ };
1071
+ const promise = (async () => {
1072
+ let result = { exitCode: 1, stopReason: "uninitialized" };
1073
+ try {
1074
+ await host.start();
1075
+ client.start();
1076
+ await client.initialize({
1077
+ protocolVersion: ACP_PROTOCOL_VERSION,
1078
+ clientCapabilities: {}
1079
+ });
1080
+ const session = await client.newSession({
1081
+ cwd: spec.cwd ?? process.cwd(),
1082
+ mcpServers: [host.acpServerEntry()]
1083
+ });
1084
+ const prompt = buildPromptContent(acpSpec);
1085
+ const promptResult = await client.prompt({
1086
+ sessionId: session.sessionId,
1087
+ prompt
1088
+ });
1089
+ result = {
1090
+ exitCode: promptResult.stopReason === "end_turn" ? 0 : 1,
1091
+ stopReason: promptResult.stopReason
1092
+ };
1093
+ return result;
1094
+ } finally {
1095
+ bridge.shutdown("acp run ended");
1096
+ await Promise.race([
1097
+ host.stop().catch(() => void 0),
1098
+ new Promise((r) => setTimeout(r, 3e3))
1099
+ ]);
1100
+ await Promise.race([
1101
+ client.close(
1102
+ /* graceMs */
1103
+ 1e3
1104
+ ).catch(() => void 0),
1105
+ new Promise((r) => setTimeout(r, 5e3))
1106
+ ]);
1107
+ }
1108
+ })();
1109
+ return { promise, controller };
1110
+ }
1111
+ function buildPromptContent(acp) {
1112
+ const parts = [];
1113
+ if (acp.systemPromptMd.trim().length > 0) {
1114
+ parts.push(`# System prompt
1115
+
1116
+ ${acp.systemPromptMd.trim()}`);
1117
+ }
1118
+ if (acp.pageContextJson && acp.pageContextJson.trim().length > 0) {
1119
+ parts.push(`# Page context (JSON)
1120
+
1121
+ \`\`\`json
1122
+ ${acp.pageContextJson}
1123
+ \`\`\``);
1124
+ }
1125
+ const history = acp.history ?? [];
1126
+ if (history.length > 0) {
1127
+ const turns = history.map((t) => `**${t.role}**: ${t.text}`).join("\n\n");
1128
+ parts.push(`# Conversation history
1129
+
1130
+ ${turns}`);
1131
+ }
1132
+ parts.push(`# Current message
1133
+
1134
+ ${acp.userPromptMd}`);
1135
+ return [{ type: "text", text: parts.join("\n\n---\n\n") }];
1136
+ }
1137
+ function translateUpdate(update, onLog) {
1138
+ if (isMessageChunk(update)) {
1139
+ if (update.sessionUpdate === "user_message_chunk") {
1140
+ return;
1141
+ }
1142
+ const text = textOfContent(update.content);
1143
+ if (!text) return;
1144
+ if (update.sessionUpdate === "agent_thought_chunk") {
1145
+ onLog("stdout", `[think] ${text}`);
1146
+ return;
1147
+ }
1148
+ onLog("stdout", text);
1149
+ return;
1150
+ }
1151
+ if (isToolCallStart(update)) {
1152
+ const status2 = update.status ?? "?";
1153
+ onLog("stdout", `
1154
+ [tool] ${update.title} (${status2})
1155
+ `);
1156
+ return;
1157
+ }
1158
+ if (isToolCallProgress(update)) {
1159
+ const status2 = update.status ?? "?";
1160
+ const title = update.title ?? "(tool)";
1161
+ onLog("stdout", `
1162
+ [tool] ${title} \u2192 ${status2}
1163
+ `);
1164
+ return;
1165
+ }
1166
+ onLog("stderr", `[acp] unmodeled update: ${update.sessionUpdate}
1167
+ `);
1168
+ }
1169
+ function textOfContent(content) {
1170
+ if (content.type !== "text") return "";
1171
+ return content.text ?? "";
1172
+ }
1173
+ var LOCAL_ACP_ADAPTERS = /* @__PURE__ */ new Set(["claude-acp"]);
1174
+ function resolveLocalAcpAdapter(command, args) {
1175
+ if (!LOCAL_ACP_ADAPTERS.has(command)) {
1176
+ return { command, args: [...args] };
1177
+ }
1178
+ const here = dirname2(fileURLToPath2(import.meta.url));
1179
+ const sourceBin = pathResolve3(here, "..", "bin", `${command}.ts`);
1180
+ if (existsSync3(sourceBin)) {
1181
+ return { command: "tsx", args: [sourceBin, ...args] };
1182
+ }
1183
+ const distBin = pathResolve3(here, `${command}.js`);
1184
+ if (existsSync3(distBin)) {
1185
+ return { command: "node", args: [distBin, ...args] };
1186
+ }
1187
+ return { command, args: [...args] };
1188
+ }
1189
+
1190
+ // src/runner/spawn.ts
403
1191
  function runJob(spec, stdinJson, handlers) {
404
1192
  return new Promise((resolve, reject) => {
405
- const child = spawn2(spec.command, spec.args ?? [], {
1193
+ const child = spawn3(spec.command, spec.args ?? [], {
406
1194
  env: { ...process.env, ...spec.env ?? {} },
407
1195
  cwd: spec.cwd,
408
1196
  stdio: ["pipe", "pipe", "pipe"]
@@ -492,7 +1280,7 @@ var AgentCallParser = class {
492
1280
  };
493
1281
 
494
1282
  // src/commands/run.ts
495
- var PKG_VERSION = "0.102.0";
1283
+ var PKG_VERSION = "0.104.0";
496
1284
  var LOG_FLUSH_MS = 800;
497
1285
  var MAX_CHUNK_SIZE = 4 * 1024;
498
1286
  async function run(opts = {}) {
@@ -529,6 +1317,7 @@ async function run(opts = {}) {
529
1317
  console.log(`[opencara] starting as ${cfg.deviceName} (${hostname2()})`);
530
1318
  client.start();
531
1319
  }
1320
+ var acpControllers = /* @__PURE__ */ new Map();
532
1321
  function handleServerMessage(msg, client, _cfg) {
533
1322
  if (msg.type === "hello-ack") {
534
1323
  console.log(`[opencara] acked as ${msg.deviceName} (${msg.agentHostId})`);
@@ -537,6 +1326,11 @@ function handleServerMessage(msg, client, _cfg) {
537
1326
  if (msg.type === "ping") return;
538
1327
  if (msg.type === "job") {
539
1328
  void executeJob(msg, client);
1329
+ return;
1330
+ }
1331
+ if (msg.type === "agent-call-result") {
1332
+ acpControllers.get(msg.runId)?.onAgentCallResult(msg);
1333
+ return;
540
1334
  }
541
1335
  }
542
1336
  async function executeJob(job, client) {
@@ -563,6 +1357,41 @@ async function executeJob(job, client) {
563
1357
  if (flushTimer) return;
564
1358
  flushTimer = setTimeout(flush, LOG_FLUSH_MS);
565
1359
  };
1360
+ if (job.spec.acp) {
1361
+ const handle = runAcpJob({
1362
+ runId,
1363
+ spec: job.spec,
1364
+ handlers: {
1365
+ onLog: (stream, chunk) => {
1366
+ pending[stream] += chunk;
1367
+ scheduleFlush();
1368
+ },
1369
+ sendAgentCall: (req) => client.send(req)
1370
+ }
1371
+ });
1372
+ acpControllers.set(runId, handle.controller);
1373
+ try {
1374
+ const result = await handle.promise;
1375
+ flush();
1376
+ client.send({
1377
+ type: "done",
1378
+ runId,
1379
+ status: result.exitCode === 0 ? "succeeded" : "failed",
1380
+ exitCode: result.exitCode
1381
+ });
1382
+ console.log(
1383
+ `[opencara] job ${runId.slice(-8)} (acp) \u2192 ${result.stopReason} exit=${result.exitCode}`
1384
+ );
1385
+ } catch (err) {
1386
+ flush();
1387
+ const message = err instanceof Error ? err.message : String(err);
1388
+ client.send({ type: "done", runId, status: "failed", errorMessage: message });
1389
+ console.error(`[opencara] job ${runId.slice(-8)} (acp) failed`, message);
1390
+ } finally {
1391
+ acpControllers.delete(runId);
1392
+ }
1393
+ return;
1394
+ }
566
1395
  const callParser = new AgentCallParser((call) => {
567
1396
  switch (call.kind) {
568
1397
  case "issue.body.set":
@@ -690,9 +1519,20 @@ async function logout() {
690
1519
 
691
1520
  // src/commands/internal.ts
692
1521
  import { execFileSync } from "node:child_process";
693
- import { mkdtempSync, rmSync, existsSync as existsSync2, realpathSync } from "node:fs";
694
- import { tmpdir } from "node:os";
1522
+ import {
1523
+ mkdirSync as mkdirSync2,
1524
+ readFileSync as readFileSync2,
1525
+ rmSync,
1526
+ existsSync as existsSync4,
1527
+ realpathSync,
1528
+ writeFileSync as writeFileSync2,
1529
+ renameSync
1530
+ } from "node:fs";
1531
+ import { homedir as homedir2 } from "node:os";
695
1532
  import { join as join2, sep } from "node:path";
1533
+ var OPENCARA_ROOT = join2(homedir2(), ".opencara");
1534
+ var WORK_ROOT = join2(OPENCARA_ROOT, "work");
1535
+ var SESSION_ROOT = join2(OPENCARA_ROOT, "sessions");
696
1536
  async function internal(argv) {
697
1537
  const sub = argv[0];
698
1538
  const rest = argv.slice(1);
@@ -701,18 +1541,26 @@ async function internal(argv) {
701
1541
  const opArgs = rest.slice(1);
702
1542
  if (op === "create") return worktreeCreate(opArgs);
703
1543
  if (op === "remove") return worktreeRemove(opArgs);
1544
+ if (op === "write-session") return worktreeWriteSession(opArgs);
704
1545
  fail(`unknown worktree op: ${op ?? "(none)"}`);
705
1546
  }
706
1547
  fail(`unknown internal subcommand: ${sub ?? "(none)"}`);
707
1548
  }
1549
+ function safeKey(rawKey) {
1550
+ return rawKey.split("/").map((part) => part.replace(/[^A-Za-z0-9._-]/g, "_")).filter((s) => s.length > 0).join(sep);
1551
+ }
708
1552
  function worktreeCreate(args) {
709
1553
  const repo = pickFlag(args, "--repo");
710
1554
  const branch = pickFlag(args, "--branch");
711
1555
  const fromRaw = pickFlag(args, "--from-branch") ?? "";
712
1556
  const fromBranch = fromRaw.length > 0 ? fromRaw : null;
1557
+ const rawKey = pickFlag(args, "--key") ?? pickFlag(args, "--session-key");
713
1558
  if (!repo || !branch) {
714
1559
  fail("worktree create requires --repo OWNER/NAME and --branch <name>");
715
1560
  }
1561
+ if (!rawKey) {
1562
+ fail("worktree create requires --key <slug>");
1563
+ }
716
1564
  if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
717
1565
  fail(`invalid --repo '${repo}' (expected OWNER/NAME)`);
718
1566
  }
@@ -723,48 +1571,113 @@ function worktreeCreate(args) {
723
1571
  if (!/^[\w-]+$/.test(token)) {
724
1572
  fail("GH_TOKEN contains unexpected characters; refusing to use");
725
1573
  }
726
- const dir = mkdtempSync(join2(tmpdir(), "opencara-wt-"));
1574
+ const key = safeKey(rawKey);
1575
+ if (!key) fail(`invalid --key '${rawKey}'`);
1576
+ const sessionDir = join2(SESSION_ROOT, key);
1577
+ const checkoutDir = join2(WORK_ROOT, key, "checkout");
727
1578
  const HELPER_SNIPPET = '!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f';
728
1579
  const cleanUrl = `https://github.com/${repo}.git`;
729
- const cloneArgs = ["-c", `credential.helper=${HELPER_SNIPPET}`, "clone", "--depth=1"];
730
- if (fromBranch) {
731
- cloneArgs.push("--branch", fromBranch);
1580
+ mkdirSync2(sessionDir, { recursive: true });
1581
+ if (existsSync4(join2(checkoutDir, ".git"))) {
1582
+ git(checkoutDir, ["fetch", "origin"]);
1583
+ git(checkoutDir, ["checkout", "-B", branch, `origin/${branch}`]);
1584
+ } else {
1585
+ mkdirSync2(checkoutDir, { recursive: true });
1586
+ const cloneArgs = ["-c", `credential.helper=${HELPER_SNIPPET}`, "clone"];
1587
+ if (fromBranch) {
1588
+ cloneArgs.push("--branch", fromBranch);
1589
+ }
1590
+ cloneArgs.push(cleanUrl, ".");
1591
+ try {
1592
+ git(checkoutDir, cloneArgs);
1593
+ if (fromBranch && branch === fromBranch) {
1594
+ git(checkoutDir, ["checkout", branch]);
1595
+ } else {
1596
+ git(checkoutDir, ["checkout", "-b", branch]);
1597
+ }
1598
+ git(checkoutDir, ["config", "credential.helper", HELPER_SNIPPET]);
1599
+ } catch (err) {
1600
+ try {
1601
+ rmSync(checkoutDir, { recursive: true, force: true });
1602
+ } catch {
1603
+ }
1604
+ throw err;
1605
+ }
732
1606
  }
733
- cloneArgs.push(cleanUrl, ".");
734
- try {
735
- git(dir, cloneArgs);
736
- git(dir, ["checkout", "-b", branch]);
737
- git(dir, ["config", "credential.helper", HELPER_SNIPPET]);
738
- } catch (err) {
1607
+ let priorSession = null;
1608
+ const sessionFile = join2(sessionDir, "agent-session.json");
1609
+ if (existsSync4(sessionFile)) {
739
1610
  try {
740
- rmSync(dir, { recursive: true, force: true });
1611
+ const parsed = JSON.parse(readFileSync2(sessionFile, "utf8"));
1612
+ if (typeof parsed.kind === "string" && typeof parsed.id === "string") {
1613
+ priorSession = { kind: parsed.kind, id: parsed.id };
1614
+ }
741
1615
  } catch {
742
1616
  }
743
- throw err;
744
1617
  }
745
- process.stdout.write(`${JSON.stringify({ workdir: dir, branch })}
746
- `);
1618
+ process.stdout.write(
1619
+ `${JSON.stringify({
1620
+ workdir: checkoutDir,
1621
+ branch,
1622
+ sessionDir,
1623
+ priorSession
1624
+ })}
1625
+ `
1626
+ );
747
1627
  }
748
- function worktreeRemove(args) {
749
- const workdir = pickFlag(args, "--workdir");
750
- if (!workdir) {
751
- fail("worktree remove requires --workdir <path>");
752
- }
753
- if (!existsSync2(workdir)) {
754
- return;
1628
+ function worktreeWriteSession(args) {
1629
+ const dir = pickFlag(args, "--session-dir");
1630
+ const kind = pickFlag(args, "--kind");
1631
+ const id = pickFlag(args, "--id");
1632
+ if (!dir || !kind || !id) {
1633
+ fail("worktree write-session requires --session-dir <path> --kind <k> --id <id>");
755
1634
  }
756
- const tmp = realpathSync(tmpdir());
1635
+ mkdirSync2(SESSION_ROOT, { recursive: true });
1636
+ const root = realpathSync(SESSION_ROOT);
757
1637
  let resolved;
758
1638
  try {
759
- resolved = realpathSync(workdir);
1639
+ resolved = realpathSync(dir);
760
1640
  } catch (err) {
761
- if (err.code === "ENOENT") return;
762
- fail(`worktree remove: cannot resolve ${workdir}: ${err.message}`);
1641
+ fail(`worktree write-session: cannot resolve ${dir}: ${err.message}`);
1642
+ }
1643
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
1644
+ fail(`worktree write-session: refuses to write to ${resolved} (not under ${root})`);
763
1645
  }
764
- if (resolved !== tmp && !resolved.startsWith(tmp + sep)) {
765
- fail(`worktree remove: refuses to remove ${resolved} (not under ${tmp})`);
1646
+ if (!/^[\w-]+$/.test(kind)) {
1647
+ fail(`worktree write-session: invalid --kind '${kind}'`);
1648
+ }
1649
+ if (id.length === 0 || id.length > 200) {
1650
+ fail("worktree write-session: --id must be 1..200 chars");
1651
+ }
1652
+ const dst = join2(resolved, "agent-session.json");
1653
+ const tmp = `${dst}.tmp`;
1654
+ writeFileSync2(tmp, JSON.stringify({ kind, id }) + "\n", { encoding: "utf8" });
1655
+ renameSync(tmp, dst);
1656
+ }
1657
+ function worktreeRemove(args) {
1658
+ const rawKey = pickFlag(args, "--key");
1659
+ if (!rawKey) {
1660
+ fail("worktree remove requires --key <slug>");
1661
+ }
1662
+ const key = safeKey(rawKey);
1663
+ if (!key) fail(`invalid --key '${rawKey}'`);
1664
+ mkdirSync2(OPENCARA_ROOT, { recursive: true });
1665
+ const opencaraRoot = realpathSync(OPENCARA_ROOT);
1666
+ for (const subtreeRoot of [WORK_ROOT, SESSION_ROOT]) {
1667
+ const target = join2(subtreeRoot, key);
1668
+ if (!existsSync4(target)) continue;
1669
+ let resolved;
1670
+ try {
1671
+ resolved = realpathSync(target);
1672
+ } catch (err) {
1673
+ if (err.code === "ENOENT") continue;
1674
+ fail(`worktree remove: cannot resolve ${target}: ${err.message}`);
1675
+ }
1676
+ if (!resolved.startsWith(opencaraRoot + sep)) {
1677
+ fail(`worktree remove: refuses to remove ${resolved} (not under ${opencaraRoot})`);
1678
+ }
1679
+ rmSync(resolved, { recursive: true, force: true });
766
1680
  }
767
- rmSync(resolved, { recursive: true, force: true });
768
1681
  }
769
1682
  function git(cwd, args) {
770
1683
  execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "inherit"] });