openlatch-client 0.6.22

OpenLatch runtime enforcement node — the capture-and-enforce adapter that evaluates every covered action against a coding agent's Autonomy Zone before it runs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
// OpenLatch — execution control for OpenCode. Managed by `openlatch init`; edits are healed.
//
// A thin shim with no imports and no policy logic: it spawns `openlatch-hook`,
// hands it one JSON payload on stdin and reads one line back. Only a Block
// verdict ever throws; every error in our own path answers Allow.
const OL_DIR = __OPENLATCH_DIR__;
const BUDGET_MS = 1500;
const TOAST_MS = 300;
const CAP_BYTES = __CAP_BYTES__;
const WIN = process.platform === "win32";
const HOOK = OL_DIR + (WIN ? "\\bin\\openlatch-hook.exe" : "/bin/openlatch-hook");
const TRUNCATE_ORDER = ["output", "args", "prompt"];
const SEEN = Symbol.for("openlatch.opencode.load-report");

const enc = new TextEncoder();
const size = (value) => enc.encode(JSON.stringify(value)).length;

// The longest string inside a value, as [holder, key] so it can be replaced.
function longest(holder, key, best) {
  const v = holder[key];
  if (typeof v === "string") {
    if (!best || v.length > best[0][best[1]].length) return [holder, key];
    return best;
  }
  if (v && typeof v === "object") {
    for (const k of Object.keys(v)) best = longest(v, k, best);
  }
  return best;
}

// Shorten the largest of `output`, `args` and `prompt`, in that order, until the
// whole serialized payload — the `truncated` list included — fits the cap. It
// works on a detached copy: `args` is OpenCode's live `output.args`, and the
// call it describes must run with the arguments the developer's agent gave.
function fit(payload) {
  if (size(payload) <= CAP_BYTES) return payload;
  const copy = JSON.parse(JSON.stringify(payload));
  const cut = [];
  for (const field of TRUNCATE_ORDER) {
    while (size(copy) > CAP_BYTES) {
      const target = longest(copy, field, null);
      if (!target || target[0][target[1]].length === 0) break;
      const s = target[0][target[1]];
      target[0][target[1]] = s.slice(0, Math.floor(s.length / 2));
      if (!cut.includes(field)) cut.push(field);
      copy.truncated = cut;
    }
    if (size(copy) <= CAP_BYTES) break;
  }
  return copy;
}

// The first line of a stream, then cancel it: a descendant holding the pipe
// open must never hold us.
async function firstLine(stream) {
  const reader = stream.getReader();
  const dec = new TextDecoder();
  let text = "";
  try {
    for (;;) {
      const { value, done } = await reader.read();
      if (done) break;
      text += dec.decode(value, { stream: true });
      const nl = text.indexOf("\n");
      if (nl >= 0) {
        text = text.slice(0, nl);
        break;
      }
    }
  } finally {
    // cancel() returns a promise: its rejection is handled here too, so an
    // errored stdout never escapes into OpenCode as an unhandled rejection.
    try { Promise.resolve(reader.cancel()).catch(() => {}); } catch {}
  }
  return text.trim();
}

// Spawn the hook. Returns the parsed verdict, or null for anything that is not
// a clean answer inside the budget. Never throws.
async function call(event, payload, captureOnly) {
  let proc = null;
  let timer = null;
  try {
    const argv = [HOOK, "--agent", "opencode", "--event", event, "--openlatch-dir", OL_DIR,
                  "--plugin-host-pid", String(process.pid)];
    if (captureOnly) argv.push("--capture-only");
    const body = JSON.stringify(fit(payload));
    // A record is not waited for, so its payload is written into the pipe
    // before we return: a Blob is written later, from the event loop, and is
    // lost when OpenCode exits right after the event (`session.idle` at the
    // end of `opencode run`), leaving the hook an empty stdin.
    proc = Bun.spawn(argv, {
      stdin: captureOnly ? "pipe" : new Blob([body]),
      stdout: captureOnly ? "ignore" : "pipe",
      stderr: "ignore",
      windowsHide: true,
    });
    if (captureOnly) {
      proc.stdin.write(body);
      proc.stdin.end();
      return null;
    }
    const answer = Promise.all([firstLine(proc.stdout), proc.exited]);
    const budget = new Promise((resolve) => { timer = setTimeout(() => resolve(null), BUDGET_MS); });
    const done = await Promise.race([answer, budget]);
    if (!done) {
      try { proc.kill("SIGKILL"); } catch {}
      return null;
    }
    const [line, code] = done;
    if (code !== 0 || !line) return null;
    const verdict = JSON.parse(line);
    return verdict && typeof verdict === "object" ? verdict : null;
  } catch {
    try { if (proc) proc.kill("SIGKILL"); } catch {}
    return null;
  } finally {
    if (timer) clearTimeout(timer);
  }
}

