@xemahq/opencode-xema-plugin 0.3.0 → 0.4.0

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.
Files changed (67) hide show
  1. package/README.md +33 -40
  2. package/dist/.source-stamp.json +2 -2
  3. package/dist/context.d.ts +5 -14
  4. package/dist/context.d.ts.map +1 -1
  5. package/dist/context.js +4 -13
  6. package/dist/context.js.map +1 -1
  7. package/dist/hooks/compaction-invariants.d.ts +8 -0
  8. package/dist/hooks/compaction-invariants.d.ts.map +1 -0
  9. package/dist/hooks/compaction-invariants.js +60 -0
  10. package/dist/hooks/compaction-invariants.js.map +1 -0
  11. package/dist/hooks/root-turn-completion.d.ts +14 -0
  12. package/dist/hooks/root-turn-completion.d.ts.map +1 -0
  13. package/dist/hooks/root-turn-completion.js +84 -0
  14. package/dist/hooks/root-turn-completion.js.map +1 -0
  15. package/dist/hooks/shell-env-hygiene.d.ts +6 -0
  16. package/dist/hooks/shell-env-hygiene.d.ts.map +1 -0
  17. package/dist/hooks/shell-env-hygiene.js +10 -0
  18. package/dist/hooks/shell-env-hygiene.js.map +1 -0
  19. package/dist/hooks/subagent-fanout-ceiling.d.ts +18 -17
  20. package/dist/hooks/subagent-fanout-ceiling.d.ts.map +1 -1
  21. package/dist/hooks/subagent-fanout-ceiling.js +106 -92
  22. package/dist/hooks/subagent-fanout-ceiling.js.map +1 -1
  23. package/dist/hooks/system-overlay.d.ts +6 -0
  24. package/dist/hooks/system-overlay.d.ts.map +1 -0
  25. package/dist/hooks/system-overlay.js +40 -0
  26. package/dist/hooks/system-overlay.js.map +1 -0
  27. package/dist/hooks/tool-surface-observation.d.ts +14 -0
  28. package/dist/hooks/tool-surface-observation.d.ts.map +1 -0
  29. package/dist/hooks/tool-surface-observation.js +30 -0
  30. package/dist/hooks/tool-surface-observation.js.map +1 -0
  31. package/dist/hooks/workspace-listing.js +3 -3
  32. package/dist/hooks/workspace-listing.js.map +1 -1
  33. package/dist/index.d.ts +9 -5
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js +9 -70
  36. package/dist/index.js.map +1 -1
  37. package/dist/plugin-id.d.ts +2 -0
  38. package/dist/plugin-id.d.ts.map +1 -0
  39. package/dist/plugin-id.js +2 -0
  40. package/dist/plugin-id.js.map +1 -0
  41. package/dist/plugin.d.ts +4 -0
  42. package/dist/plugin.d.ts.map +1 -0
  43. package/dist/plugin.js +86 -0
  44. package/dist/plugin.js.map +1 -0
  45. package/dist/runtime-events.d.ts +17 -0
  46. package/dist/runtime-events.d.ts.map +1 -0
  47. package/dist/runtime-events.js +39 -0
  48. package/dist/runtime-events.js.map +1 -0
  49. package/package.json +12 -16
  50. package/{plugin.mjs → plugin/xema-runtime-bridge/index.js} +407 -415
  51. package/plugin/xema-runtime-bridge/package.json +8 -0
  52. package/dist/hooks/session-idle-autocommit.d.ts +0 -11
  53. package/dist/hooks/session-idle-autocommit.d.ts.map +0 -1
  54. package/dist/hooks/session-idle-autocommit.js +0 -206
  55. package/dist/hooks/session-idle-autocommit.js.map +0 -1
  56. package/dist/hooks/system-prompt-overlay.d.ts +0 -3
  57. package/dist/hooks/system-prompt-overlay.d.ts.map +0 -1
  58. package/dist/hooks/system-prompt-overlay.js +0 -30
  59. package/dist/hooks/system-prompt-overlay.js.map +0 -1
  60. package/dist/opencode-types.d.ts +0 -49
  61. package/dist/opencode-types.d.ts.map +0 -1
  62. package/dist/opencode-types.js +0 -2
  63. package/dist/opencode-types.js.map +0 -1
  64. package/dist/plugin-entry.d.ts +0 -2
  65. package/dist/plugin-entry.d.ts.map +0 -1
  66. package/dist/plugin-entry.js +0 -2
  67. package/dist/plugin-entry.js.map +0 -1
@@ -4,16 +4,6 @@ var __export = (target, all) => {
4
4
  __defProp(target, name, { get: all[name], enumerable: true });
5
5
  };
6
6
 
7
- // src/index.ts
8
- import { existsSync as existsSync4 } from "node:fs";
9
- import { join as join5 } from "node:path";
10
-
11
- // src/hooks/session-idle-autocommit.ts
12
- import { execFile } from "node:child_process";
13
- import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
14
- import { join as join2 } from "node:path";
15
- import { promisify } from "node:util";
16
-
17
7
  // src/context.ts
18
8
  import { existsSync, readFileSync } from "node:fs";
19
9
  import { join } from "node:path";
@@ -19703,9 +19693,6 @@ function debug(msg) {
19703
19693
  if (!DEBUG_ENABLED) return;
19704
19694
  console.log(`${PREFIX} [debug] ${msg}`);
19705
19695
  }
19706
- function isDebug() {
19707
- return DEBUG_ENABLED;
19708
- }
19709
19696
 
19710
19697
  // src/context.ts
19711
19698
  var ContextJsonSchema = external_exports.object({
@@ -19730,7 +19717,8 @@ var ContextJsonSchema = external_exports.object({
19730
19717
  // reviewable change set from those edits.
19731
19718
  "document"
19732
19719
  ]),
19733
- surface: external_exports.enum(["pipeline-run", "agent-session", "design-system-builder", "gate-review"]).optional()
19720
+ surface: external_exports.enum(["pipeline-run", "agent-session", "design-system-builder", "gate-review"]).optional(),
19721
+ stageKey: external_exports.string().nullish()
19734
19722
  }),
19735
19723
  project: external_exports.object({
19736
19724
  orgId: external_exports.string(),
@@ -19767,35 +19755,11 @@ var ContextJsonSchema = external_exports.object({
19767
19755
  // ship the llm-registry-api that drops the field.
19768
19756
  }),
19769
19757
  /**
19770
- * Optional `git` block consumed by the `session.idle` auto-commit hook
19771
- * (see `hooks/session-idle-autocommit.ts`). Written by the orchestrator
19772
- * when the session's `BranchMode` opts into per-turn auto-commit
19773
- * (`shared_dev_publish_to_prod` — the default). Absent / `disabled` for
19774
- * `branch_per_session` sessions, where commits are user-controlled.
19775
- *
19776
- * `repoSubPath` is the working repo's path relative to the workspace
19777
- * root. When omitted, the hook commits at the workspace root itself
19778
- * (single-repo workspace contract — multi-repo workspaces are handled
19779
- * by a future follow-up; see TODO(multi-repo) in the
19780
- * hook).
19758
+ * Working files the platform materialised (kernel `ContextJson.workingFiles`).
19759
+ * Only `slug` and `path` are read — by the compaction hook, which carries
19760
+ * them as continuation invariants. Unread fields are not validated here.
19781
19761
  */
19782
- git: external_exports.object({
19783
- autoCommit: external_exports.enum(["enabled", "disabled"]),
19784
- repoSubPath: external_exports.string().nullish()
19785
- }).nullish(),
19786
- /**
19787
- * Optional `actor` block consumed by the `session.idle` auto-commit
19788
- * hook. Mirrors `ActorRefDto` in workspace-git-api: the subject that
19789
- * triggered the turn. Stamped at session-start by the orchestrator
19790
- * (single-user binding) or rotated per-turn by the multi-user session
19791
- * extension. Absent for non-session invocations (pipeline-run agents
19792
- * never auto-commit).
19793
- */
19794
- actor: external_exports.object({
19795
- kind: external_exports.enum(["org_user"]),
19796
- subjectRef: external_exports.string().min(1),
19797
- displayName: external_exports.string().min(1)
19798
- }).nullish()
19762
+ workingFiles: external_exports.array(external_exports.object({ slug: external_exports.string(), path: external_exports.string() }).loose()).optional()
19799
19763
  });
