dsh-switchman 1.3.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.
@@ -0,0 +1,620 @@
1
+ /** dsh-switchman context-watermark layer (Phase 3).
2
+ *
3
+ * Ported from opencode-switchman's src/context-watch.ts (decision core),
4
+ * src/index.ts:386-459 (banner rendering) and 2066-2110 (auto-handover with
5
+ * its anti-deadlock lesson) onto DSH services:
6
+ *
7
+ * - One dynamic systemPrompt section 'switchman:watermark' (order 10500)
8
+ * renders the `[SWITCHMAN:WATERMARK]` banner from the live token meter
9
+ * (ctx.tokenMeter.measure(agent.session).totalTokens). Root sessions get
10
+ * the tiered banner (ok/soft/hard/force/paused); subagent sessions get a
11
+ * compact cap-aware banner. The text function is side-effect free.
12
+ * - Read budget double gate: `tools/post-execute` accounts read/glob/grep/
13
+ * bash result text (~len/3.5 tokens) per session per turn; `tools/
14
+ * pre-execute` denies or caps read/glob/grep once the budget is exhausted
15
+ * at tier >= hard (wmDenyMode picks deny vs cap+warning-append).
16
+ * - Handovers borrow the session's own per-agent /compact command handler
17
+ * via ctx.commands.find(agent, "compact"): the desktop profile mounts
18
+ * compaction only inside each preset's isolated per-agent group
19
+ * (isolate: { compaction: true }), so no root-level inject or lookup can
20
+ * reach it — the borrowed handler's closure carries the right composition.
21
+ * Auto handover is fire-and-forget with a session-level in-flight flag
22
+ * plus a 10-minute cooldown. Manual /ctx-handover is the full four-step
23
+ * flow: fork a dormant backup session (subagents' fork provider, economy
24
+ * pool model), write the handover document skeleton, compact, then wake
25
+ * this session (agent.followup + sessions.flush, the schedule service's
26
+ * delivery channel) to fill in the document and continue the unfinished
27
+ * task. /ctx-pause and /ctx-resume toggle process-lifetime pause
28
+ * state (a restart resumes enforcement — the safe side).
29
+ *
30
+ * All session state is in-process Maps; every path is fail-open (a failing
31
+ * measurement or missing service degrades the banner to "" or the gate to
32
+ * allow, never throws into the assembly chain or the tool pipeline).
33
+ */
34
+
35
+ import { randomUUID } from "node:crypto";
36
+ import { mkdirSync, writeFileSync } from "node:fs";
37
+ import { join } from "node:path";
38
+
39
+ /** Tools whose result text is accounted against the per-turn read budget. */
40
+ const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
41
+
42
+ /** Tools the pre-execute budget gate may deny or cap. */
43
+ const GATED_TOOLS = new Set(["read", "glob", "grep"]);
44
+
45
+ /** Result text length → estimated tokens. */
46
+ const TOKENS_PER_CHAR = 1 / 3.5;
47
+
48
+ /** Turn budget ceiling: 2×R* plus a 0.4×R* tolerance before denying. */
49
+ const CEILING_MULTIPLE = 2.4; // 2 + 0.4 — matches the spec (limit 2×R*, tolerance 0.4×R*)
50
+
51
+ /** Idle window after which a session's accounting resets itself. */
52
+ const IDLE_RESET_MS = 15 * 60 * 1000;
53
+
54
+ /** Cooldown between automatic force-tier handovers per session. */
55
+ const HANDOVER_COOLDOWN_MS = 10 * 60 * 1000;
56
+
57
+ // ---- shared volatile-read helpers (mirrors lang.js / dispatch.js) ----------
58
+
59
+ /** Read one volatile settings field's raw value (live `.get()` reference). */
60
+ function readValue(field) {
61
+ if (field !== null && typeof field === "object" && typeof field.get === "function") {
62
+ try {
63
+ return field.get();
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+ return field;
69
+ }
70
+
71
+ /** Field value as a string ("" when not a string). */
72
+ function readString(field) {
73
+ const value = readValue(field);
74
+ return typeof value === "string" ? value : "";
75
+ }
76
+
77
+ /** Field value as a finite positive number, else the fallback. */
78
+ function readNumber(field, fallback) {
79
+ const value = readValue(field);
80
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : fallback;
81
+ }
82
+
83
+ /** Field value as a boolean, else the fallback. */
84
+ function readBool(field, fallback) {
85
+ const value = readValue(field);
86
+ return typeof value === "boolean" ? value : fallback;
87
+ }
88
+
89
+ // ---- pure helpers ----------------------------------------------------------
90
+
91
+ /** Tier for a used-token count against the thresholds. */
92
+ function levelOf(used, th) {
93
+ if (used >= th.force) return "force";
94
+ if (used >= th.hard) return "hard";
95
+ if (used >= th.soft) return "soft";
96
+ return "ok";
97
+ }
98
+
99
+ /** `42k`-style rounding for the anchor line. */
100
+ function kilo(tokens) {
101
+ return `${Math.round(tokens / 1000)}k`;
102
+ }
103
+
104
+ /** Session id off an exec/invocation agent (shared agent/session id). */
105
+ function sessionIdOf(agent) {
106
+ const id = agent?.session?.id ?? agent?.id;
107
+ return typeof id === "string" && id !== "" ? id : null;
108
+ }
109
+
110
+ /** True when the agent is a runtime root (owner-less); unknown registries
111
+ * count as root so the richer root banner wins the tie. */
112
+ function isRoot(ctx, agent) {
113
+ try {
114
+ const roots = ctx.agents?.roots?.();
115
+ return !Array.isArray(roots) || roots.includes(agent);
116
+ } catch {
117
+ return true;
118
+ }
119
+ }
120
+
121
+ /** Live threshold snapshot from the volatile settings. */
122
+ function readThresholds(config) {
123
+ return {
124
+ soft: readNumber(config.wmSoftTokens, 50000),
125
+ hard: readNumber(config.wmHardTokens, 90000),
126
+ force: readNumber(config.wmForceTokens, 130000),
127
+ };
128
+ }
129
+
130
+ /** Measure one agent's session now; null when the meter is missing/failing. */
131
+ function measureNow(ctx, agent) {
132
+ try {
133
+ const measurement = agent?.session ? ctx.tokenMeter?.measure?.(agent.session) : undefined;
134
+ const used = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
135
+ return used === null ? null : used;
136
+ } catch {
137
+ return null;
138
+ }
139
+ }
140
+
141
+ // ---- banner rendering (pure given state + measurement) ---------------------
142
+
143
+ /** Root-session banner: byte-stable anchor line + tier advice lines. */
144
+ function renderRootBanner(level, used, th, autoHandover) {
145
+ const head =
146
+ `[SWITCHMAN:WATERMARK] level=${level} used=${kilo(used)}` +
147
+ ` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
148
+ if (level === "ok") return head;
149
+ if (level === "paused")
150
+ return `${head}\nPAUSED by /ctx-pause — measurement continues, but every watermark enforcement stays off until /ctx-resume.`;
151
+ const lines = [head];
152
+ if (level === "soft") lines.push(`soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself.`);
153
+ if (level === "hard") lines.push(`hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`);
154
+ if (level === "force") {
155
+ lines.push(`force tier: MANDATORY wrap-up now — produce the final answer from what you already hold.`);
156
+ lines.push(
157
+ autoHandover
158
+ ? `an automatic context handover (compaction) is armed and will fire at the next step boundary.`
159
+ : `run /ctx-handover to trigger a manual context handover.`,
160
+ );
161
+ }
162
+ return lines.join("\n");
163
+ }
164
+
165
+ /** Subagent-session banner: the same soft/hard/force absolute-token tiers
166
+ * as the root banner ("" while ok — token economy), with subagent-flavored
167
+ * advice and the HANDOFF block due at the force tier. Pausing never applies
168
+ * to subagents (oc index.ts:187-190). */
169
+ function renderSubBanner(used, th) {
170
+ const level = levelOf(used, th);
171
+ const head =
172
+ `[SWITCHMAN:WATERMARK] subagent level=${level} used=${kilo(used)}` +
173
+ ` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
174
+ if (level === "ok") return "";
175
+ if (level === "soft")
176
+ return `${head}\nsoft tier: keep reads narrow and drop bulky raw output.`;
177
+ if (level === "hard")
178
+ return `${head}\nhard tier: stop broad reading; start wrapping toward the final summary.`;
179
+ return (
180
+ `${head}\nforce tier: stop reading and return the final summary now.\n` +
181
+ `End the FINAL message with a HANDOFF block: completed / key findings (file:line) / remaining / next steps.`
182
+ );
183
+ }
184
+
185
+ /** Whole banner for one assembly; "" whenever anything needed is missing. */
186
+ function renderBanner(ctx, config, state, agent) {
187
+ try {
188
+ const sessionId = sessionIdOf(agent);
189
+ if (sessionId === null || agent.session === undefined) return "";
190
+ const th = readThresholds(config);
191
+ const used = measureNow(ctx, agent);
192
+ if (used === null) return "";
193
+ if (!isRoot(ctx, agent)) {
194
+ // Subagents share the root soft/hard thresholds; the optional
195
+ // wmSubagentForceTokens override replaces only the force tier
196
+ // (enabled by the wmSubagentCap toggle, 0/0 = follow the root set).
197
+ const subForce = readBool(config.wmSubagentCap, true) ? readNumber(config.wmSubagentForceTokens, 0) : 0;
198
+ const subTh = subForce > 0 ? { ...th, force: subForce } : th;
199
+ return renderSubBanner(used, subTh);
200
+ }
201
+ const level = state.paused.has(sessionId) ? "paused" : levelOf(used, th);
202
+ return renderRootBanner(level, used, th, readBool(config.wmAutoHandover, true));
203
+ } catch {
204
+ return "";
205
+ }
206
+ }
207
+
208
+ // ---- per-turn read accounting ----------------------------------------------
209
+
210
+ /** Estimated tokens in one tool result (content text blocks, else stringified value). */
211
+ function resultTokens(result) {
212
+ try {
213
+ let chars = 0;
214
+ if (Array.isArray(result?.content)) {
215
+ for (const block of result.content)
216
+ if (block?.type === "text" && typeof block.text === "string") chars += block.text.length;
217
+ } else if (result?.value !== undefined && result.value !== null) {
218
+ try {
219
+ chars = JSON.stringify(result.value).length;
220
+ } catch {
221
+ chars = String(result.value).length;
222
+ }
223
+ }
224
+ return Math.round(chars * TOKENS_PER_CHAR);
225
+ } catch {
226
+ return 0;
227
+ }
228
+ }
229
+
230
+ /** Add tokens to a session's turn account (idle window resets it). */
231
+ function accountRead(state, sessionId, tokens, now = Date.now()) {
232
+ const prior = state.readUsed.get(sessionId);
233
+ if (prior !== undefined && now - prior.at > IDLE_RESET_MS) {
234
+ state.readUsed.set(sessionId, { used: tokens, at: now });
235
+ return state.readUsed.get(sessionId);
236
+ }
237
+ if (prior === undefined) {
238
+ const entry = { used: tokens, at: now };
239
+ state.readUsed.set(sessionId, entry);
240
+ return entry;
241
+ }
242
+ prior.used += tokens;
243
+ prior.at = now;
244
+ return prior;
245
+ }
246
+
247
+ /** Current turn usage for a session (idle-expired accounts read as zero). */
248
+ function turnUsed(state, sessionId, now = Date.now()) {
249
+ const entry = state.readUsed.get(sessionId);
250
+ if (entry === undefined || now - entry.at > IDLE_RESET_MS) return 0;
251
+ return entry.used;
252
+ }
253
+
254
+ // ---- layer -----------------------------------------------------------------
255
+
256
+ /** Mount the context-watermark layer: banner section, budget gates, auto
257
+ * handover, and the three /ctx-* commands. */
258
+ function applyContextWatch(ctx, config) {
259
+ /** Process-lifetime session state (never persisted). */
260
+ const state = {
261
+ paused: new Set(),
262
+ inflight: new Set(),
263
+ lastHandover: new Map(),
264
+ readUsed: new Map(),
265
+ };
266
+ const warn = (message) => ctx.logger?.warn?.(`dsh-switchman: ${message}`);
267
+
268
+ // --- A. watermark banner -------------------------------------------------
269
+ ctx.systemPrompt.section({
270
+ name: "switchman:watermark",
271
+ order: 10500,
272
+ interpolate: false,
273
+ text: (context) => renderBanner(ctx, config, state, context?.agent),
274
+ });
275
+
276
+ // --- C. handover execution: borrow the session's own /compact -----------
277
+ // The desktop profile provides compaction ONLY inside each preset's
278
+ // per-agent isolated group (isolate: { compaction: true }), where the
279
+ // per-agent command-compact /compact command lives; a root plugin cannot
280
+ // inject or resolve that service (inject would wait forever; property/get
281
+ // access throws). The sanctioned bridge is the commands registry itself:
282
+ // ctx.commands.find(agent, "compact") merges that agent's scoped layer and
283
+ // returns the definition whose handler closure already carries the right
284
+ // composition context with compaction injected.
285
+ const sessionCompact = (agent, commandId, signal) => {
286
+ let definition;
287
+ try {
288
+ definition = ctx.commands?.find?.(agent, "compact");
289
+ } catch {
290
+ definition = undefined;
291
+ }
292
+ if (definition === undefined || typeof definition.handler !== "function") return undefined;
293
+ const invocation = Object.freeze({
294
+ commandId,
295
+ agent,
296
+ rawInput: "",
297
+ attachments: Object.freeze([]),
298
+ signal,
299
+ });
300
+ return Promise.resolve(definition.handler(invocation));
301
+ };
302
+
303
+ const startHandover = (agent, sessionId) => {
304
+ state.inflight.add(sessionId);
305
+ let promise;
306
+ try {
307
+ promise = sessionCompact(agent, `switchman-auto-${sessionId}`, undefined);
308
+ if (promise === undefined)
309
+ warn(`auto handover found no per-agent /compact command for session ${sessionId} — nothing to do`);
310
+ } catch (error) {
311
+ warn(`handover failed to start: ${error?.message ?? error}`);
312
+ promise = undefined;
313
+ }
314
+ const settle = () => state.inflight.delete(sessionId);
315
+ if (promise !== undefined && typeof promise.then === "function") {
316
+ promise.then(
317
+ () => ctx.logger?.info?.(`dsh-switchman: context handover settled for session ${sessionId}`),
318
+ (error) => warn(`context handover failed for session ${sessionId}: ${error?.message ?? error}`),
319
+ ).then(settle, settle);
320
+ } else {
321
+ settle();
322
+ }
323
+ };
324
+
325
+ /** First {provider, model} route of a volatile pool field, or null. */
326
+ const firstPoolRoute = (field) => {
327
+ const raw = readValue(field);
328
+ const first = Array.isArray(raw) ? raw[0] : undefined;
329
+ if (first === null || typeof first !== "object") return null;
330
+ return typeof first.provider === "string" && typeof first.model === "string" ? first : null;
331
+ };
332
+
333
+ /** Manual handover step 1 — fork a dormant backup child of this session
334
+ * through the subagents service's fork provider (the child inherits the
335
+ * completed turns, runs one tiny confirmation turn on the economy pool
336
+ * model when configured, then stays idle as a browsable snapshot). The
337
+ * spec MUST carry a signal: the manager calls spec.signal.throwIfAborted()
338
+ * unconditionally, so a missing signal fails the whole fork with a
339
+ * TypeError (the live failure observed on 2026-10-01). The append-only
340
+ * session log always remains the full-fidelity backup, so a failed fork
341
+ * only downgrades the UX, never the data. Returns {childId, reason}. */
342
+ const forkBackup = async (agent, sessionId, signal) => {
343
+ try {
344
+ const economy = firstPoolRoute(config.poolEconomy);
345
+ const started = await ctx.subagents?.startContinuable?.({
346
+ provider: "fork",
347
+ label: "dsh-switchman handover backup",
348
+ signal: signal ?? new AbortController().signal,
349
+ request: {
350
+ parent: agent,
351
+ prompt: [
352
+ {
353
+ type: "text",
354
+ text: "[SWITCHMAN:HANDOVER-BACKUP] This session is a dormant fork snapshot taken immediately before a context handover in the parent session. Do not perform any work. Reply with exactly one line confirming you hold the complete pre-handover state, then stay idle.",
355
+ },
356
+ ],
357
+ ...(economy === null ? {} : { agentOptions: { provider: economy.provider, model: economy.model } }),
358
+ },
359
+ });
360
+ if (typeof started?.childId !== "string") {
361
+ const reason = `startContinuable returned no child id (${JSON.stringify(started ?? null)})`;
362
+ warn(
363
+ `handover backup fork failed for session ${sessionId} (continuing; the append-only log remains the full backup): ${reason}`,
364
+ );
365
+ return { childId: null, reason };
366
+ }
367
+ return { childId: started.childId, reason: null };
368
+ } catch (error) {
369
+ const reason = error?.message ?? String(error);
370
+ warn(
371
+ `handover backup fork failed for session ${sessionId} (continuing; the append-only log remains the full backup): ${reason}`,
372
+ );
373
+ return { childId: null, reason };
374
+ }
375
+ };
376
+
377
+ /** Manual handover step 2 — write the handover document skeleton into
378
+ * the session's working directory. The mechanical facts land now (before
379
+ * compaction); the agent's continuation turn fills in the task-state
380
+ * summary using the compaction summary it can still see. Null when the
381
+ * session exposes no usable cwd or the write fails. */
382
+ const writeHandoverSkeleton = (agent, sessionId, backupId, used) => {
383
+ const cwd = agent?.session?.header?.cwd;
384
+ if (typeof cwd !== "string" || cwd === "") return null;
385
+ const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
386
+ const directory = join(cwd, ".dsh-switchman", "handover");
387
+ const short = sessionId.replace(/^session-/u, "").slice(0, 8) || sessionId;
388
+ const path = join(directory, `${stamp}-${short}.md`);
389
+ try {
390
+ mkdirSync(directory, { recursive: true });
391
+ writeFileSync(
392
+ path,
393
+ [
394
+ `# SWITCHMAN handover — ${sessionId}`,
395
+ ``,
396
+ `- handed over at: ${new Date().toISOString()}`,
397
+ `- context used before compaction: ~${used ?? "unknown"} tokens`,
398
+ `- backup fork session: ${backupId ?? "(fork failed — the full history remains in this session's append-only log)"}`,
399
+ ``,
400
+ `## Task state (filled in by the agent right after the handover)`,
401
+ ``,
402
+ `- goal / objective in flight:`,
403
+ `- completed so far:`,
404
+ `- in progress:`,
405
+ `- next steps:`,
406
+ `- files touched / key references:`,
407
+ ``,
408
+ ].join("\n"),
409
+ "utf8",
410
+ );
411
+ return path;
412
+ } catch (error) {
413
+ warn(`handover document write failed: ${error?.message ?? error}`);
414
+ return null;
415
+ }
416
+ };
417
+
418
+ /** Manual handover step 4 — wake the freshly compacted session: one
419
+ * followup user message (the same delivery channel the schedule service
420
+ * uses: agent.followup + sessions.flush) instructing the agent to fill
421
+ * in the handover document from the compaction summary now in context
422
+ * and continue the unfinished task. */
423
+ const armContinuation = async (agent, backupId, docPath) => {
424
+ if (typeof agent?.followup !== "function") {
425
+ warn("handover continuation skipped: agent exposes no followup()");
426
+ return;
427
+ }
428
+ const instruction = [
429
+ "[SWITCHMAN:HANDOVER] A context handover just completed for this session.",
430
+ backupId !== null
431
+ ? `A dormant fork backup holding the full pre-handover state is session ${backupId}.`
432
+ : `No fork backup was created; the append-only session log still holds the full history.`,
433
+ docPath !== null
434
+ ? `The handover document skeleton is at ${docPath}.`
435
+ : `Create the handover document under .dsh-switchman/handover/ in the working directory.`,
436
+ "Using the compaction summary now in your context, fill in the handover document (goal, completed, in progress, next steps, files), then continue the unfinished task — do not wait for further instructions.",
437
+ ].join(" ");
438
+ try {
439
+ agent.followup({
440
+ id: randomUUID(),
441
+ role: "user",
442
+ content: [{ type: "text", text: instruction }],
443
+ source: { kind: "user" },
444
+ });
445
+ await ctx.sessions?.flush?.(agent.session);
446
+ } catch (error) {
447
+ warn(`handover continuation failed: ${error?.message ?? error}`);
448
+ }
449
+ };
450
+
451
+ /** Manual /ctx-handover — the full opencode-switchman handover flow on
452
+ * DSH primitives: (1) fork a dormant backup session, (2) write the
453
+ * handover document skeleton, (3) compact via the session's own
454
+ * per-agent /compact handler (borrowed through ctx.commands.find; the
455
+ * preset isolates compaction per agent, so no root-level access exists),
456
+ * and (4) wake this session to fill in the document and continue the
457
+ * unfinished task. Every step degrades independently; compaction is the
458
+ * only mandatory one. */
459
+ const manualHandover = async (agent, sessionId, commandId, signal) => {
460
+ if (sessionId === null || agent?.session === undefined)
461
+ return { kind: "error", text: "/ctx-handover: no active session" };
462
+ if (state.inflight.has(sessionId))
463
+ return { kind: "success", text: "[SWITCHMAN:WATERMARK] a handover is already in progress for this session." };
464
+ const borrowed = sessionCompact(agent, commandId, signal);
465
+ if (borrowed === undefined)
466
+ return {
467
+ kind: "error",
468
+ text: "/ctx-handover: this session's preset exposes no /compact command, so no compaction backend is reachable.",
469
+ };
470
+ state.inflight.add(sessionId);
471
+ try {
472
+ const backup = await forkBackup(agent, sessionId, signal);
473
+ const backupId = backup.childId;
474
+ const docPath = writeHandoverSkeleton(agent, sessionId, backupId, measureNow(ctx, agent));
475
+ const result = await borrowed;
476
+ if (result?.kind === "error")
477
+ return {
478
+ kind: "error",
479
+ text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${result.text ?? "unknown error"}`,
480
+ };
481
+ await armContinuation(agent, backupId, docPath);
482
+ const backupText =
483
+ backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
484
+ return {
485
+ kind: "success",
486
+ text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${docPath ?? "agent-created"}, compaction done, continuation armed.`,
487
+ };
488
+ } catch (error) {
489
+ return { kind: "error", text: `/ctx-handover failed: ${error?.message ?? error}` };
490
+ } finally {
491
+ state.inflight.delete(sessionId);
492
+ }
493
+ };
494
+
495
+ const maybeAutoHandover = (agent) => {
496
+ try {
497
+ const sessionId = sessionIdOf(agent);
498
+ if (sessionId === null || agent.session === undefined) return;
499
+ if (readBool(config.wmAutoHandover, true) !== true) return;
500
+ if (state.paused.has(sessionId) || state.inflight.has(sessionId)) return;
501
+ if (!isRoot(ctx, agent)) return;
502
+ const now = Date.now();
503
+ if (now - (state.lastHandover.get(sessionId) ?? 0) < HANDOVER_COOLDOWN_MS) return;
504
+ const used = measureNow(ctx, agent);
505
+ if (used === null || levelOf(used, readThresholds(config)) !== "force") return;
506
+ state.lastHandover.set(sessionId, now);
507
+ startHandover(agent, sessionId);
508
+ } catch {
509
+ /* fail-open: the banner still reports force */
510
+ }
511
+ };
512
+
513
+ // --- B+C. post-execute: account reads, cap-warn, trigger handover --------
514
+ ctx.on("tools/post-execute", (exec, result, next) => {
515
+ try {
516
+ maybeAutoHandover(exec?.agent);
517
+ const sessionId = sessionIdOf(exec?.agent);
518
+ if (sessionId !== null && ACCOUNT_TOOLS.has(exec?.name ?? "")) {
519
+ const entry = accountRead(state, sessionId, resultTokens(result));
520
+ if (GATED_TOOLS.has(exec.name) && !state.paused.has(sessionId)) {
521
+ const budget = readNumber(config.wmReadBudgetTokens, 1500);
522
+ const ceiling = budget * CEILING_MULTIPLE;
523
+ const used = measureNow(ctx, exec.agent);
524
+ const th = readThresholds(config);
525
+ if (
526
+ entry.used >= ceiling &&
527
+ used !== null &&
528
+ (levelOf(used, th) === "hard" || levelOf(used, th) === "force") &&
529
+ readString(config.wmDenyMode) !== "deny" &&
530
+ Array.isArray(result?.content)
531
+ ) {
532
+ return {
533
+ kind: "accept",
534
+ content: [
535
+ ...result.content,
536
+ {
537
+ type: "text",
538
+ text: `[SWITCHMAN:WATERMARK] read budget exceeded this turn (used ${Math.round(entry.used)}/${Math.round(ceiling)} tokens) — further reads may be denied; wrap up or delegate the remaining reading.`,
539
+ },
540
+ ],
541
+ };
542
+ }
543
+ }
544
+ }
545
+ } catch (error) {
546
+ warn(`post-execute accounting failed: ${error?.message ?? error}`);
547
+ }
548
+ return next();
549
+ });
550
+
551
+ // --- B. pre-execute: deny over-budget reads at tier >= hard --------------
552
+ ctx.on("tools/pre-execute", (exec, next) => {
553
+ try {
554
+ if (!GATED_TOOLS.has(exec?.name ?? "")) return next();
555
+ const sessionId = sessionIdOf(exec?.agent);
556
+ if (sessionId === null || state.paused.has(sessionId)) return next();
557
+ const budget = readNumber(config.wmReadBudgetTokens, 1500);
558
+ if (turnUsed(state, sessionId) < budget * CEILING_MULTIPLE) return next();
559
+ const used = measureNow(ctx, exec.agent);
560
+ if (used === null) return next();
561
+ const level = levelOf(used, readThresholds(config));
562
+ if (level !== "hard" && level !== "force") return next();
563
+ if (readString(config.wmDenyMode) !== "deny") return next();
564
+ const ceiling = budget * CEILING_MULTIPLE;
565
+ return {
566
+ kind: "deny",
567
+ reason:
568
+ `[SWITCHMAN:WATERMARK] read budget exhausted this turn (used ${Math.round(turnUsed(state, sessionId))}/${Math.round(ceiling)}).` +
569
+ ` Wrap up, or delegate the remaining reading.`,
570
+ };
571
+ } catch (error) {
572
+ warn(`read gate failed open: ${error?.message ?? error}`);
573
+ return next();
574
+ }
575
+ });
576
+
577
+ // --- turn reset: a claimed inbox batch starts a fresh accounting turn ----
578
+ ctx.on("agent/inbox/claimed", (payload) => {
579
+ const sessionId = sessionIdOf(payload?.agent);
580
+ if (sessionId !== null) state.readUsed.delete(sessionId);
581
+ });
582
+
583
+ // --- D. /ctx-pause, /ctx-resume, /ctx-handover ----------------------------
584
+ ctx.commands.register({
585
+ name: "ctx-pause",
586
+ description: "Pause dsh-switchman watermark enforcement for this session (measurement continues).",
587
+ handler: (invocation) => {
588
+ const sessionId = sessionIdOf(invocation?.agent);
589
+ if (sessionId === null) return { kind: "error", text: "/ctx-pause: no active session" };
590
+ state.paused.add(sessionId);
591
+ return {
592
+ kind: "success",
593
+ text: "[SWITCHMAN:WATERMARK] paused for this session — measurement continues, enforcement off. /ctx-resume restores it.",
594
+ };
595
+ },
596
+ });
597
+ ctx.commands.register({
598
+ name: "ctx-resume",
599
+ description: "Resume dsh-switchman watermark enforcement for this session.",
600
+ handler: (invocation) => {
601
+ const sessionId = sessionIdOf(invocation?.agent);
602
+ if (sessionId === null) return { kind: "error", text: "/ctx-resume: no active session" };
603
+ const wasPaused = state.paused.delete(sessionId);
604
+ return {
605
+ kind: "success",
606
+ text: wasPaused
607
+ ? "[SWITCHMAN:WATERMARK] resumed for this session — thresholds and the read budget apply again."
608
+ : "[SWITCHMAN:WATERMARK] was not paused; enforcement continues.",
609
+ };
610
+ },
611
+ });
612
+ ctx.commands.register({
613
+ name: "ctx-handover",
614
+ description: "Trigger a manual context handover (compaction) for this session now.",
615
+ handler: (invocation) =>
616
+ manualHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal),
617
+ });
618
+ }
619
+
620
+ export { applyContextWatch };