const isBlock = (v) => !!v && v.decision === "block" && typeof v.reason === "string" && v.reason.length > 0;

const LIFECYCLE = { "session.created": "session_start", "session.idle": "stop", "session.deleted": "session_end" };

// Session id -> parent id (a child) or null (a root), from `session.created`
// and from lookups. Bounded: the oldest entry goes first.
const CHILDREN = new Map();
const CHILDREN_MAX = 1024;
const LOOKUP_MS = 1000;

function remember(id, parent) {
  CHILDREN.delete(id);
  if (CHILDREN.size >= CHILDREN_MAX) CHILDREN.delete(CHILDREN.keys().next().value);
  CHILDREN.set(id, parent);
}

// Session id -> how many times it was deleted, bounded as CHILDREN is. A lookup
// that was in flight when its session was deleted is stale: its answer is
// neither remembered nor turned into a subagent_stop after the session_end.
const DELETIONS = new Map();

function deleted(id) {
  const count = (DELETIONS.get(id) || 0) + 1;
  DELETIONS.delete(id);
  if (DELETIONS.size >= CHILDREN_MAX) DELETIONS.delete(DELETIONS.keys().next().value);
  DELETIONS.set(id, count);
}

// A session OpenCode never announced to this process (a child resumed by
// `task_id`): asked once, within LOOKUP_MS. `ask` answers the session's info.
// Returns the parent id, null for a root, or undefined when the answer did not
// come — never remembered then.
async function lookup(ask) {
  let timer = null;
  try {
    const asked = Promise.resolve().then(ask).then(
      (info) => {
        if (!info || typeof info !== "object") return undefined;
        return typeof info.parentID === "string" ? info.parentID : info.parentID === undefined ? null : undefined;
      },
      () => undefined,
    );
    const late = new Promise((resolve) => { timer = setTimeout(() => resolve(undefined), LOOKUP_MS); });
    return await Promise.race([asked, late]);
  } catch {
    return undefined;
  } finally {
    if (timer) clearTimeout(timer);
  }
}

// One lifecycle event, on either line. `common(sessionId)` builds the shared
// fields; `parentOf` is the parent `session_start` announced (null for a root);
// `ask` is `lookup`'s question for a session this process never saw created.
async function lifecycle(wire, sessionId, parentOf, common, ask) {
  const payload = common(sessionId);
  if (wire === "session_start") {
    if (parentOf) payload.parent_session_id = parentOf;
    remember(sessionId, parentOf);
  }
  if (wire === "session_end") {
    CHILDREN.delete(sessionId);
    deleted(sessionId);
  }
  call(wire, payload, true);
  if (wire !== "stop") return;
  // A child's idle is also its subagent's end. Every idle is sent: a
  // repeated one after a cancel is recorded again.
  let parent = CHILDREN.get(sessionId);
  if (parent === undefined) {
    const generation = DELETIONS.get(sessionId);
    parent = await lookup(ask);
    if (DELETIONS.get(sessionId) !== generation) return;
    if (parent !== undefined) remember(sessionId, parent);
  }
  if (parent) call("subagent_stop", { ...common(sessionId), parent_session_id: parent }, true);
}

// Only a message whose every text part OpenCode marked synthetic (a background
// task's result injected into its parent) is not a prompt.
const allSynthetic = (parts) => {
  const text = parts.filter((p) => p && p.type === "text");
  return text.length > 0 && text.every((p) => p.synthetic === true);
};

