@alwith-ai/dsh-agent 0.2.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,78 @@
1
+ /**
2
+ * `sessions` subcommand: session-log inspection for hosts without a live
3
+ * bridge (`bun src/main.ts sessions list --json`). Reads through dsh's own
4
+ * persistence backend — the on-disk format (zstd JSONL) stays private to it.
5
+ */
6
+
7
+ import { homedir } from "node:os"
8
+ import { join } from "node:path"
9
+ import { Context } from "@deepseek-ai/cordis"
10
+ import SessionStore from "@deepseek-ai/dsh-session"
11
+ import JsonlSessionPersistence from "@deepseek-ai/dsh-session-persistence-jsonl"
12
+ import type {} from "@deepseek-ai/dsh-session-persistence"
13
+
14
+ function defaultSessionsRoot(): string {
15
+ return process.env.ALWITH_DSH_SESSIONS_ROOT ?? join(homedir(), ".dsh-agent", "sessions")
16
+ }
17
+
18
+ interface CliFlags {
19
+ root: string
20
+ positional: string[]
21
+ }
22
+
23
+ function parseFlags(argv: string[]): CliFlags {
24
+ let root = defaultSessionsRoot()
25
+ const positional: string[] = []
26
+ for (let index = 0; index < argv.length; index += 1) {
27
+ const argument = argv[index]!
28
+ if (argument === "--root") {
29
+ const value = argv[index + 1]
30
+ if (value === undefined) throw new Error("--root requires a value")
31
+ root = value
32
+ index += 1
33
+ } else if (argument.startsWith("--")) {
34
+ throw new Error(`unknown flag ${argument}`)
35
+ } else {
36
+ positional.push(argument)
37
+ }
38
+ }
39
+ return { root, positional }
40
+ }
41
+
42
+ type Persistence = NonNullable<Context["sessionPersistence"]>
43
+
44
+ async function withPersistence<T>(root: string, run: (persistence: Persistence) => Promise<T>): Promise<T> {
45
+ const ctx = new Context()
46
+ // The jsonl plugin is a backend; the coordinator that publishes
47
+ // ctx.sessionPersistence rides the session store.
48
+ await ctx.plugin(SessionStore)
49
+ await ctx.plugin(JsonlSessionPersistence, { root })
50
+ const persistence = ctx.get("sessionPersistence")
51
+ if (persistence === undefined) throw new Error("session persistence failed to mount")
52
+ try {
53
+ return await run(persistence)
54
+ } finally {
55
+ await ctx.fiber.dispose()
56
+ }
57
+ }
58
+
59
+ export async function runSessionsCli(argv: string[]): Promise<void> {
60
+ const { root, positional } = parseFlags(argv)
61
+ const [command, ...rest] = positional
62
+ if (command === "list") {
63
+ const headers = await withPersistence(root, persistence => persistence.list())
64
+ const sessions = [...headers]
65
+ .sort((a, b) => b.createdAt - a.createdAt)
66
+ .map(header => ({ id: header.id, createdAt: header.createdAt, cwd: header.cwd, parentSession: header.parentSession }))
67
+ process.stdout.write(`${JSON.stringify({ root, sessions })}\n`)
68
+ return
69
+ }
70
+ if (command === "show") {
71
+ const [id] = rest
72
+ if (id === undefined) throw new Error("usage: sessions show <sessionId> [--root <dir>]")
73
+ const inspection = await withPersistence(root, persistence => persistence.inspect(id as never))
74
+ process.stdout.write(`${JSON.stringify({ root, meta: inspection.meta, events: inspection.events })}\n`)
75
+ return
76
+ }
77
+ throw new Error(`unknown sessions command "${command ?? ""}": expected list or show`)
78
+ }
@@ -0,0 +1,19 @@
1
+ import type { Context } from "@deepseek-ai/cordis"
2
+
3
+ export declare const name: string
4
+ export declare const inject: string[]
5
+ export interface AnchoredToolBootstrapConfig {
6
+ shellTools?: string[]
7
+ commonTools?: string[]
8
+ messageSources?: string[]
9
+ anchorGate?: boolean
10
+ maxBootstrapSteps?: number
11
+ promoteAfterFirstResponse?: boolean
12
+ bootstrapMaxTokens?: number
13
+ compactionTools?: string[]
14
+ deferredSources?: string[]
15
+ deferredGraceSteps?: number
16
+ promotedPresentation?: "code"
17
+ phase1FirstCallInstruction?: string
18
+ }
19
+ export declare function apply(ctx: Context, config?: AnchoredToolBootstrapConfig): void
@@ -0,0 +1,495 @@
1
+ /**
2
+ * VENDORED — do not edit by hand.
3
+ *
4
+ * Source: dsh-web-ui (https://github.com/zhu1090093659/dsh-web-ui) at commit
5
+ * 0ea284c, packages/dsh-liangshen/presets/liangshen/tool-bootstrap.mjs
6
+ * (Apache-2.0), itself derived from
7
+ * https://github.com/xiaobright/dsh-anchored-standard (MIT) with the
8
+ * two-phase quarantine extensions. See THIRD_PARTY_NOTICES.md.
9
+ *
10
+ * Local composition notes: this sidecar promotes to the NATIVE full catalog
11
+ * (promotedPresentation stays unset — Code Mode is not composed here), and
12
+ * the deferred-injection sources are unused because the composition does not
13
+ * mount agent-instructions or a skill catalog.
14
+ */
15
+ /**
16
+ * Keep the first model request on a minimal-shaped input surface, then expose
17
+ * the full preset catalog once the session is safely anchored.
18
+ *
19
+ * Phase 1 (no persisted `tool/call` yet):
20
+ * - tool catalog: one platform shell plus `commonTools`
21
+ * - prompt sections: only the persona section (all other sections,
22
+ * including plan-mode's `plan:policy`, return after promotion)
23
+ * - runtime contexts: emptied (no sandbox/approval snapshot)
24
+ * - pre-step messages: only explicit user messages pass
25
+ *
26
+ * Promotion opens the full tool catalog and restores runtime contexts and all
27
+ * prompt sections. With `anchorGate` the promotion after the first tool call
28
+ * also requires one minimal-like reasoning block (a first block containing
29
+ * `we` and no `let me`) or the `maxBootstrapSteps` fallback.
30
+ * `promoteAfterFirstResponse` promotes a tool-less first response once it has
31
+ * responded, and also releases an anchor-gated session when its first turn
32
+ * ends (`turn/end`). With `promotedPresentation: code` the promoted catalog
33
+ * is presented as Code Mode (PTC): the wire shows a single `run_code` tool
34
+ * backed by the generated SDK, switched at the step boundary so the current
35
+ * step's native calls are never interrupted. `deferredSources` and
36
+ * `deferredGraceSteps` delay selected injected message kinds (workspace
37
+ * instructions, skill catalog) for a few steps after promotion.
38
+ *
39
+ * COMPACTION (local addition, ported from the upstream compaction-epoch
40
+ * semantics): a compaction rewrites the whole model-visible surface, so the
41
+ * first post-compaction request is a "second first request". A
42
+ * `compaction/end` event releases Code Mode (the presentation disposer) and
43
+ * resets the promotion state to the CONTROLLED phase — bootstrap pair plus
44
+ * `compactionTools` (a core work set, default none) — until a NEW durable
45
+ * promotion signal exists past that boundary. The reset lives both in the
46
+ * live `session/event` path and inside the durable-log scan, so resume and
47
+ * reload reconstruct the same phase.
48
+ *
49
+ * ROBUSTNESS: composition drift (a missing bootstrap shell or common tool)
50
+ * degrades to the full catalog with a one-time warning instead of throwing,
51
+ * so a broken composition can never lock a session out of every request.
52
+ *
53
+ * OPT-IN PHASE-1 INSTRUCTION (issue #274): `phase1FirstCallInstruction` is
54
+ * an optional string appended to the phase-1 persona; unset (the default)
55
+ * keeps the phase-1 persona the exact one-line Minimal anchor. Test builds
56
+ * use it to ask the model to ground its first answer with one Minimal-native
57
+ * tool call before responding, so first-turn capability questions are
58
+ * answered from the promoted registry instead of the cropped two-tool view.
59
+ *
60
+ * Source: https://github.com/xiaobright/dsh-anchored-standard (MIT), extended
61
+ * with the phase-1 quarantine and the stabilization controls above.
62
+ */
63
+
64
+ /** Cordis plugin name used by loader diagnostics. */
65
+ export const name = 'anchored-tool-bootstrap'
66
+
67
+ /** Prompt assembly and the tool registry must exist before this filter runs. */
68
+ export const inject = ['systemPrompt', 'tools']
69
+
70
+ /**
71
+ * Prompt section names that carry the preset persona. The `dsh-persona` row
72
+ * registers the preset persona as `deployment:persona` (the PERSONA_SECTION
73
+ * name of `@deepseek-ai/dsh-system-prompt`), shadowing the deployment
74
+ * default for the preset scope; `persona` is the legacy name kept for older
75
+ * harnesses that registered the persona section without the prefix.
76
+ */
77
+ const PERSONA_SECTION_NAMES = new Set(['deployment:persona', 'persona'])
78
+
79
+ /**
80
+ * Workspace line a promoted persona gains. Phase 1 keeps the exact one-line
81
+ * persona (the Minimal anchor); after promotion the model must also know the
82
+ * session's selected workspace, which the Standard persona carries through
83
+ * the `{{cwd}}` prompt variable. The literal cwd is read from the session
84
+ * header at assembly time instead, so the line stays correct after a
85
+ * workspace switch and a session without a selected workspace keeps the bare
86
+ * one-liner rather than failing prompt interpolation.
87
+ */
88
+ const WORKSPACE_LINE_PREFIX = '\n\nYour working directory is '
89
+
90
+ /** Message-source kinds the model may see during phase 1. */
91
+ const DEFAULT_MESSAGE_SOURCES = ['user']
92
+
93
+ /** Message-source kinds delayed after promotion. */
94
+ const DEFAULT_DEFERRED_SOURCES = []
95
+
96
+ function stringList(value, field, fallback) {
97
+ if (value === undefined) return [...fallback]
98
+ if (!Array.isArray(value) || value.length === 0 || value.some(item => typeof item !== 'string' || item.length === 0)) {
99
+ throw new TypeError(`${name}: ${field} must be a non-empty array of non-empty strings`)
100
+ }
101
+ return [...new Set(value)]
102
+ }
103
+
104
+ function stringListOrEmpty(value, field) {
105
+ if (value === undefined) return []
106
+ if (!Array.isArray(value) || value.some(item => typeof item !== 'string' || item.length === 0)) {
107
+ throw new TypeError(`${name}: ${field} must be an array of non-empty strings`)
108
+ }
109
+ return [...new Set(value)]
110
+ }
111
+
112
+ function optionalString(value, field) {
113
+ if (value === undefined) return ''
114
+ if (typeof value !== 'string') {
115
+ throw new TypeError(`${name}: ${field} must be a string`)
116
+ }
117
+ return value
118
+ }
119
+
120
+ function integerAtLeast(value, field, minimum) {
121
+ if (!Number.isInteger(value) || value < minimum) {
122
+ throw new TypeError(`${name}: ${field} must be an integer >= ${minimum}`)
123
+ }
124
+ return value
125
+ }
126
+
127
+ function countWord(text, regex) {
128
+ return [...text.matchAll(regex)].length
129
+ }
130
+
131
+ /**
132
+ * Anchor classifier for promotion gating. A reasoning block counts as
133
+ * minimal-like when it contains `we` and no `let me`; a block with any
134
+ * `let me` is standard-like; everything else is ambiguous. This is a
135
+ * deliberate relaxation of the modeltest identity probe: the gate decides
136
+ * trajectory surface, not model identity, and `we` presence without
137
+ * first-person execution phrases is the stable surface marker.
138
+ */
139
+ export function classifyReasoning(text) {
140
+ const trimmed = String(text ?? '').trim()
141
+ const we = countWord(trimmed, /\bwe\b/gi)
142
+ const letMe = countWord(trimmed, /\blet me\b/gi)
143
+ const metrics = { we, letMe }
144
+ if (we > 0 && letMe === 0) return { label: 'minimal-like', score: 4, metrics }
145
+ if (letMe > 0) return { label: 'standard-like', score: -4, metrics }
146
+ return { label: 'ambiguous', score: 0, metrics }
147
+ }
148
+
149
+ /**
150
+ * Whether the FIRST reasoning block of an assistant message classifies as
151
+ * minimal-like. Later blocks do not override an earlier standard-like first
152
+ * block.
153
+ */
154
+ export function hasAnchoredReasoning(content) {
155
+ if (!Array.isArray(content)) return false
156
+ const first = content.find(block => block?.type === 'reasoning')
157
+ return first !== undefined && classifyReasoning(first.text).label === 'minimal-like'
158
+ }
159
+
160
+ /**
161
+ * Whether one pre-step message is an explicit user message. Only `kind:
162
+ * 'user'` passes; injected kinds and source-less seed messages never pass.
163
+ */
164
+ function isAllowedMessage(message, allowedSources) {
165
+ const kind = message.source?.kind
166
+ return kind === 'user' && allowedSources.has(kind)
167
+ }
168
+
169
+ /** Whether one pre-step message belongs to a deferred injection kind. */
170
+ function isDeferredMessage(message, deferredSources) {
171
+ const kind = message.source?.kind
172
+ return kind !== undefined && deferredSources.has(kind)
173
+ }
174
+
175
+ /**
176
+ * Phase-2 promotion state per session. Sessions append events only, so the
177
+ * scan resumes from the first event it has not inspected yet.
178
+ */
179
+ const promotionBySession = new WeakMap()
180
+
181
+ /** Live agents observed by the assemble/pre-step listeners, keyed by session. */
182
+ const agentBySession = new WeakMap()
183
+
184
+ function stateFor(session) {
185
+ let state = promotionBySession.get(session)
186
+ if (state === undefined) {
187
+ state = {
188
+ next: 0,
189
+ promoted: false,
190
+ toolCalled: false,
191
+ responded: false,
192
+ anchored: false,
193
+ turnEnded: false,
194
+ steps: 0,
195
+ deferredSteps: 0,
196
+ presentationApplied: false,
197
+ hasCompacted: false,
198
+ presentationDisposer: undefined,
199
+ }
200
+ promotionBySession.set(session, state)
201
+ }
202
+ return state
203
+ }
204
+
205
+ /**
206
+ * Reset one session back to the CONTROLLED phase after a compaction. A
207
+ * compaction rewrites the whole model-visible surface — the first
208
+ * post-compaction request is a "second first request" with the same
209
+ * first-token conditions the bootstrap exists to control — so the session
210
+ * re-anchors: promotion state is cleared (the durable `next` scan pointer is
211
+ * kept, so events recorded BEFORE the boundary never re-promote), and the
212
+ * Code Mode presentation is disposed so the next assembly sees the native
213
+ * catalog and the phase-1 filter can narrow it again.
214
+ */
215
+ function resetToControlled(state) {
216
+ if (typeof state.presentationDisposer === 'function') {
217
+ try {
218
+ state.presentationDisposer()
219
+ } catch {
220
+ // A failed presentation reset must never break the session; the
221
+ // next promotion re-declares Code Mode anyway.
222
+ }
223
+ state.presentationDisposer = undefined
224
+ }
225
+ state.promoted = false
226
+ state.toolCalled = false
227
+ state.responded = false
228
+ state.anchored = false
229
+ state.turnEnded = false
230
+ state.steps = 0
231
+ state.deferredSteps = 0
232
+ state.presentationApplied = false
233
+ state.hasCompacted = true
234
+ }
235
+
236
+ /**
237
+ * Switch one agent's wire presentation to Code Mode (PTC: a single `run_code`
238
+ * tool backed by the generated SDK) after promotion. `agent.ctx.tools` is the
239
+ * per-agent view of the host registry, so the switch affects this session only.
240
+ */
241
+ function applyPresentation(agent, state, policy) {
242
+ if (state.presentationApplied || policy.promotedPresentation !== 'code') return
243
+ state.presentationApplied = true
244
+ const tools = agent.ctx.tools
245
+ if (tools === undefined) return
246
+ // The disposer restores the deployment-default (native) presentation; it is
247
+ // kept on the state so a post-compaction reset can release Code Mode and
248
+ // let the phase-1 catalog filter see the native tool list again.
249
+ state.presentationDisposer = tools.presentAs('code')
250
+ }
251
+
252
+ /**
253
+ * a) first tool call, no anchor gate — promote immediately;
254
+ * b) first tool call, anchored or `maxBootstrapSteps` fallback — promote;
255
+ * c) first tool call, still gated, but the first turn ended and
256
+ * `promoteAfterFirstResponse` is set — release on the new user turn (the
257
+ * release happens during prompt assembly, so that turn already gets the
258
+ * full catalog);
259
+ * d) tool-less first response with `promoteAfterFirstResponse` — promote.
260
+ */
261
+ function decidePromotion(state, config) {
262
+ if (state.toolCalled && config.anchorGate !== true) return true
263
+ if (state.toolCalled && config.anchorGate === true && (state.anchored || state.steps >= config.maxBootstrapSteps)) return true
264
+ if (state.toolCalled && config.anchorGate === true && config.promoteAfterFirstResponse === true && state.turnEnded) return true
265
+ if (!state.toolCalled && state.responded && config.promoteAfterFirstResponse === true) return true
266
+ return false
267
+ }
268
+
269
+ /** Scan newly appended session events and update promotion state. */
270
+ function scanEvents(state, session) {
271
+ const events = session.snapshotEvents()
272
+ for (; state.next < events.length; state.next += 1) {
273
+ const event = events[state.next]
274
+ if (event === undefined) continue
275
+ if (event.type === 'compaction/end') {
276
+ // A compaction rewrites the model-visible surface: the session falls
277
+ // back to the controlled phase until a NEW promotion signal exists
278
+ // past this boundary (the `next` pointer stays, so events before the
279
+ // boundary never re-promote). Handled inside the scan so cold starts
280
+ // reconstruct the same phase from the durable log.
281
+ resetToControlled(state)
282
+ } else if (event.type === 'tool/call') {
283
+ state.toolCalled = true
284
+ } else if (event.type === 'step/start') {
285
+ state.steps += 1
286
+ } else if (event.type === 'turn/end') {
287
+ state.turnEnded = true
288
+ } else if (event.type === 'assistant/message') {
289
+ state.responded = true
290
+ if (!state.anchored) state.anchored = hasAnchoredReasoning(event.data?.message?.content)
291
+ }
292
+ }
293
+ }
294
+
295
+ /** Update one agent's promotion state and apply its post-promotion presentation. */
296
+ function refresh(agent, policy) {
297
+ const session = agent?.session
298
+ if (session === undefined) return undefined
299
+ const state = stateFor(session)
300
+ agentBySession.set(session, agent)
301
+ if (!state.promoted) {
302
+ scanEvents(state, session)
303
+ if (decidePromotion(state, policy)) state.promoted = true
304
+ }
305
+ if (state.promoted) applyPresentation(agent, state, policy)
306
+ return state
307
+ }
308
+
309
+ /**
310
+ * Append the session's working directory to the persona section of a promoted
311
+ * assembly. Returns the assembly unchanged when there is no persona section,
312
+ * no selected workspace, or the exact line is already present.
313
+ */
314
+ function withWorkspaceLine(assembly, agent) {
315
+ const cwd = agent?.session?.header?.cwd
316
+ if (typeof cwd !== 'string' || cwd.length === 0) return assembly
317
+ if (!Array.isArray(assembly.sections)) return assembly
318
+ const line = `${WORKSPACE_LINE_PREFIX}${cwd}.`
319
+ const persona = assembly.sections.find(section =>
320
+ PERSONA_SECTION_NAMES.has(section?.name)
321
+ && typeof section?.text === 'string'
322
+ && !section.text.includes(line))
323
+ if (persona === undefined) return assembly
324
+ return {
325
+ ...assembly,
326
+ sections: assembly.sections.map(section => section === persona
327
+ ? { ...section, text: `${persona.text}${line}` }
328
+ : section),
329
+ }
330
+ }
331
+
332
+ /** Register the per-session bootstrap quarantine and promotion policy. */
333
+ export function apply(ctx, config) {
334
+ const commonTools = stringList(config.commonTools, 'commonTools')
335
+ const shellTools = stringList(config.shellTools, 'shellTools')
336
+ const messageSources = new Set(stringList(config.messageSources, 'messageSources', DEFAULT_MESSAGE_SOURCES))
337
+ const deferredSources = new Set(stringListOrEmpty(config.deferredSources, 'deferredSources'))
338
+ const presentation = config.promotedPresentation ?? 'native'
339
+ if (presentation !== 'native' && presentation !== 'code') {
340
+ throw new TypeError(`${name}: promotedPresentation must be "native" or "code"`)
341
+ }
342
+
343
+ let warned = false
344
+ const warnOnce = (message) => {
345
+ if (warned) return
346
+ warned = true
347
+ try {
348
+ ctx.logger.warn(message)
349
+ } catch {
350
+ // Logger unavailable — the guard exists only to avoid spamming.
351
+ }
352
+ }
353
+ const bootstrapMaxTokens = config.bootstrapMaxTokens === undefined
354
+ ? undefined
355
+ : integerAtLeast(config.bootstrapMaxTokens, 'bootstrapMaxTokens', 1)
356
+ // Core work set exposed during the post-compaction controlled phase, so a
357
+ // mid-task model keeps working with a small catalog instead of the full
358
+ // Standard set. Defaults to none: the session stays on the bootstrap pair
359
+ // until a new promotion signal (the composition may widen it via config).
360
+ const compactionTools = stringListOrEmpty(config.compactionTools, 'compactionTools')
361
+ // Opt-in extra line for the phase-1 persona (test builds, issue #274):
362
+ // asks the model to ground its first answer with a Minimal-native tool
363
+ // call before responding. Unset keeps the exact one-line persona.
364
+ const phase1FirstCallInstruction = optionalString(config.phase1FirstCallInstruction, 'phase1FirstCallInstruction')
365
+ const policy = {
366
+ anchorGate: config.anchorGate === true,
367
+ promoteAfterFirstResponse: config.promoteAfterFirstResponse === true,
368
+ maxBootstrapSteps: integerAtLeast(config.maxBootstrapSteps ?? 4, 'maxBootstrapSteps', 1),
369
+ deferredGraceSteps: integerAtLeast(config.deferredGraceSteps ?? 0, 'deferredGraceSteps', 0),
370
+ promotedPresentation: presentation,
371
+ bootstrapMaxTokens,
372
+ compactionTools,
373
+ phase1FirstCallInstruction,
374
+ }
375
+
376
+ // Promotion is applied at step/turn boundaries, never while a step is still
377
+ // executing tools: switching the presentation mid-step would collapse the
378
+ // native calls that step already planned. By `step/end` the tool-call and
379
+ // reasoning events are durable, so the NEXT prompt assembly already sees
380
+ // Code Mode with its generated SDK section. A `compaction/end` event
381
+ // releases Code Mode and resets the promotion state (see
382
+ // resetToControlled); the reset also runs inside scanEvents, so a cold
383
+ // start reconstructs the same controlled phase from the durable log.
384
+ ctx.on('session/event', (session, event) => {
385
+ if (event.type === 'compaction/end') {
386
+ resetToControlled(stateFor(session))
387
+ return
388
+ }
389
+ if (event.type !== 'step/end' && event.type !== 'turn/end') return
390
+ const state = stateFor(session)
391
+ if (!state.promoted) {
392
+ scanEvents(state, session)
393
+ if (decidePromotion(state, policy)) state.promoted = true
394
+ }
395
+ if (state.promoted) {
396
+ const agent = agentBySession.get(session)
397
+ if (agent !== undefined) applyPresentation(agent, state, policy)
398
+ }
399
+ })
400
+
401
+ // `prepend: true` puts both filters at the outermost position of their
402
+ // waterfall, so `await next()` always observes the complete downstream
403
+ // result (including messages appended by listener order, not row order)
404
+ // before the quarantine strips it.
405
+ ctx.on('system-prompt/assemble', async (_assembly, context, next) => {
406
+ // Downstream errors propagate untouched; only this filter's own logic is
407
+ // guarded (a filter bug must never brick every request of a session).
408
+ const assembled = await next()
409
+ const agent = context.agent
410
+ if (agent === undefined) return assembled
411
+ const state = refresh(agent, policy)
412
+ if (state.promoted) return withWorkspaceLine(assembled, agent)
413
+
414
+ const available = new Set(assembled.tools.map(tool => tool.name))
415
+ const selectedShells = shellTools.filter(toolName => available.has(toolName))
416
+ const missingCommon = commonTools.filter(toolName => !available.has(toolName))
417
+ if (selectedShells.length !== 1 || missingCommon.length > 0) {
418
+ // Composition drift must not lock a session out: degrade to the full
419
+ // catalog with a one-time warning instead of throwing (the bootstrap
420
+ // phase surfaces will simply not apply).
421
+ warnOnce(
422
+ `${name}: expected exactly one bootstrap shell and every common tool; `
423
+ + `shells=${JSON.stringify(selectedShells)}, missing=${JSON.stringify(missingCommon)} — `
424
+ + 'bootstrap disabled, full catalog exposed',
425
+ )
426
+ return assembled
427
+ }
428
+
429
+ const bootstrap = new Set([...selectedShells, ...commonTools])
430
+ // After a compaction the controlled phase widens with the core work set
431
+ // so mid-task work can continue before re-promotion.
432
+ if (state.hasCompacted) for (const toolName of compactionTools) bootstrap.add(toolName)
433
+ const sections = Array.isArray(assembled.sections)
434
+ ? assembled.sections.filter(section => PERSONA_SECTION_NAMES.has(section?.name))
435
+ : undefined
436
+ // Opt-in phase-1 instruction: appended once to the persona section so
437
+ // test builds can shift the first answer behind a Minimal-native tool
438
+ // call (issue #274). Unset leaves the exact one-line persona.
439
+ const phase1Sections = sections === undefined || phase1FirstCallInstruction === ''
440
+ ? sections
441
+ : sections.map(section => {
442
+ if (typeof section?.text !== 'string' || section.text.includes(phase1FirstCallInstruction)) return section
443
+ return { ...section, text: `${section.text}${phase1FirstCallInstruction}` }
444
+ })
445
+ return {
446
+ ...assembled,
447
+ tools: assembled.tools.filter(tool => bootstrap.has(tool.name)),
448
+ contexts: [],
449
+ ...(phase1Sections !== undefined ? { sections: phase1Sections } : {}),
450
+ }
451
+ }, { prepend: true })
452
+
453
+ ctx.on('agent/pre-step', async (payload, next) => {
454
+ const decision = await next()
455
+ const agent = payload.agent
456
+ if (agent === undefined || decision.kind !== 'enter') return decision
457
+ const state = refresh(agent, policy)
458
+ if (state === undefined) return decision
459
+
460
+ if (!state.promoted) {
461
+ return {
462
+ ...decision,
463
+ messages: decision.messages.filter(message => isAllowedMessage(message, messageSources)),
464
+ }
465
+ }
466
+ if (state.deferredSteps < policy.deferredGraceSteps) {
467
+ state.deferredSteps += 1
468
+ return {
469
+ ...decision,
470
+ messages: decision.messages.filter(message => !isDeferredMessage(message, deferredSources)),
471
+ }
472
+ }
473
+ return decision
474
+ }, { prepend: true })
475
+
476
+ // Phase 1 caps the next request output budget to bootstrapMaxTokens, the
477
+ // community-observed We-need trigger window (dsh-anchored-standard issue 6),
478
+ // and strips the cap again after promotion. The strip is mandatory:
479
+ // requestProposal(persistedHeader) carries a plain maxTokens from the
480
+ // previous header into the next request unless the adapter marked it a
481
+ // default, so an un-stripped cap would be soldered into every request.
482
+ ctx.on('agent/request', async (payload, next) => {
483
+ const resolved = await next()
484
+ const agent = payload?.agent
485
+ if (agent === undefined || policy.bootstrapMaxTokens === undefined) return resolved
486
+ const state = refresh(agent, policy)
487
+ if (state.promoted) {
488
+ if (resolved.maxTokens !== policy.bootstrapMaxTokens) return resolved
489
+ const rest = { ...resolved }
490
+ delete rest.maxTokens
491
+ return rest
492
+ }
493
+ return { ...resolved, maxTokens: policy.bootstrapMaxTokens }
494
+ }, { prepend: true })
495
+ }