dsh-switchman 1.3.1 → 1.5.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/cordis.patch.yml CHANGED
@@ -3,12 +3,20 @@
3
3
  #
4
4
  # One bundle, four preset overrides: each of the four shipped Web presets
5
5
  # (standard / ptc / minimal / cordis) keeps its exact shipped plugin list and
6
- # gains, in its persona suffix, the dsh-switchman autonomous Agent Teams
7
- # doctrine. The persona suffix renders as the LAST system-prompt section
6
+ # gains, in its persona suffix, the dsh-switchman base dispatch doctrine
7
+ # (delegation discipline, orchestration/token rules, tiered verification).
8
+ # The persona suffix renders as the LAST system-prompt section
8
9
  # (DEPLOYMENT_PERSONA_SUFFIX, order 10200), after the conservative TEAM_POLICY
9
10
  # section (order 600) contributed by the Host-level
10
11
  # @deepseek-ai/dsh-experimental-tool-agent-team, so this doctrine is the
11
- # operative team policy for sessions of every preset.
12
+ # operative discipline for sessions of every preset.
13
+ #
14
+ # The Agent Teams clauses (conservative-policy reversal, spawn_teammate
15
+ # trigger conditions, shared task-board discipline) are NOT static: the Host
16
+ # half (host/teams.js) carries them in a dynamic `[SWITCHMAN:TEAMS]` section
17
+ # (order 10250, between this suffix and the language section) that is only
18
+ # injected while the teamsMode setting is ON. OFF leaves the factory
19
+ # conservative team policy in charge; nothing here changes.
12
20
  #
13
21
  # The Agent Teams tools themselves (spawn_teammate, team_task_*, send_message,
14
22
  # wait_agent, …) come from the Host composition, i.e. the shipped
@@ -25,6 +33,17 @@
25
33
  # the shipped presets, re-sync these lists from the new preset files, then
26
34
  # reinstall this bundle.
27
35
  #
36
+ # NEVER set `modelSelectionSettings: true` on the tool-subagent-fork row (any
37
+ # preset). Only the spawn row may carry it: while the subagent model-selection
38
+ # whitelist is enabled, every `@deepseek-ai/dsh-tool-subagent` instance with
39
+ # modelSelectionSettings registers `list_subagent_models` into each new Agent's
40
+ # scope, and a second instance makes session composition throw
41
+ # `tool "list_subagent_models" is already registered in this scope` — breaking
42
+ # creation of ALL new sessions and the recomposition of old ones. The shipped
43
+ # presets (verified against installed dsh-web-app 0.2.0-rc.2 and the fork's
44
+ # 0.2.1-alpha.1) enable it on the spawn row only; fork-side explicit routing
45
+ # is not a factory capability.
46
+ #
28
47
  # Known consequences of overriding:
29
48
  # - plugin_manager `set_plugin` on these preset rows reports `overridden`.
30
49
  # - An edit saved from the Web preset editor lands in the profile patch layer,
@@ -52,14 +71,11 @@
52
71
 
53
72
  总纲:在准确理解需求、严谨思考、最优质量、最低返工率的前提下最小化总 token 消耗。返工是最贵的 token——过程与铺陈可以省,验证与关键推理绝不省。
54
73
 
55
- 系统提示词前文的默认团队策略写的是「仅当用户明确要求时才创建 teammate」。该默认策略在当前 profile 下不适用:用户安装 dsh-switchman 即预先授权,你应当自主判断、自主触发,无需再询问用户。
56
-
57
74
  ## 一、默认委派,例外亲做(delegate by default)
58
75
  - 委派只消耗一个全新子上下文、只收回一份摘要;亲自读写每一份都会膨胀本会话上下文,长期更贵。长或重的上下文偏向委派,琐碎小事留在手里。
59
76
  - 仅当满足全部条件才亲自做:认知负载低或中、读取合计 <200 行、改动 <50 行;强迭代小步试错(每步都要立刻用上一步结果);期望收益 <3k token 的收尾。
