@xemahq/opencode-xema-plugin 0.1.12 → 0.2.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.
package/README.md CHANGED
@@ -28,16 +28,44 @@
28
28
 
29
29
  ## Overview
30
30
 
31
- An Opencode plugin that registers Xema-namespaced runtime tools and a dynamic
32
- system-prompt overlay hook. The plugin is evaluated once at agent startup; it
33
- registers its tools unconditionally and defers per-role authorization to
34
- tool-execution time, where each tool reads the invocation context from the
35
- worker filesystem and rejects calls the invoking role is not permitted to make.
31
+ An Opencode plugin that registers Xema-namespaced runtime tools, a dynamic
32
+ system-prompt overlay hook, and the sub-agent fan-out ceiling. The plugin is
33
+ evaluated once at agent startup; it registers its tools unconditionally and
34
+ defers per-role authorization to tool-execution time, where each tool reads the
35
+ invocation context from the worker filesystem and rejects calls the invoking
36
+ role is not permitted to make.
36
37
 
37
38
  ## When to use it
38
39
 
39
40
  - Use it to add the Xema runtime tools and prompt overlay to an Opencode agent
40
41
  runtime.
42
+ - Use it to bound how many sub-agents one worker may run at once.
43
+
44
+ ## The sub-agent fan-out ceiling
45
+
46
+ Opencode bounds how DEEP delegation may nest (`subagent_depth`). Nothing bounds
47
+ how WIDE a turn may go, so at the platform's declared depth an unbounded
48
+ branching factor is an unbounded process — every `task` call is forked with no
49
+ pool, no semaphore and no queue.
50
+
51
+ This plugin adds the missing pool cap. It is BACKPRESSURE, not a refusal: when
52
+ the worker is already running its ceiling of sub-agents, the next `task` call
53
+ WAITS for a slot instead of being denied. An agent that cannot spawn a helper
54
+ would produce a wrong answer; one that waits produces a slower right one.
55
+
56
+ Configuration exists to TIGHTEN, never to enable:
57
+
58
+ | | |
59
+ |---|---|
60
+ | Default | 8 concurrent `task` calls per worker |
61
+ | Tighten with | `XEMA_SUBAGENT_FANOUT_LIMIT` (1..8) |
62
+ | Unset or empty | the platform default — never zero, never "off" |
63
+ | Above the default | refused at boot, with the reason |
64
+
65
+ Every degradation is logged: a task that waits says so and for how long, and
66
+ both leak backstops (a `session.idle` sweep and a per-permit TTL) name what they
67
+ reclaimed. A ceiling that silently stops binding is indistinguishable from
68
+ working software until somebody asks.
41
69
 
42
70
  ## Installation
43
71
 
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "algorithm": "sha256/len-framed-v1",
3
- "hash": "db148b94ce3037192dfbb8b60936b8b7af6162413cc6073819a29863cc5a79af",
4
- "fileCount": 14
3
+ "hash": "1ed948cb2970db3cd244537af9583485fe1afb833942f2b1f1812500f5597960",
4
+ "fileCount": 15
5
5
  }
