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