@mrc2204/opencode-bridge 0.1.1 → 0.1.2

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.
@@ -0,0 +1,288 @@
1
+ // src/observability.ts
2
+ function parseSseFramesFromBuffer(input) {
3
+ const normalized = input.replace(/\r\n/g, "\n");
4
+ const parts = normalized.split("\n\n");
5
+ const remainder = parts.pop() ?? "";
6
+ const frames = [];
7
+ for (const rawFrame of parts) {
8
+ const lines = rawFrame.split("\n");
9
+ let event;
10
+ let id;
11
+ let retry;
12
+ const dataLines = [];
13
+ for (const line of lines) {
14
+ if (!line || line.startsWith(":")) continue;
15
+ const idx = line.indexOf(":");
16
+ const field = idx >= 0 ? line.slice(0, idx).trim() : line.trim();
17
+ const value = idx >= 0 ? line.slice(idx + 1).replace(/^\s/, "") : "";
18
+ if (field === "event") event = value;
19
+ else if (field === "id") id = value;
20
+ else if (field === "retry") {
21
+ const n = Number(value);
22
+ if (Number.isFinite(n)) retry = n;
23
+ } else if (field === "data") dataLines.push(value);
24
+ }
25
+ if (dataLines.length <= 0) continue;
26
+ frames.push({
27
+ event,
28
+ id,
29
+ retry,
30
+ data: dataLines.join("\n"),
31
+ raw: rawFrame
32
+ });
33
+ }
34
+ return { frames, remainder };
35
+ }
36
+ function parseSseData(data) {
37
+ const trimmed = data.trim();
38
+ if (!trimmed) return null;
39
+ try {
40
+ return JSON.parse(trimmed);
41
+ } catch {
42
+ return { raw: trimmed };
43
+ }
44
+ }
45
+ function unwrapGlobalPayload(raw) {
46
+ let current = raw;
47
+ const wrappers = [];
48
+ for (let i = 0; i < 3; i += 1) {
49
+ if (!current || typeof current !== "object") break;
50
+ if ("payload" in current && current.payload !== void 0) {
51
+ wrappers.push("payload");
52
+ current = current.payload;
53
+ continue;
54
+ }
55
+ if ("data" in current && current.data !== void 0 && Object.keys(current).length <= 4) {
56
+ wrappers.push("data");
57
+ current = current.data;
58
+ continue;
59
+ }
60
+ break;
61
+ }
62
+ return { payload: current, wrappers };
63
+ }
64
+ function pickFirstString(obj, keys) {
65
+ if (!obj || typeof obj !== "object") return void 0;
66
+ for (const key of keys) {
67
+ const value = obj[key];
68
+ if (typeof value === "string" && value.trim()) return value.trim();
69
+ }
70
+ return void 0;
71
+ }
72
+ function asObject(value) {
73
+ return value && typeof value === "object" && !Array.isArray(value) ? value : void 0;
74
+ }
75
+ function asArray(value) {
76
+ return Array.isArray(value) ? value : [];
77
+ }
78
+ function uniqueStrings(values) {
79
+ return [...new Set(values.filter((item) => typeof item === "string" && item.trim()).map((item) => item.trim()))];
80
+ }
81
+ function inferLifecycleState(raw) {
82
+ const text = JSON.stringify(raw || {}).toLowerCase();
83
+ if (text.includes("permission") || text.includes("question.asked")) return "awaiting_permission";
84
+ if (text.includes("blocked") || text.includes("missing config") || text.includes("missing token") || text.includes("missing api_key")) return "blocked";
85
+ if (text.includes("error") || text.includes("failed")) return "failed";
86
+ if (text.includes("stalled")) return "stalled";
87
+ if (text.includes("compile") || text.includes("pytest") || text.includes("test") || text.includes("verify")) return "verifying";
88
+ if (text.includes("apply_patch") || text.includes("diff") || text.includes("edit") || text.includes("write")) return "coding";
89
+ if (text.includes("plan") || text.includes("discovery") || text.includes("ground") || text.includes("calibrate")) return "planning";
90
+ if (text.includes("complete") || text.includes("completed") || text.includes("done")) return "completed";
91
+ return "running";
92
+ }
93
+ function extractFilesChanged(raw) {
94
+ const files = [];
95
+ const root = asObject(raw) || {};
96
+ const payload = asObject(root.payload) || root;
97
+ const state = asObject(root.state) || asObject(payload.state) || {};
98
+ const metadata = asObject(state.metadata) || asObject(payload.metadata) || {};
99
+ for (const file of asArray(metadata.files)) {
100
+ const obj = asObject(file);
101
+ if (!obj) continue;
102
+ if (typeof obj.relativePath === "string") files.push(obj.relativePath);
103
+ else if (typeof obj.filePath === "string") files.push(obj.filePath);
104
+ }
105
+ for (const file of asArray(root.files)) {
106
+ if (typeof file === "string") files.push(file);
107
+ }
108
+ return uniqueStrings(files);
109
+ }
110
+ function extractVerifySummary(raw) {
111
+ const root = asObject(raw) || {};
112
+ const payload = asObject(root.payload) || root;
113
+ const state = asObject(root.state) || asObject(payload.state);
114
+ const input = asObject(state?.input);
115
+ const metadata = asObject(state?.metadata);
116
+ const command = typeof input?.command === "string" ? input.command : void 0;
117
+ const exit = typeof metadata?.exit === "number" ? metadata.exit : null;
118
+ const output = typeof state?.output === "string" ? state.output : typeof metadata?.output === "string" ? metadata.output : null;
119
+ if (!command && output == null) return null;
120
+ return {
121
+ command,
122
+ exit,
123
+ output_preview: typeof output === "string" ? output.slice(0, 500) : null
124
+ };
125
+ }
126
+ function extractBlockers(raw) {
127
+ const text = JSON.stringify(raw || {}).toLowerCase();
128
+ const blockers = [];
129
+ if (text.includes("no module named pytest")) blockers.push("pytest_missing");
130
+ if (text.includes("missing token") || text.includes("missing api_key") || text.includes("pow service not configured")) blockers.push("runtime_config_missing");
131
+ if (text.includes("permission") || text.includes("question.asked")) blockers.push("awaiting_permission");
132
+ return uniqueStrings(blockers);
133
+ }
134
+ function extractCompletionSummary(raw) {
135
+ const root = asObject(raw) || {};
136
+ const text = typeof root.text === "string" ? root.text : typeof root.summary === "string" ? root.summary : null;
137
+ return text ? text.slice(0, 2e3) : null;
138
+ }
139
+ function normalizeOpenCodeEvent(raw) {
140
+ const text = JSON.stringify(raw || {}).toLowerCase();
141
+ let kind = null;
142
+ let summary;
143
+ if (text.includes("permission") || text.includes("question.asked")) {
144
+ kind = "permission.requested";
145
+ summary = "OpenCode \u0111ang ch\u1EDD permission/user input";
146
+ } else if (text.includes("error") || text.includes("failed")) {
147
+ kind = "task.failed";
148
+ summary = "OpenCode task failed";
149
+ } else if (text.includes("stalled")) {
150
+ kind = "task.stalled";
151
+ summary = "OpenCode task stalled";
152
+ } else if (text.includes("complete") || text.includes("completed") || text.includes("done")) {
153
+ kind = "task.completed";
154
+ summary = "OpenCode task completed";
155
+ } else if (text.includes("progress") || text.includes("delta") || text.includes("assistant")) {
156
+ kind = "task.progress";
157
+ summary = "OpenCode task made progress";
158
+ } else if (text.includes("start") || text.includes("created") || text.includes("connected")) {
159
+ kind = "task.started";
160
+ summary = "OpenCode task/server started";
161
+ }
162
+ return {
163
+ kind,
164
+ summary,
165
+ raw,
166
+ lifecycleState: inferLifecycleState(raw),
167
+ filesChanged: extractFilesChanged(raw),
168
+ verifySummary: extractVerifySummary(raw),
169
+ blockers: extractBlockers(raw),
170
+ completionSummary: extractCompletionSummary(raw)
171
+ };
172
+ }
173
+ function normalizeTypedEventV1(frame, scope) {
174
+ const parsed = parseSseData(frame.data);
175
+ const unwrapped = unwrapGlobalPayload(parsed);
176
+ const normalized = normalizeOpenCodeEvent(unwrapped.payload);
177
+ return {
178
+ schema: "opencode.event.v1",
179
+ scope,
180
+ eventName: frame.event,
181
+ eventId: frame.id,
182
+ kind: normalized.kind,
183
+ summary: normalized.summary,
184
+ lifecycleState: normalized.lifecycleState,
185
+ filesChanged: normalized.filesChanged,
186
+ verifySummary: normalized.verifySummary,
187
+ blockers: normalized.blockers,
188
+ completionSummary: normalized.completionSummary,
189
+ runId: pickFirstString(unwrapped.payload, ["run_id", "runId"]),
190
+ taskId: pickFirstString(unwrapped.payload, ["task_id", "taskId"]),
191
+ sessionId: pickFirstString(unwrapped.payload, ["session_id", "sessionId", "session"]),
192
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
193
+ wrappers: unwrapped.wrappers,
194
+ payload: unwrapped.payload
195
+ };
196
+ }
197
+ function asString(value) {
198
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
199
+ }
200
+ function scoreSessionCandidate(candidate, ctx) {
201
+ const id = asString(candidate?.id) || "";
202
+ const candidateText = JSON.stringify(candidate || {}).toLowerCase();
203
+ const runId = ctx.runId?.toLowerCase();
204
+ const taskId = ctx.taskId?.toLowerCase();
205
+ const sessionKey = ctx.sessionKey?.toLowerCase();
206
+ const artifactSessionId = ctx.artifactSessionId;
207
+ let score = 0;
208
+ if (artifactSessionId && id === artifactSessionId) score += 1e3;
209
+ if (runId && candidateText.includes(runId)) score += 120;
210
+ if (taskId && candidateText.includes(taskId)) score += 70;
211
+ if (sessionKey && candidateText.includes(sessionKey)) score += 60;
212
+ if (runId && id.toLowerCase().includes(runId)) score += 30;
213
+ if (taskId && id.toLowerCase().includes(taskId)) score += 20;
214
+ score += Math.max(0, 20 - ctx.recencyRank);
215
+ return score;
216
+ }
217
+ function summarizeLifecycle(events = []) {
218
+ let current_state = null;
219
+ let last_event_kind = null;
220
+ let last_event_at = null;
221
+ const files_changed = /* @__PURE__ */ new Set();
222
+ const verify_summary = [];
223
+ const blockers = /* @__PURE__ */ new Set();
224
+ let completion_summary = null;
225
+ for (const item of events) {
226
+ if (item.lifecycleState) current_state = item.lifecycleState;
227
+ if (item.kind) last_event_kind = item.kind;
228
+ if (item.timestamp) last_event_at = item.timestamp;
229
+ for (const file of item.filesChanged || []) files_changed.add(file);
230
+ if (item.verifySummary) verify_summary.push(item.verifySummary);
231
+ for (const blocker of item.blockers || []) blockers.add(blocker);
232
+ if (item.completionSummary) completion_summary = item.completionSummary;
233
+ }
234
+ return {
235
+ current_state,
236
+ last_event_kind,
237
+ last_event_at,
238
+ files_changed: [...files_changed],
239
+ verify_summary,
240
+ blockers: [...blockers],
241
+ completion_summary
242
+ };
243
+ }
244
+ function resolveSessionId(input) {
245
+ if (input.explicitSessionId) {
246
+ return { sessionId: input.explicitSessionId, strategy: "explicit", score: 9999 };
247
+ }
248
+ const list = Array.isArray(input.sessionList) ? input.sessionList : [];
249
+ const withRecency = [...list].map((item, idx) => {
250
+ const updated = Number(item?.time?.updated || 0);
251
+ return { item, idx, updated };
252
+ }).sort((a, b) => b.updated - a.updated).map((x, rank) => ({ ...x, rank }));
253
+ if (input.artifactSessionId) {
254
+ const direct = withRecency.find((x) => asString(x.item?.id) === input.artifactSessionId);
255
+ if (direct) {
256
+ return { sessionId: input.artifactSessionId, strategy: "artifact", score: 1e3 };
257
+ }
258
+ }
259
+ let best = { score: -1 };
260
+ for (const candidate of withRecency) {
261
+ const id = asString(candidate.item?.id);
262
+ if (!id) continue;
263
+ const score = scoreSessionCandidate(candidate.item, {
264
+ runId: input.runId,
265
+ taskId: input.taskId,
266
+ sessionKey: input.sessionKey,
267
+ artifactSessionId: input.artifactSessionId,
268
+ recencyRank: candidate.rank
269
+ });
270
+ if (score > best.score) best = { id, score };
271
+ }
272
+ if (best.id && best.score > 0) {
273
+ return { sessionId: best.id, strategy: "scored_fallback", score: best.score };
274
+ }
275
+ const latest = withRecency.find((x) => asString(x.item?.id));
276
+ if (latest) return { sessionId: asString(latest.item?.id), strategy: "latest", score: 0 };
277
+ return { strategy: "none", score: -1 };
278
+ }
279
+
280
+ export {
281
+ parseSseFramesFromBuffer,
282
+ parseSseData,
283
+ unwrapGlobalPayload,
284
+ normalizeOpenCodeEvent,
285
+ normalizeTypedEventV1,
286
+ summarizeLifecycle,
287
+ resolveSessionId
288
+ };
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import {
2
2
  normalizeTypedEventV1,
3
3
  parseSseFramesFromBuffer,
4
- resolveSessionId
5
- } from "./chunk-6NIQKNRA.js";
4
+ resolveSessionId,
5
+ summarizeLifecycle
6
+ } from "./chunk-LCJRXKI3.js";
6
7
 