@@ -0,0 +1,39 @@
1
+ export declare const TASK_TOOL_ID = "task";
2
+ export declare const DEFAULT_SUBAGENT_FANOUT_LIMIT = 8;
3
+ export declare const DEFAULT_FANOUT_WAIT_TIMEOUT_MS: number;
4
+ export declare const DEFAULT_PERMIT_TTL_MS: number;
5
+ export declare const FANOUT_LIMIT_ENV = "XEMA_SUBAGENT_FANOUT_LIMIT";
6
+ export declare function resolveSubagentFanoutLimit(env?: Readonly<Record<string, string | undefined>>): number;
7
+ export interface FanoutCeilingStats {
8
+ readonly limit: number;
9
+ readonly inFlight: number;
10
+ readonly waiting: number;
11
+ readonly waitedCount: number;
12
+ readonly timedOutCount: number;
13
+ readonly sweptCount: number;
14
+ readonly ttlReclaimedCount: number;
15
+ readonly armedTimers: number;
16
+ }
17
+ export interface SubagentFanoutCeiling {
18
+ readonly before: (input: {
19
+ tool: string;
20
+ sessionID: string;
21
+ callID: string;
22
+ }) => Promise<void>;
23
+ readonly after: (input: {
24
+ tool: string;
25
+ sessionID: string;
26
+ callID: string;
27
+ }) => void;
28
+ readonly onSessionIdle: (sessionId: string) => void;
29
+ readonly dispose: () => void;
30
+ readonly stats: () => FanoutCeilingStats;
31
+ }
32
+ export interface FanoutCeilingOptions {
33
+ readonly limit: number;
34
+ readonly waitTimeoutMs?: number;
35
+ readonly permitTtlMs?: number;
36
+ readonly now?: () => number;
37
+ }
38
+ export declare function createSubagentFanoutCeiling(options: FanoutCeilingOptions): SubagentFanoutCeiling;
39
+ //# sourceMappingURL=subagent-fanout-ceiling.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subagent-fanout-ceiling.d.ts","sourceRoot":"","sources":["../../src/hooks/subagent-fanout-ceiling.ts"],"names":[],"mappings":"AA0FA,eAAO,MAAM,YAAY,SAAS,CAAC;AA6CnC,eAAO,MAAM,6BAA6B,IAAI,CAAC;AAW/C,eAAO,MAAM,8BAA8B,QAAiB,CAAC;AAU7D,eAAO,MAAM,qBAAqB,QAAiB,CAAC;AAGpD,eAAO,MAAM,gBAAgB,+BAA+B,CAAC;AAyB7D,wBAAgB,0BAA0B,CACxC,GAAG,GAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,SAAS,CAAC,CAAe,GAC9D,MAAM,CA8BR;AAoBD,MAAM,WAAW,kBAAkB;IACjC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IAEzB,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAE7B,QAAQ,CAAC,aAAa,EAAE,MAAM,CAAC;IAE/B,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,iBAAiB,EAAE,MAAM,CAAC;IAiBnC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;CAC9B;AAED,MAAM,WAAW,qBAAqB;IAEpC,QAAQ,CAAC,MAAM,EAAE,CAAC,KAAK,EAAE;QACvB,IAAI,EAAE,MAAM,CAAC;QACb,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;KAChB,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IAEpB,QAAQ,CAAC,KAAK,EAAE,CAAC,KAAK,EAAE;QACtB,IAAI,EAAE,MAAM,CAAC;QACb,SAAS,EAAE,MAAM,CAAC;QAClB,MAAM,EAAE,MAAM,CAAC;KAChB,KAAK,IAAI,CAAC;IAEX,QAAQ,CAAC,aAAa,EAAE,CAAC,SAAS,EAAE,MAAM,KAAK,IAAI,CAAC;IAgBpD,QAAQ,CAAC,OAAO,EAAE,MAAM,IAAI,CAAC;IAC7B,QAAQ,CAAC,KAAK,EAAE,MAAM,kBAAkB,CAAC;CAC1C;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAC;IAChC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAE9B,QAAQ,CAAC,GAAG,CAAC,EAAE,MAAM,MAAM,CAAC;CAC7B;AAOD,wBAAgB,2BAA2B,CACzC,OAAO,EAAE,oBAAoB,GAC5B,qBAAqB,CAgPvB"}
@@ -0,0 +1,188 @@
1
+ import { info, warn, debug } from '../logger.js';
2
+ export const TASK_TOOL_ID = 'task';
3
+ export const DEFAULT_SUBAGENT_FANOUT_LIMIT = 8;
4
+ export const DEFAULT_FANOUT_WAIT_TIMEOUT_MS = 10 * 60 * 1000;
5
+ export const DEFAULT_PERMIT_TTL_MS = 30 * 60 * 1000;
6
+ export const FANOUT_LIMIT_ENV = 'XEMA_SUBAGENT_FANOUT_LIMIT';
7
+ export function resolveSubagentFanoutLimit(env = process.env) {
8
+ const declared = env[FANOUT_LIMIT_ENV];
9
+ if (declared === undefined || declared.trim() === '') {
10
+ return DEFAULT_SUBAGENT_FANOUT_LIMIT;
11
+ }
12
+ const parsed = Number(declared.trim());
13
+ if (!Number.isInteger(parsed)) {
14
+ throw new Error(`${FANOUT_LIMIT_ENV}="${declared}" is not an integer. Expected a whole number of ` +
15
+ `concurrently executing sub-agent tasks between 1 and ${DEFAULT_SUBAGENT_FANOUT_LIMIT}.`);
16
+ }
17
+ if (parsed < 1) {
18
+ throw new Error(`${FANOUT_LIMIT_ENV}="${declared}" is below 1. A ceiling of 0 would park EVERY delegation ` +
19
+ `until it timed out, turning a resource bound into an outage. Remove the variable to ` +
20
+ `accept the platform default (${DEFAULT_SUBAGENT_FANOUT_LIMIT}).`);
21
+ }
22
+ if (parsed > DEFAULT_SUBAGENT_FANOUT_LIMIT) {
23
+ throw new Error(`${FANOUT_LIMIT_ENV}="${declared}" is above the platform default ` +
24
+ `(${DEFAULT_SUBAGENT_FANOUT_LIMIT}). Configuration may only TIGHTEN this ceiling. The ` +
25
+ `default is a stated platform decision; a wider one moves ` +
26
+ `DEFAULT_SUBAGENT_FANOUT_LIMIT, where the grounds are recorded.`);
27
+ }
28
+ return parsed;
29
+ }
30
+ export function createSubagentFanoutCeiling(options) {
31
+ const limit = options.limit;
32
+ const waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_FANOUT_WAIT_TIMEOUT_MS;
33
+ const permitTtlMs = options.permitTtlMs ?? DEFAULT_PERMIT_TTL_MS;
34
+ const now = options.now ?? (() => Date.now());
35
+ const held = new Map();
36
+ const waiters = [];
37
+ let waitedCount = 0;
38
+ let timedOutCount = 0;
39
+ let sweptCount = 0;
40
+ let ttlReclaimedCount = 0;
41
+ const armed = new Set();
42
+ function arm(fn, ms, keepProcessAlive) {
43
+ const timer = setTimeout(() => {
44
+ armed.delete(timer);
45
+ fn();
46
+ }, ms);
47
+ if (!keepProcessAlive)
48
+ timer.unref?.();
49
+ armed.add(timer);
50
+ return timer;
51
+ }
52
+ function disarm(timer) {
53
+ if (timer === undefined)
54
+ return;
55
+ clearTimeout(timer);
56
+ armed.delete(timer);
57
+ }
58
+ function take(callId, sessionId) {
59
+ const ttlTimer = arm(() => {
60
+ if (!held.has(callId))
61
+ return;
62
+ ttlReclaimedCount += 1;
63
+ warn(`fanout-ceiling: permit TTL expired for call=${callId} session=${sessionId} after ` +
64
+ `${permitTtlMs}ms — force-releasing. A task held a slot longer than any healthy ` +
65
+ `sub-agent turn; the pool would otherwise have shrunk permanently.`);
66
+ release(callId, 'ttl');
67
+ }, permitTtlMs, false);
68
+ held.set(callId, { sessionId, acquiredAt: now(), ttlTimer });
69
+ }
70
+ function release(callId, reason) {
71
+ const permit = held.get(callId);
72
+ if (permit === undefined)
73
+ return;
74
+ disarm(permit.ttlTimer);
75
+ held.delete(callId);
76
+ if (reason !== 'completed') {
77
+ debug(`fanout-ceiling: reclaimed call=${callId} via ${reason} ` +
78
+ `(held ${now() - permit.acquiredAt}ms)`);
79
+ }
80
+ drain();
81
+ }
82
+ function drain() {
83
+ while (waiters.length > 0 && held.size < limit) {
84
+ const next = waiters.shift();
85
+ if (next === undefined)
86
+ return;
87
+ disarm(next.timer);
88
+ const waitedMs = now() - next.since;
89
+ waitedCount += 1;
90
+ take(next.callId, next.sessionId);
91
+ info(`fanout-ceiling: admitted call=${next.callId} session=${next.sessionId} after waiting ` +
92
+ `${waitedMs}ms (limit=${limit}, in-flight=${held.size}, still waiting=${waiters.length})`);
93
+ next.grant();
94
+ }
95
+ }
96
+ async function before(input) {
97
+ if (input.tool !== TASK_TOOL_ID)
98
+ return;
99
+ const { callID: callId, sessionID: sessionId } = input;
100
+ if (held.size < limit) {
101
+ take(callId, sessionId);
102
+ debug(`fanout-ceiling: admitted call=${callId} immediately ` +
103
+ `(in-flight=${held.size}/${limit})`);
104
+ return;
105
+ }
106
+ const since = now();
107
+ warn(`fanout-ceiling: sub-agent fan-out at ceiling — call=${callId} session=${sessionId} is ` +
108
+ `WAITING (limit=${limit}, in-flight=${held.size}, already waiting=${waiters.length}). ` +
109
+ `This is backpressure, not a refusal: the task will start when a slot frees.`);
110
+ await new Promise((resolve) => {
111
+ const waiter = {
112
+ callId,
113
+ sessionId,
114
+ since,
115
+ grant: resolve,
116
+ timer: undefined,
117
+ };
118
+ waiter.timer = arm(() => {
119
+ const idx = waiters.indexOf(waiter);
120
+ if (idx === -1)
121
+ return;
122
+ waiters.splice(idx, 1);
123
+ timedOutCount += 1;
124
+ warn(`fanout-ceiling: call=${callId} session=${sessionId} waited ${waitTimeoutMs}ms and is ` +
125
+ `being ADMITTED OVER the ceiling (limit=${limit}, in-flight=${held.size}). The pool ` +
126
+ `did not free a slot in that window — suspect a leaked permit or a stuck sub-agent.`);
127
+ take(callId, sessionId);
128
+ resolve();
129
+ }, waitTimeoutMs, true);
130
+ waiters.push(waiter);
131
+ });
132
+ }
133
+ function after(input) {
134
+ if (input.tool !== TASK_TOOL_ID)
135
+ return;
136
+ release(input.callID, 'completed');
137
+ }
138
+ function onSessionIdle(sessionId) {
139
+ const stale = [];
140
+ for (const [callId, permit] of held) {
141
+ if (permit.sessionId === sessionId)
142
+ stale.push(callId);
143
+ }
144
+ if (stale.length === 0)
145
+ return;
146
+ sweptCount += stale.length;
147
+ warn(`fanout-ceiling: session=${sessionId} went idle still holding ${stale.length} permit(s) ` +
148
+ `[${stale.join(', ')}] — reclaiming. These tasks ended without a tool.execute.after, ` +
149
+ `which upstream does not fire on the failure path.`);
150
+ for (const callId of stale)
151
+ release(callId, 'session-idle');
152
+ }
153
+ function dispose() {
154
+ const parked = waiters.length;
155
+ const outstanding = held.size;
156
+ while (waiters.length > 0) {
157
+ const w = waiters.shift();
158
+ if (w === undefined)
159
+ break;
160
+ disarm(w.timer);
161
+ w.grant();
162
+ }
163
+ for (const permit of held.values())
164
+ disarm(permit.ttlTimer);
165
+ held.clear();
166
+ if (parked > 0 || outstanding > 0) {
167
+ info(`fanout-ceiling: disposed — released ${parked} parked task(s) and disarmed ` +
168
+ `${outstanding} permit timer(s).`);
169
+ }
170
+ }
171
+ return {
172
+ before,
173
+ after,
174
+ onSessionIdle,
175
+ dispose,
176
+ stats: () => ({
177
+ limit,
178
+ inFlight: held.size,
179
+ waiting: waiters.length,
180
+ waitedCount,
181
+ timedOutCount,
182
+ sweptCount,
183
+ ttlReclaimedCount,
184
+ armedTimers: armed.size,
185
+ }),
186
+ };
187
+ }
188
+ //# sourceMappingURL=subagent-fanout-ceiling.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"subagent-fanout-ceiling.js","sourceRoot":"","sources":["../../src/hooks/subagent-fanout-ceiling.ts"],"names":[],"mappings":"AAuFA,OAAO,EAAE,IAAI,EAAE,IAAI,EAAE,KAAK,EAAE,MAAM,cAAc,CAAC;AAGjD,MAAM,CAAC,MAAM,YAAY,GAAG,MAAM,CAAC;AA6CnC,MAAM,CAAC,MAAM,6BAA6B,GAAG,CAAC,CAAC;AAW/C,MAAM,CAAC,MAAM,8BAA8B,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAU7D,MAAM,CAAC,MAAM,qBAAqB,GAAG,EAAE,GAAG,EAAE,GAAG,IAAI,CAAC;AAGpD,MAAM,CAAC,MAAM,gBAAgB,GAAG,4BAA4B,CAAC;AAyB7D,MAAM,UAAU,0BAA0B,CACxC,MAAoD,OAAO,CAAC,GAAG;IAE/D,MAAM,QAAQ,GAAG,GAAG,CAAC,gBAAgB,CAAC,CAAC;IACvC,IAAI,QAAQ,KAAK,SAAS,IAAI,QAAQ,CAAC,IAAI,EAAE,KAAK,EAAE,EAAE,CAAC;QACrD,OAAO,6BAA6B,CAAC;IACvC,CAAC;IAGD,MAAM,MAAM,GAAG,MAAM,CAAC,QAAQ,CAAC,IAAI,EAAE,CAAC,CAAC;IACvC,IAAI,CAAC,MAAM,CAAC,SAAS,CAAC,MAAM,CAAC,EAAE,CAAC;QAC9B,MAAM,IAAI,KAAK,CACb,GAAG,gBAAgB,KAAK,QAAQ,kDAAkD;YAChF,wDAAwD,6BAA6B,GAAG,CAC3F,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,GAAG,CAAC,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,GAAG,gBAAgB,KAAK,QAAQ,2DAA2D;YACzF,sFAAsF;YACtF,gCAAgC,6BAA6B,IAAI,CACpE,CAAC;IACJ,CAAC;IACD,IAAI,MAAM,GAAG,6BAA6B,EAAE,CAAC;QAC3C,MAAM,IAAI,KAAK,CACb,GAAG,gBAAgB,KAAK,QAAQ,kCAAkC;YAChE,IAAI,6BAA6B,sDAAsD;YACvF,2DAA2D;YAC3D,gEAAgE,CACnE,CAAC;IACJ,CAAC;IACD,OAAO,MAAM,CAAC;AAChB,CAAC;AAiGD,MAAM,UAAU,2BAA2B,CACzC,OAA6B;IAE7B,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,CAAC;IAC5B,MAAM,aAAa,GAAG,OAAO,CAAC,aAAa,IAAI,8BAA8B,CAAC;IAC9E,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,qBAAqB,CAAC;IACjE,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE,CAAC,CAAC;IAE9C,MAAM,IAAI,GAAG,IAAI,GAAG,EAAsB,CAAC;IAC3C,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,WAAW,GAAG,CAAC,CAAC;IACpB,IAAI,aAAa,GAAG,CAAC,CAAC;IACtB,IAAI,UAAU,GAAG,CAAC,CAAC;IACnB,IAAI,iBAAiB,GAAG,CAAC,CAAC;IAc1B,MAAM,KAAK,GAAG,IAAI,GAAG,EAAiC,CAAC;IAUvD,SAAS,GAAG,CACV,EAAc,EACd,EAAU,EACV,gBAAyB;QAEzB,MAAM,KAAK,GAAG,UAAU,CAAC,GAAG,EAAE;YAC5B,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;YACpB,EAAE,EAAE,CAAC;QACP,CAAC,EAAE,EAAE,CAAC,CAAC;QACP,IAAI,CAAC,gBAAgB;YAAE,KAAK,CAAC,KAAK,EAAE,EAAE,CAAC;QACvC,KAAK,CAAC,GAAG,CAAC,KAAK,CAAC,CAAC;QACjB,OAAO,KAAK,CAAC;IACf,CAAC;IAED,SAAS,MAAM,CAAC,KAAgD;QAC9D,IAAI,KAAK,KAAK,SAAS;YAAE,OAAO;QAChC,YAAY,CAAC,KAAK,CAAC,CAAC;QACpB,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC;IACtB,CAAC;IAED,SAAS,IAAI,CAAC,MAAc,EAAE,SAAiB;QAC7C,MAAM,QAAQ,GAAG,GAAG,CAAC,GAAG,EAAE;YACxB,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC;gBAAE,OAAO;YAC9B,iBAAiB,IAAI,CAAC,CAAC;YACvB,IAAI,CACF,+CAA+C,MAAM,YAAY,SAAS,SAAS;gBACjF,GAAG,WAAW,mEAAmE;gBACjF,mEAAmE,CACtE,CAAC;YACF,OAAO,CAAC,MAAM,EAAE,KAAK,CAAC,CAAC;QACzB,CAAC,EAAE,WAAW,EAAE,KAAK,CAAC,CAAC;QAOvB,IAAI,CAAC,GAAG,CAAC,MAAM,EAAE,EAAE,SAAS,EAAE,UAAU,EAAE,GAAG,EAAE,EAAE,QAAQ,EAAE,CAAC,CAAC;IAC/D,CAAC;IAED,SAAS,OAAO,CAAC,MAAc,EAAE,MAAqB;QACpD,MAAM,MAAM,GAAG,IAAI,CAAC,GAAG,CAAC,MAAM,CAAC,CAAC;QAChC,IAAI,MAAM,KAAK,SAAS;YAAE,OAAO;QACjC,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QACxB,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;QACpB,IAAI,MAAM,KAAK,WAAW,EAAE,CAAC;YAC3B,KAAK,CACH,kCAAkC,MAAM,QAAQ,MAAM,GAAG;gBACvD,SAAS,GAAG,EAAE,GAAG,MAAM,CAAC,UAAU,KAAK,CAC1C,CAAC;QACJ,CAAC;QACD,KAAK,EAAE,CAAC;IACV,CAAC;IAED,SAAS,KAAK;QACZ,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,IAAI,IAAI,CAAC,IAAI,GAAG,KAAK,EAAE,CAAC;YAC/C,MAAM,IAAI,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;YAC7B,IAAI,IAAI,KAAK,SAAS;gBAAE,OAAO;YAC/B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;YACnB,MAAM,QAAQ,GAAG,GAAG,EAAE,GAAG,IAAI,CAAC,KAAK,CAAC;YACpC,WAAW,IAAI,CAAC,CAAC;YACjB,IAAI,CAAC,IAAI,CAAC,MAAM,EAAE,IAAI,CAAC,SAAS,CAAC,CAAC;YAClC,IAAI,CACF,iCAAiC,IAAI,CAAC,MAAM,YAAY,IAAI,CAAC,SAAS,iBAAiB;gBACrF,GAAG,QAAQ,aAAa,KAAK,eAAe,IAAI,CAAC,IAAI,mBAAmB,OAAO,CAAC,MAAM,GAAG,CAC5F,CAAC;YACF,IAAI,CAAC,KAAK,EAAE,CAAC;QACf,CAAC;IACH,CAAC;IAED,KAAK,UAAU,MAAM,CAAC,KAIrB;QACC,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY;YAAE,OAAO;QACxC,MAAM,EAAE,MAAM,EAAE,MAAM,EAAE,SAAS,EAAE,SAAS,EAAE,GAAG,KAAK,CAAC;QAEvD,IAAI,IAAI,CAAC,IAAI,GAAG,KAAK,EAAE,CAAC;YACtB,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;YACxB,KAAK,CACH,iCAAiC,MAAM,eAAe;gBACpD,cAAc,IAAI,CAAC,IAAI,IAAI,KAAK,GAAG,CACtC,CAAC;YACF,OAAO;QACT,CAAC;QAID,MAAM,KAAK,GAAG,GAAG,EAAE,CAAC;QACpB,IAAI,CACF,uDAAuD,MAAM,YAAY,SAAS,MAAM;YACtF,kBAAkB,KAAK,eAAe,IAAI,CAAC,IAAI,qBAAqB,OAAO,CAAC,MAAM,KAAK;YACvF,6EAA6E,CAChF,CAAC;QAEF,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,EAAE;YAClC,MAAM,MAAM,GAAW;gBACrB,MAAM;gBACN,SAAS;gBACT,KAAK;gBACL,KAAK,EAAE,OAAO;gBACd,KAAK,EAAE,SAAS;aACjB,CAAC;YACF,MAAM,CAAC,KAAK,GAAG,GAAG,CAAC,GAAG,EAAE;gBACtB,MAAM,GAAG,GAAG,OAAO,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;gBACpC,IAAI,GAAG,KAAK,CAAC,CAAC;oBAAE,OAAO;gBACvB,OAAO,CAAC,MAAM,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;gBACvB,aAAa,IAAI,CAAC,CAAC;gBAKnB,IAAI,CACF,wBAAwB,MAAM,YAAY,SAAS,WAAW,aAAa,YAAY;oBACrF,0CAA0C,KAAK,eAAe,IAAI,CAAC,IAAI,cAAc;oBACrF,oFAAoF,CACvF,CAAC;gBACF,IAAI,CAAC,MAAM,EAAE,SAAS,CAAC,CAAC;gBACxB,OAAO,EAAE,CAAC;YACZ,CAAC,EAAE,aAAa,EAAE,IAAI,CAAC,CAAC;YAgBxB,OAAO,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACvB,CAAC,CAAC,CAAC;IACL,CAAC;IAED,SAAS,KAAK,CAAC,KAId;QACC,IAAI,KAAK,CAAC,IAAI,KAAK,YAAY;YAAE,OAAO;QACxC,OAAO,CAAC,KAAK,CAAC,MAAM,EAAE,WAAW,CAAC,CAAC;IACrC,CAAC;IAED,SAAS,aAAa,CAAC,SAAiB;QAKtC,MAAM,KAAK,GAAa,EAAE,CAAC;QAC3B,KAAK,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,IAAI,IAAI,EAAE,CAAC;YACpC,IAAI,MAAM,CAAC,SAAS,KAAK,SAAS;gBAAE,KAAK,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;QACzD,CAAC;QACD,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;YAAE,OAAO;QAC/B,UAAU,IAAI,KAAK,CAAC,MAAM,CAAC;QAC3B,IAAI,CACF,2BAA2B,SAAS,4BAA4B,KAAK,CAAC,MAAM,aAAa;YACvF,IAAI,KAAK,CAAC,IAAI,CAAC,IAAI,CAAC,kEAAkE;YACtF,mDAAmD,CACtD,CAAC;QACF,KAAK,MAAM,MAAM,IAAI,KAAK;YAAE,OAAO,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAC9D,CAAC;IAED,SAAS,OAAO;QACd,MAAM,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC;QAC9B,MAAM,WAAW,GAAG,IAAI,CAAC,IAAI,CAAC;QAE9B,OAAO,OAAO,CAAC,MAAM,GAAG,CAAC,EAAE,CAAC;YAC1B,MAAM,CAAC,GAAG,OAAO,CAAC,KAAK,EAAE,CAAC;YAC1B,IAAI,CAAC,KAAK,SAAS;gBAAE,MAAM;YAC3B,MAAM,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC;YAChB,CAAC,CAAC,KAAK,EAAE,CAAC;QACZ,CAAC;QAED,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,MAAM,EAAE;YAAE,MAAM,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;QAC5D,IAAI,CAAC,KAAK,EAAE,CAAC;QACb,IAAI,MAAM,GAAG,CAAC,IAAI,WAAW,GAAG,CAAC,EAAE,CAAC;YAClC,IAAI,CACF,uCAAuC,MAAM,+BAA+B;gBAC1E,GAAG,WAAW,mBAAmB,CACpC,CAAC;QACJ,CAAC;IACH,CAAC;IAED,OAAO;QACL,MAAM;QACN,KAAK;QACL,aAAa;QACb,OAAO;QACP,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC;YACZ,KAAK;YACL,QAAQ,EAAE,IAAI,CAAC,IAAI;YACnB,OAAO,EAAE,OAAO,CAAC,MAAM;YACvB,WAAW;YACX,aAAa;YACb,UAAU;YACV,iBAAiB;YACjB,WAAW,EAAE,KAAK,CAAC,IAAI;SACxB,CAAC;KACH,CAAC;AACJ,CAAC"}
package/dist/index.d.ts CHANGED
@@ -2,6 +2,7 @@ import type { Plugin } from './opencode-types.js';
2
2
  export declare const XemaPlugin: Plugin;
