@@ -202,9 +202,10 @@ def _opencode_plugin_js() -> str:
202202// Auto-generated by `observal pull` / `observal doctor patch`
203203// Do not edit manually - regenerated on upgrade.
204204
205- import { execFileSync } from "child_process";
206205import { readFileSync, existsSync } from "fs";
207206import { join } from "path";
207+ import { request as httpRequest } from "http";
208+ import { request as httpsRequest } from "https";
208209
209210const CONFIG_PATH = join(
210211 process.env.HOME || process.env.USERPROFILE || "~",
@@ -232,28 +233,45 @@ def _opencode_plugin_js() -> str:
232233 const body = JSON.stringify(payload);
233234
234235 try {
235- execFileSync("curl", [
236- "-s", "-X", "POST",
237- "-H", "Content-Type: application/json",
238- "-H", `Authorization: Bearer ${config.access_token}`,
239- "--max-time", "10",
240- "-d", body,
241- url,
242- ], { stdio: ["pipe", "pipe", "pipe"], timeout: 12000 });
236+ const parsed = new URL(url);
237+ const reqFn = parsed.protocol === "https:" ? httpsRequest : httpRequest;
238+ const req = reqFn(url, {
239+ method: "POST",
240+ headers: {
241+ "Content-Type": "application/json",
242+ "Authorization": `Bearer ${config.access_token}`,
243+ "Content-Length": Buffer.byteLength(body),
244+ },
245+ timeout: 10000,
246+ });
247+ req.on("error", () => {});
248+ req.on("timeout", () => { req.destroy(); });
249+ req.write(body);
250+ req.end();
243251 } catch {
244252 // Non-blocking: never break the session
245253 }
246254}
247255
248256const sessionState = new Map();
257+ const MAX_TRACKED_SESSIONS = 50;
249258
250259function getState(sessionId) {
251260 if (!sessionState.has(sessionId)) {
261+ // Evict oldest sessions if we exceed the cap
262+ if (sessionState.size >= MAX_TRACKED_SESSIONS) {
263+ const oldest = sessionState.keys().next().value;
264+ sessionState.delete(oldest);
265+ }
252266 sessionState.set(sessionId, { pushedMessageIds: new Set(), lineOffset: 0 });
253267 }
254268 return sessionState.get(sessionId);
255269}
256270
271+ function cleanupSession(sessionId) {
272+ sessionState.delete(sessionId);
273+ }
274+
257275function messagesToLines(messages) {
258276 const lines = [];
259277 for (const msg of messages) {
@@ -276,6 +294,7 @@ def _opencode_plugin_js() -> str:
276294 if (info.createdAt && typeof info.createdAt === "string") { ts = info.createdAt; }
277295 else if (info.time && typeof info.time === "object" && info.time.created) { ts = new Date(info.time.created).toISOString(); }
278296 else if (info.time && typeof info.time === "string") { ts = info.time; }
297+ else if (info.timestamp && typeof info.timestamp === "string") { ts = info.timestamp; }
279298 const line = {
280299 type: role === "user" ? "user" : "assistant",
281300 timestamp: ts,
@@ -298,9 +317,9 @@ def _opencode_plugin_js() -> str:
298317}
299318
300319export const ObservalPlugin = async ({ project, client, directory }) => {
301- // Built-in agents that should NOT be tracked
302320 const BUILTIN_AGENTS = new Set(["build", "plan", "general", "explore", "scout", "compaction", "title", "summary"]);
303321 const agentSessions = new Map();
322+ const pendingPush = new Map();
304323
305324 return {
306325 event: async ({ event }) => {
@@ -309,13 +328,25 @@ def _opencode_plugin_js() -> str:
309328 const agent = event?.properties?.info?.agent || "";
310329 if (sessionId && agent && !BUILTIN_AGENTS.has(agent)) {
311330 agentSessions.set(sessionId, agent);
331+ pendingPush.set(sessionId, true);
312332 }
313333 }
334+
335+ if (event?.type === "message.updated") {
336+ const sessionId = event?.properties?.sessionID || "";
337+ if (sessionId && agentSessions.has(sessionId)) {
338+ pendingPush.set(sessionId, true);
339+ }
340+ }
341+
314342 if (event?.type === "session.idle") {
315343 const sessionId = event?.properties?.sessionID || "";
316344 if (!sessionId) return;
317345 const agent = agentSessions.get(sessionId);
318346 if (!agent) return;
347+ if (!pendingPush.get(sessionId)) return;
348+ pendingPush.delete(sessionId);
349+
319350 try {
320351 const messagesResult = await client.session.messages({ path: { id: sessionId } });
321352 const messages = messagesResult?.data || messagesResult || [];
@@ -325,14 +356,19 @@ def _opencode_plugin_js() -> str:
325356 if (newMessages.length === 0) return;
326357 const lines = messagesToLines(newMessages);
327358 if (lines.length === 0) return;
359+ const isFinal = event?.properties?.final === true || event?.properties?.reason === "completed";
328360 pushToServer({
329361 session_id: sessionId, ide: "opencode", lines, agent_id: agent,
330362 start_offset: state.lineOffset, hook_event: "session.idle",
331- final: true , total_line_count: state.lineOffset + lines.length,
363+ final: isFinal , total_line_count: state.lineOffset + lines.length,
332364 total_offset: state.lineOffset + lines.length,
333365 });
334366 for (const m of newMessages) state.pushedMessageIds.add(m.info?.id || m.id);
335367 state.lineOffset += lines.length;
368+ if (isFinal) {
369+ cleanupSession(sessionId);
370+ agentSessions.delete(sessionId);
371+ }
336372 } catch { /* Non-blocking */ }
337373 }
338374 },
@@ -740,22 +776,27 @@ def _collect_opencode_hook_plugins(hook_configs: list[dict]) -> list[dict]:
740776
741777def _opencode_command_hook_plugin (name : str , event : str , command : str ) -> str :
742778 """Generate a plugin file that runs a shell command on an OpenCode event."""
743- cmd_escaped = command .replace ("\\ " , "\\ \\ " ).replace ('"' , '\\ "' ).replace ("`" , "\\ `" )
779+ import json as _json
780+
781+ cmd_json = _json .dumps (command )
744782 return f"""// Observal hook plugin: { name }
745783// Event: { event }
746784// Auto-generated by `observal pull`
747785
748786import {{ execSync }} from "child_process";
749787
788+ const HOOK_COMMAND = { cmd_json } ;
789+
750790export const Hook_{ name .replace ("-" , "_" )} = async (ctx) => {{
751791 return {{
752792 event: async ({{ event }}) => {{
753793 if (event?.type === "{ event } ") {{
754794 try {{
755- execSync(" { cmd_escaped } " , {{
795+ execSync(HOOK_COMMAND , {{
756796 cwd: ctx.directory,
757797 timeout: 10000,
758798 stdio: ["pipe", "pipe", "pipe"],
799+ shell: true,
759800 }});
760801 }} catch {{
761802 // Non-blocking: don't break the session
@@ -769,25 +810,39 @@ def _opencode_command_hook_plugin(name: str, event: str, command: str) -> str:
769810
770811def _opencode_http_hook_plugin (name : str , event : str , url : str , timeout : int = 10 ) -> str :
771812 """Generate a plugin file that makes an HTTP request on an OpenCode event."""
772- url_escaped = url .replace ("\\ " , "\\ \\ " ).replace ('"' , '\\ "' )
813+ from urllib .parse import urlparse
814+
815+ parsed = urlparse (url )
816+ if parsed .scheme not in ("http" , "https" ) or not parsed .netloc :
817+ return f"// Observal hook plugin: { name } — SKIPPED (invalid URL)\n export {{}};\n "
818+ import json as _json
819+
820+ url_json = _json .dumps (url )
821+ is_https = url .startswith ("https" )
822+ req_module = "https" if is_https else "http"
773823 return f"""// Observal hook plugin: { name }
774824// Event: { event }
775825// Auto-generated by `observal pull`
776826
777- import {{ execFileSync }} from "child_process";
827+ import {{ request }} from "{ req_module } ";
828+
829+ const HOOK_URL = { url_json } ;
778830
779831export const Hook_{ name .replace ("-" , "_" )} = async (ctx) => {{
780832 return {{
781833 event: async ({{ event }}) => {{
782834 if (event?.type === "{ event } ") {{
783835 try {{
784- execFileSync("curl", [
785- "-s", "-X", "POST",
786- "-H", "Content-Type: application/json",
787- "--max-time", "{ timeout } ",
788- "-d", JSON.stringify({{ event: event?.type, properties: event?.properties || {{}} }}),
789- "{ url_escaped } ",
790- ], {{ timeout: { timeout * 1000 + 2000 } , stdio: ["pipe", "pipe", "pipe"] }});
836+ const body = JSON.stringify({{ event: event?.type, properties: event?.properties || {{}} }});
837+ const req = request(HOOK_URL, {{
838+ method: "POST",
839+ headers: {{ "Content-Type": "application/json", "Content-Length": Buffer.byteLength(body) }},
840+ timeout: { timeout * 1000 } ,
841+ }});
842+ req.on("error", () => {{}});
843+ req.on("timeout", () => {{ req.destroy(); }});
844+ req.write(body);
845+ req.end();
791846 }} catch {{
792847 // Non-blocking
793848 }}
0 commit comments