@mrc2204/opencode-bridge 0.1.1 → 0.1.3

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.
@@ -1,11 +1,15 @@
1
1
  import {
2
2
  normalizeTypedEventV1,
3
3
  parseSseFramesFromBuffer,
4
- resolveSessionId
5
- } from "./chunk-6NIQKNRA.js";
4
+ resolveSessionId,
5
+ summarizeLifecycle
6
+ } from "./chunk-OVQ5X54C.js";
7
+ import {
8
+ buildTaggedSessionTitle
9
+ } from "./chunk-TDVN5AFB.js";
6
10
 
7
11
  // src/runtime.ts
8
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
12
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
9
13
  import { join } from "path";
10
14
  import { spawn } from "child_process";
11
15
  import { createServer } from "net";
@@ -55,6 +59,7 @@ function getRuntimeConfig(cfg) {
55
59
  return {
56
60
  opencodeServerUrl: fileCfg.opencodeServerUrl || cfg?.opencodeServerUrl,
57
61
  projectRegistry: fileCfg.projectRegistry || cfg?.projectRegistry || [],
62
+ executionAgentMappings: fileCfg.executionAgentMappings || cfg?.executionAgentMappings || [],
58
63
  hookBaseUrl: fileCfg.hookBaseUrl || cfg?.hookBaseUrl,
59
64
  hookToken: fileCfg.hookToken || cfg?.hookToken
60
65
  };
@@ -94,13 +99,66 @@ function findRegistryEntry(cfg, projectId, repoRoot) {
94
99
  }
95
100
  return void 0;
96
101
  }