7
8
  // src/runtime.ts
8
9
  import { mkdirSync, readFileSync, writeFileSync, existsSync } from "fs";
@@ -55,6 +56,7 @@ function getRuntimeConfig(cfg) {
55
56
  return {
56
57
  opencodeServerUrl: fileCfg.opencodeServerUrl || cfg?.opencodeServerUrl,
57
58
  projectRegistry: fileCfg.projectRegistry || cfg?.projectRegistry || [],
59
+ executionAgentMappings: fileCfg.executionAgentMappings || cfg?.executionAgentMappings || [],
58
60
  hookBaseUrl: fileCfg.hookBaseUrl || cfg?.hookBaseUrl,
59
61
  hookToken: fileCfg.hookToken || cfg?.hookToken
60
62
  };
@@ -94,13 +96,66 @@ function findRegistryEntry(cfg, projectId, repoRoot) {
94
96
  }
95
97
  return void 0;
96
98
  }
99
+ function resolveExecutionAgent(input) {
100
+ const requestedAgentId = asString(input.requestedAgentId);
101
+ if (!requestedAgentId) {
102
+ return {
103
+ ok: false,
104
+ requestedAgentId: "",
105
+ mappingConfigured: false,
106
+ error: "requestedAgentId is required"
107
+ };
108
+ }
109
+ const explicitExecutionAgentId = asString(input.explicitExecutionAgentId);
110
+ if (explicitExecutionAgentId) {
111
+ return {
112
+ ok: true,
113
+ requestedAgentId,
114
+ resolvedAgentId: explicitExecutionAgentId,
115
+ strategy: "explicit_param"
116
+ };
117
+ }
118
+ const runtimeCfg = getRuntimeConfig(input.cfg);
119
+ const mappings = asArray(runtimeCfg.executionAgentMappings).map((x) => x && typeof x === "object" ? x : {}).map((x) => ({
120
+ requestedAgentId: asString(x.requestedAgentId),
121
+ executionAgentId: asString(x.executionAgentId)
122
+ })).filter((x) => x.requestedAgentId && x.executionAgentId);
123
+ if (mappings.length > 0) {
124
+ const matched = mappings.find((x) => x.requestedAgentId === requestedAgentId);
125
+ if (!matched) {
126
+ return {
127
+ ok: false,
128
+ requestedAgentId,
129
+ mappingConfigured: true,
130
+ error: `Execution agent mapping is configured but no mapping matched requestedAgentId=${requestedAgentId}`
131
+ };
132
+ }
133
+ return {
134
+ ok: true,
135
+ requestedAgentId,
136
+ resolvedAgentId: matched.executionAgentId,
137
+ strategy: "config_mapping"
138
+ };
139
+ }
140
+ return {
141
+ ok: true,
142
+ requestedAgentId,
143
+ resolvedAgentId: requestedAgentId,
144
+ strategy: "identity"
145
+ };
146
+ }
97
147
  function buildEnvelope(input) {
98
148
  return {
99
149
  task_id: input.taskId,
100
150
  run_id: input.runId,
101
- agent_id: input.agentId,
102
- session_key: buildSessionKey(input.agentId, input.taskId),
151
+ agent_id: input.resolvedAgentId,
152
+ requested_agent_id: input.requestedAgentId,
153
+ resolved_agent_id: input.resolvedAgentId,
154
+ session_key: buildSessionKey(input.resolvedAgentId, input.taskId),
103
155
  origin_session_key: input.originSessionKey,
156
+ ...input.originSessionId ? { origin_session_id: input.originSessionId } : {},
157
+ callback_target_session_key: input.originSessionKey,
158
+ ...input.originSessionId ? { callback_target_session_id: input.originSessionId } : {},
104
159
  project_id: input.projectId,
105
160
  repo_root: input.repoRoot,
106
161
  opencode_server_url: input.serverUrl,
@@ -113,6 +168,7 @@ function buildEnvelope(input) {
113
168
  function mapEventToState(event) {
114
169
  switch (event) {
115
170
  case "task.started":
171
+ return "planning";
116
172
  case "task.progress":
117
173
  return "running";
118
174
  case "permission.requested":
@@ -314,12 +370,20 @@ async function fetchJsonSafe(url) {
314
370
  }
315
371
  function resolveSessionForRun(input) {
316
372
  const artifactEnvelope = input.runStatus?.envelope;
373
+ const callbackTargetSessionId = typeof artifactEnvelope?.callback_target_session_id === "string" ? artifactEnvelope.callback_target_session_id : void 0;
374
+ const originSessionId = typeof artifactEnvelope?.origin_session_id === "string" ? artifactEnvelope.origin_session_id : void 0;
375
+ const callbackTargetSessionKey = typeof artifactEnvelope?.callback_target_session_key === "string" ? artifactEnvelope.callback_target_session_key : void 0;
376
+ const originSessionKey = typeof artifactEnvelope?.origin_session_key === "string" ? artifactEnvelope.origin_session_key : void 0;
377
+ const executionSessionKey = typeof artifactEnvelope?.session_key === "string" ? artifactEnvelope.session_key : void 0;
378
+ const artifactSessionId = callbackTargetSessionId || originSessionId || (typeof artifactEnvelope?.session_id === "string" ? artifactEnvelope.session_id : void 0) || (typeof artifactEnvelope?.sessionId === "string" ? artifactEnvelope.sessionId : void 0);
379
+ const artifactSessionKey = callbackTargetSessionKey || originSessionKey || executionSessionKey;
380
+ const hasCallerSessionKey = Boolean(callbackTargetSessionKey || originSessionKey);
317
381
  const resolved = resolveSessionId({
318
382
  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),
383
+ runId: hasCallerSessionKey ? void 0 : input.runId || input.runStatus?.runId,
384
+ taskId: hasCallerSessionKey ? void 0 : input.runStatus?.taskId,
385
+ sessionKey: artifactSessionKey,
386
+ artifactSessionId,
323
387
  sessionList: input.sessionList
324
388
  });
325
389
  return { sessionId: resolved.sessionId, strategy: resolved.strategy, ...resolved.score !== void 0 ? { score: resolved.score } : {} };
@@ -355,6 +419,11 @@ async function collectSseEvents(serverUrl, scope, options) {
355
419
  data: typed.payload,
356
420
  normalizedKind: typed.kind,
357
421
  summary: typed.summary,
422
+ lifecycle_state: typed.lifecycleState,
423
+ files_changed: typed.filesChanged,
424
+ verify_summary: typed.verifySummary,
425
+ blockers: typed.blockers,
426
+ completion_summary: typed.completionSummary,
358
427
  runId: typed.runId || options?.runIdHint,
359
428
  taskId: typed.taskId || options?.taskIdHint,
360
429
  sessionId: typed.sessionId || options?.sessionIdHint,
@@ -377,6 +446,11 @@ async function collectSseEvents(serverUrl, scope, options) {
377
446
  data: typed.payload,
378
447
  normalizedKind: typed.kind,
379
448
  summary: typed.summary,
449
+ lifecycle_state: typed.lifecycleState,
450
+ files_changed: typed.filesChanged,
451
+ verify_summary: typed.verifySummary,
452
+ blockers: typed.blockers,
453
+ completion_summary: typed.completionSummary,
380
454
  runId: typed.runId || options?.runIdHint,
381
455
  taskId: typed.taskId || options?.taskIdHint,
382
456
  sessionId: typed.sessionId || options?.sessionIdHint,
@@ -424,6 +498,7 @@ function buildHookPolicyChecklist(agentId, sessionKey) {
424
498
  }
425
499
 
426
500
  // src/registrar.ts
501
+ var PLUGIN_VERSION = "0.1.1";
427
502
  function registerOpenCodeBridgeTools(api, cfg) {
428
503
  console.log("[opencode-bridge] scaffold loaded");
429
504
  console.log(`[opencode-bridge] opencodeServerUrl=${cfg.opencodeServerUrl || "(unset)"}`);
@@ -437,7 +512,7 @@ function registerOpenCodeBridgeTools(api, cfg) {
437
512
  const runtimeCfg = getRuntimeConfig(cfg);
438
513
  const registry = normalizeRegistry(runtimeCfg.projectRegistry);
439
514
  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) }]
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) }]
441
516
  };
442
517
  }
443
518
  }, { optional: true });