// OpenCode's loader environment, read from its own process at report time (the
// daemon does not share it): the two config paths when set, whether inline
// config is set — never its value — and the disable switches, true for
// `true`/`1`/`yes`/`on`/`y` in any case, as OpenCode reads its flags.
function loaderEnv() {
  const env = process.env;
  const set = (name) => (typeof env[name] === "string" && env[name] !== "" ? env[name] : undefined);
  const flag = (name) => ["true", "1", "yes", "on", "y"].includes(String(set(name) || "").toLowerCase());
  return {
    config_dir: set("OPENCODE_CONFIG_DIR"),
    config_file: set("OPENCODE_CONFIG"),
    config_content_set: set("OPENCODE_CONFIG_CONTENT") !== undefined,
    disable_claude_code: flag("OPENCODE_DISABLE_CLAUDE_CODE"),
    disable_claude_code_prompt: flag("OPENCODE_DISABLE_CLAUDE_CODE_PROMPT"),
    disable_claude_code_skills: flag("OPENCODE_DISABLE_CLAUDE_CODE_SKILLS"),
    disable_external_skills: flag("OPENCODE_DISABLE_EXTERNAL_SKILLS"),
  };
}

// ---------------------------------------------------------------------------
// Relay arming (AGT-6 C-8 Leg A). Once per module evaluation, from
// `<OL_DIR>/model-relay/env.json`, before any model call: in a Node runtime
// (OpenCode Desktop 1.19) the OpenLatch local model relay's CA is appended to
// this process's default trust and its proxy is set for this process and the
// children it starts. Under Bun no proxy variable is ever written: Bun reads
// its CA only from the environment it started with, so a proxy without the CA
// would break TLS. Built-in modules come from process.getBuiltinModule: the
// plugin keeps no static module dependency. Nothing here ever throws.
// ---------------------------------------------------------------------------
const ARM = Symbol.for("openlatch.relay.arming");
const ARM_CA = Symbol.for("openlatch.relay.arming.ca");
const ENV_JSON_MAX = 16 * 1024;
const PROBE_MS = 500;
const ENV_JSON_KEYS = "HTTPS_PROXY,HTTP_PROXY,NODE_EXTRA_CA_CERTS,NO_PROXY,instance_id";
const INSTANCE_ID = /^[0-9a-f]{32}$/;
// undici misparses a bare `::1`, so the bracketed form rides beside it.
const LOOPBACK_NO_PROXY = ["localhost", "127.0.0.1", "::1", "[::1]"];

function builtin(name) {
  try {
    return typeof process.getBuiltinModule === "function" ? process.getBuiltinModule(name) : undefined;
  } catch {
    return undefined;
  }
}

// `http://127.0.0.1:<port>` or `http://[::1]:<port>`, nothing else.
function loopbackProxy(url) {
  const m = typeof url === "string" ? /^http:\/\/(127\.0\.0\.1|\[::1\]):(\d{1,5})\/?$/.exec(url) : null;
  if (!m) return null;
  const port = Number(m[2]);
  return port > 0 && port < 65536 ? { host: m[1] === "[::1]" ? "::1" : m[1], port } : null;
}

// An absolute path with no `..` segment, strictly under OL_DIR.
function insideOlDir(p) {
  const path = builtin("node:path");
  if (!path || typeof p !== "string" || !path.isAbsolute(p) || p.split(/[\\/]/).includes("..")) return false;
  const rel = path.relative(OL_DIR, p);
  return rel !== "" && !rel.startsWith("..") && !path.isAbsolute(rel);
}

// undefined: no file. null: a file the gate refuses. Else the five keys.
function readEnvJson() {
  try {
    const fs = builtin("node:fs");
    const path = builtin("node:path");
    if (!fs || !path) return null;
    const file = path.join(OL_DIR, "model-relay", "env.json");
    let raw;
    try {
      if (fs.statSync(file).size > ENV_JSON_MAX) return null;
      raw = fs.readFileSync(file, "utf8");
    } catch (e) {
      return e && e.code === "ENOENT" ? undefined : null;
    }
    if (raw.length > ENV_JSON_MAX) return null;
    const env = JSON.parse(raw);
    if (!env || typeof env !== "object" || Array.isArray(env)) return null;
    if (Object.keys(env).sort().join(",") !== ENV_JSON_KEYS) return null;
    if (!loopbackProxy(env.HTTPS_PROXY) || !loopbackProxy(env.HTTP_PROXY)) return null;
    if (typeof env.NO_PROXY !== "string" || !insideOlDir(env.NODE_EXTRA_CA_CERTS)) return null;
    if (typeof env.instance_id !== "string" || !INSTANCE_ID.test(env.instance_id)) return null;
    return env;
  } catch {
    return null;
  }
}

