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.
- package/LICENSE +21 -0
- package/README.md +79 -0
- package/README.zh.md +79 -0
- package/client.js +2441 -0
- package/cordis.patch.yml +599 -0
- package/host/config.js +116 -0
- package/host/context-watch.js +620 -0
- package/host/data/capability-default.json +1097 -0
- package/host/dispatch.js +205 -0
- package/host/lang.js +182 -0
- package/host/lib/rank.js +187 -0
- package/host/routes.js +398 -0
- package/icon.svg +7 -0
- package/index.js +154 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +70 -0
- package/skills/db-query/SKILL.md +322 -0
- package/skills/db-query/package-lock.json +251 -0
- package/skills/db-query/package.json +20 -0
- package/skills/db-query/references/mysql.md +137 -0
- package/skills/db-query/references/redis.md +213 -0
- package/skills/db-query/references/security.md +176 -0
- package/skills/db-query/scripts/lib/cli.js +118 -0
- package/skills/db-query/scripts/lib/config.js +215 -0
- package/skills/db-query/scripts/lib/mysql-readonly.js +655 -0
- package/skills/db-query/scripts/lib/output.js +322 -0
- package/skills/db-query/scripts/lib/redis-readonly.js +362 -0
- package/skills/db-query/scripts/mysql-query.js +198 -0
- package/skills/db-query/scripts/redis-query.js +181 -0
- package/skills/db-query/scripts/setup.sh +51 -0
- package/skills/git-commit-message/SKILL.md +49 -0
- package/skills/requirement-docs/SKILL.md +38 -0
- package/skills/requirement-docs/references/templates.md +187 -0
|
@@ -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 };
|