@@ -455,7 +530,7 @@ function registerOpenCodeBridgeTools(api, cfg) {
455
530
  name: "opencode_build_envelope",
456
531
  label: "OpenCode Build Envelope",
457
532
  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"] },
533
+ 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
534
  async execute(_id, params) {
460
535
  const entry = findRegistryEntry(cfg, params?.projectId, params?.repoRoot);
461
536
  const serverUrl = entry?.serverUrl;
@@ -465,11 +540,25 @@ function registerOpenCodeBridgeTools(api, cfg) {
465
540
  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
541
  };
467
542
  }
543
+ const requestedAgentId = asString(params?.agentId);
544
+ const resolved = resolveExecutionAgent({
545
+ cfg,
546
+ requestedAgentId: requestedAgentId || "",
547
+ explicitExecutionAgentId: asString(params?.executionAgentId)
548
+ });
549
+ if (!resolved.ok) {
550
+ return {
551
+ isError: true,
552
+ content: [{ type: "text", text: JSON.stringify({ ok: false, error: resolved.error, requestedAgentId: resolved.requestedAgentId, mappingConfigured: resolved.mappingConfigured }, null, 2) }]
553
+ };
554
+ }
468
555
  const envelope = buildEnvelope({
469
556
  taskId: params.taskId,
470
557
  runId: params.runId,
471
- agentId: params.agentId,
558
+ requestedAgentId: resolved.requestedAgentId,
559
+ resolvedAgentId: resolved.resolvedAgentId,
472
560
  originSessionKey: params.originSessionKey,
561
+ originSessionId: asString(params.originSessionId),
473
562
  projectId: params.projectId,
474
563
  repoRoot: params.repoRoot,
475
564
  serverUrl,
@@ -479,7 +568,7 @@ function registerOpenCodeBridgeTools(api, cfg) {
479
568
  priority: params.priority
480
569
  });
481
570
  return {
482
- content: [{ type: "text", text: JSON.stringify({ ok: true, envelope, registryMatch: entry || null }, null, 2) }]
571
+ content: [{ type: "text", text: JSON.stringify({ ok: true, envelope, agentResolution: resolved, registryMatch: entry || null }, null, 2) }]
483
572
  };
484
573
  }
485
574
  }, { optional: true });