3
3
  export declare function __resetBootCacheForTests(): void;
4
4
  export default XemaPlugin;
5
+ export { createSubagentFanoutCeiling, resolveSubagentFanoutLimit, DEFAULT_SUBAGENT_FANOUT_LIMIT, DEFAULT_FANOUT_WAIT_TIMEOUT_MS, DEFAULT_PERMIT_TTL_MS, FANOUT_LIMIT_ENV, TASK_TOOL_ID, type SubagentFanoutCeiling, type FanoutCeilingOptions, type FanoutCeilingStats, } from './hooks/subagent-fanout-ceiling.js';
5
6
  export { readInvocationContext } from './context.js';
6
7
  export type { InvocationContext, InvocationRole } from './context.js';
7
8
  export * from './errors.js';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AA0CA,OAAO,KAAK,EAAS,MAAM,EAAkB,MAAM,qBAAqB,CAAC;AAuBzE,eAAO,MAAM,UAAU,EAAE,MAgBxB,CAAC;AA+BF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AAyBD,eAAe,UAAU,CAAC;AAG1B,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AACrD,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACtE,cAAc,aAAa,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,iBAAiB,EACjB,mBAAmB,EACnB,mBAAmB,EACnB,KAAK,YAAY,GAClB,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAkEA,OAAO,KAAK,EAIV,MAAM,EAEP,MAAM,qBAAqB,CAAC;AAuB7B,eAAO,MAAM,UAAU,EAAE,MAgBxB,CAAC;AAgFF,wBAAgB,wBAAwB,IAAI,IAAI,CAE/C;AAyBD,eAAe,UAAU,CAAC;AAG1B,OAAO,EACL,2BAA2B,EAC3B,0BAA0B,EAC1B,6BAA6B,EAC7B,8BAA8B,EAC9B,qBAAqB,EACrB,gBAAgB,EAChB,YAAY,EACZ,KAAK,qBAAqB,EAC1B,KAAK,oBAAoB,EACzB,KAAK,kBAAkB,GACxB,MAAM,oCAAoC,CAAC;AAC5C,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AACrD,YAAY,EAAE,iBAAiB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AACtE,cAAc,aAAa,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,iBAAiB,EACjB,mBAAmB,EACnB,mBAAmB,EACnB,KAAK,YAAY,GAClB,MAAM,oBAAoB,CAAC"}
