dsh-switchman 1.8.1 → 1.10.2

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.
@@ -26,8 +26,8 @@
26
26
  * reach it — the borrowed handler's closure carries the right composition.
27
27
  * Every handover trigger — the force-tier automation (fire-and-forget,
28
28
  * session-level in-flight flag plus a 10-minute cooldown) and manual
29
- * /ctx-handover — shares one four-step flow: fork a dormant backup
30
- * session (subagents' fork provider, economy pool model); then, on
29
+ * /ctx-handover — shares one three-step flow: write the handover
30
+ * document; then, on
31
31
  * every compaction attempt, re-enumerate the live dispatched
32
32
  * subagents and refresh the handover document as the very last
33
33
  * action before compacting via the borrowed per-agent /compact
@@ -53,6 +53,7 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs";
53
53
  import { dirname, join } from "node:path";
54
54
  import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
55
55
  import { enumerateLiveSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
56
+ import { recentTrimViewTokensOf } from "./context-trim/index.js";
56
57
 
57
58
  /** Tools whose result text is accounted against the per-turn read budget. */
58
59
  const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
@@ -146,11 +147,23 @@ function readThresholds(config) {
146
147
  }
147
148
 
148
149
  /** Measure one agent's session now; null when the meter is missing/failing. */
149
- function measureNow(ctx, agent) {
150
+ export function measureNow(ctx, agent) {
150
151
  try {
151
152
  const measurement = agent?.session ? ctx.tokenMeter?.measure?.(agent.session) : undefined;
152
153
  const used = Number.isFinite(measurement?.totalTokens) ? measurement.totalTokens : null;
153
- return used === null ? null : used;
154
+ // Meter duality (design §8): prefer the trim layer's fresh outbound-view
155
+ // estimate when present — billion-context can fold far more away than the
156
+ // meter's stored tally admits, and the watermark must track what the
157
+ // model actually sees. Strictly additive + fail-open: any trim failure
158
+ // falls back to the stored measure.
159
+ try {
160
+ const sessionId = agent?.session?.id ?? (typeof agent?.id === "string" ? agent.id : null);
161
+ const viewTokens = sessionId === null ? null : recentTrimViewTokensOf(sessionId);
162
+ if (viewTokens !== null) return viewTokens;
163
+ } catch {
164
+ // fall through to the stored measure
165
+ }
166
+ return used;
154
167
  } catch {
155
168
  return null;
156
169
  }
@@ -173,7 +186,7 @@ function bucketKilo(tokens) {
173
186
  * invariant); the numbers live in the tail context line only. */
174
187
  const PROTOCOL_ROOT_TEXT = [
175
188
  "[SWITCHMAN:WATERMARK] protocol: this session is guarded by a context watermark measured every step.",
176
- "ok tier is silent. soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself. hard tier: stop broad reading (read/glob/grep are budget-limited and may be denied) and start wrapping up. force tier: MANDATORY wrap-up now — produce the final answer from what you already hold; an automatic context handover (backup + document + compaction + continuation) may run and will tell you to end the turn.",
189
+ "ok tier is silent. soft tier: prefer delegating lightweight work (subagent/workflow) over reading more context yourself. hard tier: stop broad reading (read/glob/grep are budget-limited and may be denied) and start wrapping up. force tier: MANDATORY wrap-up now — produce the final answer from what you already hold; an automatic context handover (document + compaction + continuation) may run and will tell you to end the turn.",
177
190
  "The live tier and the bucketed used-token reading (against the soft/hard/force budget) arrive ONLY as [SWITCHMAN:WATERMARK] lines inside the appended runtime-context snapshot at the conversation tail; a tier change is announced on the step it happens. /ctx-pause pauses every watermark enforcement (measurement continues), /ctx-resume restores it, /ctx-handover starts a handover immediately.",
178
191
  ].join("\n");
179
192
 
@@ -201,7 +214,7 @@ function renderRootWatermark(level, used, th, status) {
201
214
  const lines = [head, "force tier: MANDATORY wrap-up now — produce the final answer from what you already hold."];
202
215
  const phase = status?.phase;
203
216
  if (phase === "backup") {
204
- lines.push(`context handover running: forking the backup session and writing the handover document…`);
217
+ lines.push(`context handover running: writing the handover document…`);
205
218
  } else if (phase === "compacting") {
206
219
  lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
207
220
  } else if (phase === "continuation") {
@@ -210,7 +223,7 @@ function renderRootWatermark(level, used, th, status) {
210
223
  lines.push(
211
224
  status?.cooldownMin > 0
212
225
  ? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
213
- : `an automatic context handover (backup + document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
226
+ : `an automatic context handover (document + compaction + continuation) is armed and will fire at the next step boundary${status?.tool ? "; calling the ctx_handover tool triggers it immediately" : ""}.`,
214
227
  );
215
228
  } else {
216
229
  lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
@@ -409,50 +422,6 @@ function applyContextWatch(ctx, config) {
409
422
  return typeof first.provider === "string" && typeof first.model === "string" ? first : null;
410
423
  };
411
424
 
412
- /** Manual handover step 1 — fork a dormant backup child of this session
413
- * through the subagents service's fork provider (the child inherits the
414
- * completed turns, runs one tiny confirmation turn on the economy pool
415
- * model when configured, then stays idle as a browsable snapshot). The
416
- * spec MUST carry a signal: the manager calls spec.signal.throwIfAborted()
417
- * unconditionally, so a missing signal fails the whole fork with a
418
- * TypeError (the live failure observed on 2026-10-01). The append-only
419
- * session log always remains the full-fidelity backup, so a failed fork
420
- * only downgrades the UX, never the data. Returns {childId, reason}. */
421
- const forkBackup = async (agent, sessionId, signal) => {
422
- try {
423
- const economy = firstPoolRoute(config.poolEconomy);
424
- const started = await ctx.subagents?.startContinuable?.({
425
- provider: "fork",
426
- label: "dsh-switchman handover backup",
427
- signal: signal ?? new AbortController().signal,
428
- request: {
429
- parent: agent,
430
- prompt: [
431
- {
432
- type: "text",
433
- 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.",
434
- },
435
- ],
436
- ...(economy === null ? {} : { agentOptions: { provider: economy.provider, model: economy.model } }),
437
- },
438
- });
439
- if (typeof started?.childId !== "string") {
440
- const reason = `startContinuable returned no child id (${JSON.stringify(started ?? null)})`;
441
- warn(
442
- `handover backup fork failed for session ${sessionId} (continuing; the append-only log remains the full backup): ${reason}`,
443
- );
444
- return { childId: null, reason };
445
- }
446
- return { childId: started.childId, reason: null };
447
- } catch (error) {
448
- const reason = error?.message ?? String(error);
449
- warn(
450
- `handover backup fork failed for session ${sessionId} (continuing; the append-only log remains the full backup): ${reason}`,
451
- );
452
- return { childId: null, reason };
453
- }
454
- };
455
-
456
425
  /** One handover-document path per handover run (stamp frozen at run
457
426
  * start so refreshes overwrite the same file). Null when the session
458
427
  * exposes no usable cwd. */
@@ -511,7 +480,7 @@ function applyContextWatch(ctx, config) {
511
480
  ``,
512
481
  `- handed over at: ${new Date().toISOString()} (document refreshed before every compaction attempt)`,
513
482
  `- context used before compaction: ~${used ?? "unknown"} tokens`,
514
- `- backup fork session: ${backupId ?? "(fork failed — the full history remains in this session's append-only log)"}`,
483
+ `- backup snapshot: none — the append-only session log holds the full pre-handover history`,
515
484
  ``,
516
485
  ...liveSection,
517
486
  `## Task state (filled in by the agent right after the handover)`,
@@ -624,9 +593,7 @@ function applyContextWatch(ctx, config) {
624
593
  : `If the compaction summary mentions dispatched subagents that are NOT listed above, they materialized after this enumeration — a post-wake recheck will announce them within seconds; do not re-dispatch anything before it fires.`;
625
594
  const instruction = [
626
595
  "[SWITCHMAN:HANDOVER] A context handover just completed for this session.",
627
- backupId !== null
628
- ? `A dormant fork backup holding the full pre-handover state is session ${backupId}.`
629
- : `No fork backup was created; the append-only session log still holds the full history.`,
596
+ `The append-only session log holds the full pre-handover history (no fork snapshot).`,
630
597
  docFile !== null
631
598
  ? `The handover document is at ${docFile}.`
632
599
  : `Create the handover document under .dsh-switchman/handover/ in the working directory.`,
@@ -670,8 +637,11 @@ function applyContextWatch(ctx, config) {
670
637
  state.inflight.add(sessionId);
671
638
  try {
672
639
  recordHandover(sessionId, { phase: "backup", source, at: Date.now() });
673
- const backup = await forkBackup(agent, sessionId, signal);
674
- const backupId = backup.childId;
640
+ // Backup = the append-only session log itself (full fidelity). The
641
+ // old fork-snapshot step was removed 2026-11-05: the subagents
642
+ // fork API (ctx.subagents.startContinuable) is absent from the
643
+ // installed host, so the fork failed on every single run.
644
+ const backupId = null;
675
645
  // One document path per handover run (the stamp freezes here so
676
646
  // refreshes overwrite the same file). The document itself is
677
647
  // written as the LAST action before every compaction attempt —
@@ -786,7 +756,7 @@ function applyContextWatch(ctx, config) {
786
756
  if (!outcome.ok)
787
757
  return {
788
758
  kind: "error",
789
- text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${outcome.reason}`,
759
+ text: `/ctx-handover: compaction failed — ${outcome.reason}`,
790
760
  };
791
761
  recordHandover(sessionId, { phase: "continuation", source, at: Date.now() });
792
762
  // Final enumeration right after compaction settled: anything that
@@ -820,7 +790,7 @@ function applyContextWatch(ctx, config) {
820
790
  }
821
791
  await armContinuation(agent, backupId, docFile, live, appeared);
822
792
  scheduleMaterializationRecheck(agent, sessionId, backupId, docFile, live);
823
- const backupText = backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
793
+ const backupText = "session log (full pre-handover history; no fork snapshot)";
824
794
  return {
825
795
  kind: "success",
826
796
  text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${docFile ?? "agent-created"}, compaction done, continuation armed.`,
@@ -958,7 +928,7 @@ function applyContextWatch(ctx, config) {
958
928
  });
959
929
  ctx.commands.register({
960
930
  name: "ctx-handover",
961
- description: "Trigger a full context handover for this session now (backup fork + document + compaction + continuation).",
931
+ description: "Trigger a full context handover for this session now (document + compaction + continuation).",
962
932
  handler: (invocation) =>
963
933
  runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal, "manual"),
964
934
  });
@@ -976,7 +946,7 @@ function applyContextWatch(ctx, config) {
976
946
  ctx.tools?.register?.({
977
947
  name: "ctx_handover",
978
948
  description:
979
- "Trigger a full dsh-switchman context handover for this session NOW: fork a dormant backup session, write the handover document, compact this session at the idle boundary, then wake it to fill in the document and continue the task. Call it when the watermark banner reaches the force tier or the user asks for a handover, then END the turn immediately.",
949
+ "Trigger a full dsh-switchman context handover for this session NOW: write the handover document, compact this session at the idle boundary, then wake it to fill in the document and continue the task. Call it when the watermark banner reaches the force tier or the user asks for a handover, then END the turn immediately.",
980
950
  parameters: {
981
951
  type: "object",
982
952
  properties: {
@@ -1002,7 +972,7 @@ function applyContextWatch(ctx, config) {
1002
972
  ),
1003
973
  (error) => warn(`ctx_handover handover crashed: ${error?.message ?? error}`),
1004
974
  );
1005
- return "[SWITCHMAN:WATERMARK] ctx_handover scheduled: backup fork + handover document + compaction + continuation are running. END this turn now — reply briefly and call no more tools, so the compaction lands at the idle boundary. Your closing line must tell the user the handover is running in the background and will continue automatically (the session-header badge shows the live state) — otherwise the GUI looks idle during fork+compact.";
975
+ return "[SWITCHMAN:WATERMARK] ctx_handover scheduled: handover document + compaction + continuation are running. END this turn now — reply briefly and call no more tools, so the compaction lands at the idle boundary. Your closing line must tell the user the handover is running in the background and will continue automatically (the session-header badge shows the live state) — otherwise the GUI looks idle during the handover window.";
1006
976
  },
1007
977
  presentCall: (args) => ({
1008
978
  card: "generic",
@@ -2,10 +2,10 @@
2
2
  * Shared in-flight handover registry (process-lifetime, never persisted).
3
3
  *
4
4
  * context-watch.js records phase transitions from the first synchronous
5
- * line of runHandover (t=0, before the fork even starts); routes.js
5
+ * line of runHandover (t=0); routes.js
6
6
  * exposes the read-only snapshot behind GET /api/dsh-switchman/
7
7
  * handover-state so the client badge can show a live "handover in
8
- * progress" cue during the otherwise-silent fork+compact window.
8
+ * progress" cue during the otherwise-silent handover window.
9
9
  */
10
10
 
11
11
  /** sessionId -> { sessionId, phase, source?, attempt?, at }. */
package/host/routes.js CHANGED
@@ -587,7 +587,7 @@ export function makeRoutes(ctx, config) {
587
587
  handler: async (req, res) => {
588
588
  if (!guard(req, res, "GET")) return;
589
589
  // Read-only live registry: the client badge polls this during
590
- // the otherwise-silent fork+compact window ({count, handovers}),
590
+ // the otherwise-silent handover window ({count, handovers}),
591
591
  // so the GUI shows an animated "handover in progress" cue.
592
592
  const handovers = handoverSnapshot();
593
593
  writeJson(res, 200, { count: handovers.length, handovers });
@@ -149,4 +149,4 @@ function applySubagentModel(ctx) {
149
149
  });
150
150
  }
151
151
 
152
- export { applySubagentModel, sessionModelSelectionOf };
152
+ export { applySubagentModel, childSessionIdOf, sessionModelSelectionOf };
package/index.js CHANGED
@@ -20,8 +20,8 @@
20
20
  *
21
21
  * Phase 3 mounts the context-watermark layer (host/context-watch.js): the
22
22
  * `[SWITCHMAN:WATERMARK]` tiered banner from the live token meter, a per-turn
23
- * read budget with deny/cap gates, the four-step force-tier handover
24
- * (backup fork + document + compaction + continuation) plus the
23
+ * read budget with deny/cap gates, the force-tier handover
24
+ * (document + compaction + continuation) plus the
25
25
  * ctx_handover agent tool, and the /ctx-pause, /ctx-resume,
26
26
  * /ctx-handover commands.
27
27
  *
@@ -69,6 +69,9 @@ import { applySubagentModel } from "./host/subagent-model.js";
69
69
 
70
70
  // Context-watermark layer (banner + read budget + handover + /ctx-* commands).
71
71
  import { applyContextWatch } from "./host/context-watch.js";
72
+ // Context-trim layer (deterministic outbound-view folding + idle-boundary
73
+ // automatic folds + ctx_trim_fold/ctx_trim_status advisory tools).
74
+ import { applyContextTrim } from "./host/context-trim/index.js";
72
75
  // Vision dispatch gate (/vision command + vision-state route for the
73
76
  // composer-dock hint): unlocks image sending on text-only models.
74
77
  import { applyVision } from "./host/vision.js";
@@ -86,7 +89,7 @@ const name = "dsh-switchman";
86
89
  * system-prompt section registry, the settings store it writes back to, the
87
90
  * token meter and agent registry the watermark reads, the command registry
88
91
  * for /ctx-*, the tools registry for the ctx_handover agent tool, the
89
- * subagents service the handover's backup fork goes through, the sessions
92
+ * subagents service the teams layer dispatch goes through, the sessions
90
93
  * persistence the handover's continuation flush uses, the plugin-manager
91
94
  * service the teams layer's bundle enable goes through, and the session
92
95
  * query engine the subagent model annotation + per-session vision verdicts
@@ -100,7 +103,17 @@ const name = "dsh-switchman";
100
103
  * only inside each preset's per-agent isolated group (isolate: { compaction:
101
104
  * true }), never at this root — host/context-watch.js reaches it by borrowing
102
105
  * the session's own per-agent /compact handler through
103
- * ctx.commands.find(agent, "compact"). */
106
+ * ctx.commands.find(agent, "compact").
107
+ *
108
+ * The context-trim layer (host/context-trim/) adds fully-automatic context
109
+ * compression: an llm/stream listener rebuilds a deterministic outbound
110
+ * view (folded ranges replaced by ONE synthetic summary message each,
111
+ * per-session ledger) in place before every agent-loop model call, an
112
+ * agent/status idle hook runs the fold decisions (growth gate / pressure /
113
+ * emergency tiers; side-model summaries with mechanical emergency
114
+ * fallback), and the ctx_trim_fold / ctx_trim_status tools expose the
115
+ * advisory surface. Fail-open throughout: the listener always calls
116
+ * next(), and trimming degrades to a no-op on any internal failure. */
104
117
  const inject = ["skills", "systemPrompt", "settings", "tokenMeter", "commands", "tools", "agents", "webServer", "subagents", "sessions", "pluginManager", "sessionQuery"];
105
118
 
106
119
  /** Must equal `BUNDLED_SKILL_RANK` of @deepseek-ai/dsh-skill (packaged skill
@@ -193,6 +206,7 @@ function apply(ctx, config) {
193
206
  applyDispatch(ctx, config);
194
207
  applyTeams(ctx, config);
195
208
  applyContextWatch(ctx, config);
209
+ applyContextTrim(ctx, config);
196
210
  applyVision(ctx, config);
197
211
  applyIntent(ctx, config);
198
212
  applySubagentModel(ctx);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-switchman",
3
- "version": "1.8.1",
3
+ "version": "1.10.2",
4
4
  "description": "Autonomous Agent Teams doctrine for the four shipped DeepSeek Harness presets, plus language preferences, dispatch pools with model ranking, and context watermark control.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -22,6 +22,9 @@
22
22
  },
23
23
  "type": "module",
24
24
  "main": "./index.js",
25
+ "scripts": {
26
+ "test": "node --test scripts/test/*.test.mjs"
27
+ },
25
28
  "icon": "./icon.svg",
26
29
  "engines": {
27
30
  "node": ">=24",