// One request to the relay: does the listener at that address answer with the
// instance id the file was written with? A stale file or another listener on
// the port never arms. A fresh agent, so a proxy armed earlier is never used.
function instanceMatches(env, ms) {
  return new Promise((resolve) => {
    let req = null;
    let settled = false;
    let timer = null;
    const finish = (ok) => {
      if (settled) return;
      settled = true;
      if (timer) clearTimeout(timer);
      try { if (req) req.destroy(); } catch {}
      resolve(ok);
    };
    timer = setTimeout(() => finish(false), ms);
    try {
      const http = builtin("node:http");
      const target = loopbackProxy(env.HTTPS_PROXY);
      req = http.request(
        { host: target.host, port: target.port, path: "/admin/model-relay/instance", method: "GET", agent: false },
        (res) => {
          if (res.statusCode !== 200) return finish(false);
          let body = "";
          res.setEncoding("utf8");
          res.on("data", (chunk) => {
            body += chunk;
            if (body.length > 1024) finish(false);
          });
          res.on("end", () => {
            try {
              finish(JSON.parse(body).instance_id === env.instance_id);
            } catch {
              finish(false);
            }
          });
          res.on("error", () => finish(false));
        },
      );
      req.on("error", () => finish(false));
      req.end();
    } catch {
      finish(false);
    }
  });
}

// Take this process direct again: the dispatcher's own restore, then every
// `process.env` key put back to its value (or absence) before arming. The CA
// appended stays: it cannot be removed without dropping OpenCode's own roots,
// and an unused extra root sends no traffic anywhere.
function disarm() {
  const armed = globalThis[ARM];
  if (!armed) return;
  try { if (typeof armed.restore === "function") armed.restore(); } catch {}
  for (const [key, value] of armed.saved) {
    if (value === undefined) delete process.env[key];
    else process.env[key] = value;
  }
  delete globalThis[ARM];
}

const norm = (pem) => String(pem).replace(/\r\n/g, "\n").trim();

// OpenCode's current default roots, every root this plugin appended before,
// and the relay CA, de-duplicated: OpenCode's own roots are kept.
function appendCa(tls, fs, caPath) {
  const pem = norm(fs.readFileSync(caPath, "utf8"));
  if (!pem.startsWith("-----BEGIN CERTIFICATE-----")) throw new Error("not a certificate");
  const ours = globalThis[ARM_CA] || [];
  const wanted = ours.includes(pem) ? ours : [...ours, pem];
  const current = tls.getCACertificates("default").map(norm);
  const missing = wanted.filter((c) => !current.includes(c));
  if (missing.length > 0) tls.setDefaultCACertificates([...current, ...missing]);
  // Remembered only once Node accepted it: a CA it rejected is never retried on
  // a later reload, so fixing the file is enough to arm again.
  globalThis[ARM_CA] = wanted;
}

function mergeNoProxy(...values) {
  const out = [];
  for (const value of values) {
    for (const entry of String(value || "").split(",")) {
      const e = entry.trim();
      if (e && !out.includes(e)) out.push(e);
    }
  }
  return out.join(",");
}

// Bun: whether the environment this process started with names the relay —
// every set HTTPS_PROXY case and NODE_EXTRA_CA_CERTS equal env.json's; with no
// env.json, a loopback proxy and a CA path inside OL_DIR.
function namesRelay(env) {
  const e = process.env;
  const proxies = [e.HTTPS_PROXY, e.https_proxy].filter((v) => typeof v === "string" && v !== "");
  const ca = e.NODE_EXTRA_CA_CERTS;
  if (proxies.length === 0 || typeof ca !== "string" || ca === "") return false;
  if (env) return proxies.every((v) => v === env.HTTPS_PROXY) && ca === env.NODE_EXTRA_CA_CERTS;
  return proxies.every((v) => loopbackProxy(v) !== null) && insideOlDir(ca);
}