package/dist/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  import { existsSync } from 'node:fs';
2
2
  import { join } from 'node:path';
3
3
  import { buildSessionIdleAutocommit } from './hooks/session-idle-autocommit.js';
4
+ import { createSubagentFanoutCeiling, resolveSubagentFanoutLimit, } from './hooks/subagent-fanout-ceiling.js';
4
5
  import { buildSystemPromptOverlay } from './hooks/system-prompt-overlay.js';
5
- import { info, isDebug } from './logger.js';
6
+ import { info, isDebug, warn } from './logger.js';
6
7
  import { buildXemaMemoryGetTool, buildXemaMemorySetTool, } from './tools/xema-memory.js';
7
8
  const bootCache = new Map();
8
9
  export const XemaPlugin = async (ctx) => {
@@ -29,10 +30,40 @@ async function doBoot(directory) {
29
30
  info(`boot: directory=${directory} tools=${toolNames.length} [${toolNames.join(', ')}] ` +
30
31
  `context.json=${contextPresentAtBoot ? 'present' : 'absent'} ` +
31
32
  `debug=${isDebug() ? 'on' : 'off'}`);
33
+ const fanoutLimit = resolveSubagentFanoutLimit();
34
+ const fanout = createSubagentFanoutCeiling({ limit: fanoutLimit });
35
+ const autocommit = buildSessionIdleAutocommit(directory);
36
+ info(`boot: subagent fan-out ceiling active (limit=${fanoutLimit} concurrent task calls ` +
37
+ `per worker; tighten with XEMA_SUBAGENT_FANOUT_LIMIT)`);
32
38
  const hooks = {
33
39
  tool: tools,
34
40
  'experimental.chat.system.transform': buildSystemPromptOverlay(directory),
35
- event: buildSessionIdleAutocommit(directory),
41
+ 'tool.execute.before': async (input) => {
42
+ await fanout.before(input);
43
+ },
44
+ 'tool.execute.after': (input) => {
45
+ fanout.after(input);
46
+ },
47
+ dispose: async () => {
48
+ fanout.dispose();
49
+ },
50
+ event: async (input) => {
51
+ const evt = input.event;
52
+ if (evt.type === 'session.idle') {
53
+ const sessionId = evt.properties
54
+ ?.sessionID;
55
+ if (sessionId) {
56
+ try {
57
+ fanout.onSessionIdle(sessionId);
58
+ }
59
+ catch (err) {
60
+ const message = err instanceof Error ? err.message : String(err);
61
+ warn(`fanout-ceiling: session-idle sweep failed: ${message}`);
62
+ }
63
+ }
64
+ }
65
+ await autocommit(input);
66
+ },
36
67
  };
37
68
  return hooks;
38
69
  }
@@ -46,6 +77,7 @@ function buildAllTools(workspaceDir) {
46
77
  };
47
78
  }