@@ -532,7 +621,27 @@ function registerOpenCodeBridgeTools(api, cfg) {
532
621
  runId
533
622
  });
534
623
  const sessionId = resolution.sessionId;
535
- const state = artifact?.state || (sessionId ? "running" : "queued");
624
+ const eventScope = sessionId ? "session" : "global";
625
+ const events = await collectSseEvents(serverUrl, eventScope, {
626
+ limit: DEFAULT_EVENT_LIMIT,
627
+ timeoutMs: DEFAULT_OBS_TIMEOUT_MS,
628
+ runIdHint: runId,
629
+ taskIdHint: artifact?.taskId,
630
+ sessionIdHint: sessionId
631
+ });
632
+ const lifecycleSummary = summarizeLifecycle(
633
+ events.map((event) => ({
634
+ kind: event.normalizedKind,
635
+ summary: event.summary,
636
+ lifecycleState: event.lifecycle_state,
637
+ filesChanged: event.files_changed,
638
+ verifySummary: event.verify_summary,
639
+ blockers: event.blockers,
640
+ completionSummary: event.completion_summary,
641
+ timestamp: event.timestamp
642
+ }))
643
+ );
644
+ const state = lifecycleSummary.current_state || artifact?.state || (sessionId ? "running" : "queued");
536
645
  const response = {
537
646
  ok: true,
538
647
  source: {
@@ -550,8 +659,15 @@ function registerOpenCodeBridgeTools(api, cfg) {
550
659
  }
551
660
  },
552
661
  state,
662
+ current_state: lifecycleSummary.current_state || state,
553
663
  lastEvent: artifact?.lastEvent,
664
+ last_event_kind: artifact?.lastEvent,
554
665
  lastSummary: artifact?.lastSummary,
666
+ last_event_at: artifact?.updatedAt || (/* @__PURE__ */ new Date()).toISOString(),
667
+ files_changed: lifecycleSummary.files_changed,
668
+ verify_summary: lifecycleSummary.verify_summary,
669
+ blockers: lifecycleSummary.blockers,
670
+ completion_summary: lifecycleSummary.completion_summary || artifact?.lastSummary || null,
555
671
  updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
556
672
  timestamps: {
557
673
  ...artifact?.updatedAt ? { artifactUpdatedAt: artifact.updatedAt } : {},
@@ -799,10 +915,11 @@ function registerOpenCodeBridgeTools(api, cfg) {
799
915
  }
800
916
 
801
917
  // src/index.ts
918
+ var PLUGIN_VERSION2 = "0.1.1";
802
919
  var plugin = {
803
920
  id: "opencode-bridge",
804
921
  name: "OpenCode Bridge",
805
- version: "0.1.0",
922
+ version: PLUGIN_VERSION2,
806
923
  register(api) {
807
924
  const cfg = api?.pluginConfig || {};
808
925
  registerOpenCodeBridgeTools(api, cfg);
@@ -14,6 +14,15 @@ type TypedEventV1 = {
14
14
  eventId?: string;
15
15
  kind: OpenCodeEventKind | null;
16
16
  summary?: string;
17
+ lifecycleState?: "planning" | "coding" | "verifying" | "blocked" | "running" | "awaiting_permission" | "stalled" | "failed" | "completed";
18
+ filesChanged?: string[];
19
+ verifySummary?: {
20
+ command?: string;
21
+ exit?: number | null;
22
+ output_preview?: string | null;
23
+ } | null;
24
+ blockers?: string[];
25
+ completionSummary?: string | null;
17
26
  runId?: string;
18
27
  taskId?: string;
19
28
  sessionId?: string;
@@ -34,8 +43,43 @@ declare function normalizeOpenCodeEvent(raw: any): {
34
43
  kind: OpenCodeEventKind | null;
35
44
  summary?: string;
36
45
  raw: any;
46
+ lifecycleState?: TypedEventV1["lifecycleState"];
47
+ filesChanged?: string[];
48
+ verifySummary?: {
49
+ command?: string;
50
+ exit?: number | null;
51
+ output_preview?: string | null;
52
+ } | null;
53
+ blockers?: string[];
54
+ completionSummary?: string | null;
37
55
  };
38
56
  declare function normalizeTypedEventV1(frame: SseFrame, scope: EventScope): TypedEventV1;
57
+ declare function summarizeLifecycle(events?: Array<{
58
+ kind?: OpenCodeEventKind | null;
59
+ summary?: string;
60
+ lifecycleState?: TypedEventV1["lifecycleState"];
61
+ filesChanged?: string[];
62
+ verifySummary?: {
63
+ command?: string;
64
+ exit?: number | null;
65
+ output_preview?: string | null;
66
+ } | null;
67
+ blockers?: string[];
68
+ completionSummary?: string | null;
69
+ timestamp?: string;
70
+ }>): {
71
+ current_state: "planning" | "coding" | "verifying" | "blocked" | "running" | "awaiting_permission" | "stalled" | "failed" | "completed" | null;
72
+ last_event_kind: OpenCodeEventKind | null;
73
+ last_event_at: string | null;
74
+ files_changed: string[];
75
+ verify_summary: {
76
+ command?: string;
77
+ exit?: number | null;
78
+ output_preview?: string | null;
79
+ }[];
80
+ blockers: string[];
81
+ completion_summary: string | null;
82
+ };
39
83
  declare function resolveSessionId(input: {
40
84
  explicitSessionId?: string;
41
85
  runId?: string;
@@ -49,4 +93,4 @@ declare function resolveSessionId(input: {
49
93
  score?: number;
50
94
  };
51
95
 
52
- export { type EventScope, type OpenCodeEventKind, type SseFrame, type TypedEventV1, normalizeOpenCodeEvent, normalizeTypedEventV1, parseSseData, parseSseFramesFromBuffer, resolveSessionId, unwrapGlobalPayload };
96
+ export { type EventScope, type OpenCodeEventKind, type SseFrame, type TypedEventV1, normalizeOpenCodeEvent, normalizeTypedEventV1, parseSseData, parseSseFramesFromBuffer, resolveSessionId, summarizeLifecycle, unwrapGlobalPayload };
@@ -4,13 +4,15 @@ import {
4
4
  parseSseData,
5
5
  parseSseFramesFromBuffer,
6
6
  resolveSessionId,
7
+ summarizeLifecycle,
7
8
  unwrapGlobalPayload
8
- } from "./chunk-6NIQKNRA.js";
9
+ } from "./chunk-LCJRXKI3.js";
9
10
  export {
10
11
  normalizeOpenCodeEvent,
11
12
  normalizeTypedEventV1,
12
13
  parseSseData,
13
14
  parseSseFramesFromBuffer,
14
15
  resolveSessionId,
16
+ summarizeLifecycle,
15
17
  unwrapGlobalPayload
16
18
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mrc2204/opencode-bridge",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "OpenClaw ↔ OpenCode bridge plugin for routing, callback, SSE probing, and runtime-ops scaffolding.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -500,6 +500,38 @@ Agents must describe these limits honestly and avoid over-claiming completion.
500
500
 
501
501
  ### Execution lane assumptions (MANDATORY)
502
502
 
503
+ ### Current-session callback integrity (MANDATORY)
504
+
505
+ Bridge-aware execution must preserve caller identity and callback destination explicitly.
506
+
507
+ Required fields to preserve whenever available:
508
+ - `requested_agent_id`
509
+ - `resolved_agent_id`
510
+ - `origin_session_key`
511
+ - `origin_session_id`
512
+ - `callback_target_session_key`
513
+ - `callback_target_session_id`
514
+
515
+ Operational rules:
516
+ - Callback must return to the **current caller session**, not an arbitrary latest/execution session.
517
+ - If bridge/runtime cannot resolve the requested execution agent cleanly, it must **fail fast** instead of silently falling back to a default agent.
518
+ - If callback target session information is missing or inconsistent, treat that as a routing integrity error, not a soft warning.
519
+ - Reporting/trace should always expose:
520
+ - requested agent
521
+ - resolved execution agent
522
+ - callback target session key/id
523
+ - whether fallback was used
524
+
525
+ ### Runtime hygiene after completion (MANDATORY)
526
+
527
+ When a task or epic is marked `done/closed`, any OpenCode serve/process that was started only for that execution must be shut down immediately unless there is an explicit reason to keep it alive.
528
+
529
+ Operational rules:
530
+ - Do not leave lingering OpenCode serves after work completes.
531
+ - Treat serve shutdown as part of completion hygiene, not optional cleanup.
532
+ - If a serve remains alive intentionally, the agent must report the reason explicitly.
533
+
534
+
503
535
  1. **One project = one OpenCode serve instance**
504
536
  - Do not assume one shared serve is safe for multiple repos.
505
537
  - Always bind execution to a single project/repo root.
@@ -1,176 +0,0 @@
1
- // src/observability.ts
2
- function parseSseFramesFromBuffer(input) {
3
- const normalized = input.replace(/\r\n/g, "\n");
4
- const parts = normalized.split("\n\n");
5
- const remainder = parts.pop() ?? "";
6
- const frames = [];
7
- for (const rawFrame of parts) {
8
- const lines = rawFrame.split("\n");
9
- let event;
10
- let id;
11
- let retry;
12
- const dataLines = [];
13
- for (const line of lines) {
14
- if (!line || line.startsWith(":")) continue;
15
- const idx = line.indexOf(":");
16
- const field = idx >= 0 ? line.slice(0, idx).trim() : line.trim();
17
- const value = idx >= 0 ? line.slice(idx + 1).replace(/^\s/, "") : "";
18
- if (field === "event") event = value;
19
- else if (field === "id") id = value;
20
- else if (field === "retry") {
21
- const n = Number(value);
22
- if (Number.isFinite(n)) retry = n;
23
- } else if (field === "data") dataLines.push(value);
24
- }
25
- if (dataLines.length <= 0) continue;
26
- frames.push({
27
- event,
28
- id,
29
- retry,
30
- data: dataLines.join("\n"),
31
- raw: rawFrame
32
- });
33
- }
34
- return { frames, remainder };
35
- }
36
- function parseSseData(data) {
37
- const trimmed = data.trim();
38
- if (!trimmed) return null;
39
- try {
40
- return JSON.parse(trimmed);
41
- } catch {
42
- return { raw: trimmed };
43
- }
44
- }
45
- function unwrapGlobalPayload(raw) {
46
- let current = raw;
47
- const wrappers = [];
48
- for (let i = 0; i < 3; i += 1) {
49
- if (!current || typeof current !== "object") break;
50
- if ("payload" in current && current.payload !== void 0) {
51
- wrappers.push("payload");
52
- current = current.payload;
53
- continue;
54
- }
55
- if ("data" in current && current.data !== void 0 && Object.keys(current).length <= 4) {
56
- wrappers.push("data");
57
- current = current.data;
58
- continue;
59
- }
60
- break;
61
- }
62
- return { payload: current, wrappers };
63
- }
64
- function pickFirstString(obj, keys) {
65
- if (!obj || typeof obj !== "object") return void 0;
66
- for (const key of keys) {
67
- const value = obj[key];
68
- if (typeof value === "string" && value.trim()) return value.trim();
69
- }
70
- return void 0;
71
- }
72
- function normalizeOpenCodeEvent(raw) {
73
- const text = JSON.stringify(raw || {}).toLowerCase();
74
- if (text.includes("permission") || text.includes("question.asked")) {
75
- return { kind: "permission.requested", summary: "OpenCode \u0111ang ch\u1EDD permission/user input", raw };
76
- }
77
- if (text.includes("error") || text.includes("failed")) {
78
- return { kind: "task.failed", summary: "OpenCode task failed", raw };
79
- }
80
- if (text.includes("stalled")) {
81
- return { kind: "task.stalled", summary: "OpenCode task stalled", raw };
82
- }
83
- if (text.includes("complete") || text.includes("completed") || text.includes("done")) {
84
- return { kind: "task.completed", summary: "OpenCode task completed", raw };
85
- }
86
- if (text.includes("progress") || text.includes("delta") || text.includes("assistant")) {
87
- return { kind: "task.progress", summary: "OpenCode task made progress", raw };
88
- }
89
- if (text.includes("start") || text.includes("created") || text.includes("connected")) {
90
- return { kind: "task.started", summary: "OpenCode task/server started", raw };
91
- }
92
- return { kind: null, raw };
93
- }
94
- function normalizeTypedEventV1(frame, scope) {
95
- const parsed = parseSseData(frame.data);
96
- const unwrapped = unwrapGlobalPayload(parsed);
97
- const normalized = normalizeOpenCodeEvent(unwrapped.payload);
98
- return {
99
- schema: "opencode.event.v1",
100
- scope,
101
- eventName: frame.event,
102
- eventId: frame.id,
103
- kind: normalized.kind,
104
- summary: normalized.summary,
105
- runId: pickFirstString(unwrapped.payload, ["run_id", "runId"]),
106
- taskId: pickFirstString(unwrapped.payload, ["task_id", "taskId"]),
107
- sessionId: pickFirstString(unwrapped.payload, ["session_id", "sessionId", "session"]),
108
- timestamp: (/* @__PURE__ */ new Date()).toISOString(),
109
- wrappers: unwrapped.wrappers,
110
- payload: unwrapped.payload
111
- };
112
- }
113
- function asString(value) {
114
- return typeof value === "string" && value.trim() ? value.trim() : void 0;
115
- }
116
- function scoreSessionCandidate(candidate, ctx) {
117
- const id = asString(candidate?.id) || "";
118
- const candidateText = JSON.stringify(candidate || {}).toLowerCase();
119
- const runId = ctx.runId?.toLowerCase();
120
- const taskId = ctx.taskId?.toLowerCase();
121
- const sessionKey = ctx.sessionKey?.toLowerCase();
122
- const artifactSessionId = ctx.artifactSessionId;
123
- let score = 0;
124
- if (artifactSessionId && id === artifactSessionId) score += 1e3;
125
- if (runId && candidateText.includes(runId)) score += 120;
126
- if (taskId && candidateText.includes(taskId)) score += 70;
127
- if (sessionKey && candidateText.includes(sessionKey)) score += 60;
128
- if (runId && id.toLowerCase().includes(runId)) score += 30;
129
- if (taskId && id.toLowerCase().includes(taskId)) score += 20;
130
- score += Math.max(0, 20 - ctx.recencyRank);
131
- return score;
132
- }
133
- function resolveSessionId(input) {
134
- if (input.explicitSessionId) {
135
- return { sessionId: input.explicitSessionId, strategy: "explicit", score: 9999 };
136
- }
137
- const list = Array.isArray(input.sessionList) ? input.sessionList : [];
138
- const withRecency = [...list].map((item, idx) => {
139
- const updated = Number(item?.time?.updated || 0);
140
- return { item, idx, updated };
141
- }).sort((a, b) => b.updated - a.updated).map((x, rank) => ({ ...x, rank }));
142
- if (input.artifactSessionId) {
143
- const direct = withRecency.find((x) => asString(x.item?.id) === input.artifactSessionId);
144
- if (direct) {
145
- return { sessionId: input.artifactSessionId, strategy: "artifact", score: 1e3 };
146
- }
147
- }
148
- let best = { score: -1 };
149
- for (const candidate of withRecency) {
150
- const id = asString(candidate.item?.id);
151
- if (!id) continue;
152
- const score = scoreSessionCandidate(candidate.item, {
153
- runId: input.runId,
154
- taskId: input.taskId,
155
- sessionKey: input.sessionKey,
156
- artifactSessionId: input.artifactSessionId,
157
- recencyRank: candidate.rank
158
- });
159
- if (score > best.score) best = { id, score };
160
- }
161
- if (best.id && best.score > 0) {
162
- return { sessionId: best.id, strategy: "scored_fallback", score: best.score };
163
- }
164
- const latest = withRecency.find((x) => asString(x.item?.id));
165
- if (latest) return { sessionId: asString(latest.item?.id), strategy: "latest", score: 0 };
166
- return { strategy: "none", score: -1 };
167
- }
168
-
169
- export {
170
- parseSseFramesFromBuffer,
171
- parseSseData,
172
- unwrapGlobalPayload,
173
- normalizeOpenCodeEvent,
174
- normalizeTypedEventV1,
175
- resolveSessionId
176
- };