async function armRelay() {
  const isBun = typeof process.versions.bun === "string";
  const runtime = isBun ? "bun" : "node";
  try {
    const env = readEnvJson();
    if (isBun) {
      return { runtime, arming: env !== null && namesRelay(env) ? "armed_by_environment" : "not_armed" };
    }
    const http = builtin("node:http");
    const tls = builtin("node:tls");
    const fs = builtin("node:fs");
    const apis =
      !!http && !!tls && !!fs &&
      typeof http.setGlobalProxyFromEnv === "function" &&
      typeof tls.setDefaultCACertificates === "function" &&
      typeof tls.getCACertificates === "function";
    if (!env || !apis || !(await instanceMatches(env, PROBE_MS))) {
      disarm();
      return { runtime, arming: "not_armed" };
    }
    disarm();
    appendCa(tls, fs, env.NODE_EXTRA_CA_CERTS);
    const e = process.env;
    const noProxy = mergeNoProxy(e.NO_PROXY, e.no_proxy, LOOPBACK_NO_PROXY.join(","));
    const proxy = {
      HTTPS_PROXY: env.HTTPS_PROXY,
      HTTP_PROXY: env.HTTP_PROXY,
      NO_PROXY: noProxy,
      https_proxy: env.HTTPS_PROXY,
      http_proxy: env.HTTP_PROXY,
      no_proxy: noProxy,
    };
    // Children (bash, git, npm, local MCP servers) get the CA the CLI shim gives
    // them, unless the developer set their own. This process's own trust never
    // relies on it: Node reads it at start only.
    const values = e.NODE_EXTRA_CA_CERTS ? proxy : { ...proxy, NODE_EXTRA_CA_CERTS: env.NODE_EXTRA_CA_CERTS };
    // Every prior value is read before any write: on Windows the two cases are
    // one variable.
    const saved = Object.keys(values).map((key) => [key, e[key]]);
    globalThis[ARM] = { restore: undefined, saved };
    for (const [key, value] of Object.entries(values)) e[key] = value;
    // An explicit object holding both cases, so a stale lowercase value never wins.
    globalThis[ARM].restore = http.setGlobalProxyFromEnv(proxy);
    return { runtime, arming: "armed_in_process" };
  } catch {
    try { if (!isBun) disarm(); } catch {}
    return { runtime, arming: "not_armed" };
  }
}

const ARMING = await armRelay();
// Each evaluation re-ran the arming, so its outcome is reported afresh: the
// once-per-stage guard dedupes the instances of THIS evaluation only. A hot
// reload that armed or disarmed must reach the daemon, whose newest report per
// surface is the arming evidence (D-14, D-15).
globalThis[SEEN] = {};

