opencara 0.102.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.102.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":
@@ -690,9 +1497,20 @@ async function logout() {
690
1497
 
691
1498
  // src/commands/internal.ts
692
1499
  import { execFileSync } from "node:child_process";
693
- import { mkdtempSync, rmSync, existsSync as existsSync2, realpathSync } from "node:fs";
694
- import { tmpdir } from "node:os";
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";
695
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");
696
1514
  async function internal(argv) {
697
1515
  const sub = argv[0];
698
1516
  const rest = argv.slice(1);
@@ -701,18 +1519,26 @@ async function internal(argv) {
701
1519
  const opArgs = rest.slice(1);
702
1520
  if (op === "create") return worktreeCreate(opArgs);
703
1521
  if (op === "remove") return worktreeRemove(opArgs);
1522
+ if (op === "write-session") return worktreeWriteSession(opArgs);
704
1523
  fail(`unknown worktree op: ${op ?? "(none)"}`);
705
1524
  }
706
1525
  fail(`unknown internal subcommand: ${sub ?? "(none)"}`);
707
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
+ }
708
1530
  function worktreeCreate(args) {
709
1531
  const repo = pickFlag(args, "--repo");
710
1532
  const branch = pickFlag(args, "--branch");
711
1533
  const fromRaw = pickFlag(args, "--from-branch") ?? "";
712
1534
  const fromBranch = fromRaw.length > 0 ? fromRaw : null;
1535
+ const rawKey = pickFlag(args, "--key") ?? pickFlag(args, "--session-key");
713
1536
  if (!repo || !branch) {
714
1537
  fail("worktree create requires --repo OWNER/NAME and --branch <name>");
715
1538
  }
1539
+ if (!rawKey) {
1540
+ fail("worktree create requires --key <slug>");
1541
+ }
716
1542
  if (!/^[\w.-]+\/[\w.-]+$/.test(repo)) {
717
1543
  fail(`invalid --repo '${repo}' (expected OWNER/NAME)`);
718
1544
  }
@@ -723,48 +1549,113 @@ function worktreeCreate(args) {
723
1549
  if (!/^[\w-]+$/.test(token)) {
724
1550
  fail("GH_TOKEN contains unexpected characters; refusing to use");
725
1551
  }
726
- const dir = mkdtempSync(join2(tmpdir(), "opencara-wt-"));
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");
727
1556
  const HELPER_SNIPPET = '!f() { echo username=x-access-token; echo "password=$GH_TOKEN"; }; f';
728
1557
  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);
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
+ }
732
1584
  }
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) {
1585
+ let priorSession = null;
1586
+ const sessionFile = join2(sessionDir, "agent-session.json");
1587
+ if (existsSync3(sessionFile)) {
739
1588
  try {
740
- rmSync(dir, { recursive: true, force: true });
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
+ }
741
1593
  } catch {
742
1594
  }
743
- throw err;
744
1595
  }
745
- process.stdout.write(`${JSON.stringify({ workdir: dir, branch })}
746
- `);
1596
+ process.stdout.write(
1597
+ `${JSON.stringify({
1598
+ workdir: checkoutDir,
1599
+ branch,
1600
+ sessionDir,
1601
+ priorSession
1602
+ })}
1603
+ `
1604
+ );
747
1605
  }
748
- function worktreeRemove(args) {
749
- const workdir = pickFlag(args, "--workdir");
750
- if (!workdir) {
751
- fail("worktree remove requires --workdir <path>");
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>");
752
1612
  }
753
- if (!existsSync2(workdir)) {
754
- return;
755
- }
756
- const tmp = realpathSync(tmpdir());
1613
+ mkdirSync2(SESSION_ROOT, { recursive: true });
1614
+ const root = realpathSync(SESSION_ROOT);
757
1615
  let resolved;
758
1616
  try {
759
- resolved = realpathSync(workdir);
1617
+ resolved = realpathSync(dir);
760
1618
  } catch (err) {
761
- if (err.code === "ENOENT") return;
762
- fail(`worktree remove: cannot resolve ${workdir}: ${err.message}`);
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}'`);
763
1626
  }
764
- if (resolved !== tmp && !resolved.startsWith(tmp + sep)) {
765
- fail(`worktree remove: refuses to remove ${resolved} (not under ${tmp})`);
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 });
766
1658
  }
767
- rmSync(resolved, { recursive: true, force: true });
768
1659
  }
769
1660
  function git(cwd, args) {
770
1661
  execFileSync("git", args, { cwd, stdio: ["ignore", "ignore", "inherit"] });