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 +948 -35
- package/dist/claude-acp.js +281 -0
- package/dist/opencara-mcp.js +467 -0
- package/package.json +13 -4
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 =
|
|
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.
|
|
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 {
|
|
694
|
-
|
|
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
|
|
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
|
-
|
|
730
|
-
if (
|
|
731
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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(
|
|
746
|
-
|
|
1618
|
+
process.stdout.write(
|
|
1619
|
+
`${JSON.stringify({
|
|
1620
|
+
workdir: checkoutDir,
|
|
1621
|
+
branch,
|
|
1622
|
+
sessionDir,
|
|
1623
|
+
priorSession
|
|
1624
|
+
})}
|
|
1625
|
+
`
|
|
1626
|
+
);
|
|
747
1627
|
}
|
|
748
|
-
function
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
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
|
-
|
|
1635
|
+
mkdirSync2(SESSION_ROOT, { recursive: true });
|
|
1636
|
+
const root = realpathSync(SESSION_ROOT);
|
|
757
1637
|
let resolved;
|
|
758
1638
|
try {
|
|
759
|
-
resolved = realpathSync(
|
|
1639
|
+
resolved = realpathSync(dir);
|
|
760
1640
|
} catch (err) {
|
|
761
|
-
|
|
762
|
-
|
|
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 (
|
|
765
|
-
fail(`worktree
|
|
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"] });
|