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 +926 -35
- package/dist/opencara-mcp.js +467 -0
- package/package.json +11 -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,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 =
|
|
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.
|
|
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 {
|
|
694
|
-
|
|
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
|
|
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
|
-
|
|
730
|
-
if (
|
|
731
|
-
|
|
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
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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
|
-
|
|
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(
|
|
746
|
-
|
|
1596
|
+
process.stdout.write(
|
|
1597
|
+
`${JSON.stringify({
|
|
1598
|
+
workdir: checkoutDir,
|
|
1599
|
+
branch,
|
|
1600
|
+
sessionDir,
|
|
1601
|
+
priorSession
|
|
1602
|
+
})}
|
|
1603
|
+
`
|
|
1604
|
+
);
|
|
747
1605
|
}
|
|
748
|
-
function
|
|
749
|
-
const
|
|
750
|
-
|
|
751
|
-
|
|
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
|
-
|
|
754
|
-
|
|
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(
|
|
1617
|
+
resolved = realpathSync(dir);
|
|
760
1618
|
} catch (err) {
|
|
761
|
-
|
|
762
|
-
|
|
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 (
|
|
765
|
-
fail(
|
|
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"] });
|