async function openlatch(ctx) {
  let mcpServers = [];
  // Read at each call, never at load: a host may evaluate this module before
  // it sets OPENCODE_CLIENT (observed under `opencode acp`).
  const common = (hook, sessionId) => ({
    opencode_hook: hook,
    session_id: sessionId,
    directory: ctx.directory,
    worktree: ctx.worktree,
    project_id: ctx.project ? ctx.project.id : undefined,
    opencode_client: process.env.OPENCODE_CLIENT || "cli",
    mcp_servers: mcpServers,
  });
  const loadReport = (stage, cfg) => {
    try {
      const seen = (globalThis[SEEN] = globalThis[SEEN] || {});
      if (seen[stage]) return;
      seen[stage] = true;
      call("plugin_loaded", {
        opencode_hook: "plugin_loaded",
        opencode_client: process.env.OPENCODE_CLIENT || "cli",
        directory: ctx.directory,
        worktree: ctx.worktree,
        project_id: ctx.project ? ctx.project.id : undefined,
        mcp_servers: mcpServers,
        // Only the config stage knows the effective list; the module stage
        // omits the field so the daemon keeps the last list it was given.
        plugins: cfg ? (Array.isArray(cfg.plugin) ? cfg.plugin.map(String) : []) : undefined,
        loader_env: loaderEnv(),
        relay_arming: ARMING.arming,
        runtime: ARMING.runtime,
      }, true);
    } catch {}
  };
  loadReport("module", undefined);

  return {
    config: async (cfg) => {
      try {
        if (cfg && cfg.mcp && typeof cfg.mcp === "object") mcpServers = Object.keys(cfg.mcp);
        loadReport("config", cfg);
      } catch {}
    },
    "tool.execute.before": async (input, output) => {
      const verdict = await call("pre_tool_use", {
        ...common("tool.execute.before", input && input.sessionID),
        call_id: input && input.callID,
        tool: input && input.tool,
        args: output && output.args,
      }, false);
      if (isBlock(verdict)) throw new Error(verdict.reason);
    },
    "chat.message": async (input, output) => {
      let verdict = null;
      try {
        const message = (output && output.message) || {};
        const model = (input && input.model) || message.model;
        const parts = (output && Array.isArray(output.parts)) ? output.parts : [];
        if (allSynthetic(parts)) return;
        verdict = await call("user_prompt_submit", {
          ...common("chat.message", input && input.sessionID),
          message_id: message.id || (input && input.messageID),
          agent: message.agent || (input && input.agent),
          model: model && model.providerID ? `${model.providerID}/${model.modelID}` : undefined,
          prompt: parts.filter((p) => p && p.type === "text").map((p) => p.text).join("\n"),
        }, false);
      } catch {
        verdict = null;
      }
      if (isBlock(verdict)) {
        try {
          await Promise.race([
            ctx.client.tui.showToast({ body: { variant: "error", message: verdict.reason } }),
            new Promise((resolve) => setTimeout(resolve, TOAST_MS)),
          ]);
        } catch {}
        throw new Error(verdict.reason);
      }
    },
    "tool.execute.after": async (input, output) => {
      try {
        const payload = {
          ...common("tool.execute.after", input && input.sessionID),
          call_id: input && input.callID,
          tool: input && input.tool,
          args: input && input.args,
          output: output && output.output,
          title: output && output.title,
        };
        const meta = output && output.metadata;
        if (payload.tool === "task" && meta && typeof meta.sessionId === "string") {
          payload.child_session_id = meta.sessionId;
        }
        call("post_tool_use", payload, true);
      } catch {}
    },
    // OpenCode awaits this hook before it compacts: the record is never waited for.
    "experimental.session.compacting": async (input) => {
      try {
        const sessionId = input && input.sessionID;
        if (typeof sessionId === "string" && sessionId) {
          call("pre_compact", common("experimental.session.compacting", sessionId), true);
        }
      } catch {}
    },
    event: async ({ event }) => {
      try {
        const wire = event && LIFECYCLE[event.type];
        if (!wire) return;
        const props = event.properties || {};
        const sessionId = props.sessionID || (props.info && props.info.id);
        if (!sessionId) return;
        const parent = props.info && typeof props.info.parentID === "string" ? props.info.parentID : null;
        await lifecycle(wire, sessionId, parent, (id) => common(event.type, id), () =>
          Promise.resolve(ctx.client.session.get({ path: { id: sessionId } })).then((res) => res && res.data));
      } catch {}
    },
  };
}

// ---------------------------------------------------------------------------
// OpenCode 2.0. Its loader reads `default` as `{id, setup}`, and `setup`
// registers each hook on its context instead of returning them; OpenCode 1.x
// reads the same object's `server` and never sees `setup`. The same `--event`s
// and payload fields as the hooks above, so the daemon reads both lines alike:
// `opencode_hook` keeps the 1.x hook's name and `opencode_api` says which line
// sent it. A Block verdict throws from `execute.before` (the call fails and the
// model reads the reason) or from `prompt` (the turn fails before the model).
// 1.19 calls `setup` too, after `server`, with a context that has no tool
// hooks: there `setup` does nothing, so nothing is registered or reported twice.
// ---------------------------------------------------------------------------
const LIFECYCLE_2 = {
  "session.created": ["session_start", "session.created"],
  "session.execution.succeeded": ["stop", "session.idle"],
  "session.execution.failed": ["stop", "session.idle"],
  "session.execution.interrupted": ["stop", "session.idle"],
  "session.deleted": ["session_end", "session.deleted"],
};

// What the model read of a completed call: its text parts, or a plain output.
function resultText(result) {
  if (!result || typeof result !== "object") return undefined;
  if (typeof result.output === "string") return result.output;
  const text = Array.isArray(result.content) ? result.content.filter((p) => p && p.type === "text") : [];
  return text.length > 0 ? text.map((p) => p.text).join("\n") : undefined;
}