48
79
  export default XemaPlugin;
80
+ export { createSubagentFanoutCeiling, resolveSubagentFanoutLimit, DEFAULT_SUBAGENT_FANOUT_LIMIT, DEFAULT_FANOUT_WAIT_TIMEOUT_MS, DEFAULT_PERMIT_TTL_MS, FANOUT_LIMIT_ENV, TASK_TOOL_ID, } from './hooks/subagent-fanout-ceiling.js';
49
81
  export { readInvocationContext } from './context.js';
50
82
  export * from './errors.js';
51
83
  export { loadSessionState, loadSessionStateForTool, ensureRoleAllowed, ContextMissingError, RoleNotAllowedError, } from './session-state.js';
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAoCA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAEjC,OAAO,EAAE,0BAA0B,EAAE,MAAM,oCAAoC,CAAC;AAChF,OAAO,EAAE,wBAAwB,EAAE,MAAM,kCAAkC,CAAC;AAC5E,OAAO,EAAE,IAAI,EAAE,OAAO,EAAE,MAAM,aAAa,CAAC;AAG5C,OAAO,EACL,sBAAsB,EACtB,sBAAsB,GACvB,MAAM,wBAAwB,CAAC;AAgBhC,MAAM,SAAS,GAAG,IAAI,GAAG,EAA0B,CAAC;AAEpD,MAAM,CAAC,MAAM,UAAU,GAAW,KAAK,EAAE,GAAG,EAAE,EAAE;IAC9C,MAAM,EAAE,SAAS,EAAE,GAAG,GAAG,CAAC;IAC1B,MAAM,MAAM,GAAG,SAAS,CAAC,GAAG,CAAC,SAAS,CAAC,CAAC;IACxC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;QACzB,OAAO,MAAM,CAAC;IAChB,CAAC;IACD,MAAM,OAAO,GAAG,MAAM,CAAC,SAAS,CAAC,CAAC;IAClC,SAAS,CAAC,GAAG,CAAC,SAAS,EAAE,OAAO,CAAC,CAAC;IAClC,IAAI,CAAC;QACH,OAAO,MAAM,OAAO,CAAC;IACvB,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QAGb,SAAS,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC;QAC5B,MAAM,GAAG,CAAC;IACZ,CAAC;AACH,CAAC,CAAC;AAEF,KAAK,UAAU,MAAM,CAAC,SAAiB;IACrC,MAAM,KAAK,GAAG,aAAa,CAAC,SAAS,CAAC,CAAC;IACvC,MAAM,SAAS,GAAG,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,aAAa,CAAC,CAAC,CAAC,CAAC,CAAC;IACxE,MAAM,WAAW,GAAG,IAAI,CAAC,SAAS,EAAE,cAAc,CAAC,CAAC;IACpD,MAAM,oBAAoB,GAAG,UAAU,CAAC,WAAW,CAAC,CAAC;IAErD,IAAI,CACF,mBAAmB,SAAS,UAAU,SAAS,CAAC,MAAM,KAAK,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC,IAAI;QACjF,gBAAgB,oBAAoB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,QAAQ,GAAG;QAC9D,SAAS,OAAO,EAAE,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,KAAK,EAAE,CACtC,CAAC;IAEF,MAAM,KAAK,GAAU;QACnB,IAAI,EAAE,KAAK;QACX,oCAAoC,EAAE,wBAAwB,CAAC,SAAS,CAAC;QAMzE,KAAK,EAAE,0BAA0B,CAAC,SAAS,CAAC;KAC7C,CAAC;IACF,OAAO,KAAK,CAAC;AACf,CAAC;AAMD,MAAM,UAAU,wBAAwB;IACtC,SAAS,CAAC,KAAK,EAAE,CAAC;AACpB,CAAC;AAED,SAAS,aAAa,CAAC,YAAoB;IACzC,OAAO;QACL,eAAe,EAAE,sBAAsB,CAAC,YAAY,CAAC;QACrD,eAAe,EAAE,sBAAsB,CAAC,YAAY,CAAC;KAiBtD,CAAC;AACJ,CAAC;AAED,eAAe,UAAU,CAAC;AAG1B,OAAO,EAAE,qBAAqB,EAAE,MAAM,cAAc,CAAC;AAErD,cAAc,aAAa,CAAC;AAC5B,OAAO,EACL,gBAAgB,EAChB,uBAAuB,EACvB,iBAAiB,EACjB,mBAAmB,EACnB,mBAAmB,GAEpB,MAAM,oBAAoB,CAAC"}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"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"}
@@ -37,8 +37,22 @@ export type OpencodeEvent = OpencodeSessionIdleEvent | {
37
37
  type: string;
38
38
  properties?: Record<string, unknown>;
39
39
  };