19800
19764
  function readInvocationContextForHook(workspaceDir, hookName) {
19801
19765
  const path = join(workspaceDir, "context.json");
@@ -19815,220 +19779,186 @@ function readInvocationContextForHook(workspaceDir, hookName) {
19815
19779
  }
19816
19780
  }
19817
19781
 
19818
- // src/hooks/session-idle-autocommit.ts
19819
- var HOOK_NAME = "session.idle";
19820
- var DEFAULT_JWT_PATH = "/run/xema/actor.jwt";
19821
- var PUSH_RETRY_DELAY_MS = 5e3;
19822
- var execFileAsync = promisify(execFile);
19823
- var conflictFlagBySessionId = /* @__PURE__ */ new Map();
19824
- var turnCounterBySessionId = /* @__PURE__ */ new Map();
19825
- function buildSessionIdleAutocommit(workspaceDir) {
19826
- return async (input2) => {
19827
- const evt = input2.event;
19828
- if (evt.type !== "session.idle") {
19782
+ // src/hooks/compaction-invariants.ts
19783
+ var XEMA_COMPACTION_METADATA_KEY = "xema.compaction-invariants";
19784
+ var MAX_COMPACTION_INVARIANTS_CHARS = 2e3;
19785
+ var MAX_LISTED_ITEMS = 20;
19786
+ function buildCompactionInvariantsHook(directory) {
19787
+ return (input2) => {
19788
+ const ctx = readInvocationContextForHook(directory, "session.compaction");
19789
+ if (!ctx) {
19829
19790
  return;
19830
19791
  }
19831
- const idleEvent = evt;
19832
- try {
19833
- await handleSessionIdle(workspaceDir, idleEvent);
19834
- } catch (err) {
19835
- const message = err instanceof Error ? err.message : String(err);
19836
- warn(
19837
- `hook=${HOOK_NAME}: unexpected error swallowed (auto-commit is best-effort): ${message}`
19838
- );
19792
+ const text = renderCompactionInvariants(ctx);
19793
+ const part = {
19794
+ type: "text",
19795
+ text,
19796
+ metadata: { [XEMA_COMPACTION_METADATA_KEY]: true }
19797
+ };
19798
+ const existing = input2.system.findIndex(
19799
+ (entry) => entry.metadata?.[XEMA_COMPACTION_METADATA_KEY] === true
19800
+ );
19801
+ if (existing === -1) {
19802
+ input2.system.push(part);
19803
+ } else {
19804
+ input2.system[existing] = part;
19839
19805
  }
19806
+ debug(`session.compaction: contributed ${text.length} chars of continuation invariants`);
19840
19807
  };
19841
19808
  }
19842
- async function handleSessionIdle(workspaceDir, evt) {
19843
- const sessionId = evt.properties.sessionID;
19844
- if (!sessionId) {
19845
- debug(`hook=${HOOK_NAME}: event missing sessionID; skipping`);
19846
- return;
19847
- }
19848
- const ctx = readInvocationContextForHook(workspaceDir, HOOK_NAME);
19849
- if (!ctx) {
19850
- return;
19851
- }
19852
- if (ctx.git?.autoCommit !== "enabled") {
19853
- debug(
19854
- `hook=${HOOK_NAME}: session=${sessionId} autoCommit not enabled (got ${ctx.git?.autoCommit ?? "absent"}); skipping`
19855
- );
19856
- return;
19857
- }
19858
- if (!ctx.actor) {
19859
- warn(
19860
- `hook=${HOOK_NAME}: session=${sessionId} has git.autoCommit=enabled but no actor in context.json; skipping. The orchestrator must stamp context.actor for sessions opted into per-turn auto-commit.`
19861
- );
19862
- return;
19863
- }
19864
- const apiUrl = process.env.WORKSPACE_GIT_API_URL;
19865
- if (!apiUrl || apiUrl.length === 0) {
19866
- warn(
19867
- `hook=${HOOK_NAME}: session=${sessionId} WORKSPACE_GIT_API_URL env not set; skipping auto-commit. Wire the env into the worker container envelope.`
19868
- );
19869
- return;
19809
+ function listed(items) {
19810
+ const shown = items.slice(0, MAX_LISTED_ITEMS).join(", ");
19811
+ const more = items.length - MAX_LISTED_ITEMS;
19812
+ return more > 0 ? `${shown} (+${more} more)` : shown;
19813
+ }
19814
+ function renderCompactionInvariants(ctx) {
19815
+ const inv = ctx.invocation;
19816
+ const lines = [
19817
+ 'Xema continuation invariants. Preserve these facts verbatim in the summary (under "## Additional Context"); they identify the invocation and its deliverable contract and must survive compaction. Do not invent anything beyond them.',
19818
+ `- invocation: role=${inv.role}` + (inv.surface ? `, surface=${inv.surface}` : "") + (inv.runId ? `, runId=${inv.runId}` : "") + (inv.stageKey ? `, stageKey=${inv.stageKey}` : "") + (inv.sessionId ? `, sessionId=${inv.sessionId}` : "") + `, attempt=${inv.attempt}`,
19819
+ `- writePolicy: ${ctx.authority.writePolicy}`
19820
+ ];
19821
+ if (ctx.authority.mayWriteSlugs.length > 0) {
19822
+ lines.push(`- deliverable slugs: ${listed(ctx.authority.mayWriteSlugs)}`);
19870
19823
  }
19871
- const jwtPath = process.env.WORKER_ACTOR_JWT_PATH ?? DEFAULT_JWT_PATH;
19872
- const actorJwt = readActorJwt(jwtPath);
19873
- if (actorJwt === null) {
19874
- warn(
19875
- `hook=${HOOK_NAME}: session=${sessionId} actor JWT not readable at ${jwtPath}; skipping auto-commit. Verify the orchestrator mounts the actor JWT before session start.`
19876
- );
19877
- return;
19824
+ const files = ctx.workingFiles ?? [];
19825
+ if (files.length > 0) {
19826
+ lines.push(`- working files: ${listed(files.map((file2) => `${file2.slug} \u2192 ${file2.path}`))}`);
19878
19827
  }
19879
- const repoSubPath = ctx.git.repoSubPath ?? null;
19880
- const repoDir = repoSubPath ? join2(workspaceDir, repoSubPath) : workspaceDir;
19881
- const dirty = await isGitTreeDirty(repoDir);
19882
- if (dirty === void 0) {
19883
- return;
19828
+ const text = lines.join("\n");
19829
+ return text.length > MAX_COMPACTION_INVARIANTS_CHARS ? `${text.slice(0, MAX_COMPACTION_INVARIANTS_CHARS - 1)}\u2026` : text;
19830
+ }
19831
+
19832
+ // src/runtime-events.ts
19833
+ var TERMINAL_EVENT_TYPES = Object.freeze({
19834
+ "session.execution.succeeded": "succeeded" /* Succeeded */,
19835
+ "session.execution.failed": "failed" /* Failed */,
19836
+ "session.execution.interrupted": "interrupted" /* Interrupted */
19837
+ });
19838
+ function isRecord(value) {
19839
+ return value !== null && typeof value === "object" && !Array.isArray(value);
19840
+ }
19841
+ function parseRuntimeSessionEvent(event) {
19842
+ if (!isRecord(event) || typeof event.type !== "string" || !isRecord(event.data)) {
19843
+ return void 0;
19884
19844
  }
19885
- if (!dirty) {
19886
- debug(
19887
- `hook=${HOOK_NAME}: session=${sessionId} clean tree at ${repoDir}; skipping auto-commit`
19888
- );
19889
- return;
19845
+ const sessionId = event.data.sessionID;
19846
+ if (typeof sessionId !== "string" || sessionId.length === 0) {
19847
+ return void 0;
19890
19848
  }
19891
- const turn = nextTurn(sessionId);
19892
- const body = {
19893
- turn,
19894
- actor: {
19895
- kind: ctx.actor.kind,
19896
- subjectRef: ctx.actor.subjectRef,
19897
- displayName: ctx.actor.displayName
19898
- },
19899
- // Wire contract: the worker reports the OUTCOME. For the v1
19900
- // hook we forward "needs commit; service will perform / record it
19901
- // via the workspace-proxy bridge once available". Until then
19902
- // workspace-git-api treats `committed=true` as "you produced a
19903
- // commit locally, please record it"; the actual git push happens
19904
- // through the bridge.
19905
- committed: true,
19906
- sha: null,
19907
- pushFailed: false
19908
- };
19909
- const endpoint = buildAutocommitUrl(apiUrl, sessionId);
19910
- const response = await postAutocommit(endpoint, actorJwt, body, ctx);
19911
- if (response === null) {
19912
- return;
19849
+ if (event.type === "session.created") {
19850
+ const parent = event.data.parentID;
19851
+ return {
19852
+ kind: "created",
19853
+ sessionId,
19854
+ parentId: typeof parent === "string" && parent.length > 0 ? parent : void 0
19855
+ };
19913
19856
  }
19914
- if (response.conflictDetected) {
19915
- conflictFlagBySessionId.set(sessionId, { at: Date.now(), turn });
19916
- info(
19917
- `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} conflict detected (remote diverged); next user message will surface the Publish affordance`
19918
- );
19919
- return;
19857
+ const outcome = Object.hasOwn(TERMINAL_EVENT_TYPES, event.type) ? TERMINAL_EVENT_TYPES[event.type] : void 0;
19858
+ if (outcome === void 0 || typeof event.id !== "string" || event.id.length === 0) {
19859
+ return void 0;
19920
19860
  }
19921
- if (response.pushFailed) {
19922
- info(
19923
- `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} push failed; retrying once after ${PUSH_RETRY_DELAY_MS}ms`
19924
- );
19925
- await sleep(PUSH_RETRY_DELAY_MS);
19926
- const retry = await postAutocommit(endpoint, actorJwt, body, ctx);
19927
- if (retry === null || retry.pushFailed) {
19861
+ return { kind: "terminal", sessionId, outcome, executionId: event.id };
19862
+ }
19863
+
19864
+ // src/hooks/root-turn-completion.ts
19865
+ var HOOK_NAME = "turn-completed";
19866
+ var TURN_COMPLETED_PATH = "/runtime/turn-completed";
19867
+ var WORKSPACE_PROXY_URL_ENV = "WORKSPACE_PROXY_URL";
19868
+ var SERVER_PASSWORD_ENV = "OPENCODE_SERVER_PASSWORD";
19869
+ var MAX_REMEMBERED_KEYS = 1e3;
19870
+ function createRootTurnCompletion(options) {
19871
+ const parentBySession = /* @__PURE__ */ new Map();
19872
+ const delivered = /* @__PURE__ */ new Set();
19873
+ async function isRoot(sessionId) {
19874
+ if (parentBySession.has(sessionId)) return parentBySession.get(sessionId) === void 0;
19875
+ try {
19876
+ const parent = await options.resolveParentId(sessionId);
19877
+ parentBySession.set(sessionId, parent);
19878
+ return parent === void 0;
19879
+ } catch (err) {
19928
19880
  warn(
19929
- `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} push failed twice; giving up (auto-commit is best-effort). User-driven Publish flow remains available.`
19881
+ `hook=${HOOK_NAME}: cannot tell whether session=${sessionId} is a root (${err instanceof Error ? err.message : String(err)}); skipping rather than guessing.`
19930
19882
  );
19931
- return;
19932
- }
19933
- if (retry.conflictDetected) {
19934
- conflictFlagBySessionId.set(sessionId, { at: Date.now(), turn });
19935
- info(
19936
- `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} retry surfaced conflict`
19937
- );
19938
- return;
19883
+ return void 0;
19939
19884
  }
19940
- info(
19941
- `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} retry committed=${retry.committed} sha=${retry.sha ?? "-"}`
19942
- );
19943
- return;
19944
19885
  }
19945
- info(
19946
- `hook=${HOOK_NAME}: session=${sessionId} turn=${turn} committed=${response.committed} sha=${response.sha ?? "-"}`
19947
- );
19948
- }
19949
- function nextTurn(sessionId) {
19950
- const prev = turnCounterBySessionId.get(sessionId) ?? 0;
19951
- const next = prev + 1;
19952
- turnCounterBySessionId.set(sessionId, next);
19953
- return next;
19954
- }
19955
- function readActorJwt(path) {
19956
- try {
19957
- if (!existsSync2(path)) {
19958
- return null;
19886
+ function remember(key) {
19887
+ delivered.add(key);
19888
+ if (delivered.size > MAX_REMEMBERED_KEYS) {
19889
+ const oldest = delivered.values().next().value;
19890
+ if (oldest !== void 0) delivered.delete(oldest);
19959
19891
  }
19960
- const raw = readFileSync2(path, "utf-8").trim();
19961
- return raw.length > 0 ? raw : null;
19962
- } catch (err) {
19963
- const message = err instanceof Error ? err.message : String(err);
19964
- warn(`hook=${HOOK_NAME}: failed to read actor JWT at ${path}: ${message}`);
19965
- return null;
19966
- }
19967
- }
19968
- async function isGitTreeDirty(repoDir) {
19969
- try {
19970
- const { stdout } = await execFileAsync(
19971
- "git",
19972
- ["-C", repoDir, "status", "--porcelain"],
19973
- { encoding: "utf-8", maxBuffer: 16 * 1024 * 1024 }
19974
- );
19975
- return stdout.trim().length > 0;
19976
- } catch (err) {
19977
- const message = err instanceof Error ? err.message : String(err);
19978
- warn(
19979
- `hook=${HOOK_NAME}: git status failed in ${repoDir}: ${message}. Skipping auto-commit for this turn.`
19980
- );
19981
- return void 0;
19982
19892
  }
19983
- }
19984
- function buildAutocommitUrl(apiBase, sessionId) {
19985
- const base = apiBase.endsWith("/") ? apiBase.slice(0, -1) : apiBase;
19986
- return `${base}/sessions/${encodeURIComponent(sessionId)}/autocommit`;
19987
- }
19988
- async function postAutocommit(url2, actorJwt, body, ctx) {
19989
- try {
19990
- const res = await fetch(url2, {
19991
- method: "POST",
19992
- headers: {
19993
- "Content-Type": "application/json",
19994
- Authorization: `Bearer ${actorJwt}`
19995
- },
19996
- body: JSON.stringify(body)
19997
- });
19998
- if (!res.ok) {
19999
- const text = await safeReadBody(res);
19893
+ async function onSessionTerminal(sessionId, outcome, executionId) {
19894
+ try {
19895
+ if (outcome !== "succeeded" /* Succeeded */) {
19896
+ debug(`hook=${HOOK_NAME}: session=${sessionId} ended ${outcome}; not a commit point`);
19897
+ return;
19898
+ }
19899
+ const key = `${sessionId}:${executionId}`;
19900
+ if (delivered.has(key)) {
19901
+ debug(`hook=${HOOK_NAME}: ${key} already notified`);
19902
+ return;
19903
+ }
19904
+ if (await isRoot(sessionId) !== true) return;
19905
+ const env = options.env ?? process.env;
19906
+ const base = env[WORKSPACE_PROXY_URL_ENV];
19907
+ const password = env[SERVER_PASSWORD_ENV];
19908
+ if (!base || !password) {
19909
+ warn(
19910
+ `hook=${HOOK_NAME}: ${!base ? WORKSPACE_PROXY_URL_ENV : SERVER_PASSWORD_ENV} is not set; cannot notify workspace-proxy that session=${sessionId} completed a turn.`
19911
+ );
19912
+ return;
19913
+ }
19914
+ const url2 = `${base.replace(/\/$/, "")}${TURN_COMPLETED_PATH}`;
19915
+ const res = await fetch(url2, {
19916
+ method: "POST",
19917
+ headers: {
19918
+ "Content-Type": "application/json",
19919
+ Authorization: `Basic ${Buffer.from(`opencode:${password}`).toString("base64")}`,
19920
+ "Idempotency-Key": key
19921
+ },
19922
+ body: JSON.stringify({ runtimeSessionId: sessionId, executionId, outcome })
19923
+ });
19924
+ if (!res.ok) {
19925
+ const text = await res.text().catch(() => "<unreadable body>");
19926
+ warn(`hook=${HOOK_NAME}: POST ${url2} responded ${res.status}: ${text.slice(0, 512)}`);
19927
+ return;
19928
+ }
19929
+ remember(key);
19930
+ debug(`hook=${HOOK_NAME}: notified ${key}`);
19931
+ } catch (err) {
20000
19932
  warn(
20001
- `hook=${HOOK_NAME}: POST ${url2} responded ${res.status} ${res.statusText} (role=${ctx.invocation.role}, runId=${ctx.invocation.runId ?? "-"}): ${text}`
19933
+ `hook=${HOOK_NAME}: unexpected error swallowed (best-effort): ` + (err instanceof Error ? err.message : String(err))
20002
19934
  );
20003
- return null;
20004
19935
  }
20005
- const parsed = await res.json();
20006
- return parsed;
20007
- } catch (err) {
20008
- const message = err instanceof Error ? err.message : String(err);
20009
- warn(`hook=${HOOK_NAME}: POST ${url2} failed: ${message}`);
20010
- return null;
20011
19936
  }
19937
+ return {
19938
+ onSessionCreated: (sessionId, parentId) => {
19939
+ parentBySession.set(sessionId, parentId);
19940
+ },
19941
+ onSessionTerminal
19942
+ };
20012
19943
  }
20013
- async function safeReadBody(res) {
20014
- try {
20015
- const text = await res.text();
20016
- return text.length > 512 ? `${text.slice(0, 512)}\u2026` : text;
20017
- } catch {
20018
- return "<unreadable body>";
19944
+
19945
+ // src/hooks/shell-env-hygiene.ts
19946
+ var SERVER_PASSWORD_ENV_KEYS = Object.freeze([
19947
+ "OPENCODE_PASSWORD",
19948
+ "OPENCODE_SERVER_PASSWORD"
19949
+ ]);
19950
+ function scrubShellEnv(input2) {
19951
+ for (const key of SERVER_PASSWORD_ENV_KEYS) {
19952
+ delete input2.env[key];
20019
19953
  }
20020
19954
  }
20021
- function sleep(ms) {
20022
- return new Promise((resolve) => {
20023
- setTimeout(resolve, ms);
20024
- });
20025
- }
20026
19955
 
20027
19956
  // src/hooks/subagent-fanout-ceiling.ts
20028
- var TASK_TOOL_ID = "task";
19957
+ var SUBAGENT_TOOL_ID = "subagent";
20029
19958
  var DEFAULT_SUBAGENT_FANOUT_LIMIT = 8;
20030
19959
  var DEFAULT_FANOUT_WAIT_TIMEOUT_MS = 10 * 60 * 1e3;
20031
- var DEFAULT_PERMIT_TTL_MS = 30 * 60 * 1e3;
19960
+ var DEFAULT_ACTIVE_CHILD_TTL_MS = 30 * 60 * 1e3;
19961
+ var DEFAULT_PENDING_SLOT_TTL_MS = 2 * 60 * 1e3;
20032
19962
  var FANOUT_LIMIT_ENV = "XEMA_SUBAGENT_FANOUT_LIMIT";
20033
19963
  function resolveSubagentFanoutLimit(env = process.env) {
20034
19964
  const declared = env[FANOUT_LIMIT_ENV];
@@ -20038,7 +19968,7 @@ function resolveSubagentFanoutLimit(env = process.env) {
20038
19968
  const parsed = Number(declared.trim());
20039
19969
  if (!Number.isInteger(parsed)) {
20040
19970
  throw new Error(
20041
- `${FANOUT_LIMIT_ENV}="${declared}" is not an integer. Expected a whole number of concurrently executing sub-agent tasks between 1 and ${DEFAULT_SUBAGENT_FANOUT_LIMIT}.`
19971
+ `${FANOUT_LIMIT_ENV}="${declared}" is not an integer. Expected a whole number of concurrently running sub-agents between 1 and ${DEFAULT_SUBAGENT_FANOUT_LIMIT}.`
20042
19972
  );
20043
19973
  }
20044
19974
  if (parsed < 1) {
@@ -20056,15 +19986,17 @@ function resolveSubagentFanoutLimit(env = process.env) {
20056
19986
  function createSubagentFanoutCeiling(options) {
20057
19987
  const limit = options.limit;
20058
19988
  const waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_FANOUT_WAIT_TIMEOUT_MS;
20059
- const permitTtlMs = options.permitTtlMs ?? DEFAULT_PERMIT_TTL_MS;
19989
+ const pendingSlotTtlMs = options.pendingSlotTtlMs ?? DEFAULT_PENDING_SLOT_TTL_MS;
19990
+ const activeChildTtlMs = options.activeChildTtlMs ?? DEFAULT_ACTIVE_CHILD_TTL_MS;
20060
19991
  const now = options.now ?? (() => Date.now());
20061
- const held = /* @__PURE__ */ new Map();
19992
+ const pending = [];
19993
+ const active = /* @__PURE__ */ new Map();
20062
19994
  const waiters = [];
19995
+ const armed = /* @__PURE__ */ new Set();
20063
19996
  let waitedCount = 0;
20064
19997
  let timedOutCount = 0;
20065
- let sweptCount = 0;
20066
- let ttlReclaimedCount = 0;
20067
- const armed = /* @__PURE__ */ new Set();
19998
+ let pendingTtlReclaimedCount = 0;
19999
+ let activeTtlReclaimedCount = 0;
20068
20000
  function arm(fn, ms, keepProcessAlive) {
20069
20001
  const timer = setTimeout(() => {
20070
20002
  armed.delete(timer);
@@ -20079,133 +20011,152 @@ function createSubagentFanoutCeiling(options) {
20079
20011
  clearTimeout(timer);
20080
20012
  armed.delete(timer);
20081
20013
  }
20082
- function take(callId, sessionId) {
20083
- const ttlTimer = arm(() => {
20084
- if (!held.has(callId)) return;
20085
- ttlReclaimedCount += 1;
20086
- warn(
20087
- `fanout-ceiling: permit TTL expired for call=${callId} session=${sessionId} after ${permitTtlMs}ms \u2014 force-releasing. A task held a slot longer than any healthy sub-agent turn; the pool would otherwise have shrunk permanently.`
20088
- );
20089
- release(callId, "ttl");
20090
- }, permitTtlMs, false);
20091
- held.set(callId, { sessionId, acquiredAt: now(), ttlTimer });
20092
- }
20093
- function release(callId, reason) {
20094
- const permit = held.get(callId);
20095
- if (permit === void 0) return;
20096
- disarm(permit.ttlTimer);
20097
- held.delete(callId);
20098
- if (reason !== "completed") {
20099
- debug(
20100
- `fanout-ceiling: reclaimed call=${callId} via ${reason} (held ${now() - permit.acquiredAt}ms)`
20101
- );
20102
- }
20014
+ const occupied = () => pending.length + active.size;
20015
+ function reserve(callId, parentSessionId) {
20016
+ const slot = { callId, parentSessionId, since: now(), timer: void 0 };
20017
+ slot.timer = arm(
20018
+ () => {
20019
+ const index = pending.indexOf(slot);
20020
+ if (index === -1) return;
20021
+ pending.splice(index, 1);
20022
+ pendingTtlReclaimedCount += 1;
20023
+ warn(
20024
+ `fanout-ceiling: reserved slot for call=${callId} session=${parentSessionId} never became a child session within ${pendingSlotTtlMs}ms \u2014 reclaiming (its execute.after was lost; V2 does not guarantee it on abort).`
20025
+ );
20026
+ drain();
20027
+ },
20028
+ pendingSlotTtlMs,
20029
+ false
20030
+ );
20031
+ pending.push(slot);
20032
+ }
20033
+ function activate(childSessionId, parentSessionId) {
20034
+ const timer = arm(
20035
+ () => {
20036
+ if (!active.has(childSessionId)) return;
20037
+ activeTtlReclaimedCount += 1;
20038
+ warn(
20039
+ `fanout-ceiling: child session=${childSessionId} held a slot for ${activeChildTtlMs}ms with no terminal event \u2014 force-releasing so the pool cannot shrink permanently.`
20040
+ );
20041
+ releaseActive(childSessionId, "active-ttl");
20042
+ },
20043
+ activeChildTtlMs,
20044
+ false
20045
+ );
20046
+ active.set(childSessionId, { childSessionId, parentSessionId, since: now(), timer });
20047
+ }
20048
+ function releaseActive(childSessionId, reason) {
20049
+ const child = active.get(childSessionId);
20050
+ if (child === void 0) return;
20051
+ disarm(child.timer);
20052
+ active.delete(childSessionId);
20053
+ debug(
20054
+ `fanout-ceiling: released child=${childSessionId} via ${reason} (held ${now() - child.since}ms, occupied=${occupied()}/${limit})`
20055
+ );
20103
20056
  drain();
20104
20057
  }
20105
20058
  function drain() {
20106
- while (waiters.length > 0 && held.size < limit) {
20059
+ while (waiters.length > 0 && occupied() < limit) {
20107
20060
  const next = waiters.shift();
20108
20061
  if (next === void 0) return;
20109
20062
  disarm(next.timer);
20110
- const waitedMs = now() - next.since;
20111
20063
  waitedCount += 1;
20112
- take(next.callId, next.sessionId);
20064
+ reserve(next.callId, next.parentSessionId);
20113
20065
  info(
20114
- `fanout-ceiling: admitted call=${next.callId} session=${next.sessionId} after waiting ${waitedMs}ms (limit=${limit}, in-flight=${held.size}, still waiting=${waiters.length})`
20066
+ `fanout-ceiling: admitted call=${next.callId} session=${next.parentSessionId} after waiting ${now() - next.since}ms (limit=${limit}, occupied=${occupied()}, still waiting=${waiters.length})`
20115
20067
  );
20116
20068
  next.grant();
20117
20069
  }
20118
20070
  }
20119
- async function before(input2) {
20120
- if (input2.tool !== TASK_TOOL_ID) return;
20121
- const { callID: callId, sessionID: sessionId } = input2;
20122
- if (held.size < limit) {
20123
- take(callId, sessionId);
20124
- debug(
20125
- `fanout-ceiling: admitted call=${callId} immediately (in-flight=${held.size}/${limit})`
20126
- );
20071
+ async function beforeToolCall(input2) {
20072
+ if (input2.tool !== SUBAGENT_TOOL_ID) return;
20073
+ const { id: callId, sessionID: parentSessionId } = input2;
20074
+ if (occupied() < limit) {
20075
+ reserve(callId, parentSessionId);
20127
20076
  return;
20128
20077
  }
20129
20078
  const since = now();
20130
20079
  warn(
20131
- `fanout-ceiling: sub-agent fan-out at ceiling \u2014 call=${callId} session=${sessionId} is WAITING (limit=${limit}, in-flight=${held.size}, already waiting=${waiters.length}). This is backpressure, not a refusal: the task will start when a slot frees.`
20080
+ `fanout-ceiling: sub-agent fan-out at ceiling \u2014 call=${callId} session=${parentSessionId} is WAITING (limit=${limit}, pending=${pending.length}, active=${active.size}, already waiting=${waiters.length}). Backpressure, not a refusal.`
20132
20081
  );
20133
20082
  await new Promise((resolve) => {
20134
- const waiter = {
20135
- callId,
20136
- sessionId,
20137
- since,
20138
- grant: resolve,
20139
- timer: void 0
20140
- };
20141
- waiter.timer = arm(() => {
20142
- const idx = waiters.indexOf(waiter);
20143
- if (idx === -1) return;
20144
- waiters.splice(idx, 1);
20145
- timedOutCount += 1;
20146
- warn(
20147
- `fanout-ceiling: call=${callId} session=${sessionId} waited ${waitTimeoutMs}ms and is being ADMITTED OVER the ceiling (limit=${limit}, in-flight=${held.size}). The pool did not free a slot in that window \u2014 suspect a leaked permit or a stuck sub-agent.`
20148
- );
20149
- take(callId, sessionId);
20150
- resolve();
20151
- }, waitTimeoutMs, true);
20083
+ const waiter = { callId, parentSessionId, since, grant: resolve, timer: void 0 };
20084
+ waiter.timer = arm(
20085
+ () => {
20086
+ const index = waiters.indexOf(waiter);
20087
+ if (index === -1) return;
20088
+ waiters.splice(index, 1);
20089
+ timedOutCount += 1;
20090
+ warn(
20091
+ `fanout-ceiling: call=${callId} session=${parentSessionId} waited ${waitTimeoutMs}ms and is ADMITTED OVER the ceiling (limit=${limit}, occupied=${occupied()}). Suspect a leaked slot or a stuck sub-agent.`
20092
+ );
20093
+ reserve(callId, parentSessionId);
20094
+ resolve();
20095
+ },
20096
+ waitTimeoutMs,
20097
+ true
20098
+ );
20152
20099
  waiters.push(waiter);
20153
20100
  });
20154
20101
  }
20155
- function after(input2) {
20156
- if (input2.tool !== TASK_TOOL_ID) return;
20157
- release(input2.callID, "completed");
20102
+ function afterToolCall(input2) {
20103
+ if (input2.tool !== SUBAGENT_TOOL_ID) return;
20104
+ const index = pending.findIndex((slot2) => slot2.callId === input2.id);
20105
+ if (index === -1) return;
20106
+ const [slot] = pending.splice(index, 1);
20107
+ disarm(slot?.timer);
20108
+ debug(`fanout-ceiling: call=${input2.id} ended before any child existed \u2014 slot released`);
20109
+ drain();
20158
20110
  }
20159
- function onSessionIdle(sessionId) {
20160
- const stale = [];
20161
- for (const [callId, permit] of held) {
20162
- if (permit.sessionId === sessionId) stale.push(callId);
20111
+ function onSessionCreated(sessionId, parentId) {
20112
+ if (parentId === void 0 || active.has(sessionId)) return;
20113
+ const index = pending.findIndex((slot) => slot.parentSessionId === parentId);
20114
+ if (index === -1) {
20115
+ debug(`fanout-ceiling: unreserved child=${sessionId} of parent=${parentId} \u2014 counted`);
20116
+ } else {
20117
+ const [slot] = pending.splice(index, 1);
20118
+ disarm(slot?.timer);
20163
20119
  }
20164
- if (stale.length === 0) return;
20165
- sweptCount += stale.length;
20166
- warn(
20167
- `fanout-ceiling: session=${sessionId} went idle still holding ${stale.length} permit(s) [${stale.join(", ")}] \u2014 reclaiming. These tasks ended without a tool.execute.after, which upstream does not fire on the failure path.`
20168
- );
20169
- for (const callId of stale) release(callId, "session-idle");
20120
+ activate(sessionId, parentId);
20121
+ }
20122
+ function onSessionTerminal(sessionId) {
20123
+ releaseActive(sessionId, "terminal");
20170
20124
  }
20171
20125
  function dispose() {
20172
- const parked = waiters.length;
20173
- const outstanding = held.size;
20174
20126
  while (waiters.length > 0) {
20175
- const w = waiters.shift();
20176
- if (w === void 0) break;
20177
- disarm(w.timer);
20178
- w.grant();
20179
- }
20180
- for (const permit of held.values()) disarm(permit.ttlTimer);
20181
- held.clear();
20182
- if (parked > 0 || outstanding > 0) {
20183
- info(
20184
- `fanout-ceiling: disposed \u2014 released ${parked} parked task(s) and disarmed ${outstanding} permit timer(s).`
20185
- );
20127
+ const waiter = waiters.shift();
20128
+ if (waiter === void 0) break;
20129
+ disarm(waiter.timer);
20130
+ waiter.grant();
20186
20131
  }
20132
+ for (const slot of pending) disarm(slot.timer);
20133
+ pending.length = 0;
20134
+ for (const child of active.values()) disarm(child.timer);
20135
+ active.clear();
20187
20136
  }
20188
20137
  return {
20189
- before,
20190
- after,
20191
- onSessionIdle,
20138
+ beforeToolCall,
20139
+ afterToolCall,
20140
+ onSessionCreated,
20141
+ onSessionTerminal,
20192
20142
  dispose,
20193
20143
  stats: () => ({
20194
20144
  limit,
20195
- inFlight: held.size,
20145
+ pending: pending.length,
20146
+ active: active.size,
20196
20147
  waiting: waiters.length,
20197
20148
  waitedCount,
20198
20149
  timedOutCount,
20199
- sweptCount,
20200
- ttlReclaimedCount,
20150
+ pendingTtlReclaimedCount,
20151
+ activeTtlReclaimedCount,
20201
20152
  armedTimers: armed.size
20202
20153
  })
20203
20154
  };
20204
20155
  }
20205
20156
 
20206
- // src/hooks/system-prompt-overlay.ts
20207
- import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
20208
- import { join as join4 } from "node:path";
20157
+ // src/hooks/system-overlay.ts
20158
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
20159
+ import { join as join3 } from "node:path";
20209
20160
 
20210
20161
  // src/errors.ts
20211
20162
  var XemaSystemOverlayMissingError = class extends Error {
@@ -20218,7 +20169,7 @@ var XemaSystemOverlayMissingError = class extends Error {
20218
20169
 
20219
20170
  // src/hooks/workspace-listing.ts
20220
20171
  import { readdirSync, statSync } from "node:fs";
20221
- import { join as join3 } from "node:path";
20172
+ import { join as join2 } from "node:path";
20222
20173
  var MAX_ENTRIES_PER_DIR = 50;
20223
20174
  var HIDDEN_TOP_LEVEL = /* @__PURE__ */ new Set([".xema", ".opencode"]);
20224
20175
  var SKIP_CHILD_EXPANSION_TOP_LEVEL = /* @__PURE__ */ new Set([
@@ -20238,7 +20189,7 @@ function renderWorkspaceListing(workspaceRoot) {
20238
20189
  const lines = [
20239
20190
  "## Current workspace contents (depth 1)",
20240
20191
  "",
20241
- "This is a snapshot of `/workspace/` taken at the start of this turn. Use it to orient before reaching for `glob`, `list`, or `read`. Hidden: platform-private slots (`.xema/`, `.opencode/`).",
20192
+ "This is a snapshot of `/workspace/` taken at the start of this turn. Use it to orient before reaching for `glob` or `read`. Hidden: platform-private slots (`.xema/`, `.opencode/`).",
20242
20193
  ""
20243
20194
  ];
20244
20195
  let topLevel;
@@ -20260,13 +20211,13 @@ function renderWorkspaceListing(workspaceRoot) {
20260
20211
  }
20261
20212
  function renderTopLevelEntry(workspaceRoot, entry) {
20262
20213
  const relPath = entry.name;
20263
- const absPath = join3(workspaceRoot, entry.name);
20214
+ const absPath = join2(workspaceRoot, entry.name);
20264
20215
  if (!entry.isDirectory) {
20265
20216
  return [`- \`/workspace/${relPath}\``];
20266
20217
  }
20267
20218
  if (SKIP_CHILD_EXPANSION_TOP_LEVEL.has(entry.name)) {
20268
20219
  return [
20269
- `- \`/workspace/${relPath}/\` _(children omitted: known implicit/noisy directory \u2014 use \`glob\`/\`list\` if needed)_`
20220
+ `- \`/workspace/${relPath}/\` _(children omitted: known implicit/noisy directory \u2014 use \`glob\` if needed)_`
20270
20221
  ];
20271
20222
  }
20272
20223
  let children;
@@ -20284,7 +20235,7 @@ function renderTopLevelEntry(workspaceRoot, entry) {
20284
20235
  return ` - \`${child.name}${suffix}\``;
20285
20236
  });
20286
20237
  const overflow = children.length - shown.length;
20287
- const overflowLine = overflow > 0 ? [` - _(${overflow} more entr${overflow === 1 ? "y" : "ies"} \u2014 use \`glob\`/\`list\` to enumerate)_`] : [];
20238
+ const overflowLine = overflow > 0 ? [` - _(${overflow} more entr${overflow === 1 ? "y" : "ies"} \u2014 use \`glob\` to enumerate)_`] : [];
20288
20239
  return [`- \`/workspace/${relPath}/\``, ...childLines, ...overflowLine];
20289
20240
  }
20290
20241
  function readDirSorted(dir) {
@@ -20310,7 +20261,7 @@ function readDirSorted(dir) {
20310
20261
  function resolveIsDirectory(parent, dirent) {
20311
20262
  if (dirent.isSymbolicLink()) {
20312
20263
  try {
20313
- return statSync(join3(parent, dirent.name)).isDirectory();
20264
+ return statSync(join2(parent, dirent.name)).isDirectory();
20314
20265
  } catch {
20315
20266
  return false;
20316
20267
  }
@@ -20318,114 +20269,155 @@ function resolveIsDirectory(parent, dirent) {
20318
20269
  return dirent.isDirectory();
20319
20270
  }
20320
20271
 
20321
- // src/hooks/system-prompt-overlay.ts
20322
- function buildSystemPromptOverlay(directory) {
20323
- return async (_input, output2) => {
20324
- const ctx = readInvocationContextForHook(
20325
- directory,
20326
- "experimental.chat.system.transform"
20327
- );
20272
+ // src/hooks/system-overlay.ts
20273
+ var XEMA_OVERLAY_METADATA_KEY = "xema.system-overlay";
20274
+ function buildSystemOverlayHook(directory) {
20275
+ return (input2) => {
20276
+ const ctx = readInvocationContextForHook(directory, "session.context");
20328
20277
  if (!ctx) {
20329
20278
  return;
20330
20279
  }
20331
- const overlayPath = join4(directory, ".xema", "system-overlay.md");
20332
- if (!existsSync3(overlayPath)) {
20280
+ const overlayPath = join3(directory, ".xema", "system-overlay.md");
20281
+ if (!existsSync2(overlayPath)) {
20333
20282
  throw new XemaSystemOverlayMissingError(
20334
- `Xema session ${ctx.invocation.runId ?? "unknown"} (role=${ctx.invocation.role}, sessionId=${ctx.invocation.sessionId ?? "-"}) is missing /workspace/.xema/system-overlay.md. The platform contract is undefined; refusing to start the chat. Verify the agent-run-context render flow emitted the system-overlay slot.`
20335
- );
20336
- }
20337
- const bytes = readFileSync3(overlayPath, "utf8");
20338
- if (output2.system.length !== 1) {
20339
- throw new Error(
20340
- `experimental.chat.system.transform: expected output.system to contain exactly 1 entry on hook entry, got ${output2.system.length}. Another plugin or an OpenCode change broke the seed-then-extend contract this hook relies on.`
20283
+ `Xema session ${ctx.invocation.runId ?? "unknown"} (role=${ctx.invocation.role}, sessionId=${ctx.invocation.sessionId ?? "-"}) is missing /workspace/.xema/system-overlay.md. The platform contract is undefined; refusing the model request. Verify the agent-run-context render flow emitted the system-overlay slot.`
20341
20284
  );
20342
20285
  }
20286
+ const bytes = readFileSync2(overlayPath, "utf8");
20343
20287
  const listing = renderWorkspaceListing(directory);
20344
- output2.system[0] = `${output2.system[0]}
20288
+ const part = {
20289
+ type: "text",
20290
+ text: `${bytes}
20345
20291
 
20346
- ${bytes}
20347
-
20348
- ${listing}`;
20292
+ ${listing}`,
20293
+ metadata: { [XEMA_OVERLAY_METADATA_KEY]: true }
20294
+ };
20295
+ const existing = input2.system.findIndex(
20296
+ (entry) => entry.metadata?.[XEMA_OVERLAY_METADATA_KEY] === true
20297
+ );
20298
+ if (existing === -1) {
20299
+ input2.system.push(part);
20300
+ } else {
20301
+ input2.system[existing] = part;
20302
+ }
20349
20303
  debug(
20350
- `experimental.chat.system.transform: concatenated ${bytes.length} overlay bytes + ${listing.length} listing bytes into system[0] (role=${ctx.invocation.role})`
20304
+ `session.context: ${existing === -1 ? "appended" : "replaced"} overlay part (${bytes.length} overlay + ${listing.length} listing bytes, role=${ctx.invocation.role}, opencodeSession=${input2.sessionID})`
20351
20305
  );
20352
20306
  };
20353
20307
  }
20354
20308
 
20355
- // src/index.ts
20356
- var bootCache = /* @__PURE__ */ new Map();
20357
- var XemaPlugin = async (ctx) => {
20358
- const { directory } = ctx;
20359
- const cached2 = bootCache.get(directory);
20360
- if (cached2 !== void 0) {
20361
- return cached2;
20362
- }
20363
- const booting = doBoot(directory);
20364
- bootCache.set(directory, booting);
20309
+ // src/hooks/tool-surface-observation.ts
20310
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
20311
+ import { join as join4 } from "node:path";
20312
+ var TOOL_SURFACE_FILE = "opencode-tool-surface.json";
20313
+ var DEFAULT_RUNTIME_STATE_DIR = "/run/xema/runtime";
20314
+ var RUNTIME_STATE_DIR_ENV = "XEMA_RUNTIME_STATE_DIR";
20315
+ function resolveRuntimeStateDir(env = process.env) {
20316
+ const declared = env[RUNTIME_STATE_DIR_ENV];
20317
+ return declared !== void 0 && declared.trim() !== "" ? declared : DEFAULT_RUNTIME_STATE_DIR;
20318
+ }
20319
+ async function observeToolSurface(options) {
20365
20320
  try {
20366
- return await booting;
20321
+ const observedAt = (options.now ?? Date.now)();
20322
+ const ids = [...await options.listToolIds()].sort();
20323
+ const doc = { observedAt, tools: ids };
20324
+ mkdirSync(options.stateDir, { recursive: true });
20325
+ const target = join4(options.stateDir, TOOL_SURFACE_FILE);
20326
+ const temp = `${target}.${process.pid}.tmp`;
20327
+ writeFileSync(temp, `${JSON.stringify(doc)}
20328
+ `, "utf8");
20329
+ renameSync(temp, target);
20330
+ debug(`tool-surface: observed ${ids.length} tool(s) \u2192 ${target}`);
20331
+ return doc;
20367
20332
  } catch (err) {
20368
- bootCache.delete(directory);
20369
- throw err;
20333
+ warn(
20334
+ `tool-surface: observation failed (observation only \u2014 no authority is affected): ` + (err instanceof Error ? err.message : String(err))
20335
+ );
20336
+ return void 0;
20370
20337
  }
20371
- };
20372
- async function doBoot(directory) {
20373
- const contextPath = join5(directory, "context.json");
20374
- const contextPresentAtBoot = existsSync4(contextPath);
20375
- info(
20376
- `boot: directory=${directory} tools=0 (hooks only) context.json=${contextPresentAtBoot ? "present" : "absent"} debug=${isDebug() ? "on" : "off"}`
20377
- );
20378
- const fanoutLimit = resolveSubagentFanoutLimit();
20379
- const fanout = createSubagentFanoutCeiling({ limit: fanoutLimit });
20380
- const autocommit = buildSessionIdleAutocommit(directory);
20338
+ }
20339
+
20340
+ // src/plugin-id.ts
20341
+ var XEMA_RUNTIME_BRIDGE_PLUGIN_ID = "xema-runtime-bridge";
20342
+
20343
+ // src/plugin.ts
20344
+ var TOOL_SURFACE_EVENTS = /* @__PURE__ */ new Set([
20345
+ "plugin.updated",
20346
+ "integration.updated",
20347
+ "skill.updated",
20348
+ "agent.updated"
20349
+ ]);
20350
+ async function setup(ctx) {
20351
+ const directory = ctx.location.directory;
20352
+ const limit = resolveSubagentFanoutLimit();
20353
+ const fanout = createSubagentFanoutCeiling({ limit });
20354
+ const turnCompletion = createRootTurnCompletion({
20355
+ resolveParentId: async (sessionId) => {
20356
+ const session = await ctx.session.get({ sessionID: sessionId });
20357
+ return session.parentID !== void 0 && session.parentID.length > 0 ? session.parentID : void 0;
20358
+ }
20359
+ });
20360
+ const stateDir = resolveRuntimeStateDir();
20361
+ const observe = () => observeToolSurface({
20362
+ stateDir,
20363
+ listToolIds: async () => (await ctx.tool.list()).map((tool) => tool.id)
20364
+ });
20365
+ const registrations = [
20366
+ await ctx.session.hook("context", buildSystemOverlayHook(directory)),
20367
+ await ctx.session.hook("compaction", buildCompactionInvariantsHook(directory)),
20368
+ await ctx.tool.hook(
20369
+ "execute.before",
20370
+ (input2) => fanout.beforeToolCall({ tool: input2.tool, sessionID: input2.sessionID, id: input2.id })
20371
+ ),
20372
+ await ctx.tool.hook("execute.after", (input2) => {
20373
+ fanout.afterToolCall({ tool: input2.tool, sessionID: input2.sessionID, id: input2.id });
20374
+ }),
20375
+ await ctx.shell.hook("create.before", scrubShellEnv)
20376
+ ];
20377
+ const abort = new AbortController();
20378
+ const events = (async () => {
20379
+ try {
20380
+ for await (const event of ctx.event.subscribe({ signal: abort.signal })) {
20381
+ handleEvent(event);
20382
+ }
20383
+ } catch (err) {
20384
+ if (!abort.signal.aborted) {
20385
+ warn(
20386
+ `event subscription ended unexpectedly: ${err instanceof Error ? err.message : String(err)}. Fan-out children now release only by TTL and turn-completion notices stop until restart.`
20387
+ );
20388
+ }
20389
+ }
20390
+ })();
20391
+ function handleEvent(event) {
20392
+ const type = event?.type;
20393
+ if (typeof type === "string" && TOOL_SURFACE_EVENTS.has(type)) {
20394
+ void observe();
20395
+ }
20396
+ const parsed = parseRuntimeSessionEvent(event);
20397
+ if (parsed === void 0) return;
20398
+ if (parsed.kind === "created") {
20399
+ fanout.onSessionCreated(parsed.sessionId, parsed.parentId);
20400
+ turnCompletion.onSessionCreated(parsed.sessionId, parsed.parentId);
20401
+ return;
20402
+ }
20403
+ fanout.onSessionTerminal(parsed.sessionId);
20404
+ void turnCompletion.onSessionTerminal(parsed.sessionId, parsed.outcome, parsed.executionId);
20405
+ }
20406
+ await observe();
20381
20407
  info(
20382
- `boot: subagent fan-out ceiling active (limit=${fanoutLimit} concurrent task calls per worker; tighten with XEMA_SUBAGENT_FANOUT_LIMIT)`
20408
+ `setup: ${XEMA_RUNTIME_BRIDGE_PLUGIN_ID} ready (OpenCode ${ctx.app.version}, dir=${directory}, tools=0, sub-agent fan-out limit=${limit}, tool surface \u2192 ${stateDir})`
20383
20409
  );
20384
- const hooks = {
20385
- "experimental.chat.system.transform": buildSystemPromptOverlay(directory),
20386
- // Backpressure on sub-agent fan-out. Upstream awaits this hook immediately
20387
- // before the tool runs, so parking here parks the `task` call BEFORE the
20388
- // child session exists — a pool cap that makes the N+1th spawn wait, not a
20389
- // counter that aborts a run already paid for.
20390
- "tool.execute.before": async (input2) => {
20391
- await fanout.before(input2);
20392
- },
20393
- "tool.execute.after": (input2) => {
20394
- fanout.after(input2);
20395
- },
20396
- // Shutdown. Disarms the ceiling's timers and lets any parked `task` call
20397
- // through, so a torn-down worker leaves nothing armed and nothing pending.
20398
- dispose: async () => {
20399
- fanout.dispose();
20400
- },
20401
- // TWO consumers of `session.idle` now, so the channel fans out. opencode
20402
- // exposes session lifecycle only as a discriminated event union, not as
20403
- // dedicated hooks, and `Hooks.event` is a single function — so composition
20404
- // has to happen here.
20405
- //
20406
- // The order matters and the isolation matters more: the fan-out sweep is a
20407
- // LEAK BACKSTOP, so it must run even if auto-commit fails. Auto-commit
20408
- // already swallows its own errors by hard invariant, but relying on that
20409
- // from the outside would make this correct only by someone else's promise.
20410
- event: async (input2) => {
20411
- const evt = input2.event;
20412
- if (evt.type === "session.idle") {
20413
- const sessionId = evt.properties?.sessionID;
20414
- if (sessionId) {
20415
- try {
20416
- fanout.onSessionIdle(sessionId);
20417
- } catch (err) {
20418
- const message = err instanceof Error ? err.message : String(err);
20419
- warn(`fanout-ceiling: session-idle sweep failed: ${message}`);
20420
- }
20421
- }
20422
- }
20423
- await autocommit(input2);
20410
+ return async () => {
20411
+ abort.abort();
20412
+ fanout.dispose();
20413
+ for (const registration of registrations) {
20414
+ await registration.dispose();
20424
20415
  }
20416
+ await events;
20425
20417
  };
20426
- return hooks;
20427
20418
  }
20419
+ var plugin = { id: XEMA_RUNTIME_BRIDGE_PLUGIN_ID, setup };
20420
+ var plugin_default = plugin;
20428
20421
  export {
20429
- XemaPlugin,
20430
- XemaPlugin as default
20422
+ plugin_default as default
20431
20423
  };