async function setup(ctx) {
  if (!ctx || !ctx.tool || typeof ctx.tool.hook !== "function") return undefined;
  const location = ctx.location || {};
  const project = location.project || {};
  let mcpServers = [];
  const shared = () => ({
    opencode_api: 2,
    directory: location.directory,
    worktree: project.directory,
    project_id: project.id,
    opencode_client: process.env.OPENCODE_CLIENT || "cli",
    mcp_servers: mcpServers,
  });
  const common = (hook, sessionId) => ({ opencode_hook: hook, session_id: sessionId, ...shared() });
  const registrations = [];
  const register = async (domain, name, callback) => {
    try {
      registrations.push(await ctx[domain].hook(name, callback));
    } catch {}
  };

  // The MCP servers OpenCode knows, by name: those connected at setup, then
  // each one a status change names.
  const addServer = (name) => {
    if (typeof name === "string" && name && !mcpServers.includes(name)) mcpServers = [...mcpServers, name];
  };
  try {
    const listed = await ctx.mcp.list();
    for (const server of Array.isArray(listed && listed.data) ? listed.data : []) addServer(server && server.name);
  } catch {}
  try {
    const seen = (globalThis[SEEN] = globalThis[SEEN] || {});
    if (!seen.module) {
      seen.module = true;
      // 2.0 has no config hook: `plugins` is omitted, as the module stage omits it.
      call("plugin_loaded", {
        opencode_hook: "plugin_loaded",
        ...shared(),
        loader_env: loaderEnv(),
        relay_arming: ARMING.arming,
        runtime: ARMING.runtime,
      }, true);
    }
  } catch {}

  await register("tool", "execute.before", async (e) => {
    const verdict = await call("pre_tool_use", {
      ...common("tool.execute.before", e && e.sessionID),
      call_id: e && e.id,
      tool: e && e.tool,
      args: e && e.input,
    }, false);
    if (isBlock(verdict)) throw new Error(verdict.reason);
  });
  await register("tool", "execute.after", async (e) => {
    try {
      if (!e || e.status !== "completed") return;
      const meta = (e.result && e.result.metadata) || {};
      const payload = {
        ...common("tool.execute.after", e.sessionID),
        call_id: e.id,
        tool: e.tool,
        args: e.input,
        output: resultText(e.result),
        title: typeof meta.title === "string" ? meta.title : undefined,
      };
      if (payload.tool === "subagent" && typeof meta.sessionID === "string") {
        payload.child_session_id = meta.sessionID;
      }
      call("post_tool_use", payload, true);
    } catch {}
  });
  await register("session", "prompt", async (e) => {
    let verdict = null;
    try {
      const prompt = (e && e.prompt) || {};
      verdict = await call("user_prompt_submit", {
        ...common("chat.message", e && e.sessionID),
        message_id: e && e.messageID,
        prompt: typeof prompt.text === "string" ? prompt.text : "",
      }, false);
    } catch {
      verdict = null;
    }
    if (isBlock(verdict)) throw new Error(verdict.reason);
  });
  // OpenCode awaits this hook before it compacts: the record is never waited for.
  await register("session", "compaction", async (e) => {
    try {
      const sessionId = e && e.sessionID;
      if (typeof sessionId === "string" && sessionId) {
        call("pre_compact", common("experimental.session.compacting", sessionId), true);
      }
    } catch {}
  });

  // The lifecycle has no hook on 2.0: it is read from the event stream until
  // the plugin unloads.
  const stop = new AbortController();
  (async () => {
    try {
      for await (const event of ctx.event.subscribe({ signal: stop.signal })) {
        try {
          const data = (event && event.data) || {};
          if (event && event.type === "mcp.status.changed") addServer(data.server);
          const mapped = event && LIFECYCLE_2[event.type];
          const sessionId = data.sessionID;
          if (!mapped || typeof sessionId !== "string" || !sessionId) continue;
          const [wire, hook] = mapped;
          const parent = typeof data.parentID === "string" ? data.parentID : null;
          // Not awaited: a lookup never holds the next event.
          lifecycle(wire, sessionId, parent, (id) => common(hook, id), () =>
            Promise.resolve(ctx.session.get({ sessionID: sessionId }))).catch(() => {});
        } catch {}
      }
    } catch {}
  })();

  // 2.0 hot-reloads this file: the old copy lets go of everything it holds.
  return async () => {
    stop.abort();
    await Promise.all(registrations.map((r) => Promise.resolve().then(() => r.dispose()).catch(() => {})));
  };
}

export default { id: "openlatch", server: openlatch, setup };