102
+ function resolveExecutionAgent(input) {
103
+ const requestedAgentId = asString(input.requestedAgentId);
104
+ if (!requestedAgentId) {
105
+ return {
106
+ ok: false,
107
+ requestedAgentId: "",
108
+ mappingConfigured: false,
109
+ error: "requestedAgentId is required"
110
+ };
111
+ }
112
+ const explicitExecutionAgentId = asString(input.explicitExecutionAgentId);
113
+ if (explicitExecutionAgentId) {
114
+ return {
115
+ ok: true,
116
+ requestedAgentId,
117
+ resolvedAgentId: explicitExecutionAgentId,
118
+ strategy: "explicit_param"
119
+ };
120
+ }
121
+ const runtimeCfg = getRuntimeConfig(input.cfg);
122
+ const mappings = asArray(runtimeCfg.executionAgentMappings).map((x) => x && typeof x === "object" ? x : {}).map((x) => ({
123
+ requestedAgentId: asString(x.requestedAgentId),
124
+ executionAgentId: asString(x.executionAgentId)
125
+ })).filter((x) => x.requestedAgentId && x.executionAgentId);
126
+ if (mappings.length > 0) {
127
+ const matched = mappings.find((x) => x.requestedAgentId === requestedAgentId);
128
+ if (!matched) {
129
+ return {
130
+ ok: false,
131
+ requestedAgentId,
132
+ mappingConfigured: true,
133
+ error: `Execution agent mapping is configured but no mapping matched requestedAgentId=${requestedAgentId}`
134
+ };
135
+ }
136
+ return {
137
+ ok: true,
138
+ requestedAgentId,
139
+ resolvedAgentId: matched.executionAgentId,
140
+ strategy: "config_mapping"
141
+ };
142
+ }
143
+ return {
144
+ ok: true,
145
+ requestedAgentId,
146
+ resolvedAgentId: requestedAgentId,
147
+ strategy: "identity"
148
+ };
149
+ }
97
150
  function buildEnvelope(input) {
98
151
  return {
99
152
  task_id: input.taskId,
100
153
  run_id: input.runId,
101
- agent_id: input.agentId,
102
- session_key: buildSessionKey(input.agentId, input.taskId),
154
+ agent_id: input.resolvedAgentId,
155
+ requested_agent_id: input.requestedAgentId,
156
+ resolved_agent_id: input.resolvedAgentId,
157
+ session_key: buildSessionKey(input.resolvedAgentId, input.taskId),
103
158
  origin_session_key: input.originSessionKey,
159
+ ...input.originSessionId ? { origin_session_id: input.originSessionId } : {},
160
+ callback_target_session_key: input.originSessionKey,
161
+ ...input.originSessionId ? { callback_target_session_id: input.originSessionId } : {},
104
162
  project_id: input.projectId,
105
163
  repo_root: input.repoRoot,
106
164
  opencode_server_url: input.serverUrl,
@@ -113,6 +171,7 @@ function buildEnvelope(input) {
113
171
  function mapEventToState(event) {
114
172
  switch (event) {
115
173
  case "task.started":
174
+ return "planning";
116
175
  case "task.progress":
117
176
  return "running";
118
177
  case "permission.requested":
@@ -127,6 +186,30 @@ function mapEventToState(event) {
127
186
  return "running";
128
187
  }
129
188
  }
189
+ function assertCallbackTargetSessionKey(envelope) {
190
+ const callbackTargetSessionKey = asString(envelope?.callback_target_session_key);
191
+ if (!callbackTargetSessionKey) {
192
+ throw new Error("Invalid routing envelope: missing callback_target_session_key (required for current-session callback integrity)");
193
+ }
194
+ return callbackTargetSessionKey;
195
+ }
196
+ function buildHooksAgentCallback(input) {
197
+ const state = mapEventToState(input.event);
198
+ const message = input.summary || `OpenCode event=${input.event} state=${state} task=${input.envelope.task_id} run=${input.envelope.run_id} project=${input.envelope.project_id} requestedAgent=${input.envelope.requested_agent_id} resolvedAgent=${input.envelope.resolved_agent_id}`;
199
+ const callbackTargetSessionKey = assertCallbackTargetSessionKey(input.envelope);
200
+ return {
201
+ message,
202
+ name: "OpenCode",
203
+ // callback should wake requester/origin lane, not execution lane
204
+ agentId: input.envelope.requested_agent_id,
205
+ sessionKey: callbackTargetSessionKey,
206
+ ...input.envelope.callback_target_session_id ? { sessionId: input.envelope.callback_target_session_id } : {},
207
+ wakeMode: "now",
208
+ deliver: false,
209
+ ...input.envelope.channel ? { channel: input.envelope.channel } : {},
210
+ ...input.envelope.to ? { to: input.envelope.to } : {}
211
+ };
212
+ }
130
213
  function evaluateLifecycle(input) {
131
214
  const nowMs = input.nowMs ?? Date.now();
132
215
  const softStallMs = input.softStallMs ?? DEFAULT_SOFT_STALL_MS;
@@ -160,14 +243,46 @@ function evaluateLifecycle(input) {
160
243
  function getRunStateDir() {
161
244
  return join(getBridgeStateDir(), "runs");
162
245
  }
246
+ function writeRunStatus(status) {
247
+ const dir = getRunStateDir();
248
+ mkdirSync(dir, { recursive: true });
249
+ const path = join(dir, `${status.runId}.json`);
250
+ writeFileSync(path, JSON.stringify(status, null, 2), "utf8");
251
+ return path;
252
+ }
163
253
  function readRunStatus(runId) {
164
254
  const path = join(getRunStateDir(), `${runId}.json`);
165
255
  if (!existsSync(path)) return null;
166
256
  return JSON.parse(readFileSync(path, "utf8"));
167
257
  }
258
+ function patchRunStatus(runId, patch) {
259
+ const current = readRunStatus(runId);
260
+ if (!current) return null;
261
+ const next = typeof patch === "function" ? patch(current) : { ...current, ...patch };
262
+ writeRunStatus(next);
263
+ return next;
264
+ }
265
+ function listRunStatuses() {
266
+ const dir = getRunStateDir();
267
+ if (!existsSync(dir)) return [];
268
+ return readdirSync(dir).filter((name) => name.endsWith(".json")).map((name) => {
269
+ try {
270
+ return JSON.parse(readFileSync(join(dir, name), "utf8"));
271
+ } catch {
272
+ return null;
273
+ }
274
+ }).filter(Boolean);
275
+ }
168
276
  function getAuditDir() {
169
277
  return join(getBridgeStateDir(), "audit");
170
278
  }
279
+ function appendAudit(record) {
280
+ const dir = getAuditDir();
281
+ mkdirSync(dir, { recursive: true });
282
+ const path = join(dir, "callbacks.jsonl");
283
+ writeFileSync(path, JSON.stringify(record) + "\n", { encoding: "utf8", flag: "a" });
284
+ return path;
285
+ }
171
286
  function getServeRegistryPath() {
172
287
  return join(getBridgeStateDir(), "registry.json");
173
288
  }
@@ -247,16 +362,43 @@ async function waitForHealth(serverUrl, timeoutMs = 1e4) {
247
362
  }
248
363
  return false;
249
364
  }
365
+ async function fetchServeProjectBinding(serverUrl) {
366
+ const normalizedUrl = serverUrl.replace(/\/$/, "");
367
+ const [projectRes, pathRes] = await Promise.all([
368
+ fetchJsonSafe(`${normalizedUrl}/project/current`),
369
+ fetchJsonSafe(`${normalizedUrl}/path`)
370
+ ]);
371
+ const project = projectRes.ok ? projectRes.data : void 0;
372
+ const path = pathRes.ok ? pathRes.data : void 0;
373
+ const directory = asString(project?.directory) || asString(project?.path) || asString(path?.directory) || asString(path?.cwd) || asString(path?.path);
374
+ if (directory) {
375
+ return { ok: true, directory, project, path };
376
+ }
377
+ return {
378
+ ok: false,
379
+ project,
380
+ path,
381
+ error: projectRes.error || pathRes.error || "Could not determine serve project binding"
382
+ };
383
+ }
384
+ async function isServeBoundToRepo(serverUrl, repoRoot) {
385
+ const binding = await fetchServeProjectBinding(serverUrl);
386
+ return binding.ok && binding.directory === repoRoot;
387
+ }
250
388
  async function spawnServeForProject(input) {
251
389
  const existing = normalizeServeRegistry(readServeRegistry()).entries.find((x) => x.project_id === input.project_id || x.repo_root === input.repo_root);
252
390
  if (existing && existing.status === "running") {
253
391
  const healthy2 = await waitForHealth(existing.opencode_server_url, 2e3);
254
392
  if (healthy2) {
255
- return { reused: true, entry: existing, registryPath: getServeRegistryPath() };
393
+ const boundToRepo = await isServeBoundToRepo(existing.opencode_server_url, input.repo_root);
394
+ if (boundToRepo) {
395
+ return { reused: true, entry: existing, registryPath: getServeRegistryPath() };
396
+ }
256
397
  }
257
398
  }
258
399
  const port = await allocatePort();
259
400
  const child = spawn("opencode", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
401
+ cwd: input.repo_root,
260
402
  detached: true,
261
403
  stdio: "ignore"
262
404
  });
@@ -314,12 +456,20 @@ async function fetchJsonSafe(url) {
314
456
  }
315
457
  function resolveSessionForRun(input) {
316
458
  const artifactEnvelope = input.runStatus?.envelope;
459
+ const callbackTargetSessionId = typeof artifactEnvelope?.callback_target_session_id === "string" ? artifactEnvelope.callback_target_session_id : void 0;
460
+ const originSessionId = typeof artifactEnvelope?.origin_session_id === "string" ? artifactEnvelope.origin_session_id : void 0;
461
+ const callbackTargetSessionKey = typeof artifactEnvelope?.callback_target_session_key === "string" ? artifactEnvelope.callback_target_session_key : void 0;
462
+ const originSessionKey = typeof artifactEnvelope?.origin_session_key === "string" ? artifactEnvelope.origin_session_key : void 0;
463
+ const executionSessionKey = typeof artifactEnvelope?.session_key === "string" ? artifactEnvelope.session_key : void 0;
464
+ const artifactSessionId = callbackTargetSessionId || originSessionId || (typeof artifactEnvelope?.session_id === "string" ? artifactEnvelope.session_id : void 0) || (typeof artifactEnvelope?.sessionId === "string" ? artifactEnvelope.sessionId : void 0);
465
+ const artifactSessionKey = callbackTargetSessionKey || originSessionKey || executionSessionKey;
466
+ const hasCallerSessionKey = Boolean(callbackTargetSessionKey || originSessionKey);
317
467
  const resolved = resolveSessionId({
318
468
  explicitSessionId: input.sessionId,
319
- runId: input.runId || input.runStatus?.runId,
320
- taskId: input.runStatus?.taskId,
321
- sessionKey: artifactEnvelope?.session_key,
322
- artifactSessionId: (typeof artifactEnvelope?.session_id === "string" ? artifactEnvelope.session_id : void 0) || (typeof artifactEnvelope?.sessionId === "string" ? artifactEnvelope.sessionId : void 0),
469
+ runId: hasCallerSessionKey ? void 0 : input.runId || input.runStatus?.runId,
470
+ taskId: hasCallerSessionKey ? void 0 : input.runStatus?.taskId,
471
+ sessionKey: artifactSessionKey,
472
+ artifactSessionId,
323
473
  sessionList: input.sessionList
324
474
  });
325
475
  return { sessionId: resolved.sessionId, strategy: resolved.strategy, ...resolved.score !== void 0 ? { score: resolved.score } : {} };
@@ -355,6 +505,11 @@ async function collectSseEvents(serverUrl, scope, options) {
355
505
  data: typed.payload,
356
506
  normalizedKind: typed.kind,
357
507
  summary: typed.summary,
508
+ lifecycle_state: typed.lifecycleState,
509
+ files_changed: typed.filesChanged,
510
+ verify_summary: typed.verifySummary,
511
+ blockers: typed.blockers,
512
+ completion_summary: typed.completionSummary,
358
513
  runId: typed.runId || options?.runIdHint,
359
514
  taskId: typed.taskId || options?.taskIdHint,
360
515
  sessionId: typed.sessionId || options?.sessionIdHint,
@@ -377,6 +532,11 @@ async function collectSseEvents(serverUrl, scope, options) {
377
532
  data: typed.payload,
378
533
  normalizedKind: typed.kind,
379
534
  summary: typed.summary,
535
+ lifecycle_state: typed.lifecycleState,
536
+ files_changed: typed.filesChanged,
537
+ verify_summary: typed.verifySummary,
538
+ blockers: typed.blockers,
539
+ completion_summary: typed.completionSummary,
380
540
  runId: typed.runId || options?.runIdHint,
381
541
  taskId: typed.taskId || options?.taskIdHint,
382
542
  sessionId: typed.sessionId || options?.sessionIdHint,
@@ -401,9 +561,275 @@ async function collectSseEvents(serverUrl, scope, options) {
401
561
  clearTimeout(timeout);
402
562
  }
403
563
  }
564
+ async function executeHooksAgentCallback(hookBaseUrl, hookToken, callback) {
565
+ const response = await fetch(`${hookBaseUrl.replace(/\/$/, "")}/hooks/agent`, {
566
+ method: "POST",
567
+ headers: {
568
+ Authorization: `Bearer ${hookToken}`,
569
+ "Content-Type": "application/json"
570
+ },
571
+ body: JSON.stringify(callback)
572
+ });
573
+ const text = await response.text();
574
+ return {
575
+ ok: response.ok,
576
+ status: response.status,
577
+ body: text
578
+ };
579
+ }
580
+ function coerceLifecycleState(value) {
581
+ if (!value) return "running";
582
+ if (value === "queued" || value === "server_ready" || value === "session_created" || value === "prompt_sent" || value === "planning" || value === "coding" || value === "verifying" || value === "blocked" || value === "running" || value === "awaiting_permission" || value === "stalled" || value === "failed" || value === "completed") {
583
+ return value;
584
+ }
585
+ return "running";
586
+ }
587
+ function isTerminalLifecycleState(state) {
588
+ return state === "completed" || state === "failed";
589
+ }
590
+ function buildTerminalEventFromState(state, fallback) {
591
+ if (state === "completed") return "task.completed";
592
+ if (state === "failed") return "task.failed";
593
+ return fallback || "task.progress";
594
+ }
595
+ async function createSessionForEnvelope(envelope) {
596
+ const serverUrl = envelope.opencode_server_url.replace(/\/$/, "");
597
+ const taggedTitle = buildTaggedSessionTitle({
598
+ runId: envelope.run_id,
599
+ taskId: envelope.task_id,
600
+ requested: envelope.requested_agent_id,
601
+ resolved: envelope.resolved_agent_id,
602
+ callbackSession: envelope.callback_target_session_key,
603
+ callbackSessionId: envelope.callback_target_session_id,
604
+ projectId: envelope.project_id,
605
+ repoRoot: envelope.repo_root
606
+ });
607
+ const createdRes = await fetch(`${serverUrl}/session`, {
608
+ method: "POST",
609
+ headers: { "Content-Type": "application/json" },
610
+ body: JSON.stringify({ title: taggedTitle })
611
+ });
612
+ const createdText = await createdRes.text();
613
+ let created;
614
+ try {
615
+ created = createdText ? JSON.parse(createdText) : null;
616
+ } catch {
617
+ created = { raw: createdText };
618
+ }
619
+ if (!createdRes.ok) {
620
+ throw new Error(`session create failed: ${createdRes.status} ${createdText}`);
621
+ }
622
+ const sessionId = asString(created?.id);
623
+ if (!sessionId) {
624
+ throw new Error("session create failed: missing session id");
625
+ }
626
+ return { sessionId, created };
627
+ }
628
+ async function sendPromptAsyncForEnvelope(envelope, sessionId, prompt) {
629
+ const serverUrl = envelope.opencode_server_url.replace(/\/$/, "");
630
+ const response = await fetch(`${serverUrl}/session/${sessionId}/prompt_async`, {
631
+ method: "POST",
632
+ headers: { "Content-Type": "application/json" },
633
+ body: JSON.stringify({
634
+ agent: envelope.resolved_agent_id,
635
+ parts: [{ type: "text", text: prompt }]
636
+ })
637
+ });
638
+ const text = await response.text();
639
+ if (!response.ok) {
640
+ throw new Error(`prompt_async failed: ${response.status} ${text}`);
641
+ }
642
+ }
643
+ async function maybeSendTerminalCallback(input) {
644
+ const runtimeCfg = getRuntimeConfig(input.cfg);
645
+ if (!runtimeCfg.hookBaseUrl || !runtimeCfg.hookToken) {
646
+ const updated = patchRunStatus(input.runStatus.runId, (current) => ({
647
+ ...current,
648
+ callbackError: "hook config missing",
649
+ callbackAttempts: (current.callbackAttempts || 0) + 1,
650
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
651
+ }));
652
+ return { ok: false, skipped: true, reason: "hook_config_missing", runStatus: updated || input.runStatus };
653
+ }
654
+ if (input.runStatus.callbackSentAt) {
655
+ return { ok: true, skipped: true, reason: "already_sent", runStatus: input.runStatus };
656
+ }
657
+ const callback = buildHooksAgentCallback({
658
+ event: input.event,
659
+ envelope: input.runStatus.envelope,
660
+ summary: input.summary || input.runStatus.lastSummary
661
+ });
662
+ const attempts = (input.runStatus.callbackAttempts || 0) + 1;
663
+ try {
664
+ const result = await executeHooksAgentCallback(runtimeCfg.hookBaseUrl, runtimeCfg.hookToken, callback);
665
+ const createdAt = (/* @__PURE__ */ new Date()).toISOString();
666
+ appendAudit({
667
+ taskId: input.runStatus.taskId,
668
+ runId: input.runStatus.runId,
669
+ agentId: callback.agentId,
670
+ requestedAgentId: input.runStatus.envelope.requested_agent_id,
671
+ resolvedAgentId: input.runStatus.envelope.resolved_agent_id,
672
+ sessionKey: input.runStatus.envelope.session_key,
673
+ callbackTargetSessionKey: input.runStatus.envelope.callback_target_session_key,
674
+ callbackTargetSessionId: input.runStatus.envelope.callback_target_session_id,
675
+ event: input.event,
676
+ callbackStatus: result.status,
677
+ callbackOk: result.ok,
678
+ callbackBody: result.body,
679
+ createdAt
680
+ });
681
+ const updated = patchRunStatus(input.runStatus.runId, (current) => ({
682
+ ...current,
683
+ callbackAttempts: attempts,
684
+ callbackSentAt: result.ok ? createdAt : current.callbackSentAt,
685
+ callbackStatus: result.status,
686
+ callbackOk: result.ok,
687
+ callbackBody: result.body,
688
+ callbackError: result.ok ? void 0 : `hook returned status=${result.status}`,
689
+ updatedAt: createdAt
690
+ }));
691
+ return { ok: result.ok, skipped: false, callback, result, runStatus: updated || input.runStatus };
692
+ } catch (error) {
693
+ const message = error?.message || String(error);
694
+ const updated = patchRunStatus(input.runStatus.runId, (current) => ({
695
+ ...current,
696
+ callbackAttempts: attempts,
697
+ callbackError: message,
698
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
699
+ }));
700
+ return { ok: false, skipped: false, error: message, runStatus: updated || input.runStatus };
701
+ }
702
+ }
703
+ async function watchRunToTerminal(input) {
704
+ const pollIntervalMs = Math.max(250, asNumber(input.pollIntervalMs) || 1e3);
705
+ const maxWaitMs = Math.max(1e3, asNumber(input.maxWaitMs) || 5 * 60 * 1e3);
706
+ const startedAt = Date.now();
707
+ let current = patchRunStatus(input.runId, (status) => ({
708
+ ...status,
709
+ watcherStartedAt: status.watcherStartedAt || (/* @__PURE__ */ new Date()).toISOString(),
710
+ watcherHeartbeatAt: (/* @__PURE__ */ new Date()).toISOString(),
711
+ watcherState: "active",
712
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
713
+ }));
714
+ if (!current) {
715
+ throw new Error(`Run status not found for runId=${input.runId}`);
716
+ }
717
+ while (Date.now() - startedAt <= maxWaitMs) {
718
+ current = readRunStatus(input.runId);
719
+ if (!current) throw new Error(`Run status disappeared for runId=${input.runId}`);
720
+ const events = await collectSseEvents(current.envelope.opencode_server_url, "global", {
721
+ limit: DEFAULT_EVENT_LIMIT,
722
+ timeoutMs: Math.max(200, pollIntervalMs),
723
+ runIdHint: current.runId,
724
+ taskIdHint: current.taskId,
725
+ sessionIdHint: current.sessionId
726
+ });
727
+ if (events.length > 0) {
728
+ const summary = summarizeLifecycle(
729
+ events.map((event) => ({
730
+ kind: event.normalizedKind,
731
+ summary: event.summary,
732
+ lifecycleState: event.lifecycle_state,
733
+ filesChanged: event.files_changed,
734
+ verifySummary: event.verify_summary,
735
+ blockers: event.blockers,
736
+ completionSummary: event.completion_summary,
737
+ timestamp: event.timestamp
738
+ }))
739
+ );
740
+ current = patchRunStatus(input.runId, (status) => ({
741
+ ...status,
742
+ state: coerceLifecycleState(summary.currentState),
743
+ lastEvent: summary.last_event_kind || status.lastEvent || null,
744
+ lastSummary: summary.completion_summary || events[events.length - 1]?.summary || status.lastSummary,
745
+ sessionId: status.sessionId || events.find((event) => event.sessionId)?.sessionId,
746
+ watcherHeartbeatAt: (/* @__PURE__ */ new Date()).toISOString(),
747
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
748
+ })) || current;
749
+ } else {
750
+ current = patchRunStatus(input.runId, (status) => ({
751
+ ...status,
752
+ watcherHeartbeatAt: (/* @__PURE__ */ new Date()).toISOString(),
753
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
754
+ })) || current;
755
+ }
756
+ if (isTerminalLifecycleState(current.state)) {
757
+ const callback = await maybeSendTerminalCallback({
758
+ cfg: input.cfg,
759
+ runStatus: current,
760
+ event: buildTerminalEventFromState(current.state, current.lastEvent),
761
+ summary: current.lastSummary
762
+ });
763
+ current = patchRunStatus(input.runId, (status) => ({
764
+ ...status,
765
+ watcherCompletedAt: (/* @__PURE__ */ new Date()).toISOString(),
766
+ watcherState: callback.ok ? "completed" : "failed",
767
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
768
+ })) || current;
769
+ return { ok: callback.ok, terminal: true, runStatus: current, callback };
770
+ }
771
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
772
+ }
773
+ current = patchRunStatus(input.runId, (status) => ({
774
+ ...status,
775
+ watcherCompletedAt: (/* @__PURE__ */ new Date()).toISOString(),
776
+ watcherState: "failed",
777
+ callbackError: status.callbackError || "watcher_timeout",
778
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
779
+ })) || current;
780
+ return { ok: false, terminal: false, timeout: true, runStatus: current };
781
+ }
782
+ async function startExecutionRun(input) {
783
+ const initial = {
784
+ taskId: input.envelope.task_id,
785
+ runId: input.envelope.run_id,
786
+ state: "queued",
787
+ lastEvent: null,
788
+ lastSummary: void 0,
789
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
790
+ envelope: input.envelope,
791
+ watcherState: "pending"
792
+ };
793
+ writeRunStatus(initial);
794
+ const session = await createSessionForEnvelope(input.envelope);
795
+ let current = patchRunStatus(input.envelope.run_id, (status) => ({
796
+ ...status,
797
+ sessionId: session.sessionId,
798
+ state: "session_created",
799
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
800
+ })) || initial;
801
+ await sendPromptAsyncForEnvelope(input.envelope, session.sessionId, input.prompt);
802
+ current = patchRunStatus(input.envelope.run_id, (status) => ({
803
+ ...status,
804
+ state: "prompt_sent",
805
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
806
+ })) || current;
807
+ void watchRunToTerminal({
808
+ cfg: input.cfg,
809
+ runId: input.envelope.run_id,
810
+ pollIntervalMs: input.pollIntervalMs,
811
+ maxWaitMs: input.maxWaitMs
812
+ }).catch((error) => {
813
+ patchRunStatus(input.envelope.run_id, (status) => ({
814
+ ...status,
815
+ watcherCompletedAt: (/* @__PURE__ */ new Date()).toISOString(),
816
+ watcherState: "failed",
817
+ callbackError: status.callbackError || error?.message || String(error),
818
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
819
+ }));
820
+ });
821
+ return {
822
+ ok: true,
823
+ runId: input.envelope.run_id,
824
+ taskId: input.envelope.task_id,
825
+ sessionId: session.sessionId,
826
+ state: current.state,
827
+ watcherStarted: true
828
+ };
829
+ }
404
830
  function buildHookPolicyChecklist(agentId, sessionKey) {
405
831
  return {
406
- callbackPrimary: "/hooks/agent",
832
+ primaryCallbackPath: "/hooks/agent",
407
833
  requirements: {
408
834
  hooksEnabled: true,
409
835
  allowRequestSessionKey: true,
@@ -424,10 +850,30 @@ function buildHookPolicyChecklist(agentId, sessionKey) {
424
850
  }
425
851
 
426
852
  // src/registrar.ts
853
+ var PLUGIN_VERSION = "0.1.3";
854
+ function buildExecutionPrompt(params, envelope) {
855
+ const providedPrompt = asString(params?.prompt);
856
+ if (providedPrompt) return providedPrompt;
857
+ const objective = asString(params?.objective) || asString(params?.message) || `Complete task ${envelope.task_id}`;
858
+ const constraints = Array.isArray(params?.constraints) ? params.constraints.filter((x) => typeof x === "string" && x.trim()) : [];
859
+ const acceptance = Array.isArray(params?.acceptanceCriteria) ? params.acceptanceCriteria.filter((x) => typeof x === "string" && x.trim()) : [];
860
+ return [
861
+ `Task: ${objective}`,
862
+ `Run ID: ${envelope.run_id}`,
863
+ `Task ID: ${envelope.task_id}`,
864
+ `Requested agent: ${envelope.requested_agent_id}`,
865
+ `Resolved execution agent: ${envelope.resolved_agent_id}`,
866
+ constraints.length ? `Constraints:
867
+ - ${constraints.join("\n- ")}` : void 0,
868
+ acceptance.length ? `Acceptance criteria:
869
+ - ${acceptance.join("\n- ")}` : void 0,
870
+ "When complete, summarize files changed, verification performed, blockers (if any), and completion outcome in the session output."
871
+ ].filter(Boolean).join("\n\n");
872
+ }
427
873
  function registerOpenCodeBridgeTools(api, cfg) {
428
- console.log("[opencode-bridge] scaffold loaded");
874
+ console.log("[opencode-bridge] plugin loaded");
429
875
  console.log(`[opencode-bridge] opencodeServerUrl=${cfg.opencodeServerUrl || "(unset)"}`);
430
- console.log("[opencode-bridge] registering opencode_* tool set");
876
+ console.log("[opencode-bridge] registering opencode_* tools");
431
877
  api.registerTool({
432
878
  name: "opencode_status",
433
879
  label: "OpenCode Status",
@@ -437,7 +883,7 @@ function registerOpenCodeBridgeTools(api, cfg) {
437
883
  const runtimeCfg = getRuntimeConfig(cfg);
438
884
  const registry = normalizeRegistry(runtimeCfg.projectRegistry);
439
885
  return {
440
- content: [{ type: "text", text: JSON.stringify({ ok: true, pluginId: "opencode-bridge", version: "0.1.0", assumption: "1 project = 1 opencode serve instance", sessionKeyConvention: "hook:opencode:<agentId>:<taskId>", lifecycleStates: ["queued", "server_ready", "session_created", "prompt_sent", "running", "awaiting_permission", "stalled", "failed", "completed"], requiredEnvelopeFields: ["task_id", "run_id", "agent_id", "session_key", "origin_session_key", "project_id", "repo_root", "opencode_server_url"], callbackPrimary: "/hooks/agent", callbackNotPrimary: ["/hooks/wake", "cron", "group:sessions"], config: { bridgeConfigPath: getBridgeConfigPath(), opencodeServerUrl: runtimeCfg.opencodeServerUrl || null, hookBaseUrl: runtimeCfg.hookBaseUrl || null, hookTokenPresent: Boolean(runtimeCfg.hookToken), projectRegistry: registry, stateDir: getBridgeStateDir(), runStateDir: getRunStateDir(), auditDir: getAuditDir() }, note: "Runtime-ops scaffold in progress. Plugin-owned config/state is stored under ~/.openclaw/opencode-bridge. New projects are auto-registered only when using opencode_serve_spawn (not by passive envelope build alone)." }, null, 2) }]
886
+ content: [{ type: "text", text: JSON.stringify({ ok: true, pluginId: "opencode-bridge", version: PLUGIN_VERSION, assumption: "1 project = 1 opencode serve instance", sessionKeyConvention: "hook:opencode:<agentId>:<taskId>", lifecycleStates: ["queued", "server_ready", "session_created", "prompt_sent", "planning", "coding", "verifying", "blocked", "running", "awaiting_permission", "stalled", "failed", "completed"], requiredEnvelopeFields: ["task_id", "run_id", "agent_id", "requested_agent_id", "resolved_agent_id", "session_key", "origin_session_key", "callback_target_session_key", "project_id", "repo_root", "opencode_server_url"], primaryCallbackPath: "/hooks/agent", alternativeSignalPaths: ["/hooks/wake", "cron", "group:sessions"], config: { bridgeConfigPath: getBridgeConfigPath(), opencodeServerUrl: runtimeCfg.opencodeServerUrl || null, hookBaseUrl: runtimeCfg.hookBaseUrl || null, hookTokenPresent: Boolean(runtimeCfg.hookToken), projectRegistry: registry, executionAgentMappings: runtimeCfg.executionAgentMappings || [], stateDir: getBridgeStateDir(), runStateDir: getRunStateDir(), auditDir: getAuditDir() }, note: "Plugin-owned config/state is stored under ~/.openclaw/opencode-bridge. New projects are auto-registered only when using opencode_serve_spawn, not by passive envelope build alone." }, null, 2) }]
441
887
  };
442
888
  }
443
889
  }, { optional: true });
@@ -455,7 +901,7 @@ function registerOpenCodeBridgeTools(api, cfg) {
455
901
  name: "opencode_build_envelope",
456
902
  label: "OpenCode Build Envelope",
457
903
  description: "D\u1EF1ng routing envelope chu\u1EA9n cho task delegate sang OpenCode v\u1EDBi sessionKey convention hook:opencode:<agentId>:<taskId>.",
458
- parameters: { type: "object", properties: { taskId: { type: "string" }, runId: { type: "string" }, agentId: { type: "string" }, originSessionKey: { type: "string" }, projectId: { type: "string" }, repoRoot: { type: "string" }, channel: { type: "string" }, to: { type: "string" }, deliver: { type: "boolean" }, priority: { type: "string" } }, required: ["taskId", "runId", "agentId", "originSessionKey", "projectId", "repoRoot"] },
904
+ parameters: { type: "object", properties: { taskId: { type: "string" }, runId: { type: "string" }, agentId: { type: "string", description: "Requester/origin agent id" }, executionAgentId: { type: "string", description: "Optional explicit OpenCode execution agent id" }, originSessionKey: { type: "string" }, originSessionId: { type: "string" }, projectId: { type: "string" }, repoRoot: { type: "string" }, channel: { type: "string" }, to: { type: "string" }, deliver: { type: "boolean" }, priority: { type: "string" } }, required: ["taskId", "runId", "agentId", "originSessionKey", "projectId", "repoRoot"] },
459
905
  async execute(_id, params) {
460
906
  const entry = findRegistryEntry(cfg, params?.projectId, params?.repoRoot);
461
907
  const serverUrl = entry?.serverUrl;
@@ -465,11 +911,25 @@ function registerOpenCodeBridgeTools(api, cfg) {
465
911
  content: [{ type: "text", text: JSON.stringify({ ok: false, error: "Missing project registry mapping. Use opencode_serve_spawn for the project or add a matching projectRegistry entry in ~/.openclaw/opencode-bridge/config.json first." }, null, 2) }]
466
912
  };
467
913
  }
914
+ const requestedAgentId = asString(params?.agentId);
915
+ const resolved = resolveExecutionAgent({
916
+ cfg,
917
+ requestedAgentId: requestedAgentId || "",
918
+ explicitExecutionAgentId: asString(params?.executionAgentId)
919
+ });
920
+ if (!resolved.ok) {
921
+ return {
922
+ isError: true,
923
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: resolved.error, requestedAgentId: resolved.requestedAgentId, mappingConfigured: resolved.mappingConfigured }, null, 2) }]
924
+ };
925
+ }
468
926
  const envelope = buildEnvelope({
469
927
  taskId: params.taskId,
470
928
  runId: params.runId,
471
- agentId: params.agentId,
929
+ requestedAgentId: resolved.requestedAgentId,
930
+ resolvedAgentId: resolved.resolvedAgentId,
472
931
  originSessionKey: params.originSessionKey,
932
+ originSessionId: asString(params.originSessionId),
473
933
  projectId: params.projectId,
474
934
  repoRoot: params.repoRoot,
475
935
  serverUrl,
@@ -479,7 +939,7 @@ function registerOpenCodeBridgeTools(api, cfg) {
479
939
  priority: params.priority
480
940
  });
481
941
  return {
482
- content: [{ type: "text", text: JSON.stringify({ ok: true, envelope, registryMatch: entry || null }, null, 2) }]
942
+ content: [{ type: "text", text: JSON.stringify({ ok: true, envelope, agentResolution: resolved, registryMatch: entry || null }, null, 2) }]
483
943
  };
484
944
  }
485
945
  }, { optional: true });
@@ -493,6 +953,100 @@ function registerOpenCodeBridgeTools(api, cfg) {
493
953
  return { content: [{ type: "text", text: JSON.stringify({ ok: true, checklist }, null, 2) }] };
494
954
  }
495
955
  }, { optional: true });
956
+ api.registerTool({
957
+ name: "opencode_execute_task",
958
+ label: "OpenCode Execute Task",
959
+ description: "Execution entrypoint: resolve/spawn serve, create session, send prompt async, start SSE-driven watcher, callback once v\u1EC1 /hooks/agent khi terminal.",
960
+ parameters: {
961
+ type: "object",
962
+ properties: {
963
+ taskId: { type: "string" },
964
+ runId: { type: "string" },
965
+ agentId: { type: "string", description: "Requester/origin agent id" },
966
+ executionAgentId: { type: "string" },
967
+ originSessionKey: { type: "string" },
968
+ originSessionId: { type: "string" },
969
+ projectId: { type: "string" },
970
+ repoRoot: { type: "string" },
971
+ prompt: { type: "string" },
972
+ objective: { type: "string" },
973
+ message: { type: "string" },
974
+ constraints: { type: "array", items: { type: "string" } },
975
+ acceptanceCriteria: { type: "array", items: { type: "string" } },
976
+ channel: { type: "string" },
977
+ to: { type: "string" },
978
+ deliver: { type: "boolean" },
979
+ priority: { type: "string" },
980
+ idleTimeoutMs: { type: "number" },
981
+ pollIntervalMs: { type: "number" },
982
+ maxWaitMs: { type: "number" }
983
+ },
984
+ required: ["taskId", "runId", "agentId", "originSessionKey", "projectId", "repoRoot"]
985
+ },
986
+ async execute(_id, params) {
987
+ const resolved = resolveExecutionAgent({
988
+ cfg,
989
+ requestedAgentId: asString(params?.agentId) || "",
990
+ explicitExecutionAgentId: asString(params?.executionAgentId)
991
+ });
992
+ if (!resolved.ok) {
993
+ return {
994
+ isError: true,
995
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: resolved.error, requestedAgentId: resolved.requestedAgentId, mappingConfigured: resolved.mappingConfigured }, null, 2) }]
996
+ };
997
+ }
998
+ const spawned = await spawnServeForProject({
999
+ project_id: params.projectId,
1000
+ repo_root: params.repoRoot,
1001
+ idle_timeout_ms: asNumber(params.idleTimeoutMs)
1002
+ });
1003
+ const serverUrl = spawned.entry.opencode_server_url;
1004
+ const envelope = buildEnvelope({
1005
+ taskId: params.taskId,
1006
+ runId: params.runId,
1007
+ requestedAgentId: resolved.requestedAgentId,
1008
+ resolvedAgentId: resolved.resolvedAgentId,
1009
+ originSessionKey: params.originSessionKey,
1010
+ originSessionId: asString(params.originSessionId),
1011
+ projectId: params.projectId,
1012
+ repoRoot: params.repoRoot,
1013
+ serverUrl,
1014
+ channel: params.channel,
1015
+ to: params.to,
1016
+ deliver: params.deliver,
1017
+ priority: params.priority
1018
+ });
1019
+ const prompt = buildExecutionPrompt(params, envelope);
1020
+ const execution = await startExecutionRun({
1021
+ cfg,
1022
+ envelope,
1023
+ prompt,
1024
+ pollIntervalMs: asNumber(params.pollIntervalMs),
1025
+ maxWaitMs: asNumber(params.maxWaitMs)
1026
+ });
1027
+ const snapshot = listRunStatuses().find((item) => item.runId === params.runId) || null;
1028
+ return {
1029
+ content: [{
1030
+ type: "text",
1031
+ text: JSON.stringify({
1032
+ ok: true,
1033
+ spawned,
1034
+ envelope,
1035
+ prompt,
1036
+ execution: {
1037
+ ok: execution.ok,
1038
+ runId: execution.runId,
1039
+ taskId: execution.taskId,
1040
+ sessionId: execution.sessionId,
1041
+ state: execution.state,
1042
+ watcherStarted: execution.watcherStarted
1043
+ },
1044
+ runStatus: snapshot
1045
+ }, null, 2)
1046
+ }]
1047
+ };
1048
+ }
1049
+ }, { optional: true });
496
1050
  api.registerTool({
497
1051
  name: "opencode_evaluate_lifecycle",
498
1052
  label: "OpenCode Evaluate Lifecycle",
@@ -532,11 +1086,31 @@ function registerOpenCodeBridgeTools(api, cfg) {
532
1086
  runId
533
1087
  });
534
1088
  const sessionId = resolution.sessionId;
535
- const state = artifact?.state || (sessionId ? "running" : "queued");
1089
+ const eventScope = sessionId ? "session" : "global";
1090
+ const events = await collectSseEvents(serverUrl, eventScope, {
1091
+ limit: DEFAULT_EVENT_LIMIT,
1092
+ timeoutMs: DEFAULT_OBS_TIMEOUT_MS,
1093
+ runIdHint: runId,
1094
+ taskIdHint: artifact?.taskId,
1095
+ sessionIdHint: sessionId
1096
+ });
1097
+ const lifecycleSummary = summarizeLifecycle(
1098
+ events.map((event) => ({
1099
+ kind: event.normalizedKind,
1100
+ summary: event.summary,
1101
+ lifecycleState: event.lifecycle_state,
1102
+ filesChanged: event.files_changed,
1103
+ verifySummary: event.verify_summary,
1104
+ blockers: event.blockers,
1105
+ completionSummary: event.completion_summary,
1106
+ timestamp: event.timestamp
1107
+ }))
1108
+ );
1109
+ const state = lifecycleSummary.currentState || artifact?.state || (sessionId ? "running" : "queued");
536
1110
  const response = {
537
1111
  ok: true,
538
1112
  source: {
539
- runStatusArtifact: Boolean(artifact),
1113
+ runArtifact: Boolean(artifact),
540
1114
  opencodeApi: true
541
1115
  },
542
1116
  runId: runId || void 0,
@@ -550,8 +1124,16 @@ function registerOpenCodeBridgeTools(api, cfg) {
550
1124
  }
551
1125
  },
552
1126
  state,
1127
+ currentState: lifecycleSummary.currentState || state,
1128
+ current_state: lifecycleSummary.currentState || state,
553
1129
  lastEvent: artifact?.lastEvent,
1130
+ last_event_kind: artifact?.lastEvent,
554
1131
  lastSummary: artifact?.lastSummary,
1132
+ last_event_at: artifact?.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
1133
+ files_changed: lifecycleSummary.files_changed,
1134
+ verify_summary: lifecycleSummary.verify_summary,
1135
+ blockers: lifecycleSummary.blockers,
1136
+ completion_summary: lifecycleSummary.completion_summary || artifact?.lastSummary || null,
555
1137
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
556
1138
  timestamps: {
557
1139
  ...artifact?.updatedAt ? { artifactUpdatedAt: artifact.updatedAt } : {},
@@ -799,16 +1381,17 @@ function registerOpenCodeBridgeTools(api, cfg) {
799
1381
  }
800
1382
 
801
1383
  // src/index.ts
1384
+ var PLUGIN_VERSION2 = "0.1.3";
802
1385
  var plugin = {
803
1386
  id: "opencode-bridge",
804
1387
  name: "OpenCode Bridge",
805
- version: "0.1.0",
1388
+ version: PLUGIN_VERSION2,
806
1389
  register(api) {
807
1390
  const cfg = api?.pluginConfig || {};
808
1391
  registerOpenCodeBridgeTools(api, cfg);
809
1392
  }
810
1393
  };
811
- var index_default = plugin;
1394
+ var src_default = plugin;
812
1395
  export {
813
- index_default as default
1396
+ src_default as default
814
1397
  };