@mrc2204/opencode-bridge 0.1.2 → 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.
@@ -3,10 +3,13 @@ import {
3
3
  parseSseFramesFromBuffer,
4
4
  resolveSessionId,
5
5
  summarizeLifecycle
6
- } from "./chunk-LCJRXKI3.js";
6
+ } from "./chunk-OVQ5X54C.js";
7
+ import {
8
+ buildTaggedSessionTitle
9
+ } from "./chunk-TDVN5AFB.js";
7
10
 
8
11
  // src/runtime.ts
9
- import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
12
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, readdirSync } from "fs";
10
13
  import { join } from "path";
11
14
  import { spawn } from "child_process";
12
15
  import { createServer } from "net";
@@ -183,6 +186,30 @@ function mapEventToState(event) {
183
186
  return "running";
184
187
  }
185
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
+ }
186
213
  function evaluateLifecycle(input) {
187
214
  const nowMs = input.nowMs ?? Date.now();
188
215
  const softStallMs = input.softStallMs ?? DEFAULT_SOFT_STALL_MS;
@@ -216,14 +243,46 @@ function evaluateLifecycle(input) {
216
243
  function getRunStateDir() {
217
244
  return join(getBridgeStateDir(), "runs");
218
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
+ }
219
253
  function readRunStatus(runId) {
220
254
  const path = join(getRunStateDir(), `${runId}.json`);
221
255
  if (!existsSync(path)) return null;
222
256
  return JSON.parse(readFileSync(path, "utf8"));
223
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
+ }
224
276
  function getAuditDir() {
225
277
  return join(getBridgeStateDir(), "audit");
226
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
+ }
227
286
  function getServeRegistryPath() {
228
287
  return join(getBridgeStateDir(), "registry.json");
229
288
  }
@@ -303,16 +362,43 @@ async function waitForHealth(serverUrl, timeoutMs = 1e4) {
303
362
  }
304
363
  return false;
305
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
+ }
306
388
  async function spawnServeForProject(input) {
307
389
  const existing = normalizeServeRegistry(readServeRegistry()).entries.find((x) => x.project_id === input.project_id || x.repo_root === input.repo_root);
308
390
  if (existing && existing.status === "running") {
309
391
  const healthy2 = await waitForHealth(existing.opencode_server_url, 2e3);
310
392
  if (healthy2) {
311
- 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
+ }
312
397
  }
313
398
  }
314
399
  const port = await allocatePort();
315
400
  const child = spawn("opencode", ["serve", "--hostname", "127.0.0.1", "--port", String(port)], {
401
+ cwd: input.repo_root,
316
402
  detached: true,
317
403
  stdio: "ignore"
318
404
  });
@@ -475,9 +561,275 @@ async function collectSseEvents(serverUrl, scope, options) {
475
561
  clearTimeout(timeout);
476
562
  }
477
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
+ }
478
830
  function buildHookPolicyChecklist(agentId, sessionKey) {
479
831
  return {
480
- callbackPrimary: "/hooks/agent",
832
+ primaryCallbackPath: "/hooks/agent",
481
833
  requirements: {
482
834
  hooksEnabled: true,
483
835
  allowRequestSessionKey: true,
@@ -498,11 +850,30 @@ function buildHookPolicyChecklist(agentId, sessionKey) {
498
850
  }
499
851
 
500
852
  // src/registrar.ts
501
- var PLUGIN_VERSION = "0.1.1";
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
+ }
502
873
  function registerOpenCodeBridgeTools(api, cfg) {
503
- console.log("[opencode-bridge] scaffold loaded");
874
+ console.log("[opencode-bridge] plugin loaded");
504
875
  console.log(`[opencode-bridge] opencodeServerUrl=${cfg.opencodeServerUrl || "(unset)"}`);
505
- console.log("[opencode-bridge] registering opencode_* tool set");
876
+ console.log("[opencode-bridge] registering opencode_* tools");
506
877
  api.registerTool({
507
878
  name: "opencode_status",
508
879
  label: "OpenCode Status",
@@ -512,7 +883,7 @@ function registerOpenCodeBridgeTools(api, cfg) {
512
883
  const runtimeCfg = getRuntimeConfig(cfg);
513
884
  const registry = normalizeRegistry(runtimeCfg.projectRegistry);
514
885
  return {
515
- 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", "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"], 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, executionAgentMappings: runtimeCfg.executionAgentMappings || [], 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) }]
516
887
  };
517
888
  }
518
889
  }, { optional: true });
@@ -582,6 +953,100 @@ function registerOpenCodeBridgeTools(api, cfg) {
582
953
  return { content: [{ type: "text", text: JSON.stringify({ ok: true, checklist }, null, 2) }] };
583
954
  }
584
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 });
585
1050
  api.registerTool({
586
1051
  name: "opencode_evaluate_lifecycle",
587
1052
  label: "OpenCode Evaluate Lifecycle",
@@ -641,11 +1106,11 @@ function registerOpenCodeBridgeTools(api, cfg) {
641
1106
  timestamp: event.timestamp
642
1107
  }))
643
1108
  );
