openlatch-client 0.6.19

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
// 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. Returns the parent id, null for a
// root, or undefined when the answer did not come — never remembered then.
async function lookup(client, id) {
  let timer = null;
  try {
    const asked = Promise.resolve(client.session.get({ path: { id } })).then(
      (res) => {
        const info = res && res.data;
        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);
  }
}

// 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] = {};

export default 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 payload = common(event.type, sessionId);
        if (wire === "session_start") {
          const parent = props.info && typeof props.info.parentID === "string" ? props.info.parentID : null;
          if (parent) payload.parent_session_id = parent;
          remember(sessionId, parent);
        }
        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(ctx.client, sessionId);
          if (DELETIONS.get(sessionId) !== generation) return;
          if (parent !== undefined) remember(sessionId, parent);
        }
        if (parent) call("subagent_stop", { ...common(event.type, sessionId), parent_session_id: parent }, true);
      } catch {}
    },
  };
}