opencode-orchestrator-uni 0.4.0 → 0.5.1

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.
@@ -0,0 +1,1236 @@
1
+ /**
2
+ * session-budget — the kit's session protocol as runtime behaviour.
3
+ *
4
+ * The process documents describe how to keep a session healthy: a session is one
5
+ * slice with a measurable DoD, the prompt budget is bounded, a compaction is a
6
+ * stop signal, tool output belongs in a file rather than in context, and a
7
+ * handoff is written before the next session starts. Prose alone does not hold:
8
+ * a long-running root drifts past the budget silently. This module makes the
9
+ * protocol observable and, where it is safe, enforced:
10
+ *
11
+ * - `session.context` — estimate the prompt (system + messages), keep counters,
12
+ * and, when the estimate crosses a budget band or a compaction just happened,
13
+ * append one short reminder to the request's system parts. The reminder text is
14
+ * constant within a band, so prefix caching is not broken on every turn;
15
+ * - compaction events — the `session.compaction` hook is registered on every host
16
+ * and counts attempts (`compactionAttempts`) plus a tentative note that names no
17
+ * compaction number and claims no limit; a compaction is confirmed only by
18
+ * `session.compaction.ended` (deduplicated), which is what raises the limit.
19
+ * When the event stream dies, the guard flips `compactionSource` to `hook` and
20
+ * reports the failure in `guard.streamFailures`; on a host without an event
21
+ * stream attempts grow but no confirmed compaction or stop signal appears, so a
22
+ * failed attempt is never mistaken for a lossy summary;
23
+ * - `tool.execute.after` — cap the model-facing content of configured tools.
24
+ * The full output is written to a temp file; the model gets a head plus the
25
+ * path. Only tools that produce read-only text (shell/bash/webfetch by
26
+ * default) are capped — `read`, `execute` (Code Mode), edit tools and the
27
+ * kit's own tools keep their contract.
28
+ *
29
+ * Everything here is fail-open: a hook error leaves the request/tool result
30
+ * untouched and increments `errors`, because a broken guard must never break the
31
+ * session it tries to protect. Counters are in-memory and scoped to one plugin
32
+ * instance; `orch_uni_session_stats` exposes them to the orchestrator.
33
+ */
34
+ import { randomUUID } from "node:crypto"
35
+ import { lstat, mkdir, mkdtemp, realpath, rm, stat, writeFile } from "node:fs/promises"
36
+ import { tmpdir } from "node:os"
37
+ import path from "node:path"
38
+ import type { Plugin } from "@opencode/plugin"
39
+ import type { Locale } from "../index"
40
+
41
+ /** Session guard defaults: 150k estimated prompt tokens, at most one compaction. */
42
+ export const DEFAULT_SESSION_GUARD = {
43
+ enabled: true,
44
+ promptTokensSoft: 150_000,
45
+ maxCompactions: 1,
46
+ } as const
47
+
48
+ /** Tool-output guard defaults: read-only text tools, 20k chars, 4k head kept. */
49
+ export const DEFAULT_TOOL_OUTPUT_GUARD = {
50
+ enabled: true,
51
+ maxChars: 20_000,
52
+ headChars: 4_000,
53
+ tools: ["shell", "bash", "webfetch"],
54
+ } as const
55
+
56
+ export interface SessionGuardConfig {
57
+ readonly enabled: boolean
58
+ readonly promptTokensSoft: number
59
+ readonly maxCompactions: number
60
+ }
61
+
62
+ export interface ToolOutputGuardConfig {
63
+ readonly enabled: boolean
64
+ readonly maxChars: number
65
+ readonly headChars: number
66
+ readonly tools: readonly string[]
67
+ }
68
+
69
+ export interface SessionBudgetConfig {
70
+ readonly session: SessionGuardConfig
71
+ readonly toolOutput: ToolOutputGuardConfig
72
+ }
73
+
74
+ /** Where compaction counts come from: the event stream, or the legacy hook fallback. */
75
+ export type CompactionSource = "event" | "hook"
76
+
77
+ /** Counters of one session, as exposed by `orch_uni_session_stats`. */
78
+ export interface SessionBudgetCounters {
79
+ readonly sessionID: string
80
+ readonly requests: number
81
+ readonly promptTokensLast: number
82
+ readonly promptTokensMax: number
83
+ readonly promptTokensMedian: number
84
+ readonly band: number
85
+ /** Confirmed compactions only: `session.compaction.ended` events on an event host. */
86
+ readonly compactions: number
87
+ /** Compaction attempts observed through the always-registered hook. */
88
+ readonly compactionAttempts: number
89
+ readonly compactionFailures: number
90
+ readonly compactionSource: CompactionSource
91
+ readonly reminders: number
92
+ readonly toolCalls: number
93
+ readonly outputsCapped: number
94
+ readonly outputCharsSaved: number
95
+ /** Hook errors observed while handling this session; a dead event stream is in `guard.streamFailures`. */
96
+ readonly errors: number
97
+ /** All tool calls by normalized tool name (namespace stripped, lower-case). */
98
+ readonly toolCallsByTool: Readonly<Record<string, number>>
99
+ /** Capped outputs by normalized tool name; every called tool has an entry (possibly 0). */
100
+ readonly toolOutputsCappedByTool: Readonly<Record<string, number>>
101
+ }
102
+
103
+ interface MutableCounters {
104
+ sessionID: string
105
+ requests: number
106
+ promptTokensLast: number
107
+ promptTokensMax: number
108
+ samples: number[]
109
+ band: number
110
+ compactions: number
111
+ compactionAttempts: number
112
+ compactionFailures: number
113
+ /** Confirmed compactions whose lossy note was not delivered yet. */
114
+ pendingConfirmed: number
115
+ /** An attempt is in flight: a terminal event has not arrived yet. */
116
+ tentative: boolean
117
+ reminders: number
118
+ toolCalls: number
119
+ outputsCapped: number
120
+ outputCharsSaved: number
121
+ errors: number
122
+ toolCallsByTool: Record<string, number>
123
+ toolOutputsCappedByTool: Record<string, number>
124
+ /** Deduplication window for compaction events (`event.id` / durable position). */
125
+ seenCompactionEvents: Set<string>
126
+ }
127
+
128
+ /** The guard's live wiring state, as exposed by `orch_uni_session_stats`. */
129
+ export interface SessionBudgetGuard {
130
+ /** `false` when this host has no hook API or the registration failed. */
131
+ readonly available: boolean
132
+ readonly hooksRegistered: boolean
133
+ readonly sessionEnabled: boolean
134
+ /**
135
+ * `true` only when capping can actually fire: the private root exists **and**
136
+ * the effective tool list is not empty.
137
+ */
138
+ readonly toolOutputEnabled: boolean
139
+ /** The effective (normalized, protected names removed) list of capped tools. */
140
+ readonly toolOutputTools: readonly string[]
141
+ readonly compactionSource: CompactionSource
142
+ /** `true` while the compaction event stream is subscribed and consuming. */
143
+ readonly eventLive: boolean
144
+ /** Number of event-stream failures (0 or 1); kept out of the per-session `errors`. */
145
+ readonly streamFailures: number
146
+ /** Hook calls without a usable `sessionID`; they are dropped, not bucketed under `""`. */
147
+ readonly unkeyedCalls: number
148
+ }
149
+
150
+ /** Read-only view handed to the tools; keeps the hook state private. */
151
+ export interface SessionBudgetReport {
152
+ readonly available: boolean
153
+ readonly config: SessionBudgetConfig
154
+ readonly stats: (sessionID: string) => SessionBudgetCounters
155
+ /** `true` once any event or hook observed this session; `session.deleted` forgets it. */
156
+ readonly tracked: (sessionID: string) => boolean
157
+ readonly guard: () => SessionBudgetGuard
158
+ }
159
+
160
+ export interface SessionBudget extends SessionBudgetReport {
161
+ readonly cleanup: Plugin.Cleanup
162
+ }
163
+
164
+ /**
165
+ * Test seam for the private output root: a failing factory simulates an unusable
166
+ * temp directory (`mkdtemp` throws), and a factory returning a known path lets a
167
+ * test inspect modes and cleanup. Production always uses the default.
168
+ */
169
+ export interface SessionBudgetRuntime {
170
+ readonly createOutputRoot?: () => Promise<string>
171
+ /**
172
+ * Test seam between `mkdir` of the session directory and the link/realpath
173
+ * check: the containment test swaps the directory for a junction to prove
174
+ * that a link planted there is refused. Production never sets it.
175
+ */
176
+ readonly afterSessionDirectoryCreated?: (directory: string) => Promise<void> | void
177
+ }
178
+
179
+ const DEFAULT_RUNTIME: SessionBudgetRuntime = {}
180
+
181
+ const DEFAULT_CONFIG: SessionBudgetConfig = {
182
+ session: DEFAULT_SESSION_GUARD,
183
+ toolOutput: DEFAULT_TOOL_OUTPUT_GUARD,
184
+ }
185
+
186
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
187
+ return typeof value === "object" && value !== null && !Array.isArray(value)
188
+ ? (value as Record<string, unknown>)
189
+ : undefined
190
+ }
191
+
192
+ function errorMessage(error: unknown): string {
193
+ return error instanceof Error ? error.message : String(error)
194
+ }
195
+
196
+ /**
197
+ * `JSON.stringify` throws on BigInt and cyclic values; `String` throws on a
198
+ * hostile `toString`/`toJSON`. A warning formatter must never be the reason a
199
+ * guard fails to parse its options, so the total form has a last-resort marker.
200
+ */
201
+ export function safeJson(value: unknown): string {
202
+ try {
203
+ const text = JSON.stringify(value)
204
+ if (typeof text === "string") return text
205
+ } catch {
206
+ // BigInt, a cycle or a throwing toJSON: try the plain string form.
207
+ }
208
+ try {
209
+ return String(value)
210
+ } catch {
211
+ return "[unserializable]"
212
+ }
213
+ }
214
+
215
+ function finiteNumber(value: unknown, fallback: number, min: number, where: string, warn: Warn): number {
216
+ if (value === undefined) return fallback
217
+ if (typeof value !== "number" || !Number.isFinite(value) || value < min) {
218
+ warn(`${where}: expected a number >= ${min}, got ${safeJson(value)}, using ${fallback}`)
219
+ return fallback
220
+ }
221
+ return value
222
+ }
223
+
224
+ function booleanOption(value: unknown, fallback: boolean, where: string, warn: Warn): boolean {
225
+ if (value === undefined) return fallback
226
+ if (typeof value !== "boolean") {
227
+ warn(`${where}: expected a boolean, got ${safeJson(value)}, using ${fallback}`)
228
+ return fallback
229
+ }
230
+ return value
231
+ }
232
+
233
+ type Warn = (message: string) => void
234
+
235
+ /** Tools whose full output is part of their contract; capping them breaks work. */
236
+ const PROTECTED_TOOLS: ReadonlySet<string> = new Set([
237
+ "read",
238
+ "write",
239
+ "edit",
240
+ "patch",
241
+ "execute",
242
+ "subagent",
243
+ "question",
244
+ ])
245
+
246
+ /**
247
+ * The kit's own tools are never capped. Runtime names are namespaced
248
+ * (`orch_uni.session_stats`) and the `orch_uni` prefix is caught by
249
+ * `isProtectedTool`, but a config entry may name the bare segment
250
+ * (`session_stats`); those are refused by segment so the protection cannot be
251
+ * configured away.
252
+ */
253
+ const KIT_TOOL_NAMES: ReadonlySet<string> = new Set([
254
+ "session_stats",
255
+ "sessions",
256
+ "snapshot",
257
+ "context_lint",
258
+ "validate_report",
259
+ ])
260
+
261
+ /**
262
+ * Runtime tool names may carry a namespace (`functions.shell`); the guard cares
263
+ * about the tool itself. Normalization strips the namespace, trims and
264
+ * lower-cases, and is applied to both the config list and the runtime name.
265
+ */
266
+ function normalizeToolName(value: string): string {
267
+ const lower = value.trim().toLowerCase()
268
+ const dot = lower.lastIndexOf(".")
269
+ return dot === -1 ? lower : lower.slice(dot + 1)
270
+ }
271
+
272
+ function isProtectedTool(name: string): boolean {
273
+ return PROTECTED_TOOLS.has(name) || name.startsWith("orch_uni")
274
+ }
275
+
276
+ /**
277
+ * Parse `options.sessionGuard` / `options.toolOutputGuard` from the plugin
278
+ * entry. Invalid fields are reported and replaced by the defaults; the guard
279
+ * never fails setup because of a typo in the config.
280
+ */
281
+ export function readSessionBudgetOptions(options: unknown, warn: Warn): SessionBudgetConfig {
282
+ const root = asRecord(options)
283
+ const sessionRaw = asRecord(root?.sessionGuard)
284
+ const outputRaw = asRecord(root?.toolOutputGuard)
285
+ if (root?.sessionGuard !== undefined && sessionRaw === undefined) {
286
+ warn("options.sessionGuard: expected an object, ignored")
287
+ }
288
+ if (root?.toolOutputGuard !== undefined && outputRaw === undefined) {
289
+ warn("options.toolOutputGuard: expected an object, ignored")
290
+ }
291
+
292
+ const session: SessionGuardConfig = {
293
+ enabled: booleanOption(sessionRaw?.enabled, DEFAULT_SESSION_GUARD.enabled, "options.sessionGuard.enabled", warn),
294
+ promptTokensSoft: finiteNumber(
295
+ sessionRaw?.promptTokensSoft,
296
+ DEFAULT_SESSION_GUARD.promptTokensSoft,
297
+ 1000,
298
+ "options.sessionGuard.promptTokensSoft",
299
+ warn,
300
+ ),
301
+ maxCompactions: Math.max(
302
+ 0,
303
+ Math.floor(
304
+ finiteNumber(
305
+ sessionRaw?.maxCompactions,
306
+ DEFAULT_SESSION_GUARD.maxCompactions,
307
+ 0,
308
+ "options.sessionGuard.maxCompactions",
309
+ warn,
310
+ ),
311
+ ),
312
+ ),
313
+ }
314
+
315
+ let tools: readonly string[] = DEFAULT_TOOL_OUTPUT_GUARD.tools
316
+ if (outputRaw?.tools !== undefined) {
317
+ if (Array.isArray(outputRaw.tools) && outputRaw.tools.every((item) => typeof item === "string")) {
318
+ const normalized: string[] = []
319
+ for (const item of outputRaw.tools) {
320
+ const full = item.trim().toLowerCase()
321
+ const name = normalizeToolName(item)
322
+ if (name === "") continue
323
+ if (isProtectedTool(full) || isProtectedTool(name)) {
324
+ warn(`options.toolOutputGuard.tools: "${full}" is a protected tool and cannot be capped, ignored`)
325
+ continue
326
+ }
327
+ if (KIT_TOOL_NAMES.has(name)) {
328
+ warn(`options.toolOutputGuard.tools: "${full}" is a kit tool and cannot be capped, ignored`)
329
+ continue
330
+ }
331
+ if (!normalized.includes(name)) normalized.push(name)
332
+ }
333
+ if (normalized.length === 0) {
334
+ warn(
335
+ outputRaw.tools.length === 0
336
+ ? "options.toolOutputGuard.tools: the list is empty, tool output capping is disabled"
337
+ : "options.toolOutputGuard.tools: every entry was empty or protected, tool output capping is disabled",
338
+ )
339
+ }
340
+ tools = normalized
341
+ } else {
342
+ warn(
343
+ `options.toolOutputGuard.tools: expected an array of strings, got ${safeJson(outputRaw.tools)}, using ${DEFAULT_TOOL_OUTPUT_GUARD.tools.join("|")}`,
344
+ )
345
+ }
346
+ }
347
+
348
+ const maxChars = finiteNumber(
349
+ outputRaw?.maxChars,
350
+ DEFAULT_TOOL_OUTPUT_GUARD.maxChars,
351
+ 100,
352
+ "options.toolOutputGuard.maxChars",
353
+ warn,
354
+ )
355
+ const headChars = finiteNumber(
356
+ outputRaw?.headChars,
357
+ DEFAULT_TOOL_OUTPUT_GUARD.headChars,
358
+ 0,
359
+ "options.toolOutputGuard.headChars",
360
+ warn,
361
+ )
362
+
363
+ return {
364
+ session,
365
+ toolOutput: {
366
+ enabled: booleanOption(
367
+ outputRaw?.enabled,
368
+ DEFAULT_TOOL_OUTPUT_GUARD.enabled,
369
+ "options.toolOutputGuard.enabled",
370
+ warn,
371
+ ),
372
+ maxChars,
373
+ headChars: Math.min(headChars, maxChars),
374
+ tools,
375
+ },
376
+ }
377
+ }
378
+
379
+ /** Rough token estimate of any JSON-ish value: text characters / 3.3. */
380
+ export function estimateTokens(value: unknown): number {
381
+ return Math.round(countChars(value, new Set()) / 3.3)
382
+ }
383
+
384
+ function countChars(value: unknown, seen: Set<object>): number {
385
+ if (typeof value === "string") return value.length
386
+ if (typeof value !== "object" || value === null) return 0
387
+ if (ArrayBuffer.isView(value)) return value.byteLength
388
+ if (seen.has(value)) return 0
389
+ seen.add(value)
390
+ if (Array.isArray(value)) {
391
+ let total = 0
392
+ for (const item of value) total += countChars(item, seen)
393
+ return total
394
+ }
395
+ let total = 0
396
+ for (const item of Object.values(value as Record<string, unknown>)) total += countChars(item, seen)
397
+ return total
398
+ }
399
+
400
+ /** Threshold label without rounding: 1000 → `1k`, 1500 → `1.5k`, 150000 → `150k`. */
401
+ export function formatThreshold(value: number): string {
402
+ if (value < 1000) return String(value)
403
+ return `${value / 1000}k`
404
+ }
405
+
406
+ /** `maxCompactions: 0` disables the limit; otherwise the comparison is inclusive. */
407
+ export function compactionLimitReached(config: SessionBudgetConfig, count: number): boolean {
408
+ return config.session.maxCompactions > 0 && count >= config.session.maxCompactions
409
+ }
410
+
411
+ function reminderText(locale: Locale, band: number, config: SessionBudgetConfig): string {
412
+ const threshold = formatThreshold(config.session.promptTokensSoft)
413
+ const max = config.toolOutput.maxChars
414
+ if (locale === "ru") {
415
+ return (
416
+ `[Гигиена сессии] Оценка контекста выше бюджета (уровень ${band}×${threshold} токенов). ` +
417
+ "Закрой срез сейчас: handoff по session-discipline.md → коммит → новая сессия. " +
418
+ `Батчинг: ≥3 независимых команд за ход; вывод больше ${max} символов — в файл.`
419
+ )
420
+ }
421
+ return (
422
+ `[Session hygiene] Estimated context is above the budget (level ${band}×${threshold} tokens). ` +
423
+ "Close the slice now: handoff per session-discipline.md → commit → new session. " +
424
+ `Batch: ≥3 independent commands per turn; output over ${max} chars → file.`
425
+ )
426
+ }
427
+
428
+ function compactionText(locale: Locale, count: number, config: SessionBudgetConfig): string {
429
+ const max = config.session.maxCompactions
430
+ const over = max > 0 && count >= max
431
+ if (locale === "ru") {
432
+ return (
433
+ `[Гигиена сессии] Компакция #${count} (${max > 0 ? `лимит ${max}` : "лимит отключён"}). Компакция lossy: ` +
434
+ `зафиксируй решения, риски и следующий шаг в handoff; ` +
435
+ `не полагайся на пересказ.${over ? " Лимит исчерпан — новая сессия обязательна." : ""}`
436
+ )
437
+ }
438
+ return (
439
+ `[Session hygiene] Compaction #${count} (${max > 0 ? `limit ${max}` : "limit disabled"}). Compaction is lossy: ` +
440
+ `pin decisions, risks and the next step ` +
441
+ `to the handoff; do not trust the summary.${over ? " Limit reached — start a new session." : ""}`
442
+ )
443
+ }
444
+
445
+ /**
446
+ * A compaction attempt was observed through the always-registered hook, but no
447
+ * terminal event has confirmed or failed it yet. The text deliberately names no
448
+ * compaction number and no limit: only a confirmed compaction may do that.
449
+ */
450
+ function tentativeCompactionText(locale: Locale): string {
451
+ if (locale === "ru") {
452
+ return (
453
+ "[Гигиена сессии] Попытка компакции, подтверждение ожидается. " +
454
+ "Зафиксируй решения, риски и следующий шаг в handoff — не полагайся на пересказ."
455
+ )
456
+ }
457
+ return (
458
+ "[Session hygiene] Compaction attempt in progress, confirmation pending. " +
459
+ "Pin decisions, risks and the next step to the handoff; do not trust the summary."
460
+ )
461
+ }
462
+
463
+ interface ToolContentText {
464
+ readonly type: "text"
465
+ readonly text: string
466
+ }
467
+
468
+ interface ToolContentFile {
469
+ readonly type: "file"
470
+ readonly uri: string
471
+ readonly mime: string
472
+ readonly name?: string
473
+ }
474
+
475
+ type ToolContent = ToolContentText | ToolContentFile
476
+
477
+ /** Model-facing text of a tool result: a string or the text parts of an array. */
478
+ export function toolResultText(content: string | ReadonlyArray<ToolContent> | undefined): string {
479
+ if (typeof content === "string") return content
480
+ if (!Array.isArray(content)) return ""
481
+ return content
482
+ .filter((part): part is ToolContentText => part.type === "text")
483
+ .map((part) => part.text)
484
+ .join("\n")
485
+ }
486
+
487
+ /** Replace the model-facing text, keeping file parts (images, attachments) intact. */
488
+ function cappedContent(
489
+ content: string | ReadonlyArray<ToolContent>,
490
+ stub: string,
491
+ ): string | ReadonlyArray<ToolContent> {
492
+ if (typeof content === "string") return stub
493
+ const files = content.filter((part): part is ToolContentFile => part.type === "file")
494
+ return [{ type: "text", text: stub }, ...files]
495
+ }
496
+
497
+ /** Prefix of the per-process private root; `mkdtemp` appends its own random suffix. */
498
+ const OUTPUT_ROOT_PREFIX = "opencode-tool-output-"
499
+
500
+ /** One path inside the root? `path.relative` also catches `..` escapes. */
501
+ function isInside(root: string, target: string): boolean {
502
+ const relative = path.relative(root, target)
503
+ return relative === "" || (!relative.startsWith("..") && !path.isAbsolute(relative))
504
+ }
505
+
506
+ function emptyCounters(sessionID: string, source: CompactionSource): SessionBudgetCounters {
507
+ return {
508
+ sessionID,
509
+ requests: 0,
510
+ promptTokensLast: 0,
511
+ promptTokensMax: 0,
512
+ promptTokensMedian: 0,
513
+ band: 0,
514
+ compactions: 0,
515
+ compactionAttempts: 0,
516
+ compactionFailures: 0,
517
+ compactionSource: source,
518
+ reminders: 0,
519
+ toolCalls: 0,
520
+ outputsCapped: 0,
521
+ outputCharsSaved: 0,
522
+ errors: 0,
523
+ toolCallsByTool: {},
524
+ toolOutputsCappedByTool: {},
525
+ }
526
+ }
527
+
528
+ /**
529
+ * Lone surrogates (a JSON string escaped as `\ud800`, a sliced UTF-16 buffer) are
530
+ * not encodable as UTF-8: `writeFile` substitutes U+FFFD, so the file would not
531
+ * match the text whose length the stub reports. Normalize once, up front, and use
532
+ * the same string for the file, the head and the counters.
533
+ */
534
+ function wellFormed(text: string): string {
535
+ return text.isWellFormed() ? text : text.toWellFormed()
536
+ }
537
+
538
+ /** `headChars` code units, extended by one when the cut would split a surrogate pair. */
539
+ function headOf(text: string, headChars: number): string {
540
+ if (headChars <= 0) return ""
541
+ if (headChars >= text.length) return text
542
+ const last = text.charCodeAt(headChars - 1)
543
+ const end = last >= 0xd800 && last <= 0xdbff ? headChars + 1 : headChars
544
+ return text.slice(0, end)
545
+ }
546
+
547
+ function cappedStub(head: string, text: string, file: string): string {
548
+ return `${head}\n\n[... ${text.length} chars total; showing first ${head.length}; full output: ${file}]\n`
549
+ }
550
+
551
+ /** Reject a symlink or an NTFS junction planted at a path the guard writes through. */
552
+ async function assertNotLink(target: string, what: string): Promise<void> {
553
+ const entry = await lstat(target)
554
+ if (entry.isSymbolicLink()) {
555
+ throw new Error(`refusing to write a capped tool output: ${what} ${target} is a symlink/junction`)
556
+ }
557
+ }
558
+
559
+ /**
560
+ * Create the per-process private root: a fresh directory under `os.tmpdir()`
561
+ * with a random name (`mkdtemp` appends its own suffix, mode 0700 on POSIX).
562
+ * A pre-created `opencode-tool-output` directory or junction is never looked at.
563
+ */
564
+ async function createPrivateOutputRoot(): Promise<string> {
565
+ return await mkdtemp(path.join(tmpdir(), OUTPUT_ROOT_PREFIX))
566
+ }
567
+
568
+ /**
569
+ * POSIX: the private root must belong to this user and be closed to group and
570
+ * other. A root that fails the check is never used — the caller disables the
571
+ * tool-output guard instead of writing through a shared directory. Windows has
572
+ * no POSIX ownership/mode bits here, so the check is skipped there.
573
+ */
574
+ async function assertPrivateRoot(root: string): Promise<void> {
575
+ if (process.platform === "win32") return
576
+ const entry = await stat(root)
577
+ const uid = typeof process.getuid === "function" ? process.getuid() : undefined
578
+ if (uid !== undefined && entry.uid !== uid) {
579
+ throw new Error(`output root ${root} is owned by uid ${entry.uid}, expected ${uid}`)
580
+ }
581
+ if ((entry.mode & 0o077) !== 0) {
582
+ throw new Error(`output root ${root} mode ${(entry.mode & 0o777).toString(8)} grants group/other access`)
583
+ }
584
+ }
585
+
586
+ /**
587
+ * Reuse the session directory from the cache or create one random 0700 directory
588
+ * inside the root. The directory is `lstat`ed (a symlink/junction is refused) and
589
+ * its `realpath` must stay inside the real root. The returned path is the written
590
+ * one. Fail-open at the call site: a throw leaves the tool result untouched.
591
+ */
592
+ async function sessionOutputDirectory(
593
+ root: string,
594
+ sessionID: string,
595
+ cache: Map<string, string>,
596
+ runtime: SessionBudgetRuntime,
597
+ ): Promise<string> {
598
+ const cached = cache.get(sessionID)
599
+ if (cached !== undefined) return cached
600
+ const directory = path.join(root, `s-${randomUUID()}`)
601
+ await mkdir(directory, { mode: 0o700 })
602
+ await runtime.afterSessionDirectoryCreated?.(directory)
603
+ await assertNotLink(directory, "session directory")
604
+ const realDirectory = await realpath(directory)
605
+ if (!isInside(root, realDirectory)) {
606
+ throw new Error(`refusing to write a capped tool output outside ${root} (got ${realDirectory})`)
607
+ }
608
+ cache.set(sessionID, realDirectory)
609
+ return realDirectory
610
+ }
611
+
612
+ /** Best effort: a leftover temp file must never break the tool result. */
613
+ async function removeFileQuietly(file: string): Promise<void> {
614
+ try {
615
+ await rm(file, { force: true })
616
+ } catch {
617
+ // best effort: nothing else to do with an orphaned temp file
618
+ }
619
+ }
620
+
621
+ /**
622
+ * Write the full output under a random `c-<uuid>.txt` name with `wx` and mode
623
+ * 0600. A name collision is practically impossible; one retry with a fresh random
624
+ * name is kept as a fallback.
625
+ */
626
+ async function writeCappedOutput(directory: string, text: string): Promise<string> {
627
+ const attempt = async (): Promise<string> => {
628
+ const target = path.join(directory, `c-${randomUUID()}.txt`)
629
+ await writeFile(target, text, { encoding: "utf8", mode: 0o600, flag: "wx" })
630
+ return target
631
+ }
632
+ try {
633
+ return await attempt()
634
+ } catch (error) {
635
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error
636
+ }
637
+ return await attempt()
638
+ }
639
+
640
+ /** Median of a small sample window; `0` for an empty window. */
641
+ function median(values: readonly number[]): number {
642
+ if (values.length === 0) return 0
643
+ const sorted = [...values].sort((a, b) => a - b)
644
+ const middle = Math.floor(sorted.length / 2)
645
+ return sorted.length % 2 === 0 ? Math.round((sorted[middle - 1]! + sorted[middle]!) / 2) : sorted[middle]!
646
+ }
647
+
648
+ interface SessionDurableEnvelopeLike {
649
+ readonly aggregateID?: unknown
650
+ readonly seq?: unknown
651
+ }
652
+
653
+ interface SessionEventLike {
654
+ readonly id?: unknown
655
+ readonly type?: unknown
656
+ readonly data?: { readonly sessionID?: unknown }
657
+ readonly durable?: SessionDurableEnvelopeLike
658
+ }
659
+
660
+ /** How many compaction event keys one session remembers for deduplication. */
661
+ const SEEN_COMPACTION_EVENTS = 256
662
+
663
+ /**
664
+ * Stable identity of a compaction event: the event id when the host sends one,
665
+ * otherwise the durable log position. Without either the event cannot be told
666
+ * apart from a repeat, so it is processed (fail-open in the direction of counting).
667
+ */
668
+ function compactionEventKey(event: SessionEventLike): string | undefined {
669
+ if (typeof event.id === "string" && event.id !== "") return `id:${event.id}`
670
+ const durable = event.durable
671
+ if (
672
+ durable !== undefined &&
673
+ typeof durable.aggregateID === "string" &&
674
+ durable.aggregateID !== "" &&
675
+ (typeof durable.seq === "number" || typeof durable.seq === "string")
676
+ ) {
677
+ return `durable:${durable.aggregateID}:${String(durable.seq)}`
678
+ }
679
+ return undefined
680
+ }
681
+
682
+ /** Remember one event key; `false` means it was already processed. */
683
+ function firstSighting(state: MutableCounters, key: string): boolean {
684
+ if (state.seenCompactionEvents.has(key)) return false
685
+ state.seenCompactionEvents.add(key)
686
+ if (state.seenCompactionEvents.size > SEEN_COMPACTION_EVENTS) {
687
+ const oldest = state.seenCompactionEvents.values().next().value
688
+ if (oldest !== undefined) state.seenCompactionEvents.delete(oldest)
689
+ }
690
+ return true
691
+ }
692
+
693
+ type EventSubscribe = (options: { signal: AbortSignal }) => AsyncIterable<unknown>
694
+
695
+ /** `ctx.event.subscribe` is optional at runtime even though the type declares it. */
696
+ function eventSubscriber(ctx: Plugin.Context): EventSubscribe | undefined {
697
+ const event = (ctx as { event?: unknown }).event
698
+ if (typeof event !== "object" || event === null) return undefined
699
+ const subscribe = (event as Record<string, unknown>).subscribe
700
+ return typeof subscribe === "function" ? (subscribe.bind(event) as EventSubscribe) : undefined
701
+ }
702
+
703
+ /**
704
+ * The host contract says `event.subscribe` returns an `AsyncIterable`. A version
705
+ * skew can return `undefined`, a `Promise`, a plain object or a factory that
706
+ * yields garbage; the guard treats all of that as a dead stream (hook fallback)
707
+ * instead of throwing during setup.
708
+ *
709
+ * Exported for the test seam: this is the first of the two independent lines that
710
+ * keep a broken host from leaving the guard lying (the second is the `.catch`
711
+ * barrier on the subscription task). A single mutation of either line still
712
+ * degrades correctly, so the suite pins each line on its own here and in
713
+ * `closeIteratorQuietly`.
714
+ */
715
+ export function asyncIteratorOf(stream: unknown): AsyncIterator<unknown> {
716
+ const factory = (stream as { [Symbol.asyncIterator]?: unknown } | null | undefined)?.[Symbol.asyncIterator]
717
+ if (typeof factory !== "function") {
718
+ throw new TypeError("event.subscribe did not return an async iterable")
719
+ }
720
+ const iterator = (factory as () => unknown).call(stream)
721
+ if (typeof iterator !== "object" || iterator === null) {
722
+ throw new TypeError("event.subscribe returned an async iterable without an iterator")
723
+ }
724
+ return iterator as AsyncIterator<unknown>
725
+ }
726
+
727
+ /**
728
+ * Best effort: close a host iterator so its own resources (socket, timer) are
729
+ * released. The whole body is guarded: a hostile host can expose a throwing
730
+ * `return` getter, and this helper runs inside the subscription task's `finally`,
731
+ * which must stay total.
732
+ *
733
+ * Exported for the test seam (see `asyncIteratorOf`).
734
+ */
735
+ export function closeIteratorQuietly(iterator: AsyncIterator<unknown> | null | undefined): void {
736
+ try {
737
+ const close = iterator?.return
738
+ if (typeof close !== "function") return
739
+ void Promise.resolve(close.call(iterator)).catch(() => {})
740
+ } catch {
741
+ // best effort: a failing close must not affect the guard state
742
+ }
743
+ }
744
+
745
+ /**
746
+ * Register the hooks. Every host gets the `session.compaction` hook (attempts and
747
+ * a tentative note that names no count and no limit); on a host with an event
748
+ * stream `session.compaction.ended` confirms a compaction and
749
+ * `session.compaction.failed` drops the tentative note. If the stream ends or
750
+ * throws, `compactionSource` flips to `hook` and `guard` exposes `eventLive: false`
751
+ * plus `streamFailures: 1`, so a dead stream is visible instead of silently
752
+ * freezing the count. The tool-output guard writes into one private root per
753
+ * plugin instance (`mkdtemp(os.tmpdir()/opencode-tool-output-)`), never into a
754
+ * fixed, guessable name; a root that cannot be created or is not private (POSIX
755
+ * owner/mode) disables that guard with a warning instead of failing calls. The
756
+ * returned `stats` reports one session's counters; `cleanup` aborts the
757
+ * subscription, disposes every registration (safe to call twice) and removes the
758
+ * private root best-effort. A host without the V2 hook domains degrades to
759
+ * counters only: the guard is a policy layer, it must never be the reason a
760
+ * plugin fails to load.
761
+ */
762
+ export async function createSessionBudget(
763
+ ctx: Plugin.Context,
764
+ config: SessionBudgetConfig = DEFAULT_CONFIG,
765
+ locale: Locale = "en",
766
+ warn: (message: string) => void = () => {},
767
+ runtime: SessionBudgetRuntime = DEFAULT_RUNTIME,
768
+ ): Promise<SessionBudget> {
769
+ const sessionHook = ctx.session?.hook
770
+ const toolHook = ctx.tool?.hook
771
+ if (typeof sessionHook !== "function" || typeof toolHook !== "function") {
772
+ warn("session guard disabled: this OpenCode build exposes no session/tool hook API")
773
+ return {
774
+ available: false,
775
+ config,
776
+ cleanup: async () => {},
777
+ stats: (sessionID) => emptyCounters(sessionID, "hook"),
778
+ tracked: () => false,
779
+ guard: () => ({
780
+ available: false,
781
+ hooksRegistered: false,
782
+ sessionEnabled: config.session.enabled,
783
+ // No hooks, no private root: capping cannot fire here at all.
784
+ toolOutputEnabled: false,
785
+ toolOutputTools: config.toolOutput.tools,
786
+ compactionSource: "hook",
787
+ eventLive: false,
788
+ streamFailures: 0,
789
+ unkeyedCalls: 0,
790
+ }),
791
+ }
792
+ }
793
+
794
+ const workspace = (ctx as { location?: { directory?: unknown } }).location?.directory
795
+ if (typeof workspace === "string" && workspace !== "") {
796
+ const temp = path.resolve(tmpdir())
797
+ if (isInside(path.resolve(workspace), temp)) {
798
+ warn(
799
+ `session guard: os.tmpdir() (${temp}) is inside the workspace (${path.resolve(workspace)}); ` +
800
+ "capped tool outputs will be written there, the private root is removed when the plugin unloads",
801
+ )
802
+ }
803
+ }
804
+
805
+ // One private root per plugin instance, created eagerly: a temp directory that
806
+ // cannot be created or is not private (POSIX owner/mode) disables the
807
+ // tool-output guard once, with a warning, instead of failing every capped call.
808
+ // A failure never breaks the session: the tool result is left untouched.
809
+ let outputRoot: string | undefined
810
+ if (config.toolOutput.enabled) {
811
+ try {
812
+ const created = await (runtime.createOutputRoot ?? createPrivateOutputRoot)()
813
+ await assertPrivateRoot(created)
814
+ outputRoot = await realpath(created)
815
+ } catch (error) {
816
+ warn(
817
+ `tool output guard disabled: no private temp root (${errorMessage(error)}); ` +
818
+ "tool results are left untouched",
819
+ )
820
+ }
821
+ }
822
+ // Capping needs both a private root and at least one tool to cap: a root with an
823
+ // empty effective list would report an enabled guard that can never fire.
824
+ const outputGuardEnabled = outputRoot !== undefined && config.toolOutput.tools.length > 0
825
+
826
+ const states = new Map<string, MutableCounters>()
827
+ /** `sessionID → realpath` of its private directory, evicted with `session.deleted`. */
828
+ const sessionDirectories = new Map<string, string>()
829
+ /** Ids whose `session.deleted` was observed: late events/hooks must not resurrect them. */
830
+ const deletedSessions = new Set<string>()
831
+ let compactionSource: CompactionSource = "hook"
832
+ let eventLive = false
833
+ let streamFailures = 0
834
+ /** Hook calls whose `sessionID` was missing or empty: dropped, never bucketed under `""`. */
835
+ let unkeyedCalls = 0
836
+ const stateFor = (sessionID: string): MutableCounters => {
837
+ let state = states.get(sessionID)
838
+ if (state === undefined) {
839
+ state = {
840
+ sessionID,
841
+ requests: 0,
842
+ promptTokensLast: 0,
843
+ promptTokensMax: 0,
844
+ samples: [],
845
+ band: 0,
846
+ compactions: 0,
847
+ compactionAttempts: 0,
848
+ compactionFailures: 0,
849
+ pendingConfirmed: 0,
850
+ tentative: false,
851
+ reminders: 0,
852
+ toolCalls: 0,
853
+ outputsCapped: 0,
854
+ outputCharsSaved: 0,
855
+ errors: 0,
856
+ // Null-prototype maps: a tool literally named `constructor` or `__proto__`
857
+ // must not collide with `Object.prototype`.
858
+ toolCallsByTool: Object.create(null) as Record<string, number>,
859
+ toolOutputsCappedByTool: Object.create(null) as Record<string, number>,
860
+ seenCompactionEvents: new Set<string>(),
861
+ }
862
+ states.set(sessionID, state)
863
+ }
864
+ return state
865
+ }
866
+
867
+ const registrations: { dispose: () => Promise<void> | void }[] = []
868
+ const compactionSubscription = new AbortController()
869
+ let available = true
870
+ let hooksRegistered = false
871
+
872
+ try {
873
+ const subscribe = eventSubscriber(ctx)
874
+ if (subscribe !== undefined) {
875
+ try {
876
+ const stream = subscribe({ signal: compactionSubscription.signal })
877
+ compactionSource = "event"
878
+ eventLive = true
879
+ void (async () => {
880
+ // Race every read against the abort signal: a host that ignores the
881
+ // signal (or a `next()` that never settles) must not keep this loop and
882
+ // its closures alive after `cleanup()`.
883
+ const aborted = new Promise<"aborted">((resolve) => {
884
+ const signal = compactionSubscription.signal
885
+ if (signal.aborted) {
886
+ resolve("aborted")
887
+ return
888
+ }
889
+ signal.addEventListener("abort", () => resolve("aborted"), { once: true })
890
+ })
891
+ // Two independent lines keep a broken host from leaving the guard lying:
892
+ // the iterator is built inside this try, and the task carries a `.catch`
893
+ // barrier (below). A single mutation of either line still degrades
894
+ // correctly on purpose; each line has its own witness in the unit tests.
895
+ let iterator: AsyncIterator<unknown> | undefined
896
+ try {
897
+ iterator = asyncIteratorOf(stream)
898
+ while (true) {
899
+ const step = await Promise.race([iterator.next(), aborted])
900
+ if (step === "aborted") break
901
+ if (step.done === true) break
902
+ const record = step.value as SessionEventLike | null | undefined
903
+ if (record === null || record === undefined) continue
904
+ const sessionID = record.data?.sessionID
905
+ if (typeof sessionID !== "string" || sessionID === "") continue
906
+ const type = record.type
907
+ // `session.deleted` forgets the session and plants a tombstone: counters
908
+ // and the dedup window go away, so a reused id starts clean, and late
909
+ // events for the deleted id are ignored instead of resurrecting it.
910
+ // `session.created` is the only event allowed to lift the tombstone.
911
+ if (type === "session.deleted") {
912
+ states.delete(sessionID)
913
+ sessionDirectories.delete(sessionID)
914
+ deletedSessions.add(sessionID)
915
+ continue
916
+ }
917
+ if (deletedSessions.has(sessionID)) {
918
+ if (type !== "session.created") continue
919
+ deletedSessions.delete(sessionID)
920
+ }
921
+ const state = stateFor(sessionID)
922
+ try {
923
+ if (type === "session.compaction.ended") {
924
+ const key = compactionEventKey(record)
925
+ if (key !== undefined && !firstSighting(state, key)) continue
926
+ state.compactions += 1
927
+ state.pendingConfirmed = state.compactions
928
+ state.tentative = false
929
+ } else if (type === "session.compaction.failed") {
930
+ const key = compactionEventKey(record)
931
+ if (key !== undefined && !firstSighting(state, key)) continue
932
+ state.compactionFailures += 1
933
+ // A failed attempt produces no lossy summary: drop only the tentative
934
+ // slot; a confirmed compaction still waiting for delivery is kept.
935
+ state.tentative = false
936
+ }
937
+ } catch {
938
+ state.errors += 1
939
+ }
940
+ }
941
+ } catch {
942
+ // Subscription ended or the server went away; the finally block flips
943
+ // the source to the hook, which is registered either way.
944
+ } finally {
945
+ // Best effort, whatever the exit path (done or abort): the host may hold
946
+ // a socket, a timer or an unsubscribe callback in `return()`.
947
+ closeIteratorQuietly(iterator)
948
+ eventLive = false
949
+ compactionSource = "hook"
950
+ if (!compactionSubscription.signal.aborted) streamFailures = 1
951
+ }
952
+ })().catch(() => {
953
+ // Last barrier: no path of this task may surface as an unhandled
954
+ // rejection, and a task that died must never leave the guard lying.
955
+ eventLive = false
956
+ compactionSource = "hook"
957
+ if (!compactionSubscription.signal.aborted) streamFailures = 1
958
+ })
959
+ } catch {
960
+ compactionSource = "hook"
961
+ eventLive = false
962
+ streamFailures = 1
963
+ }
964
+ }
965
+
966
+ registrations.push(
967
+ await ctx.session.hook("context", (input) => {
968
+ const payload = input as { sessionID?: unknown } | null | undefined
969
+ if (payload === null || payload === undefined) return
970
+ const sessionID = payload.sessionID
971
+ if (typeof sessionID !== "string" || sessionID === "") {
972
+ unkeyedCalls += 1
973
+ return
974
+ }
975
+ if (deletedSessions.has(sessionID)) return
976
+ let state: MutableCounters | undefined
977
+ try {
978
+ state = stateFor(sessionID)
979
+ state.requests += 1
980
+ const tokens = estimateTokens(input.system) + estimateTokens(input.messages)
981
+ state.promptTokensLast = tokens
982
+ if (tokens > state.promptTokensMax) state.promptTokensMax = tokens
983
+ state.samples.push(tokens)
984
+ if (state.samples.length > 256) state.samples.shift()
985
+ if (!config.session.enabled) return
986
+
987
+ const parts: string[] = []
988
+ const band = Math.floor((tokens - 1) / config.session.promptTokensSoft)
989
+ const level = band > state.band ? band : state.band
990
+ if (band > state.band) {
991
+ parts.push(reminderText(locale, band, config))
992
+ }
993
+ // Confirmed note first: only `session.compaction.ended` raises
994
+ // `pendingConfirmed`, so the number and the limit are facts even if the
995
+ // event stream died after the event arrived. Otherwise an attempt note
996
+ // goes out without a number and without a limit claim.
997
+ const confirmedNote = state.pendingConfirmed > 0
998
+ const tentativeNote = !confirmedNote && state.tentative
999
+ if (confirmedNote) {
1000
+ parts.push(compactionText(locale, state.pendingConfirmed, config))
1001
+ } else if (tentativeNote) {
1002
+ parts.push(tentativeCompactionText(locale))
1003
+ }
1004
+ if (parts.length === 0) return
1005
+ input.system.push(...parts.map((text) => ({ type: "text" as const, text })))
1006
+ // Marks move only after the reminder was actually delivered to the
1007
+ // request: a host whose `system` array rejects the push (frozen/foreign
1008
+ // array) must not lose the note for this level forever.
1009
+ state.band = level
1010
+ if (confirmedNote) state.pendingConfirmed = 0
1011
+ if (confirmedNote || tentativeNote) state.tentative = false
1012
+ state.reminders += parts.length
1013
+ } catch {
1014
+ if (state !== undefined) state.errors += 1
1015
+ }
1016
+ }),
1017
+ )
1018
+
1019
+ // `session.compaction` is registered on every host: the hook sees every attempt,
1020
+ // so the counters keep moving even when the event stream is absent or dies. The
1021
+ // hook only records a tentative attempt — `compactions` grows on
1022
+ // `session.compaction.ended` (event hosts), and only that count raises the limit.
1023
+ registrations.push(
1024
+ await ctx.session.hook("compaction", (input) => {
1025
+ const payload = input as { sessionID?: unknown } | null | undefined
1026
+ if (payload === null || payload === undefined) return
1027
+ const sessionID = payload.sessionID
1028
+ if (typeof sessionID !== "string" || sessionID === "") {
1029
+ unkeyedCalls += 1
1030
+ return
1031
+ }
1032
+ if (deletedSessions.has(sessionID)) return
1033
+ let state: MutableCounters | undefined
1034
+ try {
1035
+ state = stateFor(sessionID)
1036
+ state.compactionAttempts += 1
1037
+ // Tentative: the nearest primary after an attempt gets an attempt note even
1038
+ // when the terminal event is delayed. Never touches `compactions` or a
1039
+ // confirmed note that is still waiting for delivery.
1040
+ state.tentative = true
1041
+ } catch {
1042
+ if (state !== undefined) state.errors += 1
1043
+ }
1044
+ }),
1045
+ )
1046
+
1047
+ registrations.push(
1048
+ await ctx.tool.hook("execute.after", async (input) => {
1049
+ const payload = input as { sessionID?: unknown } | null | undefined
1050
+ if (payload === null || payload === undefined) return
1051
+ const sessionID = payload.sessionID
1052
+ if (typeof sessionID !== "string" || sessionID === "") {
1053
+ unkeyedCalls += 1
1054
+ return
1055
+ }
1056
+ if (deletedSessions.has(sessionID)) return
1057
+ let state: MutableCounters | undefined
1058
+ try {
1059
+ state = stateFor(sessionID)
1060
+ state.toolCalls += 1
1061
+ const name = normalizeToolName(String(input.tool))
1062
+ state.toolCallsByTool[name] = (state.toolCallsByTool[name] ?? 0) + 1
1063
+ state.toolOutputsCappedByTool[name] ??= 0
1064
+ if (input.status !== "completed") return
1065
+ if (!outputGuardEnabled || outputRoot === undefined) return
1066
+ // The kit's own tools are checked on the raw name too: a namespace-less
1067
+ // config entry (`session_stats`) must not turn one into a capped tool, and
1068
+ // the segment list is enforced here as well so a hand-built config cannot
1069
+ // skip the parser.
1070
+ if (
1071
+ isProtectedTool(String(input.tool).trim().toLowerCase()) ||
1072
+ isProtectedTool(name) ||
1073
+ KIT_TOOL_NAMES.has(name)
1074
+ ) {
1075
+ return
1076
+ }
1077
+ if (!config.toolOutput.tools.includes(name)) return
1078
+
1079
+ const result = input.result as {
1080
+ content?: string | ReadonlyArray<ToolContent>
1081
+ metadata?: Record<string, unknown>
1082
+ }
1083
+ // One normalized string for the file, the head and the counters: a lone
1084
+ // surrogate would otherwise be replaced by U+FFFD on disk only.
1085
+ const text = wellFormed(toolResultText(result.content))
1086
+ if (text.length <= config.toolOutput.maxChars) return
1087
+
1088
+ const head = headOf(text, config.toolOutput.headChars)
1089
+ const root = outputRoot
1090
+ const writeOnce = async (): Promise<string> => {
1091
+ const directory = await sessionOutputDirectory(root, sessionID, sessionDirectories, runtime)
1092
+ return await writeCappedOutput(directory, text)
1093
+ }
1094
+ let written: string
1095
+ try {
1096
+ written = await writeOnce()
1097
+ } catch (error) {
1098
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
1099
+ state.errors += 1
1100
+ return
1101
+ }
1102
+ // The cached session directory (or the root itself) was removed outside
1103
+ // the plugin (`%TEMP%` cleanup): drop the stale path and retry once.
1104
+ sessionDirectories.delete(sessionID)
1105
+ try {
1106
+ written = await writeOnce()
1107
+ } catch {
1108
+ state.errors += 1
1109
+ return
1110
+ }
1111
+ }
1112
+ const stub = cappedStub(head, text, written)
1113
+ // Capping must shrink the context: a stub at least as long as the text
1114
+ // would make the guard itself the problem, so the file is removed and the
1115
+ // result stays untouched.
1116
+ if (stub.length >= text.length) {
1117
+ await removeFileQuietly(written)
1118
+ return
1119
+ }
1120
+ // Assign, then verify: a host may hand out a frozen/readonly result. An
1121
+ // unapplied stub leaves an orphaned file, so it is removed and the result
1122
+ // is left exactly as it came.
1123
+ const original = result.content
1124
+ const replacement = cappedContent(original as string | ReadonlyArray<ToolContent>, stub)
1125
+ try {
1126
+ result.content = replacement
1127
+ if (result.content !== replacement) throw new Error("tool result content is readonly")
1128
+ result.metadata = {
1129
+ ...(result.metadata ?? {}),
1130
+ toolOutputGuard: { chars: text.length, path: written },
1131
+ }
1132
+ } catch {
1133
+ try {
1134
+ result.content = original
1135
+ } catch {
1136
+ // frozen object: there is nothing to restore
1137
+ }
1138
+ await removeFileQuietly(written)
1139
+ state.errors += 1
1140
+ return
1141
+ }
1142
+ state.outputsCapped += 1
1143
+ state.toolOutputsCappedByTool[name] = (state.toolOutputsCappedByTool[name] ?? 0) + 1
1144
+ state.outputCharsSaved += Math.max(0, text.length - stub.length)
1145
+ } catch {
1146
+ if (state !== undefined) state.errors += 1
1147
+ }
1148
+ }),
1149
+ )
1150
+ } catch (error) {
1151
+ available = false
1152
+ hooksRegistered = false
1153
+ compactionSubscription.abort()
1154
+ warn(`session guard disabled: hook registration failed (${errorMessage(error)})`)
1155
+ for (const registration of registrations.reverse()) {
1156
+ try {
1157
+ await registration.dispose()
1158
+ } catch {
1159
+ // best effort: a failed disposal must not mask the original failure
1160
+ }
1161
+ }
1162
+ registrations.length = 0
1163
+ }
1164
+ if (available) hooksRegistered = true
1165
+
1166
+ let disposed = false
1167
+ const cleanup: Plugin.Cleanup = async () => {
1168
+ if (disposed) return
1169
+ disposed = true
1170
+ // Terminal state first: a host that ignores the abort signal must not leave
1171
+ // the guard reporting a live subscription after unload.
1172
+ eventLive = false
1173
+ compactionSource = "hook"
1174
+ compactionSubscription.abort()
1175
+ for (const registration of registrations.reverse()) {
1176
+ try {
1177
+ await registration.dispose()
1178
+ } catch {
1179
+ // A failing disposal must not prevent the other hooks from being removed.
1180
+ }
1181
+ }
1182
+ sessionDirectories.clear()
1183
+ if (outputRoot !== undefined) {
1184
+ try {
1185
+ await rm(outputRoot, { recursive: true, force: true })
1186
+ } catch {
1187
+ // best effort: a leftover temp root must not break plugin unload
1188
+ }
1189
+ }
1190
+ }
1191
+
1192
+ const guard = (): SessionBudgetGuard => ({
1193
+ available,
1194
+ hooksRegistered,
1195
+ sessionEnabled: config.session.enabled,
1196
+ toolOutputEnabled: outputGuardEnabled,
1197
+ toolOutputTools: config.toolOutput.tools,
1198
+ compactionSource,
1199
+ eventLive,
1200
+ streamFailures,
1201
+ unkeyedCalls,
1202
+ })
1203
+
1204
+ return {
1205
+ available,
1206
+ config,
1207
+ cleanup,
1208
+ guard,
1209
+ tracked: (sessionID) => states.has(sessionID),
1210
+ stats: (sessionID: string): SessionBudgetCounters => {
1211
+ const state = states.get(sessionID)
1212
+ if (state === undefined) {
1213
+ return emptyCounters(sessionID, compactionSource)
1214
+ }
1215
+ return {
1216
+ sessionID: state.sessionID,
1217
+ requests: state.requests,
1218
+ promptTokensLast: state.promptTokensLast,
1219
+ promptTokensMax: state.promptTokensMax,
1220
+ promptTokensMedian: median(state.samples),
1221
+ band: state.band,
1222
+ compactions: state.compactions,
1223
+ compactionAttempts: state.compactionAttempts,
1224
+ compactionFailures: state.compactionFailures,
1225
+ compactionSource,
1226
+ reminders: state.reminders,
1227
+ toolCalls: state.toolCalls,
1228
+ outputsCapped: state.outputsCapped,
1229
+ outputCharsSaved: state.outputCharsSaved,
1230
+ errors: state.errors,
1231
+ toolCallsByTool: { ...state.toolCallsByTool },
1232
+ toolOutputsCappedByTool: { ...state.toolOutputsCappedByTool },
1233
+ }
1234
+ },
1235
+ }
1236
+ }