644
- const state = lifecycleSummary.current_state || artifact?.state || (sessionId ? "running" : "queued");
1109
+ const state = lifecycleSummary.currentState || artifact?.state || (sessionId ? "running" : "queued");
645
1110
  const response = {
646
1111
  ok: true,
647
1112
  source: {
648
- runStatusArtifact: Boolean(artifact),
1113
+ runArtifact: Boolean(artifact),
649
1114
  opencodeApi: true
650
1115
  },
651
1116
  runId: runId || void 0,
@@ -659,7 +1124,8 @@ function registerOpenCodeBridgeTools(api, cfg) {
659
1124
  }
660
1125
  },
661
1126
  state,
662
- current_state: lifecycleSummary.current_state || state,
1127
+ currentState: lifecycleSummary.currentState || state,
1128
+ current_state: lifecycleSummary.currentState || state,
663
1129
  lastEvent: artifact?.lastEvent,
664
1130
  last_event_kind: artifact?.lastEvent,
665
1131
  lastSummary: artifact?.lastSummary,
@@ -915,7 +1381,7 @@ function registerOpenCodeBridgeTools(api, cfg) {
915
1381
  }
916
1382
 
917
1383
  // src/index.ts
918
- var PLUGIN_VERSION2 = "0.1.1";
1384
+ var PLUGIN_VERSION2 = "0.1.3";
919
1385
  var plugin = {
920
1386
  id: "opencode-bridge",
921
1387
  name: "OpenCode Bridge",
@@ -925,7 +1391,7 @@ var plugin = {
925
1391
  registerOpenCodeBridgeTools(api, cfg);
926
1392
  }
927
1393
  };
928
- var index_default = plugin;
1394
+ var src_default = plugin;
929
1395
  export {
930
- index_default as default
1396
+ src_default as default
931
1397
  };
@@ -68,6 +68,7 @@ declare function summarizeLifecycle(events?: Array<{
68
68
  completionSummary?: string | null;
69
69
  timestamp?: string;
70
70
  }>): {
71
+ currentState: "planning" | "coding" | "verifying" | "blocked" | "running" | "awaiting_permission" | "stalled" | "failed" | "completed" | null;
71
72
  current_state: "planning" | "coding" | "verifying" | "blocked" | "running" | "awaiting_permission" | "stalled" | "failed" | "completed" | null;
72
73
  last_event_kind: OpenCodeEventKind | null;
73
74
  last_event_at: string | null;
@@ -6,7 +6,7 @@ import {
6
6
  resolveSessionId,
7
7
  summarizeLifecycle,
8
8
  unwrapGlobalPayload
9
- } from "./chunk-LCJRXKI3.js";
9
+ } from "./chunk-OVQ5X54C.js";
10
10
  export {
11
11
  normalizeOpenCodeEvent,
12
12
  normalizeTypedEventV1,
@@ -0,0 +1,33 @@
1
+ type BridgeSessionTagFields = {
2
+ runId: string;
3
+ taskId: string;
4
+ requested: string;
5
+ resolved: string;
6
+ callbackSession: string;
7
+ callbackSessionId?: string;
8
+ projectId?: string;
9
+ repoRoot?: string;
10
+ };
11
+ type OpenCodePluginCallbackAuditRecord = {
12
+ phase?: string;
13
+ event_type?: string;
14
+ session_id?: string;
15
+ title?: string;
16
+ tags?: Record<string, string> | null;
17
+ dedupeKey?: string;
18
+ ok?: boolean;
19
+ status?: number;
20
+ reason?: string;
21
+ body?: string;
22
+ payload?: any;
23
+ raw?: any;
24
+ created_at: string;
25
+ };
26
+ declare function buildTaggedSessionTitle(fields: BridgeSessionTagFields): string;
27
+ declare function parseTaggedSessionTitle(title?: string): Record<string, string> | null;
28
+ declare function buildPluginCallbackDedupeKey(input: {
29
+ sessionId?: string;
30
+ runId?: string;
31
+ }): string;
32
+
33
+ export { type BridgeSessionTagFields, type OpenCodePluginCallbackAuditRecord, buildPluginCallbackDedupeKey, buildTaggedSessionTitle, parseTaggedSessionTitle };
@@ -0,0 +1,10 @@
1
+ import {
2
+ buildPluginCallbackDedupeKey,
3
+ buildTaggedSessionTitle,
4
+ parseTaggedSessionTitle
5
+ } from "./chunk-TDVN5AFB.js";
6
+ export {
7
+ buildPluginCallbackDedupeKey,
8
+ buildTaggedSessionTitle,
9
+ parseTaggedSessionTitle
10
+ };
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "opencode-bridge",
3
3
  "name": "OpenCode Bridge",
4
- "description": "Bridge plugin scaffold for OpenClaw ↔ OpenCode orchestration.",
5
- "version": "0.1.1",
4
+ "description": "Bridge plugin for OpenClaw ↔ OpenCode orchestration, execution routing, and callback observability.",
5
+ "version": "0.1.3",
6
6
  "configSchema": {
7
7
  "type": "object",
8
8
  "additionalProperties": false,