60
77
  - 位置不确定的检索、跨多文件调查、大范围重构、独立研究或方案对比:一律委派,侦察优先派 economy 泳道。
61
78
  - 任务开始时判断一次,中途规模或结构变化时重做;结论不必请示用户。
62
- - 需要多成员持久协作、共享任务板、消息往来的用 Agent Teams(满足任一即触发 spawn_teammate:可并行的多个独立子任务;量大且自包含;主上下文水位已高;需要角色分离);一次性、无需共享状态的单点调查用 subagent。
63
79
 
64
80
  ## 二、编排与 token 纪律(Lead 职责)
65
81
  - prompt 必须自包含:目标、已知事实与结论、文件路径、期望输出与验收标准;已查明的事实不让对方重查;固定段在前、可变段在后。
@@ -68,11 +84,11 @@
68
84
  - 禁止复活死会话:子智能体达 cap 终止后,用自包含 prompt 新派一个,不 resume 旧壳。
69
85
  - 广搜先问:首次整项目范围的搜索先向用户澄清范围,定点检索不受此限。
70
86
  - 大额派发先估成本:预计 ≥3 个成员或总消耗 >100k token 时,先报规模与预估请用户确认。
71
- - 写作域隔离:共享任务上写明互不重叠的 write_scopes;用 team_task_* 维护任务板与依赖顺序;Lead 汇总最终 diff、复核关键改动并运行测试;用 wait_agent 等到必需的 teammate 完成,绝不在结果未回前下最终结论。
87
+ - 子路由两通道:显式传 provider/model/effort 任一字段会经 DSH 会话级授权白名单校验(设置页「允许 Agent 为子智能体选择模型」勾选的授权列表,与泳道池无关);被拒时用 list_subagent_models 查实际可用路由再派,或改为完全不传三字段走隐式继承(不受校验,子智能体用默认值或继承主会话路由)。泳道池只是偏好排序,授权边界以 DSH 设置为准,不试图改写它。
72
88
 
73
89
  ## 三、分级验证(质量下限,不可省)
74
90
  - 改动 >20 行或涉及多个调用点:交 tester 验证。
75
- - 改动 >300 行,或触及核心/安全/数据一致性逻辑:交 reviewer 独立复审。复审池有多个模型时优先选与主会话不同的模型;复审池只有与主会话相同的模型、或确需复用时,允许同模型复审,只需在结论中声明 DOWNGRADED。
91
+ - 改动 >300 行,或触及核心/安全/数据一致性逻辑:交 reviewer 独立复审。复审独立性以「代码作者」为锚而非主会话:作者模型 = 写出该改动的智能体的模型(子智能体按派发时显式路由记,未显式指定按继承记为主会话模型)。优先选与作者模型不同的复审模型;能同时避开主会话模型更佳(其集成复核视角已覆盖)。池内确实避不开作者模型时允许同模型复审,在结论中声明 DOWNGRADED 并注明相对作者还是相对主会话降级。
76
92
  - 任何降到更弱模型或更省验证的决定都必须显式声明;沉默放弃验证禁止。
77
93
 
78
94
  ## 四、边界
package/host/config.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * - lang* project language preference (Phase 1)
12
12
  * - pool/rank dispatch pools and capability ranking (Phase 2)
13
13
  * - wm* context watermark control (Phase 3)
14
+ * - teams* Agent Teams doctrine mode + whitelist sync (teams mode)
14
15
  *
15
16
  * Ported from opencode-switchman's opencode-switchman.jsonc surface
16
17
  * (oc/src/config.ts:23-51, oc/src/types.ts:281-344) onto DSH settings.