40
+ export interface ToolExecuteHookInput {
41
+ tool: string;
42
+ sessionID: string;
43
+ callID: string;
44
+ }
40
45
  export interface Hooks {
41
46
  tool?: Record<string, ToolDefinition>;
47
+ dispose?: () => Promise<void>;
48
+ 'tool.execute.before'?: (input: ToolExecuteHookInput, output: {
49
+ args: unknown;
50
+ }) => Promise<void> | void;
51
+ 'tool.execute.after'?: (input: ToolExecuteHookInput, output: {
52
+ title: string;
53
+ output: string;
54
+ metadata: unknown;
55
+ }) => Promise<void> | void;
42
56
  'experimental.chat.system.transform'?: (input: ExperimentalChatSystemTransformInput, output: ExperimentalChatSystemTransformOutput) => Promise<void> | void;
43
57
  event?: (input: {
44
58
  event: OpencodeEvent;
@@ -1 +1 @@
1
- {"version":3,"file":"opencode-types.d.ts","sourceRoot":"","sources":["../src/opencode-types.ts"],"names":[],"mappings":"AASA,OAAO,KAAK,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAE7B,UAAU,WAAW;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,SAAS,EAAE,MAAM,CAAC;IAClB,KAAK,EAAE,MAAM,CAAC;IACd,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,WAAW,CAAC;IACnB,QAAQ,CAAC,KAAK,EAAE;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,QAAQ,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;KAAE,GAAG,IAAI,CAAC;CAC/E;AAED,MAAM,WAAW,cAAc,CAAC,IAAI,SAAS,CAAC,CAAC,WAAW,GAAG,CAAC,CAAC,WAAW;IACxE,WAAW,EAAE,MAAM,CAAC;IACpB,IAAI,EAAE,IAAI,CAAC;IACX,OAAO,CACL,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,EAChC,OAAO,EAAE,WAAW,GACnB,OAAO,CAAC,MAAM,CAAC,CAAC;CACpB;AAQD,MAAM,WAAW,qBAAqB;IACpC,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,oCAAoC;IACnD,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,qBAAqB,CAAC;CAC9B;AAED,MAAM,WAAW,qCAAqC;IACpD,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAUD,MAAM,MAAM,wBAAwB,GAAG;IACrC,IAAI,EAAE,cAAc,CAAC;IACrB,UAAU,EAAE;QAAE,SAAS,EAAE,MAAM,CAAA;KAAE,CAAC;CACnC,CAAC;AAEF,MAAM,MAAM,aAAa,GACrB,wBAAwB,GACxB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,UAAU,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE,CAAC;AAE3D,MAAM,WAAW,KAAK;IACpB,IAAI,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,cAAc,CAAC,CAAC;IAOtC,oCAAoC,CAAC,EAAE,CACrC,KAAK,EAAE,oCAAoC,EAC3C,MAAM,EAAE,qCAAqC,KAC1C,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;IAQ1B,KAAK,CAAC,EAAE,CAAC,KAAK,EAAE;QAAE,KAAK,EAAE,aAAa,CAAA;KAAE,KAAK,OAAO,CAAC,IAAI,CAAC,GAAG,IAAI,CAAC;CACnE;AAED,UAAU,WAAW;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,QAAQ,EAAE,MAAM,CAAC;IACjB,SAAS,EAAE,GAAG,CAAC;CAChB;AAED,MAAM,MAAM,MAAM,GAAG,CAAC,KAAK,EAAE,WAAW,KAAK,OAAO,CAAC,KAAK,CAAC,CAAC;AAO5D,wBAAgB,IAAI,CAAC,IAAI,SAAS,CAAC,CAAC,WAAW,EAC7C,KAAK,EAAE,cAAc,CAAC,IAAI,CAAC,GAC1B,cAAc,CAAC,IAAI,CAAC,CAEtB"}
1
+ {"version":3,"file":"opencode-types.d.ts","sourceRoot":"","sources":["../src/opencode-types.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"opencode-types.js","sourceRoot":"","sources":["../src/opencode-types.ts"],"names":[],"mappings":"AAsGA,MAAM,UAAU,IAAI,CAClB,KAA2B;IAE3B,OAAO,KAAK,CAAC;AACf,CAAC"}
1
+ {"version":3,"file":"opencode-types.js","sourceRoot":"","sources":["../src/opencode-types.ts"],"names":[],"mappings":"AAoJA,MAAM,UAAU,IAAI,CAClB,KAA2B;IAE3B,OAAO,KAAK,CAAC;AACf,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@xemahq/opencode-xema-plugin",
3
- "version": "0.1.12",
4
- "description": "Opencode plugin providing Xema-namespaced runtime tools (xema_memory_*) and the dynamic system-prompt overlay hook.",
3
+ "version": "0.2.0",
4
+ "description": "Opencode plugin providing Xema-namespaced runtime tools (xema_memory_*), the dynamic system-prompt overlay hook, and the sub-agent fan-out ceiling.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
7
7
  "homepage": "https://xema.dev",
@@ -25,7 +25,7 @@
25
25
  "peerDependencies": {
26
26
  "@opencode-ai/plugin": "^1.0.0",
27
27
  "zod": "^4.0.0",
28
- "@xemahq/kernel-contracts": "^12.0.0 || ^13.0.0 || ^14.0.0 || ^19.0.0"
28
+ "@xemahq/kernel-contracts": ">=12.0.0"
29
29
  },
30
30
  "devDependencies": {
31
31
  "@opencode-ai/plugin": "^1.15.10",
@@ -33,7 +33,7 @@
33
33
  "esbuild": "^0.25.4",
34
34
  "typescript": "5.9.3",
35
35
  "zod": "^4.3.6",
36
- "@xemahq/kernel-contracts": "^19.0.0"
36
+ "@xemahq/kernel-contracts": "^33.5.0"
37
37
  },
38
38
  "exports": {
39
39
  ".": {
package/plugin.mjs CHANGED
@@ -14866,6 +14866,185 @@ function sleep(ms) {
14866
14866
  });
14867
14867
  }
14868
14868
 
14869
+ // src/hooks/subagent-fanout-ceiling.ts
14870
+ var TASK_TOOL_ID = "task";
14871
+ var DEFAULT_SUBAGENT_FANOUT_LIMIT = 8;
14872
+ var DEFAULT_FANOUT_WAIT_TIMEOUT_MS = 10 * 60 * 1e3;
14873
+ var DEFAULT_PERMIT_TTL_MS = 30 * 60 * 1e3;
14874
+ var FANOUT_LIMIT_ENV = "XEMA_SUBAGENT_FANOUT_LIMIT";
14875
+ function resolveSubagentFanoutLimit(env = process.env) {
14876
+ const declared = env[FANOUT_LIMIT_ENV];
14877
+ if (declared === void 0 || declared.trim() === "") {
14878
+ return DEFAULT_SUBAGENT_FANOUT_LIMIT;
14879
+ }
14880
+ const parsed = Number(declared.trim());
14881
+ if (!Number.isInteger(parsed)) {
14882
+ throw new Error(
14883
+ `${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}.`
14884
+ );
14885
+ }
14886
+ if (parsed < 1) {
14887
+ throw new Error(
14888
+ `${FANOUT_LIMIT_ENV}="${declared}" is below 1. A ceiling of 0 would park EVERY delegation until it timed out, turning a resource bound into an outage. Remove the variable to accept the platform default (${DEFAULT_SUBAGENT_FANOUT_LIMIT}).`
14889
+ );
14890
+ }
14891
+ if (parsed > DEFAULT_SUBAGENT_FANOUT_LIMIT) {
14892
+ throw new Error(
14893
+ `${FANOUT_LIMIT_ENV}="${declared}" is above the platform default (${DEFAULT_SUBAGENT_FANOUT_LIMIT}). Configuration may only TIGHTEN this ceiling. The default is a stated platform decision; a wider one moves DEFAULT_SUBAGENT_FANOUT_LIMIT, where the grounds are recorded.`
14894
+ );
14895
+ }
14896
+ return parsed;
14897
+ }
14898
+ function createSubagentFanoutCeiling(options) {
14899
+ const limit = options.limit;
14900
+ const waitTimeoutMs = options.waitTimeoutMs ?? DEFAULT_FANOUT_WAIT_TIMEOUT_MS;
14901
+ const permitTtlMs = options.permitTtlMs ?? DEFAULT_PERMIT_TTL_MS;
14902
+ const now = options.now ?? (() => Date.now());
14903
+ const held = /* @__PURE__ */ new Map();
14904
+ const waiters = [];
14905
+ let waitedCount = 0;
14906
+ let timedOutCount = 0;
14907
+ let sweptCount = 0;
14908
+ let ttlReclaimedCount = 0;
14909
+ const armed = /* @__PURE__ */ new Set();
14910
+ function arm(fn, ms, keepProcessAlive) {
14911
+ const timer = setTimeout(() => {
14912
+ armed.delete(timer);
14913
+ fn();
14914
+ }, ms);
14915
+ if (!keepProcessAlive) timer.unref?.();
14916
+ armed.add(timer);
14917
+ return timer;
14918
+ }
14919
+ function disarm(timer) {
14920
+ if (timer === void 0) return;
14921
+ clearTimeout(timer);
14922
+ armed.delete(timer);
14923
+ }
14924
+ function take(callId, sessionId) {
14925
+ const ttlTimer = arm(() => {
14926
+ if (!held.has(callId)) return;
14927
+ ttlReclaimedCount += 1;
14928
+ warn(
14929
+ `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.`
14930
+ );
14931
+ release(callId, "ttl");
14932
+ }, permitTtlMs, false);
14933
+ held.set(callId, { sessionId, acquiredAt: now(), ttlTimer });
14934
+ }
14935
+ function release(callId, reason) {
14936
+ const permit = held.get(callId);
14937
+ if (permit === void 0) return;
14938
+ disarm(permit.ttlTimer);
14939
+ held.delete(callId);
14940
+ if (reason !== "completed") {
14941
+ debug(
14942
+ `fanout-ceiling: reclaimed call=${callId} via ${reason} (held ${now() - permit.acquiredAt}ms)`
14943
+ );
14944
+ }
14945
+ drain();
14946
+ }
14947
+ function drain() {
14948
+ while (waiters.length > 0 && held.size < limit) {
14949
+ const next = waiters.shift();
14950
+ if (next === void 0) return;
14951
+ disarm(next.timer);
14952
+ const waitedMs = now() - next.since;
14953
+ waitedCount += 1;
14954
+ take(next.callId, next.sessionId);
14955
+ info(
14956
+ `fanout-ceiling: admitted call=${next.callId} session=${next.sessionId} after waiting ${waitedMs}ms (limit=${limit}, in-flight=${held.size}, still waiting=${waiters.length})`
14957
+ );
14958
+ next.grant();
14959
+ }
14960
+ }
14961
+ async function before(input) {
14962
+ if (input.tool !== TASK_TOOL_ID) return;
14963
+ const { callID: callId, sessionID: sessionId } = input;
14964
+ if (held.size < limit) {
14965
+ take(callId, sessionId);
14966
+ debug(
14967
+ `fanout-ceiling: admitted call=${callId} immediately (in-flight=${held.size}/${limit})`
14968
+ );
14969
+ return;
14970
+ }
14971
+ const since = now();
14972
+ warn(
14973
+ `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.`
14974
+ );
14975
+ await new Promise((resolve) => {
14976
+ const waiter = {
14977
+ callId,
14978
+ sessionId,
14979
+ since,
14980
+ grant: resolve,
14981
+ timer: void 0
14982
+ };
14983
+ waiter.timer = arm(() => {
14984
+ const idx = waiters.indexOf(waiter);
14985
+ if (idx === -1) return;
14986
+ waiters.splice(idx, 1);
14987
+ timedOutCount += 1;
14988
+ warn(
14989
+ `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.`
14990
+ );
14991
+ take(callId, sessionId);
14992
+ resolve();
14993
+ }, waitTimeoutMs, true);
14994
+ waiters.push(waiter);
14995
+ });
14996
+ }
14997
+ function after(input) {
14998
+ if (input.tool !== TASK_TOOL_ID) return;
14999
+ release(input.callID, "completed");
15000
+ }
15001
+ function onSessionIdle(sessionId) {
15002
+ const stale = [];
15003
+ for (const [callId, permit] of held) {
15004
+ if (permit.sessionId === sessionId) stale.push(callId);
15005
+ }
15006
+ if (stale.length === 0) return;
15007
+ sweptCount += stale.length;
15008
+ warn(
15009
+ `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.`
15010
+ );
15011
+ for (const callId of stale) release(callId, "session-idle");
15012
+ }
15013
+ function dispose() {
15014
+ const parked = waiters.length;
15015
+ const outstanding = held.size;
15016
+ while (waiters.length > 0) {
15017
+ const w = waiters.shift();
15018
+ if (w === void 0) break;
15019
+ disarm(w.timer);
15020
+ w.grant();
15021
+ }
15022
+ for (const permit of held.values()) disarm(permit.ttlTimer);
15023
+ held.clear();
15024
+ if (parked > 0 || outstanding > 0) {
15025
+ info(
15026
+ `fanout-ceiling: disposed \u2014 released ${parked} parked task(s) and disarmed ${outstanding} permit timer(s).`
15027
+ );
15028
+ }
15029
+ }
15030
+ return {
15031
+ before,
15032
+ after,
15033
+ onSessionIdle,
15034
+ dispose,
15035
+ stats: () => ({
15036
+ limit,
15037
+ inFlight: held.size,
15038
+ waiting: waiters.length,
15039
+ waitedCount,
15040
+ timedOutCount,
15041
+ sweptCount,
15042
+ ttlReclaimedCount,
15043
+ armedTimers: armed.size
15044
+ })
15045
+ };
15046
+ }
15047
+
14869
15048
  // src/hooks/system-prompt-overlay.ts
14870
15049
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
14871
15050
  import { join as join4 } from "node:path";
@@ -15252,15 +15431,54 @@ async function doBoot(directory) {
15252
15431
  info(
15253
15432
  `boot: directory=${directory} tools=${toolNames.length} [${toolNames.join(", ")}] context.json=${contextPresentAtBoot ? "present" : "absent"} debug=${isDebug() ? "on" : "off"}`
15254
15433
  );
15434
+ const fanoutLimit = resolveSubagentFanoutLimit();
15435
+ const fanout = createSubagentFanoutCeiling({ limit: fanoutLimit });
15436
+ const autocommit = buildSessionIdleAutocommit(directory);
15437
+ info(
15438
+ `boot: subagent fan-out ceiling active (limit=${fanoutLimit} concurrent task calls per worker; tighten with XEMA_SUBAGENT_FANOUT_LIMIT)`
15439
+ );
15255
15440
  const hooks = {
15256
15441
  tool: tools,
15257
15442
  "experimental.chat.system.transform": buildSystemPromptOverlay(directory),
15258
- // Per-turn auto-commit. Listens for `session.idle` via the
15259
- // generic `event` channel — opencode 1.15.x does not expose a
15260
- // dedicated `session.idle` hook, only the event union. The handler
15261
- // is a no-op for sessions whose context.json does not opt in
15262
- // (`git.autoCommit !== 'enabled'`).
15263
- event: buildSessionIdleAutocommit(directory)
15443
+ // Backpressure on sub-agent fan-out. Upstream awaits this hook immediately
15444
+ // before the tool runs, so parking here parks the `task` call BEFORE the
15445
+ // child session exists — a pool cap that makes the N+1th spawn wait, not a
15446
+ // counter that aborts a run already paid for.
15447
+ "tool.execute.before": async (input) => {
15448
+ await fanout.before(input);
15449
+ },
15450
+ "tool.execute.after": (input) => {
15451
+ fanout.after(input);
15452
+ },
15453
+ // Shutdown. Disarms the ceiling's timers and lets any parked `task` call
15454
+ // through, so a torn-down worker leaves nothing armed and nothing pending.
15455
+ dispose: async () => {
15456
+ fanout.dispose();
15457
+ },
15458
+ // TWO consumers of `session.idle` now, so the channel fans out. opencode
15459
+ // exposes session lifecycle only as a discriminated event union, not as
15460
+ // dedicated hooks, and `Hooks.event` is a single function — so composition
15461
+ // has to happen here.
15462
+ //
15463
+ // The order matters and the isolation matters more: the fan-out sweep is a
15464
+ // LEAK BACKSTOP, so it must run even if auto-commit fails. Auto-commit
15465
+ // already swallows its own errors by hard invariant, but relying on that
15466
+ // from the outside would make this correct only by someone else's promise.
15467
+ event: async (input) => {
15468
+ const evt = input.event;
15469
+ if (evt.type === "session.idle") {
15470
+ const sessionId = evt.properties?.sessionID;
15471
+ if (sessionId) {
15472
+ try {
15473
+ fanout.onSessionIdle(sessionId);
15474
+ } catch (err) {
15475
+ const message = err instanceof Error ? err.message : String(err);
15476
+ warn(`fanout-ceiling: session-idle sweep failed: ${message}`);
15477
+ }
15478
+ }
15479
+ }
15480
+ await autocommit(input);
15481
+ }
15264
15482
  };
15265
15483
  return hooks;
15266
15484
  }