@@ -46,6 +47,10 @@ export const EffortEntrySchema = z.object({
46
47
  /** All dsh-switchman settings. Field names are the settings mutation paths. */
47
48
  export const Config = z.object({
48
49
  // --- Phase 1: project language preference -----------------------------
50
+ /** Where the three language slots live: "global" = these profile-level
51
+ * fields below; "project" = each project's .switchman/lang.json (per
52
+ * session cwd; missing file = ask once per session and save there). */
53
+ langScope: z.union(["global", "project"]).default("global").volatile(),
49
54
  /** Language for conversational replies. Empty = ask once, then remember. */
50
55
  langConversation: z.string().default("").volatile(),
51
56
  /** Language for code comments. Empty = follow langConversation. */
@@ -101,6 +106,24 @@ export const Config = z.object({
101
106
  wmSubagentCap: z.boolean().default(true).volatile(),
102
107
  /** Subagent hard cap; 0 = share wmForceTokens. */
103
108
  wmSubagentForceTokens: z.number().min(0).default(0).volatile(),
109
+
110
+ // --- Teams mode: doctrine injection + whitelist sync -------------------
111
+ /** Agent Teams master switch. ON = inject the `[SWITCHMAN:TEAMS]` doctrine
112
+ * section (order 10250), keep the DSH child-model whitelist detection in
113
+ * the pools table, and asynchronously ensure the agent-team-profile
114
+ * bundle is enabled. OFF = plain subagent dispatch mode: no teams
115
+ * doctrine, no whitelist detection (the DSH factory conservative team
116
+ * policy takes over); the bundle is never disabled and the whitelist is
117
+ * never cleared. */
118
+ teamsMode: z.boolean().default(false).volatile(),
119
+ /** Replace-sync the six-pool union into DSH's subagent model-selection
120
+ * whitelist (`subagent-model-selection-settings`) whenever the teams
121
+ * orchestration runs — an INDEPENDENT switch: it does not require
122
+ * teamsMode, and teamsMode alone does not sync. switchman is the single
123
+ * source of truth: the deduplicated union overwrites allowedModels
124
+ * wholesale. The whitelist only applies to top-level sessions composed
125
+ * afterwards. */
126
+ syncWhitelist: z.boolean().default(false).volatile(),
104
127
  });
105
128
 
106
129
  /** The six dispatch lanes, in display order. */
@@ -18,14 +18,19 @@
18
18
  * compaction only inside each preset's isolated per-agent group
19
19
  * (isolate: { compaction: true }), so no root-level inject or lookup can
20
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).
21
+ * Every handover trigger — the force-tier automation (fire-and-forget,
22
+ * session-level in-flight flag plus a 10-minute cooldown) and manual
23
+ * /ctx-handover — shares one four-step flow: fork a dormant backup
24
+ * session (subagents' fork provider, economy pool model), write the
25
+ * handover document skeleton, compact via the borrowed per-agent
26
+ * /compact (retried on a growing backoff until the agent goes idle,
27
+ * because the automation fires mid-turn), then wake this session
28
+ * (agent.followup + sessions.flush, the schedule service's delivery
29
+ * channel) to fill in the document and continue the unfinished task.
30
+ * The live handover phase feeds the force-tier banner so progress is
31
+ * visible instead of silent. /ctx-pause and /ctx-resume toggle
32
+ * process-lifetime pause state (a restart resumes enforcement — the
33
+ * safe side).
29
34
  *
30
35
  * All session state is in-process Maps; every path is fail-open (a failing
31
36
  * measurement or missing service degrades the banner to "" or the gate to
@@ -35,6 +40,8 @@
35
40
  import { randomUUID } from "node:crypto";
36
41
  import { mkdirSync, writeFileSync } from "node:fs";
37
42
  import { join } from "node:path";
43
+ import { clearHandover, handoverOf, recordHandover } from "./handover-state.js";
44
+ import { enumerateRunningSubagents, sessionRecordPath, sessionRecordsDir } from "./subagents.js";
38
45
 
39
46
  /** Tools whose result text is accounted against the per-turn read budget. */
40
47
  const ACCOUNT_TOOLS = new Set(["read", "glob", "grep", "bash"]);
@@ -140,8 +147,9 @@ function measureNow(ctx, agent) {
140
147
 
141
148
  // ---- banner rendering (pure given state + measurement) ---------------------
142
149
 
143
- /** Root-session banner: byte-stable anchor line + tier advice lines. */
144
- function renderRootBanner(level, used, th, autoHandover) {
150
+ /** Root-session banner: byte-stable anchor line + tier advice lines plus
151
+ * the live handover progress (phase/attempt) and auto-cooldown state. */
152
+ function renderRootBanner(level, used, th, status) {
145
153
  const head =
146
154
  `[SWITCHMAN:WATERMARK] level=${level} used=${kilo(used)}` +
147
155
  ` budget=${kilo(th.soft)}/${kilo(th.hard)}/${kilo(th.force)}`;
@@ -153,11 +161,22 @@ function renderRootBanner(level, used, th, autoHandover) {
153
161
  if (level === "hard") lines.push(`hard tier: stop broad reading (read/glob/grep are budget-limited this turn) and start wrapping up.`);
154
162
  if (level === "force") {
155
163
  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
- );
164
+ const phase = status?.phase;
165
+ if (phase === "backup") {
166
+ lines.push(`context handover running: forking the backup session and writing the handover document…`);
167
+ } else if (phase === "compacting") {
168
+ lines.push(`context handover running: compaction attempt ${status?.attempt ?? "?"} (retries while the turn is still running)…`);
169
+ } else if (phase === "continuation") {
170
+ lines.push(`context handover completing: waking this session to fill in the document and continue the task…`);
171
+ } else if (status?.autoHandover) {
172
+ lines.push(
173
+ status?.cooldownMin > 0
174
+ ? `automatic handover cooldown: ${status.cooldownMin} min left; run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to force one now.`
175
+ : `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" : ""}.`,
176
+ );
177
+ } else {
178
+ lines.push(`run /ctx-handover${status?.tool ? " or call the ctx_handover tool" : ""} to trigger a manual context handover.`);
179
+ }
161
180
  }
162
181
  return lines.join("\n");
163
182
  }
@@ -199,7 +218,15 @@ function renderBanner(ctx, config, state, agent) {
199
218
  return renderSubBanner(used, subTh);
200
219
  }
201
220
  const level = state.paused.has(sessionId) ? "paused" : levelOf(used, th);
202
- return renderRootBanner(level, used, th, readBool(config.wmAutoHandover, true));
221
+ const live = handoverOf(sessionId);
222
+ const elapsed = Date.now() - (state.lastHandover.get(sessionId) ?? 0);
223
+ return renderRootBanner(level, used, th, {
224
+ autoHandover: readBool(config.wmAutoHandover, true),
225
+ phase: live?.phase,
226
+ attempt: live?.attempt,
227
+ cooldownMin: Math.max(0, Math.ceil((HANDOVER_COOLDOWN_MS - elapsed) / 60_000)),
228
+ tool: state.handoverTool ?? undefined,
229
+ });
203
230
  } catch {
204
231
  return "";
205
232
  }
@@ -260,6 +287,7 @@ function applyContextWatch(ctx, config) {
260
287
  const state = {
261
288
  paused: new Set(),
262
289
  inflight: new Set(),
290
+ handoverTool: null,
263
291
  lastHandover: new Map(),
264
292
  readUsed: new Map(),
265
293
  };
@@ -282,46 +310,32 @@ function applyContextWatch(ctx, config) {
282
310
  // ctx.commands.find(agent, "compact") merges that agent's scoped layer and
283
311
  // returns the definition whose handler closure already carries the right
284
312
  // composition context with compaction injected.
285
- const sessionCompact = (agent, commandId, signal) => {
286
- let definition;
313
+ /** Find the agent's scoped /compact definition without invoking it. */
314
+ const compactDefinition = (agent) => {
287
315
  try {
288
- definition = ctx.commands?.find?.(agent, "compact");
316
+ const definition = ctx.commands?.find?.(agent, "compact");
317
+ return typeof definition?.handler === "function" ? definition : undefined;
289
318
  } catch {
290
- definition = undefined;
319
+ return undefined;
291
320
  }
292
- if (definition === undefined || typeof definition.handler !== "function") return undefined;
321
+ };
322
+
323
+ const sessionCompact = (agent, commandId, signal) => {
324
+ const definition = compactDefinition(agent);
325
+ if (definition === undefined) return undefined;
293
326
  const invocation = Object.freeze({
294
327
  commandId,
295
328
  agent,
296
329
  rawInput: "",
297
330
  attachments: Object.freeze([]),
298
- signal,
331
+ // The borrowed /compact handler (and compactNow behind it) touches
332
+ // signal.throwIfAborted()/aborted unconditionally; the auto path
333
+ // has no real signal, so always carry a fresh un-aborted one.
334
+ signal: signal ?? new AbortController().signal,
299
335
  });
300
336
  return Promise.resolve(definition.handler(invocation));
301
337
  };
302
338
 
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
339
  /** First {provider, model} route of a volatile pool field, or null. */
326
340
  const firstPoolRoute = (field) => {
327
341
  const raw = readValue(field);
@@ -378,14 +392,30 @@ function applyContextWatch(ctx, config) {
378
392
  * the session's working directory. The mechanical facts land now (before
379
393
  * compaction); the agent's continuation turn fills in the task-state
380
394
  * 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) => {
395
+ * session exposes no usable cwd or the write fails. Any still-running
396
+ * background subagents are recorded too: compaction orphans them from
397
+ * every plugin-visible registry, so without this section the compacted
398
+ * agent cannot know its dispatched work is alive (and double-dispatches).
399
+ * */
400
+ const writeHandoverSkeleton = (agent, sessionId, backupId, used, running) => {
383
401
  const cwd = agent?.session?.header?.cwd;
384
402
  if (typeof cwd !== "string" || cwd === "") return null;
385
403
  const stamp = new Date().toISOString().replace(/[:.]/gu, "-");
386
404
  const directory = join(cwd, ".dsh-switchman", "handover");
387
405
  const short = sessionId.replace(/^session-/u, "").slice(0, 8) || sessionId;
388
406
  const path = join(directory, `${stamp}-${short}.md`);
407
+ const runningSection =
408
+ Array.isArray(running) && running.length > 0
409
+ ? [
410
+ `## Running background subagents (captured before compaction — still alive)`,
411
+ ``,
412
+ ...running.map(
413
+ (child) =>
414
+ `- \`${child.id}\`${child.label ? ` — ${child.label}` : ""}${child.mode ? ` (${child.mode})` : ""} — STILL RUNNING. Collect its report when it settles (in-session completion notice, or read its session record at ${sessionRecordPath(child.id)}) before re-dispatching anything similar — do not duplicate live work.`,
415
+ ),
416
+ ``,
417
+ ]
418
+ : [];
389
419
  try {
390
420
  mkdirSync(directory, { recursive: true });
391
421
  writeFileSync(
@@ -397,6 +427,7 @@ function applyContextWatch(ctx, config) {
397
427
  `- context used before compaction: ~${used ?? "unknown"} tokens`,
398
428
  `- backup fork session: ${backupId ?? "(fork failed — the full history remains in this session's append-only log)"}`,
399
429
  ``,
430
+ ...runningSection,
400
431
  `## Task state (filled in by the agent right after the handover)`,
401
432
  ``,
402
433
  `- goal / objective in flight:`,
@@ -420,11 +451,23 @@ function applyContextWatch(ctx, config) {
420
451
  * uses: agent.followup + sessions.flush) instructing the agent to fill
421
452
  * in the handover document from the compaction summary now in context
422
453
  * and continue the unfinished task. */
423
- const armContinuation = async (agent, backupId, docPath) => {
454
+ const armContinuation = async (agent, backupId, docPath, running) => {
424
455
  if (typeof agent?.followup !== "function") {
425
456
  warn("handover continuation skipped: agent exposes no followup()");
426
457
  return;
427
458
  }
459
+ const runningCount = Array.isArray(running) ? running.length : 0;
460
+ const runningSentence =
461
+ runningCount === 0
462
+ ? `No background subagents were detected running at handover time.`
463
+ : docPath !== null
464
+ ? `${runningCount} background subagent(s) dispatched earlier are STILL RUNNING and are listed in the handover document — collect their results (in-session completion notices, or their session records) instead of re-dispatching duplicate work.`
465
+ : `${runningCount} background subagent(s) dispatched earlier are STILL RUNNING (the handover document could not be written; inline list): ${running
466
+ .map(
467
+ (child) =>
468
+ `${child.id}${child.label ? ` (${child.label})` : ""} → record ${sessionRecordPath(child.id)}`,
469
+ )
470
+ .join("; ")} — collect their results instead of re-dispatching duplicate work (records live under ${sessionRecordsDir()}/<childId>.json).`;
428
471
  const instruction = [
429
472
  "[SWITCHMAN:HANDOVER] A context handover just completed for this session.",
430
473
  backupId !== null
@@ -433,6 +476,7 @@ function applyContextWatch(ctx, config) {
433
476
  docPath !== null
434
477
  ? `The handover document skeleton is at ${docPath}.`
435
478
  : `Create the handover document under .dsh-switchman/handover/ in the working directory.`,
479
+ runningSentence,
436
480
  "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
481
  ].join(" ");
438
482
  try {
@@ -448,39 +492,134 @@ function applyContextWatch(ctx, config) {
448
492
  }
449
493
  };
450
494
 
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) => {
495
+ /** The shared four-step handover (see the header comment), used by
496
+ * manual /ctx-handover and the force-tier automation alike. The
497
+ * compaction step retries on a growing backoff: both triggers usually
498
+ * fire mid-turn, the runMaintenance behind the borrowed /compact only
499
+ * accepts an IDLE agent, and "busy" arrives as a RESOLVED
500
+ * {kind:"error"} (not a rejection) — so both branches funnel into
501
+ * retryOrFail. Once the agent goes idle the compact lands before the
502
+ * next turn's driver (maintenance latches wake requests by design).
503
+ * The per-session phase map feeds the force-tier banner so the wait is
504
+ * visible instead of silent. */
505
+ const runHandover = async (agent, sessionId, commandId, signal, source = "manual") => {
460
506
  if (sessionId === null || agent?.session === undefined)
461
507
  return { kind: "error", text: "/ctx-handover: no active session" };
462
508
  if (state.inflight.has(sessionId))
463
509
  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)
510
+ if (compactDefinition(agent) === undefined)
466
511
  return {
467
512
  kind: "error",
468
513
  text: "/ctx-handover: this session's preset exposes no /compact command, so no compaction backend is reachable.",
469
514
  };
470
515
  state.inflight.add(sessionId);
471
516
  try {
517
+ recordHandover(sessionId, { phase: "backup", source, at: Date.now() });
472
518
  const backup = await forkBackup(agent, sessionId, signal);
473
519
  const backupId = backup.childId;
474
- const docPath = writeHandoverSkeleton(agent, sessionId, backupId, measureNow(ctx, agent));
475
- const result = await borrowed;
476
- if (result?.kind === "error")
520
+ // Snapshot the live background children BEFORE compaction — a
521
+ // dispatch racing the handover would otherwise be missed, and the
522
+ // same snapshot feeds both the document section and the
523
+ // continuation instruction. The sanctioned services answer first
524
+ // (live registry, no flush lag); the projcache disk scan below is
525
+ // the fail-open fallback.
526
+ const runningSubagents = await enumerateRunningSubagents(ctx, sessionId);
527
+ const docPath = writeHandoverSkeleton(agent, sessionId, backupId, measureNow(ctx, agent), runningSubagents);
528
+ const delays = [5_000, 15_000, 30_000, 60_000, 120_000, 300_000];
529
+ const outcome = await new Promise((resolve) => {
530
+ let attempts = 0;
531
+ let attempting = false;
532
+ let timer = null;
533
+ let watchdog = null;
534
+ let finished = false;
535
+ let offStatus = null;
536
+ const finish = (ok, reason) => {
537
+ if (finished) return;
538
+ finished = true;
539
+ if (timer !== null) clearTimeout(timer);
540
+ if (watchdog !== null) clearTimeout(watchdog);
541
+ if (offStatus !== null) {
542
+ try {
543
+ offStatus();
544
+ } catch {
545
+ /* best-effort listener dispose */
546
+ }
547
+ }
548
+ resolve({ ok, reason });
549
+ };
550
+ const attempt = () => {
551
+ if (finished || attempting) return;
552
+ attempting = true;
553
+ attempts += 1;
554
+ if (timer !== null) {
555
+ clearTimeout(timer);
556
+ timer = null;
557
+ }
558
+ recordHandover(sessionId, { phase: "compacting", source, attempt: attempts, at: Date.now() });
559
+ let promise;
560
+ try {
561
+ promise = sessionCompact(agent, commandId ?? `switchman-${source}-${sessionId}`, signal);
562
+ } catch (error) {
563
+ attempting = false;
564
+ finish(false, `handover failed to start: ${error?.message ?? error}`);
565
+ return;
566
+ }
567
+ if (promise === undefined) {
568
+ attempting = false;
569
+ finish(false, "the per-agent /compact command disappeared");
570
+ return;
571
+ }
572
+ const settleAttempt = (ok, reason) => {
573
+ attempting = false;
574
+ if (ok) {
575
+ finish(true);
576
+ return;
577
+ }
578
+ if (attempts <= delays.length) timer = setTimeout(attempt, delays[attempts - 1]);
579
+ else finish(false, String(reason));
580
+ };
581
+ promise.then(
582
+ (value) => {
583
+ if (value !== null && typeof value === "object" && value.kind === "error") {
584
+ settleAttempt(false, value.text ?? "compact returned an error result");
585
+ return;
586
+ }
587
+ settleAttempt(true);
588
+ },
589
+ (error) => settleAttempt(false, error?.message ?? String(error)),
590
+ );
591
+ };
592
+ // Promptness: the agent/status idle transition (the same event
593
+ // the official compaction layer uses for overflow-retry
594
+ // cleanup) attempts the moment the turn really ends; the
595
+ // timers above stay as the backstop for missed or filtered
596
+ // events. Status reads "idle" externally while our own
597
+ // maintenance runs, and the `attempting` latch collapses
598
+ // those re-entrant events.
599
+ try {
600
+ offStatus = ctx.on("agent/status", (payload) => {
601
+ if (payload?.agent === agent && payload?.status === "idle") attempt();
602
+ });
603
+ } catch {
604
+ /* timer-only fallback */
605
+ }
606
+ // Watchdog: a borrowed /compact that never settles would hold
607
+ // the in-flight latch (and this listener) forever; 20 min is
608
+ // far beyond any real compaction (~2 min observed) yet finite.
609
+ watchdog = setTimeout(
610
+ () => finish(false, "handover watchdog timeout — the borrowed /compact never settled"),
611
+ 20 * 60_000,
612
+ );
613
+ attempt();
614
+ });
615
+ if (!outcome.ok)
477
616
  return {
478
617
  kind: "error",
479
- text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${result.text ?? "unknown error"}`,
618
+ text: `/ctx-handover: compaction failed after backup ${backupId ?? "n/a"} — ${outcome.reason}`,
480
619
  };
481
- await armContinuation(agent, backupId, docPath);
482
- const backupText =
483
- backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
620
+ recordHandover(sessionId, { phase: "continuation", source, at: Date.now() });
621
+ await armContinuation(agent, backupId, docPath, runningSubagents);
622
+ const backupText = backupId ?? `n/a (${backup.reason ?? "log retains full history"})`;
484
623
  return {
485
624
  kind: "success",
486
625
  text: `[SWITCHMAN:WATERMARK] handover complete — backup ${backupText}, document ${docPath ?? "agent-created"}, compaction done, continuation armed.`,
@@ -488,6 +627,7 @@ function applyContextWatch(ctx, config) {
488
627
  } catch (error) {
489
628
  return { kind: "error", text: `/ctx-handover failed: ${error?.message ?? error}` };
490
629
  } finally {
630
+ clearHandover(sessionId);
491
631
  state.inflight.delete(sessionId);
492
632
  }
493
633
  };
@@ -504,7 +644,13 @@ function applyContextWatch(ctx, config) {
504
644
  const used = measureNow(ctx, agent);
505
645
  if (used === null || levelOf(used, readThresholds(config)) !== "force") return;
506
646
  state.lastHandover.set(sessionId, now);
507
- startHandover(agent, sessionId);
647
+ runHandover(agent, sessionId, `switchman-auto-${sessionId}`, undefined, "auto").then(
648
+ (result) =>
649
+ ctx.logger?.[result?.kind === "error" ? "warn" : "info"]?.(
650
+ `dsh-switchman: ${result?.text ?? "handover ended"}`,
651
+ ),
652
+ (error) => warn(`auto handover crashed: ${error?.message ?? error}`),
653
+ );
508
654
  } catch {
509
655
  /* fail-open: the banner still reports force */
510
656
  }
@@ -611,10 +757,63 @@ function applyContextWatch(ctx, config) {
611
757
  });
612
758
  ctx.commands.register({
613
759
  name: "ctx-handover",
614
- description: "Trigger a manual context handover (compaction) for this session now.",
760
+ description: "Trigger a full context handover for this session now (backup fork + document + compaction + continuation).",
615
761
  handler: (invocation) =>
616
- manualHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal),
762
+ runHandover(invocation?.agent, sessionIdOf(invocation?.agent), invocation?.commandId, invocation?.signal, "manual"),
617
763
  });
764
+
765
+ // --- E. ctx_handover: the agent-callable front door to the same
766
+ // four-step handover. The definition uses the register-level shape —
767
+ // bare ctx.tools.register does not run defineTool's DSL compilation,
768
+ // so parameters is a full object-root JSON Schema — and registers
769
+ // best-effort: a validation rejection only skips the tool, never the
770
+ // plugin. execute returns
771
+ // fast on purpose — awaiting the compaction here would keep the agent
772
+ // busy and starve the idle-only /compact — so the flow runs detached
773
+ // and the result tells the model to end its turn immediately.
774
+ try {
775
+ ctx.tools?.register?.({
776
+ name: "ctx_handover",
777
+ description:
778
+ "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.",
779
+ parameters: {
780
+ type: "object",
781
+ properties: {
782
+ reason: {
783
+ type: "string",
784
+ description: "Why the handover is triggered now (e.g. 'force tier watermark reached', 'user request'). Optional.",
785
+ },
786
+ },
787
+ },
788
+ output: {
789
+ schema: { type: "string" },
790
+ render: (_args, value) => [{ type: "text", text: value }],
791
+ },
792
+ async execute(_args, exec) {
793
+ const agent = exec?.agent;
794
+ const sessionId = sessionIdOf(agent);
795
+ // No exec.signal here on purpose: the compact must outlive the
796
+ // calling turn, and a turn-scoped AbortSignal could abort it.
797
+ runHandover(agent, sessionId, `switchman-tool-${sessionId ?? "session"}`, undefined, "tool").then(
798
+ (result) =>
799
+ ctx.logger?.[result?.kind === "error" ? "warn" : "info"]?.(
800
+ `dsh-switchman: ${result?.text ?? "handover ended"}`,
801
+ ),
802
+ (error) => warn(`ctx_handover handover crashed: ${error?.message ?? error}`),
803
+ );
804
+ 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.";
805
+ },
806
+ presentCall: (args) => ({
807
+ card: "generic",
808
+ title: "Switchman context handover",
809
+ kind: "other",
810
+ rawInput: args,
811
+ }),
812
+ });
813
+ state.handoverTool = "ctx_handover";
814
+ } catch (error) {
815
+ warn(`ctx_handover tool registration failed: ${error?.message ?? error}`);
816
+ }
618
817
  }
619
818
 
620
819
  export { applyContextWatch };