Merge remote-tracking branch 'origin/master' into scoped-layers-store

# Conflicts:
#	docs/architecture.md
This commit is contained in:
Tianyi Cui
2026-07-22 12:34:27 +08:00
335 changed files with 14200 additions and 6591 deletions

View File

@@ -31,6 +31,7 @@ Packages live at `packages/<group>/<pkg>/`; groups are containers, while names r
| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface |
| [`session-persistence/`](session-persistence/README.md) | Persistence capability family: the seam + JSONL/SQLite backends | Product — stable surface |
| [`session-query/`](session-query/README.md) | Session retrieval: logical corpus, bounded reads, lineage, and event relationships | Product — stable surface |
| [`session-title/`](session-title/README.md) | Log-backed session titles: fallback service, shared LLM policy, and opt-in providers | Product — stable surface |
| [`sdk/`](sdk/README.md) | Project SDK tooling | Product — stable surface |
| [`ui/`](ui/README.md) | Editor/client integration surfaces: ACP bridge, JSON-RPC SDK server, user-approval/user-interaction seams, ask-user tool | Product — stable surface |
| [`examples/`](examples/README.md) | Demo bundles (agent-spine + TUI/one-shot CLI/ACP/JSON-RPC bins) the leaves load | Support — example infra |
@@ -43,6 +44,6 @@ Groups distinguish product API from support infrastructure. New packages join an
The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI).
The rule it must obey: **extension plugins depend on interfaces, never on the concrete loop.** `dsh-agent-loop` is swappable UI/hook/tool plugins keep working against the `dsh-agent` vocabulary if the loop is replaced. The sanctioned exception is a **composition/bundle** package like `dsh-agent-spine-demo`, whose whole job is to assemble the concrete spine: it depends on `dsh-agent-loop` (and the other concrete spine plugins) on purpose. The rule constrains plugins that EXTEND the system, not the bundle that COMPOSES it. A swappable capability splits into interface / implementation / consumer packages (the bash trio is the template — see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)).
**Extension plugins depend on interfaces, never the concrete loop.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities split into interface / implementation / consumer packages; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md).
Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts).

View File

@@ -26,7 +26,7 @@ The plugin also contributes the `tool:bash` prompt section (order 105): check th
### Managed shell environment
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-home`](../../util/home/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
Every foreground and background model bash call receives a newly collected trusted `DSH_*` environment. `DSH_HOME` is the absolute Harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`dshHome` config, then ambient `$DSH_HOME`, then `~/.dsh`) and `DSH_SHELL=1` identifies the managed child. Agent calls additionally receive `DSH_SESSION_ID=agent.session.header.id`; when the active persistence seam locates a JSONL artifact they also receive `DSH_SESSION_JSONL=<absolute target path>`. The JSONL path is a location hint: it may not exist before the first flush or contain the current buffered turn, and it is not an authorization credential.
`ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; tool-bash's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam.

View File

@@ -29,12 +29,12 @@
"peerDependencies": {
"@deepseek-ai/dsh-agent": "^0.0.1",
"@deepseek-ai/dsh-bash": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-sandbox": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-sandbox-policy": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-tasks": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
@@ -50,9 +50,9 @@
"@deepseek-ai/dsh-agent-loop-testkit": "workspace:^",
"@deepseek-ai/dsh-bash": "workspace:^",
"@deepseek-ai/dsh-bash-local": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-sandbox": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",

View File

@@ -23,7 +23,7 @@ import { ESCALATION_TARGETS, approveEscalation, validateEscalationArgs } from '@
import { effectiveSandboxMode } from '@deepseek-ai/dsh-sandbox-policy'
import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash'
import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-home'
import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths'
import { processOutcome } from './background.ts'
import { parseExitStatus, renderProcessRead, renderResult } from './render.ts'

View File

@@ -33,7 +33,7 @@
"path": "../../bash/bash"
},
{
"path": "../../util/home"
"path": "../../util/paths"
},
{
"path": "../../tasks/tasks"

View File

@@ -15,7 +15,7 @@ This backend owns the compaction policy:
- **Convergence** — retry head-checkpoint compaction up to `compactionRetries`; reject a summary that does not shrink its source, and throw if retries cannot return below threshold.
- **Summarization** — a direct `llm/stream` call uses the configured provider/model pair and cap, falling back to the latest logged request target and then the agent target, without running the loop-only `agent/request` seam. The call replays the conversation's own system prompt, tools, and shadowed-region messages verbatim and appends the compaction instruction as the final user message, so it reuses the provider's warm prefix cache instead of invalidating it. Only returned text enters the checkpoint, excluding reasoning and tool calls that would leak private reasoning or create an orphaned call.
- **Framing** — the replacement user message marks established checkpoint context with `<compacted-summary>` tags. The raw summary remains on the provenance event, and later automatic cycles merge the prior checkpoint.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Lifecycle** — `compactRegion()` mutates `agent.session` and records its start, summary, replacement, and end. After asynchronous summarization it rejects a changed surface-node snapshot, while unrelated log-only events may append without invalidating the selected span. The serial `agent/post-step` listener checks pressure after successful output and tool work are durable but before `step/end`. Canonical provider overflow is handled through `agent/request-error` after the failed step closes.
- **Overflow recovery** — provider-confirmed overflow needs no capacity metadata: it bypasses normal pressure and retention, prunes, then attempts one maximal balanced head reduction while leaving the newest indivisible unit. Retry is authorized whenever `surface.replaceGeneration` advances, including when pruning lands before later summary work throws. No replacement, an exhausted target-specific cap, cancellation, or an unknown/noncanonical error preserves the original provider failure.
- **Failure handling** — an unmatched `compact/start` is an inert crash marker because no summary replacement landed. A region failure records an error end; the surface remains unchanged unless pruning already landed. Operational post-step failures warn and continue, while overflow-recovery failure preserves the original provider error only when no earlier replacement advanced the surface. Cancellation remains authoritative after any progress.

View File

@@ -4,6 +4,7 @@
* @module @deepseek-ai/dsh-compact-basic/region
*/
import { isDeepStrictEqual } from 'node:util'
import {
toolPairingBalancedAfter,
toolPairingBalancedBefore,
@@ -113,8 +114,8 @@ export async function compactSurfaceRegion(
const shadowedSeqs = nodes.slice(startIdx, endIdx + 1)
const startEvent = session.append('compact/start', { turn: tail.turn })
try {
// Capture after the lock event so any later durable append, including a
// log-only one, invalidates the async selection before replacement.
// Capture after the lock event so a later surface mutation invalidates the
// async selection before replacement. Unrelated log-only facts may append.
const lockedMeasurement = dependencies.meter.measure(session)
const selected = lockedMeasurement.nodes.slice(startIdx, endIdx + 1)
if (selected.length !== shadowedSeqs.length
@@ -126,8 +127,8 @@ export async function compactSurfaceRegion(
const { summary, provider, model, maxTokens } = await dependencies.summarize(summarizationInput, agent, signal)
const currentMeasurement = dependencies.meter.measure(session)
if (currentMeasurement.logRevision !== lockedMeasurement.logRevision) {
throw new Error('compaction: session log changed during summarization')
if (!isDeepStrictEqual(currentMeasurement.nodes, lockedMeasurement.nodes)) {
throw new Error('compaction: session surface changed during summarization')
}
const framedSummary = frameSummary(summary)
const framedSummaryTokenCount = dependencies.meter.estimateMessage({

View File

@@ -936,13 +936,13 @@ describe('compaction region transaction', () => {
.toMatchObject({ error: 'plain failure' })
})
it('rejects concurrent durable appends before committing the replacement', async () => {
it('tolerates concurrent log-only appends while the selected surface is stable', async () => {
const compact = service()
const session = conversation(2)
compact.mutateDuringSummary = () => {
session.append('request/header', {
header: { config: { provider: MODEL, model: MODEL } },
reason: 'initial',
reason: 'change',
})
}
const nodes = session.surface.nodes
@@ -951,7 +951,26 @@ describe('compaction region transaction', () => {
nodes[0]!,
nodes[2]!,
agent(session, MODEL),
)).rejects.toThrow(/session log changed/)
)).resolves.toMatchObject({ shadowedSeqs: nodes.slice(0, 3) })
expect(session.events.some(event => event.type === 'compact/summary')).toBe(true)
})
it('rejects concurrent surface appends before committing the replacement', async () => {
const compact = service()
const session = conversation(2)
compact.mutateDuringSummary = () => {
session.append('context/message', {
content: [{ type: 'text', text: 'concurrent surface mutation' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })
}
const nodes = session.surface.nodes
await expect(compact.compactRegion(
nodes[0]!,
nodes[2]!,
agent(session, MODEL),
)).rejects.toThrow(/session surface changed/)
expect(session.events.some(event => event.type === 'compact/summary')).toBe(false)
})

View File

@@ -47,7 +47,7 @@ The surface mutation (step 4) sits **inside** the lock bracket: `compact/end` is
## Blocking
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
Compaction is serialized via a log-recorded lock: `compactRegion` refuses to start if the last `compact/start` has no matching `compact/end` after it. The lock is the log (not an in-memory mutex), so it survives replay and a persistence backend can detect an orphaned `compact/start` on reload. The lock brackets the **whole** operation — summarization, the `compact/summary` provenance record, *and* the `user/message` surface replacement all happen before `compact/end` — so a `session/event` listener firing on `compact/end` never observes the lock free while the surface mutation is still pending. The basic backend revalidates the selected surface after summarization: a surface change rejects, while an unrelated log-only append does not invalidate the replacement. `compact/end` is appended even when summarization throws, so a failure can never wedge the lock.
## Events

View File

@@ -9,7 +9,7 @@ import { lstat, stat } from 'node:fs/promises'
import { dirname, isAbsolute, join, relative, resolve } from 'node:path'
import type { FileSystem, FsInfo, FsPathInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { DEFAULT_DSH_HOME_DISPLAY, defaultDshHome } from '@deepseek-ai/dsh-paths'
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
@@ -469,5 +469,5 @@ export async function readScopeInstruction(
}
function userGlobalDisplayPath(dshHome: string): string {
return dshHome === resolve(defaultDshHome()) ? `${DEFAULT_DSH_HOME_DISPLAY}/AGENTS.md` : '$DSH_HOME/AGENTS.md'
return `${dshHomeDisplay(dshHome)}/AGENTS.md`
}

View File

@@ -1,5 +1,5 @@
import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'node:fs/promises'
import { dirname, join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
@@ -61,6 +61,7 @@ class RecordingFileSystem extends FileSystem {
entries = new Map<string, { type: FsInfo['type']; content?: string; version?: FsVersion }>()
lstatTypes = new Map<string, FsPathInfo['type']>()
throwOnStat = new Set<string>()
throwOnRead = new Set<string>()
omitSizes = new Set<string>()
readTargets: string[] = []
readTextTargets: string[] = []
@@ -69,7 +70,7 @@ class RecordingFileSystem extends FileSystem {
override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
if (opts?.signal !== undefined) this.signals.push(opts.signal)
opts?.signal?.throwIfAborted()
const absolute = join(opts?.cwd ?? '/', path)
const absolute = resolve(opts?.cwd ?? '/', path)
return { targetKey: FsTargetKey(absolute), displayPath: absolute }
}
@@ -113,6 +114,7 @@ class RecordingFileSystem extends FileSystem {
if (signal !== undefined) this.signals.push(signal)
signal?.throwIfAborted()
this.readTargets.push(target.targetKey)
if (this.throwOnRead.has(target.targetKey)) throw new Error(`read failed: ${target.displayPath}`)
const content = this.entries.get(target.targetKey)?.content ?? ''
return (async function* () {
const midpoint = Math.ceil(content.length / 2)
@@ -299,8 +301,8 @@ describe('workspace context instruction discovery', () => {
expect(files.map(file => file.displayPath)).toEqual([
'$DSH_HOME/AGENTS.md',
'AGENTS.md',
'packages/CLAUDE.md',
'packages/app/AGENTS.md',
join('packages', 'CLAUDE.md'),
join('packages', 'app', 'AGENTS.md'),
])
expect(files.map(file => file.absolutePath)).not.toContain(join(root, 'CLAUDE.md'))
} finally {
@@ -358,22 +360,25 @@ describe('workspace context instruction discovery', () => {
}
})
it('skips a file that becomes unreadable after discovery without failing the request', async () => {
it('skips a provider file whose read fails after a successful metadata probe', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
const cwd = join(root, 'pkg')
await mkdir(join(root, '.git'), { recursive: true })
await mkdir(cwd, { recursive: true })
const leaf = join(cwd, 'AGENTS.md')
await write(leaf, 'secret-ish rule')
await chmod(leaf, 0)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(leaf, { type: 'file', content: 'secret-ish rule' })
fs.throwOnRead.add(leaf)
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 })
const loaded = await loadBaselineInstructions({ cwd, dshHome: home, maxBytes: 65536 }, fs)
expect(loaded).toBeUndefined()
await chmod(leaf, 0o600)
expect(fs.readTargets).toEqual([leaf])
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
@@ -958,7 +963,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('omitted AGENTS.md')
expect(derivedText(agent)).toContain('Instructions from: pkg/AGENTS.md\n\npackage rule')
expect(derivedText(agent)).toContain(`Instructions from: ${join('pkg', 'AGENTS.md')}\n\npackage rule`)
} finally {
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
@@ -1447,7 +1452,7 @@ describe('workspace context request injection', () => {
await composeBaselinePrefix(ctx, agent)
expect(derivedText(agent)).toContain('Instructions from: AGENTS.md\n\nroot schema default rule')
expect(derivedText(agent)).toContain('Instructions from: child/AGENTS.md\n\nchild schema default rule')
expect(derivedText(agent)).toContain(`Instructions from: ${join('child', 'AGENTS.md')}\n\nchild schema default rule`)
await ctx.fiber.dispose()
} finally {
await rm(root, { recursive: true, force: true })
@@ -1751,7 +1756,7 @@ describe('dynamic nested workspace context injection', () => {
changes: [{
action: 'set',
scope: 'pkg',
path: 'pkg/AGENTS.md',
path: join('pkg', 'AGENTS.md'),
}],
})
const meta = workspaceContextOf(result)?.meta
@@ -1765,7 +1770,7 @@ describe('dynamic nested workspace context injection', () => {
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toBe([
'<system-reminder>',
'Additional instructions from: pkg/AGENTS.md',
`Additional instructions from: ${join('pkg', 'AGENTS.md')}`,
'',
'These instructions apply to work under `pkg`. Use them as guidance when relevant; more specific instructions take precedence. They do not override system, developer, or direct user instructions.',
'',
@@ -1804,7 +1809,7 @@ describe('dynamic nested workspace context injection', () => {
})
const text = blocksText(workspaceContextOf(result)?.content)
expect(text).toContain('Additional instructions from: pkg/CLAUDE.local.md')
expect(text).toContain(`Additional instructions from: ${join('pkg', 'CLAUDE.local.md')}`)
expect(text).toContain('local package rule')
expect(text).not.toContain('native package rule')
} finally {
@@ -1985,11 +1990,11 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(changed)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(changed)?.content)).toBe([
'<system-reminder>',
'Updated instructions from: pkg/AGENTS.md',
`Updated instructions from: ${join('pkg', 'AGENTS.md')}`,
'',
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
'',
@@ -2032,11 +2037,11 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(changed)?.meta).toMatchObject({
changes: [{
action: 'replace', scope: 'pkg', path: 'pkg/CLAUDE.md', previousPath: 'pkg/AGENTS.md',
action: 'replace', scope: 'pkg', path: join('pkg', 'CLAUDE.md'), previousPath: join('pkg', 'AGENTS.md'),
}],
})
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('Updated instructions from: pkg/CLAUDE.md')
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('The instructions previously loaded from `pkg/AGENTS.md` no longer apply. Use the following content for `pkg` instead.')
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`Updated instructions from: ${join('pkg', 'CLAUDE.md')}`)
expect(blocksText(workspaceContextOf(changed)?.content)).toContain(`The instructions previously loaded from \`${join('pkg', 'AGENTS.md')}\` no longer apply. Use the following content for \`pkg\` instead.`)
expect(blocksText(workspaceContextOf(changed)?.content)).toContain('fallback package rule')
expect(unchanged.additionalContexts).toBeUndefined()
} finally {
@@ -2070,11 +2075,11 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(removed)?.meta).toEqual({
kind: 'workspace-instructions',
version: 1,
changes: [{ action: 'remove', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'remove', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(removed)?.content)).toBe([
'<system-reminder>',
'Instructions removed: pkg/AGENTS.md',
`Instructions removed: ${join('pkg', 'AGENTS.md')}`,
'',
'The previously loaded instructions from this file no longer apply.',
'</system-reminder>',
@@ -2115,9 +2120,9 @@ describe('dynamic nested workspace context injection', () => {
})
expect(workspaceContextOf(restored)?.meta).toMatchObject({
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('Additional instructions from: pkg/AGENTS.md')
expect(blocksText(workspaceContextOf(restored)?.content)).toContain(`Additional instructions from: ${join('pkg', 'AGENTS.md')}`)
expect(blocksText(workspaceContextOf(restored)?.content)).toContain('restored package rule')
} finally {
await rm(root, { recursive: true, force: true })
@@ -2222,7 +2227,7 @@ describe('dynamic nested workspace context injection', () => {
const update = resumed.session.events.findLast(event => event.type === 'context/message')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'replace', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(update?.type === 'context/message' && blocksText(update.data.content)).toContain('new nested rule after resume')
} finally {
@@ -2350,8 +2355,8 @@ describe('dynamic nested workspace context injection', () => {
})
const firstText = blocksText(workspaceContextOf(first)?.content)
expect(firstText).toContain('omitted pkg/AGENTS.md')
expect(firstText).not.toContain('## pkg/AGENTS.md')
expect(firstText).toContain(`omitted ${join('pkg', 'AGENTS.md')}`)
expect(firstText).not.toContain(`## ${join('pkg', 'AGENTS.md')}`)
expect(firstText).toContain('subtree rule')
expect(blocksText(workspaceContextOf(second)?.content)).toContain('parent rule')
} finally {
@@ -2494,14 +2499,19 @@ describe('dynamic nested workspace context injection', () => {
it('skips unreadable nested instruction files without attaching empty context', async () => {
const root = await tempRepo()
const home = await tempRepo()
const ctx = new Context()
try {
await mkdir(join(root, '.git'), { recursive: true })
const nested = join(root, 'pkg/AGENTS.md')
await write(nested, 'nested package rule')
await write(join(root, 'pkg/deep/file.txt'), 'hello')
await chmod(nested, 0)
const ctx = new Context()
await mountFileToolsAndWorkspaceContext(ctx, { dshHome: home, maxBytes: 65536 })
await ctx.plugin(SystemPrompt)
await ctx.plugin(ToolRegistry)
await ctx.plugin(RecordingFileSystem)
const fs = ctx.fs as RecordingFileSystem
fs.entries.set(join(root, '.git'), { type: 'directory' })
fs.entries.set(nested, { type: 'file', content: 'nested package rule' })
fs.entries.set(join(root, 'pkg/deep/file.txt'), { type: 'file', content: 'hello' })
fs.throwOnRead.add(nested)
await ctx.plugin(ToolFs)
await ctx.plugin(workspaceContext, { dshHome: home, maxBytes: 65536 })
const result = await ctx.tools.execute({
signal: testToolSignal,
@@ -2513,8 +2523,9 @@ describe('dynamic nested workspace context injection', () => {
expect(result.isError).toBe(false)
expect(result.additionalContexts).toBeUndefined()
await chmod(nested, 0o600)
expect(fs.readTargets).toContain(nested)
} finally {
await ctx.fiber.dispose()
await rm(root, { recursive: true, force: true })
await rm(home, { recursive: true, force: true })
}
@@ -2551,7 +2562,7 @@ describe('dynamic nested workspace context injection', () => {
expect(workspaceContextOf(result)?.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
expect(workspaceContextOf(result)?.meta).toMatchObject({
kind: 'workspace-instructions',
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md' }],
changes: [{ action: 'set', scope: 'pkg', path: join('pkg', 'AGENTS.md') }],
})
expect(blocksText(workspaceContextOf(result)?.content)).toContain('nested package rule')
expect(blocksText(workspaceContextOf(result)?.content)).not.toContain('downstream context')

View File

@@ -421,6 +421,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'listSessions(): Promise<SessionRecord[]>',
jsDoc: '/**\n * List the complete logical corpus using live-preferred records.\n * @returns deterministic newest-first cloned session records.\n */',
},
{
signature: 'async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined>',
jsDoc: '/**\n * Fold the latest log-backed title from one live-preferred logical session.\n * @param sessionId - live or persisted session id to read.\n * @returns latest title snapshot, or `undefined` when the log has no title event.\n */',
},
{
signature: 'async listEvents(sessionId: SessionId): Promise<SessionEventRecord[]>',
jsDoc: '/**\n * List lightweight raw-log event records for one logical session.\n * @param sessionId - live-preferred session id to read.\n * @returns event records in ascending seq order.\n */',
@@ -463,6 +467,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
signature: 'async flush(session: Session): Promise<void>',
jsDoc: '/**\n * Dispatch the awaited `session/flush` durability checkpoint for `session`,\n * with the carrier captured at {@link enter}. THE flush entry point: the\n * store owns the carrier, so callers (the loop\'s turn-end checkpoint, idle\n * injection, teardown drains) must come through here rather than dispatch a\n * raw `ctx.parallel(\'session/flush\', …)` — one owner, one spelling, and the\n * scoped-dispatch invariant can pin it.\n * @param session - the session whose buffered events must reach durable storage.\n * @returns resolves when every flush listener has settled; after all settle,\n * rejects with the first registered listener failure if any listener failed.\n */',
},
{
signature: 'async appendOutOfBand<T extends OutOfBandSessionEventType>( session: Session, type: T, data: SessionEventMap[T], trigger: TurnTrigger, ): Promise<SessionEvent<T>>',
jsDoc: '/**\n * Append one plugin-declared log-only event without borrowing the agent\n * loop\'s lifecycle. An open turn receives the event directly and remains\n * responsible for its ordinary checkpoint. A closed log receives one\n * zero-step turn around the event, followed by an awaited flush.\n *\n * Once the synthetic `turn/start` commits, this method always attempts its\n * matching `turn/end` and flush, including when the target append fails.\n * Detachment requested by an event or flush listener is deferred until that\n * sequence settles, so publication cannot switch from a live scoped session\n * to an unobserved bare `Session` halfway through the update.\n *\n * @param session - exact live session that owns the target log.\n * @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.\n * @param data - typed JSON payload for the target event.\n * @param trigger - plugin-owned turn trigger used only when the log is closed.\n * @returns the accepted target event with its assigned sequence and timestamp.\n * @throws when the session is detached, another out-of-band append is active,\n * event acceptance fails, the synthetic turn cannot close, or flushing fails.\n */',
},
{
signature: 'get(id: SessionId): Session | undefined',
jsDoc: '/**\n * Look up a live session.\n * @param id - the session id to look up.\n * @returns the session, or undefined when no live session has that id.\n */',
@@ -477,6 +485,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
],
},
{
key: 'sessionTitle',
summary: 'Log-backed title fold plus asynchronous fallback generation.',
methods: [
{
signature: 'get(session: Session): SessionTitleSnapshot | undefined',
jsDoc: '/**\n * Read the latest folded title from one live or replayed session.\n * @param session - session whose log is the title source of truth.\n * @returns latest title snapshot, or `undefined` before eligible input.\n */',
},
{
signature: 'async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined>',
jsDoc: '/**\n * Explicitly retry the registered provider, or materialize the built-in\n * fallback when no provider is registered.\n * @param session - exact live session to refresh.\n * @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection.\n * @returns latest accepted title, or `undefined` when no eligible text exists.\n */',
},
{
signature: 'register(provider: SessionTitleProvider): () => Promise<void>',
jsDoc: '/**\n * Register the sole optional title provider. Disposal aborts its pending and\n * active work before another provider may register.\n * @param provider - provider identity, cadence, and generation function.\n * @returns exact Cordis effect disposer, which settles after active calls quiesce.\n */',
},
],
},
{
key: 'skills',
summary: 'Registry of skill providers.',
@@ -872,7 +898,7 @@ export const EVENT_API: readonly EventApiEntry[] = [
name: 'llm/stream',
mode: 'waterfall',
signature: '\'llm/stream\'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>',
jsDoc: '/**\n * Waterfall around every streaming model call (retry, replay, routing).\n * Bound to the {@link LlmService}; call `next()` to reach the resolved\n * adapter\'s stream, or yield your own chunks to short-circuit.\n * @param options - the full request. A LOOP-built request arrives\n * deep-frozen (mutation throws): its content is a pure function of the\n * session log (the reconstructability Agent Note), so listeners read it, never\n * rewrite it. A hand-built one-shot (compaction summarize) is the\n * caller\'s own object and stays mutable here.\n * @mode waterfall\n */',
jsDoc: '/**\n * Waterfall around every streaming model call (retry, replay, routing).\n * Bound to the {@link LlmService}; call `next()` to reach the resolved\n * adapter\'s stream, or yield your own chunks to short-circuit.\n * @param options - the full request. A LOOP-built request carries the\n * process-local {@link markAgentLoopRequest} identity and arrives deep-frozen\n * (mutation throws): its content is a pure function of the session log (the\n * reconstructability Agent Note), so listeners read it, never rewrite it.\n * Hand-built calls own their mutability policy and do not carry that marker.\n * @mode waterfall\n */',
summary: 'Waterfall around every streaming model call (retry, replay, routing).',
},
{
@@ -1398,6 +1424,14 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'MessageSourceMap',
declaration: 'export interface MessageSourceMap {\n user: {\n kind: \'user\';\n };\n plugin: {\n kind: \'plugin\';\n plugin: string;\n };\n}',
},
{
name: 'OutOfBandSessionEventMap',
declaration: 'export interface OutOfBandSessionEventMap {\n}',
},
{
name: 'OutOfBandSessionEventType',
declaration: 'export type OutOfBandSessionEventType = Exclude<Extract<SessionEventType, keyof OutOfBandSessionEventMap>, SurfaceEventType>;',
},
{
name: 'PresetOption',
declaration: 'export interface PresetOption {\n value: string;\n name: string;\n description?: string;\n}',
@@ -1522,6 +1556,46 @@ export const TYPE_API: readonly TypeApiEntry[] = [
name: 'SessionRecord',
declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}',
},
{
name: 'SessionTitleAutomaticMode',
declaration: 'export type SessionTitleAutomaticMode = \'first-message\' | \'all-user-messages\';',
},
{
name: 'SessionTitleEventData',
declaration: 'export interface SessionTitleEventData {\n readonly title: string;\n readonly messageSeqs: number[];\n readonly source: SessionTitleSource;\n}',
},
{
name: 'SessionTitleModelProvenance',
declaration: 'export interface SessionTitleModelProvenance {\n readonly provider: string;\n readonly model: string;\n}',
},
{
name: 'SessionTitleProvider',
declaration: 'export interface SessionTitleProvider {\n readonly id: SessionTitleProviderId;\n readonly automatic: SessionTitleAutomaticMode;\n generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>;\n}',
},
{
name: 'SessionTitleProviderId',
declaration: 'export type SessionTitleProviderId = Branded<\'SessionTitleProviderId\'>;',
},
{
name: 'SessionTitleProviderRequest',
declaration: 'export interface SessionTitleProviderRequest {\n readonly session: Session;\n readonly messages: readonly SessionTitleUserMessage[];\n readonly route?: SessionTitleModelProvenance;\n readonly signal: AbortSignal;\n}',
},
{
name: 'SessionTitleProviderResult',
declaration: 'export interface SessionTitleProviderResult {\n readonly title: string;\n readonly messageSeqs: readonly number[];\n readonly model?: SessionTitleModelProvenance;\n}',
},
{
name: 'SessionTitleSnapshot',
declaration: 'export interface SessionTitleSnapshot extends SessionTitleEventData {\n readonly eventSeq: number;\n readonly updatedAt: number;\n}',
},
{
name: 'SessionTitleSource',
declaration: 'export type SessionTitleSource = {\n readonly kind: \'fallback\';\n} | {\n readonly kind: \'provider\';\n readonly provider: SessionTitleProviderId;\n readonly model?: SessionTitleModelProvenance;\n};',
},
{
name: 'SessionTitleUserMessage',
declaration: 'export interface SessionTitleUserMessage {\n readonly seq: number;\n readonly text: string;\n}',
},
{
name: 'SkillCandidate',
declaration: 'export interface SkillCandidate extends SkillSummary {\n readonly rank: number;\n readonly locator: unknown;\n readonly path?: string;\n readonly metadata?: Readonly<Record<string, unknown>>;\n}',

View File

@@ -15,4 +15,4 @@ The session log, system-prompt assembly, tool registry, agent vocabulary, and co
`agent-loop` is the one concrete implementation of the `agent` seam and lives here because it is the harness's default product loop. It runs each driver inside `ctx.agents.withInitiator()`. Extension plugins depend on `agent`, including when they need the initiating Agent, and never on `agent-loop` directly, so the loop stays swappable.
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.
The default composition that wires this spine into a runnable agent lives in [`examples/agent-spine-demo`](../examples/agent-spine-demo/README.md): one bundle plugin that loads the control spine plus selected default capabilities (`timer` + `llm` + sessions + fallback session titles + system-prompt + tools + agents + invariants + the local [skill family](../skill/README.md) + `tool-bash` + workspace-context + `agent-loop`) and forwards `agent-loop`'s `agents` list as its own config. It sits in `examples/` — ready-to-run demo/reference bundles — not in `core/`: `core/` ships the swappable spine pieces, while a demo bundle picks one concrete composition of them and adds a front door.

View File

@@ -29,7 +29,7 @@ The config-driven `ctx.agentLoop.create()` path keeps its agent owned by the loo
### Invariant companion
The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop marks each request with an internal non-enumerable identity before freezing it; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id.
The optional `@deepseek-ai/dsh-agent-loop/invariant` companion registers request reconstruction with `ctx.invariants`. The loop records each exact frozen request in the process-local identity set owned by `dsh-llm`; the companion then requires a live session and independently rebuilds the message boundary and folded request header from the log. Direct one-shot calls remain outside this contract even when callers freeze them or attach a session id.
### Configuration (schemastery)

View File

@@ -4,10 +4,9 @@
*/
import type { Context } from 'cordis'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants'
import { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session'
import { isLoopRequest } from './request-marker.ts'
const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop'
@@ -21,7 +20,7 @@ const install: InvariantInstaller = Object.assign((ctx: Context, fail: Invariant
// Prepend prevents a short-circuiting replay listener from silencing the
// check; correctness itself comes from the sequence-bounded reconstruction.
ctx.on('llm/stream', (options: GenerateOptions, next) => {
if (!isLoopRequest(options)) return next()
if (!isAgentLoopRequest(options)) return next()
if (!Object.isFrozen(options)) fail('a loop-built request must be frozen')
if (options.sessionId === undefined) fail('a loop-built request must carry a session id')
const session = ctx.sessions.get(options.sessionId)

View File

@@ -8,14 +8,13 @@
import type { Context } from 'cordis'
import type { ContentBlock, FinishReason, GenerateOptions, LlmCallConfig, LlmFailure, Message } from '@deepseek-ai/dsh-llm'
import { isDeepStrictEqual } from 'node:util'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf } from '@deepseek-ai/dsh-llm'
import { BlockAssembler, HarnessError, LlmError, assertNever, deepFreeze, errorChain, llmFailureOf, markAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import { agentEvents, agentInterruptReasonOf, assembleContextFor } from '@deepseek-ai/dsh-agent'
import type { AgentEventDispatch, ContinuationDecision, HookContext, PromptDecision, RequestError, RequestErrorDecision } from '@deepseek-ai/dsh-agent'
import { canonicalHeader } from '@deepseek-ai/dsh-session'
import type { Session, TurnEndReason, TurnTrigger } from '@deepseek-ai/dsh-session'
import { createTransmissionLog, recordRequestHeader } from './request-log.ts'
import type { TransmissionLog } from './request-log.ts'
import { markLoopRequest } from './request-marker.ts'
import { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt'
import type {} from '@deepseek-ai/dsh-tools'
@@ -598,7 +597,7 @@ async function runStep(
recordRequestHeader(session, transmission, header)
// Freeze the logged header plus boundary snapshot; the prefix precedes derived history.
const request: GenerateOptions = deepFreeze(markLoopRequest({
const request: GenerateOptions = markAgentLoopRequest(deepFreeze({
provider: header.config.provider,
model: header.config.model,
messages: [...header.messagePrefix ?? [], ...boundaryMessages],

View File

@@ -1,22 +0,0 @@
/** Internal identity shared by the independently bundled loop and invariant companion. */
const LOOP_REQUEST = Symbol.for('@deepseek-ai/dsh-agent-loop/request')
/**
* Mark a request as owned by the agent loop before it is frozen.
* @param request - mutable request object being assembled by the loop.
* @returns the same request with a non-enumerable loop identity.
*/
export function markLoopRequest<T extends object>(request: T): T {
Object.defineProperty(request, LOOP_REQUEST, { value: true })
return request
}
/**
* Test whether a request carries the agent loop's internal identity.
* @param request - request observed at the LLM stream boundary.
* @returns whether the loop marked this exact request object.
*/
export function isLoopRequest(request: object): boolean {
return Reflect.get(request, LOOP_REQUEST) === true
}

View File

@@ -3,7 +3,7 @@ import { Context } from 'cordis'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import InvariantService from '@deepseek-ai/dsh-invariants'
import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant'
import { markLoopRequest } from '../src/request-marker.ts'
import { markAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm'
async function setup(): Promise<Context> {
const ctx = new Context()
@@ -18,7 +18,8 @@ function dispatch(ctx: Context, options: unknown): void {
}
function loopRequest<T extends object>(options: T): Readonly<T> {
return Object.freeze(markLoopRequest(options))
markAgentLoopRequest(options as GenerateOptions)
return Object.freeze(options)
}
async function requestSetup() {
@@ -94,8 +95,10 @@ describe('request-reconstruction invariant', () => {
it('rejects malformed requests carrying the loop marker', async () => {
const { ctx, session } = await requestSetup()
const messages: GenerateOptions['messages'] = []
Object.freeze(messages)
expect(() => {
dispatch(ctx, markLoopRequest({ model: 'm', messages: Object.freeze([]), sessionId: session.id }))
dispatch(ctx, markAgentLoopRequest({ provider: 'p', model: 'm', messages, sessionId: session.id }))
}).toThrow(/request must be frozen/)
expect(() => {
dispatch(ctx, loopRequest({ model: 'm', messages: Object.freeze([]) }))

View File

@@ -12,6 +12,8 @@ Creates and holds event-sourced `Session` instances. Persistence is intentionall
- `ctx.sessions.create(id?, { seed?, meta? }?)` validates and detaches durable seed/header data, fills the version and id, defaults `createdAt` to now, publishes the session, and binds it to the calling fiber. Persisted reconstruction supplies its original `createdAt`, `seedLength`, and `delegationDepth`.
- `ctx.sessions.flush(session)` dispatches the awaited parallel durability checkpoint through the session's captured scope. Every listener starts and the call waits for all to settle before reporting failure; unpublished, detached, and stale objects reject.
- `ctx.sessions.appendOutOfBand(session, type, data, trigger)` accepts only plugin event types opted into `OutOfBandSessionEventMap`. It appends directly inside an open turn; otherwise it atomically opens a zero-step plugin turn, appends, closes, and flushes. A target failure still closes and flushes the synthetic turn, and detach is deferred until the sequence settles.
- `findLastMessageTurnEnd(events)` pairs message-triggered starts with their ends and returns the latest matched `turn/end`. Outcome consumers use this fold instead of the raw latest turn boundary because a later injection or plugin-owned zero-step turn has its own outcome.
- `ctx.sessions.fork(source, boundary?, childSessionId?): Session` — Resolve a live session object or id, select a seed through the inclusive `boundary` event seq (default: current last event), require that boundary to be `turn/end`, and create a live child session with lineage metadata.
- `ctx.sessions.get(id: SessionId): Session | undefined`
- `ctx.sessions.list(): Session[]`
@@ -64,7 +66,7 @@ Durable values need one accepted representation, not a check followed by a secon
The append-only log's event types, enumerated member by member — payloads, surface badges, provenance — in the generated [persistence log event catalog](../../../docs/persistence-catalog.md). Token accounting reads per-step `assistant/chunk { type: 'usage' }` records and treats `assistant/message.usage` as the committed-step fallback when no usage chunk exists; failed model-request attempts have no assistant message. Provider/model/replay provenance rides on `assistant/message`; an operational error's step is on `turn/end.reason` for `kind: 'error'`, with structured provider facts for a final model-request failure.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog.
Merge-extensible via `SessionEventMap` — a plugin declaration-merges its own types (the compaction seam's `compact/*`, bounded recovery's non-surface `llm/retry`, the hook bridges' `hook/*`); merged members appear in the same catalog. `OutOfBandSessionEventMap` is a separate empty-by-default marker map: an event owner must merge the same key there before `appendOutOfBand()` accepts that log-only type, while surface and lifecycle types remain excluded.
Also defines `TurnTriggerMap` and `TurnEndReasonMap` (merge-extensible sum types for typed turn boundaries — `kind`-tagged instead of strings). A final model-request error retains one structured `LlmFailure`; other turn errors retain message/code, and both identify the failed step.

View File

@@ -13,7 +13,7 @@ import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { Message } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, SessionId } from './types.ts'
import type { CreateSessionOptions, EpochHeader, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType } from './types.ts'
import type { CreateSessionOptions, EpochHeader, OutOfBandSessionEventType, SessionEvent, SessionEventMap, SessionEventType, SessionHeader, SurfaceIntent, SurfaceEventType, TurnTrigger } from './types.ts'
import { snapshotJsonValue } from './json.ts'
import { SurfaceManager } from './surface.ts'
import type { SessionSurface } from './surface.ts'
@@ -27,6 +27,27 @@ export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from '
export { foldSurface, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts'
export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts'
/**
* Find the latest closed message-triggered turn, excluding injection and
* plugin-owned zero-step turns.
* @param events - session events, or an owned suffix, to inspect.
* @returns the latest matching turn end, or `undefined`.
*/
export function findLastMessageTurnEnd(
events: readonly SessionEvent[],
): SessionEvent<'turn/end'> | undefined {
const messageTurns = new Set<number>()
let latest: SessionEvent<'turn/end'> | undefined
for (const event of events) {
if (event.type === 'turn/start') {
if (event.data.trigger.kind === 'message') messageTurns.add(event.data.turn)
continue
}
if (event.type === 'turn/end' && messageTurns.delete(event.data.turn)) latest = event
}
return latest
}
declare module 'cordis' {
interface Context {
sessions: SessionStore
@@ -227,6 +248,7 @@ interface SessionEntry {
announced: boolean
announcing: boolean
appending: boolean
outOfBand: boolean
detachRequested: boolean
detach(): void
}
@@ -401,7 +423,7 @@ export class Session {
} finally {
if (entry !== undefined) {
entry.appending = false
if (entry.detachRequested && !entry.announcing) entry.detach()
if (entry.detachRequested && !entry.announcing && !entry.outOfBand) entry.detach()
}
}
}
@@ -685,6 +707,7 @@ export class SessionStore extends Service {
announced: false,
announcing: false,
appending: false,
outOfBand: false,
detachRequested: false,
detach: () => { this.detachEntered(entry) },
}
@@ -697,7 +720,7 @@ export class SessionStore extends Service {
// A lifecycle listener may own the advanced detach capability. Keep the
// entry and its publication hooks live until synchronous creation or append
// publication unwinds, then publish the paired disposal edge.
if (entry.announcing || entry.appending) {
if (entry.announcing || entry.appending || entry.outOfBand) {
entry.detachRequested = true
return
}
@@ -751,7 +774,7 @@ export class SessionStore extends Service {
}
} finally {
entry.announcing = false
if (entry.detachRequested && !entry.appending) entry.detach()
if (entry.detachRequested && !entry.appending && !entry.outOfBand) entry.detach()
}
}
@@ -795,6 +818,87 @@ export class SessionStore extends Service {
if (failure !== undefined) throw failure.reason
}
/**
* Append one plugin-declared log-only event without borrowing the agent
* loop's lifecycle. An open turn receives the event directly and remains
* responsible for its ordinary checkpoint. A closed log receives one
* zero-step turn around the event, followed by an awaited flush.
*
* Once the synthetic `turn/start` commits, this method always attempts its
* matching `turn/end` and flush, including when the target append fails.
* Detachment requested by an event or flush listener is deferred until that
* sequence settles, so publication cannot switch from a live scoped session
* to an unobserved bare `Session` halfway through the update.
*
* @param session - exact live session that owns the target log.
* @param type - event type opted into {@link OutOfBandSessionEventMap} by its owner.
* @param data - typed JSON payload for the target event.
* @param trigger - plugin-owned turn trigger used only when the log is closed.
* @returns the accepted target event with its assigned sequence and timestamp.
* @throws when the session is detached, another out-of-band append is active,
* event acceptance fails, the synthetic turn cannot close, or flushing fails.
*/
async appendOutOfBand<T extends OutOfBandSessionEventType>(
session: Session,
type: T,
data: SessionEventMap[T],
trigger: TurnTrigger,
): Promise<SessionEvent<T>> {
const entry = this.liveEntryFor(session)
if (entry.outOfBand) {
throw new Error(`session "${session.id}" already has an out-of-band append in progress`)
}
entry.outOfBand = true
// `T` is excluded from SurfaceEventType by OutOfBandSessionEventType, but
// TypeScript does not reduce Session.append's conditional rest parameter
// through a generic intersection. Preserve that proven two-argument call
// shape without widening the public Session.append overload.
const appendLogOnly = session.append.bind(session) as unknown as <K extends OutOfBandSessionEventType>(
eventType: K,
eventData: SessionEventMap[K],
) => SessionEvent<K>
try {
const lastBoundary = session.events.findLast(event => event.type === 'turn/start' || event.type === 'turn/end')
if (lastBoundary?.type === 'turn/start') {
return appendLogOnly(type, data)
}
const lastStart = session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
let accepted: SessionEvent<T> | undefined
let failure: unknown
let opened = false
try {
session.append('turn/start', { turn, trigger })
opened = true
accepted = appendLogOnly(type, data)
} catch (error: unknown) {
failure = error
} finally {
if (opened) {
// The only target types admitted by OutOfBandSessionEventMap are
// log-only plugin events, so the synthetic turn remains open here.
session.append('turn/end', { turn, reason: { kind: 'completed' } })
try {
await this.flush(session)
} catch (error: unknown) {
if (failure === undefined) failure = error
}
}
}
if (failure !== undefined) {
// eslint-disable-next-line @typescript-eslint/only-throw-error -- preserve an arbitrary flush-listener rejection exactly
throw failure
}
/* v8 ignore next -- accepted is assigned unless an append failure was captured above. */
if (accepted === undefined) throw new Error('out-of-band append completed without an accepted event')
return accepted
} finally {
entry.outOfBand = false
if (entry.detachRequested && !entry.announcing && !entry.appending) entry.detach()
}
}
/** Return the exact live entry; detached/prepared objects reject. */
private liveEntryFor(session: Session): SessionEntry {
const entry = attachments.get(session)

View File

@@ -264,9 +264,23 @@ export interface SessionEventMap {
'request/header': { header: EpochHeader; reason: RequestHeaderReason }
}
/**
* Marker map for plugin-owned log-only events accepted by
* `SessionStore.appendOutOfBand()`. A plugin extends this map with the same key
* it adds to {@link SessionEventMap}; surface and lifecycle events stay
* ineligible unless their owner explicitly opts them into this narrow seam.
*/
export interface OutOfBandSessionEventMap {}
/** The appendable event-type keys of {@link SessionEventMap}, plugin-merged extensions included. */
export type SessionEventType = keyof SessionEventMap
/** Plugin-declared non-surface event types accepted by `SessionStore.appendOutOfBand()`. */
export type OutOfBandSessionEventType = Exclude<
Extract<SessionEventType, keyof OutOfBandSessionEventMap>,
SurfaceEventType
>
/**
* The subset of {@link SessionEventType} values whose events produce LLM
* messages and are eligible to appear on the ordered surface. Only these

View File

@@ -0,0 +1,226 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
'test/log-only': { value: string }
}
interface OutOfBandSessionEventMap {
'test/log-only': true
}
}
const updateTrigger = { kind: 'injection', source: { kind: 'plugin', plugin: 'test' } } as const
describe('SessionStore.appendOutOfBand', () => {
it('joins an open turn without adding a boundary or flushing it', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('open'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const event = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'inside' },
updateTrigger,
)
expect(event).toMatchObject({ type: 'test/log-only', seq: 1, data: { value: 'inside' } })
expect(session.events.map(item => item.type)).toEqual(['turn/start', 'test/log-only'])
expect(flushes).toBe(0)
})
it('wraps a closed log in one zero-step turn and flushes the balanced update', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('closed'))
const flushedTypes: string[][] = []
ctx.on('session/flush', (flushed) => {
flushedTypes.push(flushed.events.map(event => event.type))
})
const first = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
const second = await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'second' },
updateTrigger,
)
expect(first.seq).toBe(1)
expect(second.seq).toBe(4)
expect(session.events).toMatchObject([
{ type: 'turn/start', seq: 0, data: { turn: 1, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 1, data: { value: 'first' } },
{ type: 'turn/end', seq: 2, data: { turn: 1, reason: { kind: 'completed' } } },
{ type: 'turn/start', seq: 3, data: { turn: 2, trigger: updateTrigger } },
{ type: 'test/log-only', seq: 4, data: { value: 'second' } },
{ type: 'turn/end', seq: 5, data: { turn: 2, reason: { kind: 'completed' } } },
])
expect(flushedTypes).toEqual([
['turn/start', 'test/log-only', 'turn/end'],
['turn/start', 'test/log-only', 'turn/end', 'turn/start', 'test/log-only', 'turn/end'],
])
})
it('closes and flushes a zero-step turn when the target event is rejected', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('rejected'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toMatchObject([
{ type: 'turn/start', data: { turn: 1 } },
{ type: 'turn/end', data: { turn: 1, reason: { kind: 'completed' } } },
])
expect(flushes).toBe(1)
})
it('does not flush when the synthetic turn cannot open', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('start-failure'))
let flushes = 0
ctx.on('session/flush', () => { flushes += 1 })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'unreachable' },
{ ...updateTrigger, invalid: 1n } as never,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events).toEqual([])
expect(flushes).toBe(0)
})
it('preserves a target rejection when the balancing flush also rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('target-and-flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 1n } as never,
updateTrigger,
)).rejects.toThrow(/non-JSON-serializable/)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'turn/end',
])
})
it('keeps the session attached through publication and its flush', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('dispose'))
const detach = ctx.sessions.enter(session)
ctx.sessions.announce(session)
let liveDuringFlush = false
ctx.on('session/event', (_observed, event) => {
if (event.type === 'turn/start') detach()
})
ctx.on('session/flush', () => {
liveDuringFlush = ctx.sessions.get(session.id) === session
})
await ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'last' },
updateTrigger,
)
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
expect(liveDuringFlush).toBe(true)
expect(ctx.sessions.get(session.id)).toBeUndefined()
})
it('rejects detached sessions before opening a turn', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.prepare(SessionId('detached'))
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'nope' },
updateTrigger,
)).rejects.toThrow('session "detached" is not live in this store')
expect(session.events).toEqual([])
})
it('leaves a balanced log when the durability checkpoint rejects', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('flush-failure'))
ctx.on('session/flush', () => { throw new Error('disk failed') })
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'accepted' },
updateTrigger,
)).rejects.toThrow('disk failed')
expect(session.events.map(event => event.type)).toEqual([
'turn/start',
'test/log-only',
'turn/end',
])
})
it('rejects overlapping updates while the first append is still settling', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
const session = ctx.sessions.create(SessionId('overlap'))
let release!: () => void
const checkpoint = new Promise<void>((resolve) => {
release = resolve
})
ctx.on('session/flush', () => checkpoint)
const first = ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'first' },
updateTrigger,
)
await expect(ctx.sessions.appendOutOfBand(
session,
'test/log-only',
{ value: 'overlap' },
updateTrigger,
)).rejects.toThrow(/out-of-band append in progress/)
release()
await expect(first).resolves.toMatchObject({ data: { value: 'first' } })
})
})

View File

@@ -1,7 +1,13 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, {
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
Session,
SessionEvent,
SessionId,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
describe('Session', () => {
@@ -48,6 +54,42 @@ describe('Session', () => {
expect(structuredClone(turnEnd.data.reason)).toEqual({ kind: 'max-tokens' })
})
it('finds the latest message-turn outcome past later non-message turns', () => {
const session = new Session(SessionId('message-turn-outcome'))
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
session.append('context/message', {
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
session.append('turn/start', {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
content: [{ type: 'text', text: 'bounded prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
session.append('context/message', {
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
})
it('round-trips the coarse aborted turn outcome', () => {
const session = new Session(SessionId('aborted'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })

View File

@@ -4,7 +4,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling
| Package | npm name | Role |
|---|---|---|
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with an opt-in persisted-goal stack |
| `agent-spine-demo/` | `@deepseek-ai/dsh-agent-spine-demo` | The executor-less/UI-less agent spine as one bundle plugin, with fallback session titles and an opt-in persisted-goal stack |
| `tui-demo/` | `@deepseek-ai/dsh-tui-demo` | Full-screen terminal app: the spine + persisted goals + `/goal` command + JSONL persistence + `dsh-tui` + a pre-created `main` agent, with a boot `bin` |
| `cli-demo/` | `@deepseek-ai/dsh-cli-demo` | Headless one-shot app: the spine + JSONL persistence + a pre-created `main` agent, with text and DSH-native JSON output |
| `acp-demo/` | `@deepseek-ai/dsh-acp-demo` | ACP server app: the spine + persisted goals + `/goal` command + JSONL persistence + the [`acp`](../ui/acp/README.md) bridge (no stdout logger), with a boot `bin` |

View File

@@ -33,6 +33,7 @@ Because the package wires no logger entry, an ACP leaf has **nothing to get wron
| `persona` | — | the deployment persona template (may reference `{{provider}}`/`{{model}}`/`{{cwd}}`), routed to `dsh-system-prompt` |
| `toolOrder` | — | explicit model-facing tool order (a name list with one `'<unlisted-tools>'` rest entry; absent — lexicographic; an unregistered name fails each turn at prompt assembly), routed to `dsh-system-prompt` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `sessionTitle` | spine example limits | fallback title word/byte limits routed through `dsh-agent-spine-demo` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config (`native` / `code` / `both`), routed through `dsh-agent-spine-demo` |
| `workspaceContext` | (required) | workspace-instruction byte budget/config, or `false`; routed to the providerless-safe `dsh-workspace-context` plugin |
| `skills` | owner defaults | registry-cache, local-provider, and model-facing skill-tool config, routed through `dsh-agent-spine-demo` |

View File

@@ -50,6 +50,8 @@ export interface Config {
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
@@ -82,6 +84,7 @@ export const Config: z<Config> = z.object({
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
dshHome: z.string(),
sessionTitle: agentCore.SessionTitleConfigSchema,
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persistenceCompression: JsonlCompressionSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),

View File

@@ -12,6 +12,7 @@ Read this package for the whole plugin tree and its composition order.
@cordisjs/plugin-timer timer service (writes nothing to stdout)
@deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary
@deepseek-ai/dsh-session event-sourced session log + store
@deepseek-ai/dsh-session-title log-backed title service + deterministic fallback
@deepseek-ai/dsh-system-prompt prompt-section + tool-schema assembly
@deepseek-ai/dsh-tools registry + guarded pre/around/post/final-result pipeline
@deepseek-ai/dsh-skill skill provider registry
@@ -41,6 +42,7 @@ Read this package for the whole plugin tree and its composition order.
The spine is everything COMMON to every front door. The swappable and front-door-coupled pieces stay out, picked by whatever loads the bundle:
- **the LLM adapter** — the bundle ships the abstract `llm` service; the leaf registers a concrete adapter on `ctx.llm` (`llm-deepseek`, `llm-pi-ai`, `llm-replay`).
- **model-backed session-title providers** — the bundle mounts the fallback service with overridable example limits (5 words, 40 fallback bytes, 80 accepted-title bytes); a leaf may opt into exactly one first-message or all-messages LLM provider.
- **the bash executor** — the bundle ships `tool-bash` (the consumer schema); the leaf provides `ctx.bash` (`bash-local` or a sandboxed impl).
- **non-local skill providers** — the bundle ships the skill registry, the local filesystem provider, and the `skill` tool; deployments can add other providers such as embedded or remote catalogs as siblings.
- **presentation + per-app infra** — the terminal TUI or ACP front door and `hmr`. These form the coupled front-door cluster that the app packages ([`dsh-tui-demo`](../tui-demo/README.md), [`dsh-acp-demo`](../acp-demo/README.md)) bake in. `timer` is in the spine because it is common and stdout-silent; front doors own stdout and remain outside.
@@ -51,11 +53,11 @@ This is the [interface/implementation/consumer seam](../../../.agents/notes/impl
```ts
import type { Config } from '@deepseek-ai/dsh-agent-spine-demo'
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? }
// { agents?, maxParallelToolCalls?, persona?, toolOrder?, tools?, dshHome?, sessionTitle?, skills?, workspaceContext, toolBash?, toolTasks?, goals?, invariants?, llmRetry? }
// workspaceContext requires { maxBytes } or false; the other owner schemas supply defaults.
```
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-home`](../../util/home/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
The bundle FORWARDS each field to the child that owns it: `agents` and `maxParallelToolCalls` to `agent-loop` (`agents` defaults to `[]`; the cap defaults there), so each app supplies its own pre-created agents — TUI and headless apps pre-create `main`, while the ACP app creates agents on demand at `session/new`; `llmRetry` to the bounded retry policy; `persona` and `toolOrder` to `dsh-system-prompt`; `tools` to the tool registry for its presentation mode; `sessionTitle` to the fallback title service; `skills.registry`, `skills.local`, and `skills.tool` to the skill registry, local provider, and model-facing consumer; the required `workspaceContext` choice to `dsh-workspace-context` (`{ maxBytes }` enables loading and `false` disables it); `invariants` to the invariant service; and `toolBash`/`toolTasks` to the two model-facing tool plugins the bundle owns. Omitted `sessionTitle` uses the explicit example policy of 5 words, 40 fallback bytes, and 80 accepted-title bytes. A `goals` object opts into the persisted domain, model tools, and same-session driver while forwarding `goals.domain` and `goals.tool` to their owners; omission or `false` leaves the stack absent so headless callers retain one-turn settlement. Set `skills.enabled: false` to omit both the local provider and model-facing skill tool, and set `toolTasks: false` to retain the task service for foreground producers without exposing `task_output` / `task_list` / `task_kill`. It resolves `dshHome` once through [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) and forwards that absolute value to tool-bash's managed environment and enabled local skill discovery. An absent top-level `dshHome` adopts `skills.local.dshHome`; supplying both with different resolved paths fails loudly. `toolBash.enableRunInBackground` controls only the bash producer; independently loaded producers keep their own config. Workspace instructions register before the skill catalog so their session-prefix message renders first. App packages use `pickSpineConfig()` to copy only these bundle-owned fields.
For example, `{ invariants: { enabled: true, package_allowlist: ['^@deepseek-ai/dsh-'], package_blocklist: ['agent-loop$'] } }` keeps the package-owned companions mounted but suppresses the blocked owner. Blocklist matches override allowlist matches; see [`dsh-invariants`](../../support/invariants/README.md) for regex and lifecycle rules.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-agent-spine-demo",
"description": "The default executor-less/UI-less agent spine with bounded retry and optional persisted goals",
"description": "The default executor-less/UI-less agent spine with fallback session titles, bounded retry, and optional persisted goals",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -32,12 +32,13 @@
"@deepseek-ai/dsh-agent-loop": "^0.0.1",
"@deepseek-ai/dsh-goal": "^0.0.1",
"@deepseek-ai/dsh-goal-session": "^0.0.1",
"@deepseek-ai/dsh-home": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-scope": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-skill-local": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
@@ -57,12 +58,13 @@
"@deepseek-ai/dsh-goal": "workspace:^",
"@deepseek-ai/dsh-goal-session": "workspace:^",
"@deepseek-ai/dsh-fs-local": "workspace:^",
"@deepseek-ai/dsh-home": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-scope": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-skill-local": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",

View File

@@ -13,6 +13,7 @@ import Timer from '@cordisjs/plugin-timer'
import z from 'schemastery'
import LlmService from '@deepseek-ai/dsh-llm'
import SessionStore from '@deepseek-ai/dsh-session'
import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title'
import SystemPrompt, { type Config as SystemPromptConfig } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type Config as ToolsConfig } from '@deepseek-ai/dsh-tools'
import SkillService, { type Config as SkillRegistryConfig } from '@deepseek-ai/dsh-skill'
@@ -33,10 +34,17 @@ import * as toolSkill from '@deepseek-ai/dsh-tool-skill'
import * as toolTasks from '@deepseek-ai/dsh-tool-tasks'
import AgentLoop, { type Config as AgentLoopConfig } from '@deepseek-ai/dsh-agent-loop'
import * as llmRetry from '@deepseek-ai/dsh-llm-retry'
import { resolveDshHome } from '@deepseek-ai/dsh-home'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
export const name = 'agent-spine-demo'
/** Overridable example policy used when a bundle consumer omits `sessionTitle`. */
const EXAMPLE_SESSION_TITLE_CONFIG: SessionTitleConfig = {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 80,
}
/** Skill bundle config forwarded to the registry, local provider, and model-facing consumer. */
export interface SkillConfig {
/** Mount the bundled local skill provider and model-facing skill tool (default true). */
@@ -63,7 +71,8 @@ export interface GoalConfig {
* bridge, simply omits it), `persona` and `toolOrder` to the system-prompt
* plugin (the deployment's persona section and the explicit model-facing tool
* order), the `tools` object to the tool registry (its presentation `mode`),
* `dshHome` to bash environment and local skill discovery, `skills` to the
* `dshHome` to bash environment and local skill discovery, `sessionTitle` to
* the fallback title service, `skills` to the
* skill registry/local provider/tool consumer, `workspaceContext` to the
* workspace-context loader, `llmRetry` to the bounded request-recovery policy,
* and `toolBash`/`toolTasks` to the model-facing tool plugins this bundle owns.
@@ -88,6 +97,8 @@ export interface Config {
tools?: ToolsConfig
/** DeepSeek Harness home directory shared by shell context and local skill discovery. */
dshHome?: string
/** Deterministic fallback and accepted-title limits; omission uses the bundle's example policy. */
sessionTitle?: SessionTitleConfig
/** Workspace-context loader controls with an explicit byte budget; set `false` for hermetic prompts. */
workspaceContext: workspaceContext.Config | false
/** Skill registry, local provider, and model-facing consumer config. */
@@ -112,6 +123,10 @@ export const SkillConfigSchema: z<SkillConfig> = z.object({
tool: toolSkill.Config,
})
/** The session-title config schema with the shared bundle's overridable example limits. */
export const SessionTitleConfigSchema: z<SessionTitleConfig> = SessionTitleService.Config
.default(EXAMPLE_SESSION_TITLE_CONFIG)
/** The bash-tool config schema exported for app packages that forward `toolBash`. */
export const ToolBashConfigSchema: z<toolBash.Config> = toolBash.Config
@@ -134,6 +149,7 @@ export const Config = z.intersect([
z.object({
tools: ToolRegistry.Config,
dshHome: z.string(),
sessionTitle: SessionTitleConfigSchema,
skills: SkillConfigSchema,
workspaceContext: z.union([z.const(false), workspaceContext.Config]).required(),
toolBash: ToolBashConfigSchema,
@@ -141,7 +157,7 @@ export const Config = z.intersect([
invariants: InvariantService.Config,
goals: z.union([z.const(false), GoalConfigSchema]),
llmRetry: LlmRetryConfigSchema,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants' | 'goals' | 'llmRetry'>>,
}) as unknown as z<Pick<Config, 'tools' | 'dshHome' | 'sessionTitle' | 'skills' | 'workspaceContext' | 'toolBash' | 'toolTasks' | 'invariants' | 'goals' | 'llmRetry'>>,
]) as unknown as z<Config>
/**
@@ -156,6 +172,7 @@ export function pickSpineConfig(config: Omit<Config, 'agents'>): Omit<Config, 'a
...config.toolOrder !== undefined ? { toolOrder: config.toolOrder } : {},
...config.tools !== undefined ? { tools: config.tools } : {},
...config.dshHome !== undefined ? { dshHome: config.dshHome } : {},
...config.sessionTitle !== undefined ? { sessionTitle: config.sessionTitle } : {},
workspaceContext: config.workspaceContext,
...config.skills !== undefined ? { skills: config.skills } : {},
...config.toolBash !== undefined ? { toolBash: config.toolBash } : {},
@@ -187,6 +204,7 @@ export function apply(ctx: Context, config: Config): void {
ctx.plugin(Timer)
ctx.plugin(LlmService)
ctx.plugin(SessionStore)
ctx.plugin(SessionTitleService, config.sessionTitle ?? EXAMPLE_SESSION_TITLE_CONFIG)
// Owner schemas resolve defaults; forward toolOrder only when explicitly set.
ctx.plugin(SystemPrompt, {
persona: config.persona ?? '',

View File

@@ -131,6 +131,7 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(ctx.get('timer')).toBeDefined()
expect(ctx.get('llm')).toBeDefined()
expect(ctx.get('sessions')).toBeDefined()
expect(ctx.get('sessionTitle')).toBeDefined()
expect(ctx.get('systemPrompt')).toBeDefined()
expect(ctx.get('tools')).toBeDefined()
expect(ctx.get('skills')).toBeDefined()
@@ -142,6 +143,30 @@ describe('dsh-agent-spine-demo bundle', () => {
await ctx.fiber.dispose()
})
it('forwards configurable fallback title limits to the bundled service', async () => {
const ctx = await mount({
workspaceContext: false,
sessionTitle: {
fallbackMaxWords: 1,
fallbackMaxBytes: 40,
maxTitleBytes: 80,
},
})
const session = ctx.sessions.create(SessionId('configured-title-limits'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
content: [{ type: 'text', text: 'One two three four' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await new Promise(resolve => setTimeout(resolve, 0))
expect(ctx.sessionTitle.get(session)?.title).toBe('One')
await ctx.fiber.dispose()
})
it('opts into the configured persisted-goal domain, tools, and same-session driver', async () => {
const ctx = await mount({
workspaceContext: false,
@@ -218,6 +243,7 @@ describe('dsh-agent-spine-demo bundle', () => {
expect(retryEvents).toHaveLength(1)
expect(retryEvents[0]?.data.retry).toBe(1)
expect(retryEvents[0]?.data.maxRetries).toBe(1)
expect(handle.agent.session.events.find(event => event.type === 'session/title')?.data.title).toBe('recover')
expect(messageText(handle.agent.session.deriveMessages().at(-1))).toBe('recovered by bundled policy')
await handle.dispose()
await ctx.fiber.dispose()
@@ -491,6 +517,7 @@ describe('dsh-agent-spine-demo bundle', () => {
toolOrder: ['zulu'],
tools: { mode: 'native' as const },
dshHome: '/tmp/dsh-home',
sessionTitle: { fallbackMaxWords: 3, fallbackMaxBytes: 24, maxTitleBytes: 60 },
workspaceContext: false as const,
skills: { enabled: false },
toolBash: { enableRunInBackground: false },
@@ -504,6 +531,7 @@ describe('dsh-agent-spine-demo bundle', () => {
toolOrder: appConfig.toolOrder,
tools: appConfig.tools,
dshHome: appConfig.dshHome,
sessionTitle: appConfig.sessionTitle,
workspaceContext: false,
skills: appConfig.skills,
toolBash: appConfig.toolBash,

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../core/system-prompt"
},
@@ -63,7 +66,7 @@
"path": "../../support/invariants"
},
{
"path": "../../util/home"
"path": "../../util/paths"
},
{
"path": "../../bash/tool-bash"

View File

@@ -15,6 +15,7 @@ The package mounts no console logger, interactive UI, user-interaction service,
| `toolOrder` | lexicographic | explicit model-facing tool order in `dsh-system-prompt` |
| `tools` | `{ mode: 'native' }` | tool-registry presentation config through `dsh-agent-spine-demo` |
| `dshHome` | `$DSH_HOME` or `~/.dsh` | Harness home exposed to model bash and used by local skill discovery |
| `sessionTitle` | spine example limits | Fallback title word/byte limits through `dsh-agent-spine-demo` |
| `skills` | owner defaults | skill registry, local provider, and model-facing skill tool |
| `toolBash` | owner defaults | model-facing bash config, including this producer's background opt-in |
| `toolTasks` | owner defaults | generic `task_output` wait bounds |

View File

@@ -37,6 +37,8 @@ export interface Config {
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
@@ -64,6 +66,7 @@ export const Config: z<Config> = z.object({
persistenceCompression: JsonlCompressionSchema,
persona: z.string(),
dshHome: z.string(),
sessionTitle: agentCore.SessionTitleConfigSchema,
skills: agentCore.SkillConfigSchema,
// Absent means lexicographic order; schemastery's native array default is [].
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),

View File

@@ -29,6 +29,7 @@ Swappable LLM, bash, filesystem, and other capability providers remain in the le
| `toolOrder` | lexicographic | Explicit model-facing tool order |
| `tools` | owner default | Tool presentation mode |
| `dshHome` | owner default | Harness home used by bash and skills |
| `sessionTitle` | spine example limits | Fallback title word/byte limits |
| `skills` | owner defaults | Skill registry, local provider, and tool config |
| `toolBash` | owner defaults | Model-facing bash tool config |
| `toolTasks` | owner defaults | Background-task control-tool config, or `false` |

View File

@@ -45,6 +45,8 @@ export interface Config {
tools?: ToolsConfig
/** DeepSeek Harness home directory exposed to bash and used for local skill discovery. */
dshHome?: string
/** Fallback session-title limits forwarded through agent-spine-demo. */
sessionTitle?: NonNullable<agentCore.Config['sessionTitle']>
/** Directory the JSONL session backend writes under. Defaults to `./.sessions`. */
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
@@ -79,6 +81,7 @@ export const Config: z<Config> = z.object({
toolOrder: z.array(z.string()).default(undefined as unknown as string[]),
tools: ToolRegistry.Config,
dshHome: z.string(),
sessionTitle: agentCore.SessionTitleConfigSchema,
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persistenceCompression: JsonlCompressionSchema,
welcome: z.string().default(DEFAULT_WELCOME),

View File

@@ -16,7 +16,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() })
- **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence.
- **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` streams it in chunks (cross-chunk decoding) so a huge file never has to be held whole in memory. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) decides which to call by size and owns the line windowing.
- **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`.
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`. The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, fsyncs, then renames over the target. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` creates a missing target and rejects an existing one (`FS_NOT_OBSERVED`); `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`).
- **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`).
The package-root SDK surface is the default/named `LocalFileSystem` class plus `Config`. Raw I/O lives in `src/fsio.ts` (Cordis-free, independently unit-tested); `src/index.ts` is the thin service wiring.

View File

@@ -32,6 +32,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"koffi": "^3.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -12,6 +12,7 @@ import type { BigIntStats, Dirent, Stats } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import { TextDecoder } from 'node:util'
import { FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
import { copyFileDaclWin32, replaceFileWin32 } from './win32.ts'
const BINARY_SAMPLE_BYTES = 8192
@@ -74,10 +75,16 @@ function versionOf(info: BigIntStats): FsVersion {
* file before it is renamed over the target.
*/
export interface FsIoInternals {
/** Override the host platform for native-publication unit coverage. */
platform?: NodeJS.Platform
/** Override the generated private staging-dir name (relative to the target dir). */
tempDirName?: (writePath: string) => string
/** Override the generated temp-file name (relative to the private staging dir). */
tempName?: (writePath: string) => string
/** Override the Win32 DACL copy boundary. */
copyFileDacl?: (source: string, destination: string) => Promise<void>
/** Override the Win32 security-preserving replacement boundary. */
replaceFile?: (replaced: string, replacement: string) => Promise<void>
/** Test hook after the temp file is written/synced but before final chmod+rename. */
inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise<void>
}
@@ -133,6 +140,7 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
// A path component is a file, not a directory (e.g. "afile/child.txt" where
// "afile" is a regular file): the target can neither exist nor be created,
// so surface the structured taxonomy instead of a raw Node ENOTDIR.
/* v8 ignore next -- Windows reports this case as ENOENT and repairs it in the ancestor walk below. */
if (isENOTDIR(error)) throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
/* v8 ignore next -- non-ENOENT realpath failure needs a permission/IO fault; ENOENT falls through to ancestor resolution. */
if (!isENOENT(error)) throw error
@@ -145,8 +153,22 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
while (true) {
try {
const realAncestor = await realpath(ancestor)
// On Windows, realpath of a regular file succeeds where POSIX returns
// ENOTDIR (the OS reports ENOENT for `regular-file/child`, not ENOTDIR).
// Stat the ancestor to restore the semantic distinction: a non-directory
// ancestor means the target passes through a file and can never be created.
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX reports ENOTDIR before this point. */
if (process.platform === 'win32') {
const parentInfo = await stat(realAncestor)
if (!parentInfo.isDirectory()) {
throw new FsError(`cannot resolve "${displayPath}": a parent path segment is not a directory`, 'FS_NOT_FOUND')
}
}
/* v8 ignore stop */
return { displayPath, targetKey: FsTargetKey(join(realAncestor, ...missing)) }
} catch (error: unknown) {
/* v8 ignore next -- native Windows coverage exercises the FsError raised by the repair above. */
if (error instanceof FsError) throw error
/* v8 ignore next -- a non-ENOENT realpath failure needs a permission/IO fault. */
if (!isENOENT(error)) throw error
const parent = dirname(ancestor)
@@ -160,7 +182,9 @@ export async function resolveLocalTarget(cwd: string, path: string): Promise<Loc
function pathType(info: Stats | BigIntStats): PathInfo['type'] {
if (info.isFile()) return 'file'
/* v8 ignore else -- Windows has no special-entry fixture for the non-directory branch. */
if (info.isDirectory()) return 'directory'
/* v8 ignore next -- the corresponding special-entry return is covered on POSIX. */
return 'other'
}
@@ -224,6 +248,7 @@ function listingIoError(displayPath: string, error: unknown): FsError {
if (error instanceof FsError) return error
/* v8 ignore next -- requires the listed target/parent to disappear between successful preflight and listing/child resolution. */
if (isENOENT(error) || isENOTDIR(error)) return new FsError(`cannot list "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
/* v8 ignore next -- Windows chmod does not deny directory listing; POSIX covers permission translation. */
if (isPermissionError(error)) return new FsError(`cannot list "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
return new FsError(`cannot list "${displayPath}": ${errorMessage(error)}`, 'FS_IO_ERROR', { cause: error })
}
@@ -394,9 +419,13 @@ async function removeStagingDirOrThrow(stagingDir: string, originalError: unknow
/**
* Atomically replace a file through a private, synced staging file in the same directory.
* POSIX protects the staging directory and file with `0o700` and `0o600`. A new Windows file
* inherits the destination directory's DACL; a replacement copies the existing target's DACL
* onto the empty temp before writing and preserves the target descriptor at publication.
* @param absolutePath - destination; missing parent directories are created.
* @param content - the full UTF-8 text to write.
* @param mode - final mode, or `0o600` when omitted.
* @param mode - existing destination's POSIX mode to preserve, or `undefined` for a new file;
* inert as a mode on Windows but identifies replacement security semantics.
* @param signal - cancellation checked before the final rename.
* @param internals - test seam for pinning temp names and observing the staged file.
*/
@@ -416,6 +445,9 @@ export async function writeFileAtomic(
const stagingDir = join(directory, stagingDirName)
const tempName = internals.tempName?.(absolutePath) ?? `${basename(absolutePath)}.tmp`
const tempPath = join(stagingDir, tempName)
const platform = internals.platform ?? process.platform
const copyFileDacl = internals.copyFileDacl ?? copyFileDaclWin32
const replaceFile = internals.replaceFile ?? replaceFileWin32
let handle: Awaited<ReturnType<typeof open>> | undefined
let stagingCreated = false
try {
@@ -425,6 +457,9 @@ export async function writeFileAtomic(
handle = await open(tempPath, 'wx', 0o600)
await handle.chmod(0o600)
if (platform === 'win32' && mode !== undefined) {
await copyFileDacl(absolutePath, tempPath)
}
await handle.writeFile(content, { encoding: 'utf8', ...signal ? { signal } : {} })
await handle.sync()
await internals.inspectTemp?.({ stagingDir, tempPath })
@@ -433,7 +468,18 @@ export async function writeFileAtomic(
handle = undefined
throwIfAborted(signal, 'write')
await rename(tempPath, absolutePath)
if (platform === 'win32' && mode !== undefined) {
try {
await replaceFile(absolutePath, tempPath)
} catch (error: unknown) {
// Preserve the old behavior when an external actor removes the observed target during
// staging: the temp already carries that target's protected DACL, so rename recreates it.
if (!isENOENT(error)) throw error
await rename(tempPath, absolutePath)
}
} else {
await rename(tempPath, absolutePath)
}
await rm(stagingDir, { recursive: true, force: true })
} catch (error: unknown) {
/* v8 ignore next -- abort-mid-write needs a writeFile/signal race; the non-abort (rename/open) side is tested. */

View File

@@ -0,0 +1,134 @@
/**
* Windows security-descriptor helpers for atomic local-file replacement. Koffi loads lazily so
* non-Windows processes never open Win32 libraries.
* @module @deepseek-ai/dsh-fs-local/win32
*/
import { toNamespacedPath } from 'node:path'
type GetFileSecurityW = (
path: string,
requestedInformation: number,
descriptor: Buffer | null,
length: number,
needed: [number],
) => number
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
type ReplaceFileW = (
replaced: string,
replacement: string,
backup: null,
flags: number,
exclude: null,
reserved: null,
) => number
type GetLastError = () => number
interface Win32Bindings {
getFileSecurityW: GetFileSecurityW
setFileSecurityW: SetFileSecurityW
replaceFileW: ReplaceFileW
getLastError: GetLastError
}
interface Win32ErrnoException extends NodeJS.ErrnoException {
win32Code: number
}
const DACL_SECURITY_INFORMATION = 0x00000004
const PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
let bindings: Win32Bindings | undefined
async function win32(): Promise<Win32Bindings> {
if (bindings !== undefined) return bindings
const koffi = (await import('koffi')).default
const advapi32 = koffi.load('advapi32.dll')
const kernel32 = koffi.load('kernel32.dll')
bindings = {
getFileSecurityW: advapi32.func('int __stdcall GetFileSecurityW(const char16_t *path, uint32_t requested, void *descriptor, uint32_t length, _Out_ uint32_t *needed)') as GetFileSecurityW,
setFileSecurityW: advapi32.func('int __stdcall SetFileSecurityW(const char16_t *path, uint32_t information, const void *descriptor)') as SetFileSecurityW,
replaceFileW: kernel32.func('int __stdcall ReplaceFileW(const char16_t *replaced, const char16_t *replacement, const char16_t *backup, uint32_t flags, void *exclude, void *reserved)') as ReplaceFileW,
getLastError: kernel32.func('uint32_t __stdcall GetLastError()') as GetLastError,
}
return bindings
}
function errnoCode(win32Code: number): string {
switch (win32Code) {
case ERROR_FILE_NOT_FOUND:
case ERROR_PATH_NOT_FOUND:
return 'ENOENT'
case ERROR_ACCESS_DENIED:
return 'EACCES'
default:
return 'EIO'
}
}
function win32Error(syscall: string, win32Code: number, path: string): Win32ErrnoException {
const code = errnoCode(win32Code)
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path}`) as Win32ErrnoException
error.code = code
error.errno = win32Code
error.syscall = syscall
error.path = path
error.win32Code = win32Code
return error
}
/**
* Read a file's self-relative DACL security descriptor.
* @param path - existing file whose DACL is read.
* @returns a descriptor buffer accepted by `SetFileSecurityW`.
*/
export async function readFileDaclWin32(path: string): Promise<Buffer> {
const api = await win32()
const nativePath = toNamespacedPath(path)
const needed: [number] = [0]
api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, null, 0, needed)
if (needed[0] === 0) throw win32Error('GetFileSecurityW', api.getLastError(), path)
const descriptor = Buffer.alloc(needed[0])
if (api.getFileSecurityW(nativePath, DACL_SECURITY_INFORMATION, descriptor, descriptor.length, needed) === 0) {
throw win32Error('GetFileSecurityW', api.getLastError(), path)
}
return descriptor.subarray(0, needed[0])
}
/**
* Copy an existing file's DACL onto another file and protect it from staging-parent inheritance.
* The destination must still be empty when confidentiality depends on this call.
* @param source - existing file whose DACL is copied.
* @param destination - existing file that receives the protected DACL.
*/
export async function copyFileDaclWin32(source: string, destination: string): Promise<void> {
const descriptor = await readFileDaclWin32(source)
const api = await win32()
const information = (DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION) >>> 0
if (api.setFileSecurityW(toNamespacedPath(destination), information, descriptor) === 0) {
throw win32Error('SetFileSecurityW', api.getLastError(), destination)
}
}
/**
* Replace a Windows file while preserving the replaced file's ACL and other replace metadata.
* @param replaced - existing destination file.
* @param replacement - closed staging file on the same volume.
*/
export async function replaceFileWin32(replaced: string, replacement: string): Promise<void> {
const api = await win32()
if (api.replaceFileW(
toNamespacedPath(replaced),
toNamespacedPath(replacement),
null,
0,
null,
null,
) === 0) {
throw win32Error('ReplaceFileW', api.getLastError(), replaced)
}
}

View File

@@ -6,7 +6,7 @@
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { chmod, mkdtemp, readFile, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { chmod, mkdtemp, readFile, rename, rm, stat, symlink, unlink, writeFile, mkdir, readdir, realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createServer } from 'node:net'
@@ -23,6 +23,7 @@ import {
writeFileAtomic,
} from '../src/fsio.ts'
import type { LocalTarget } from '../src/fsio.ts'
import { copyFileDaclWin32, readFileDaclWin32 } from '../src/win32.ts'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
let dir: string
@@ -367,24 +368,135 @@ describe('streamWholeText', () => {
})
})
// Windows drives only the read-only attribute through `chmod` and reports synthetic `stat` mode
// bits, so mode assertions are POSIX-only; native DACL preservation is asserted separately.
const posixModes = process.platform !== 'win32'
function daclAcePolicy(descriptor: Buffer): string[] {
const daclOffset = descriptor.readUInt32LE(16)
if (daclOffset === 0) return []
const aceCount = descriptor.readUInt16LE(daclOffset + 4)
const policy: string[] = []
const seen = new Set<string>()
let offset = daclOffset + 8
for (let index = 0; index < aceCount; index++) {
const size = descriptor.readUInt16LE(offset + 2)
const ace = Buffer.from(descriptor.subarray(offset, offset + size))
// INHERITED_ACE records provenance, not the entry's access policy.
ace.writeUInt8(ace.readUInt8(1) & ~0x10, 1)
const key = ace.toString('hex')
if (!seen.has(key)) {
seen.add(key)
policy.push(key)
}
offset += size
}
return policy
}
describe('writeFileAtomic — temp-file safety', () => {
it('writes through a private staging dir and owner-only temp file', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
if (posixModes) await chmod(file, 0o640)
let inspected = false
await writeFileAtomic(file, 'hello', 0o640, undefined, {
inspectTemp: async ({ stagingDir, tempPath }) => {
inspected = true
expect((await stat(stagingDir)).mode & 0o777).toBe(0o700)
expect((await stat(tempPath)).mode & 0o777).toBe(0o600)
const [staging, temp] = await Promise.all([stat(stagingDir), stat(tempPath)])
expect(staging.isDirectory()).toBe(true)
expect(temp.isFile()).toBe(true)
if (posixModes) {
expect(staging.mode & 0o777).toBe(0o700)
expect(temp.mode & 0o777).toBe(0o600)
}
},
})
expect(inspected).toBe(true)
expect(await readFile(file, 'utf8')).toBe('hello')
expect((await stat(file)).mode & 0o777).toBe(0o640)
if (posixModes) expect((await stat(file)).mode & 0o777).toBe(0o640)
expect((await readdir(dir)).filter(n => n.includes('.tmp'))).toEqual([])
})
it('creates new files owner-only by default', async () => {
it.skipIf(process.platform !== 'win32')('protects staged content with the existing target DACL and preserves it after replacement', async () => {
const file = join(dir, 'protected.txt')
await writeFile(file, 'old')
await copyFileDaclWin32(file, file)
const expectedDacl = await readFileDaclWin32(file)
await writeFileAtomic(file, 'new', (await stat(file)).mode, undefined, {
inspectTemp: async ({ tempPath }) => {
expect(await readFileDaclWin32(tempPath)).toEqual(expectedDacl)
},
})
expect(await readFile(file, 'utf8')).toBe('new')
expect(daclAcePolicy(await readFileDaclWin32(file))).toEqual(daclAcePolicy(expectedDacl))
})
it('copies a Windows target DACL before content and publishes through secure replacement', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const calls: string[] = []
await writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: async (source, temp) => {
calls.push(`copy:${source}`)
expect(await readFile(temp, 'utf8')).toBe('')
},
replaceFile: async (target, temp) => {
calls.push(`replace:${target}`)
await rename(temp, target)
},
})
expect(calls).toEqual([`copy:${file}`, `replace:${file}`])
expect(await readFile(file, 'utf8')).toBe('new')
})
it('creates a new Windows file through directory inheritance without replacement calls', async () => {
const file = join(dir, 'new.txt')
const unexpected = async (): Promise<void> => { throw new Error('unexpected native replacement call') }
await writeFileAtomic(file, 'new', undefined, undefined, {
platform: 'win32',
copyFileDacl: unexpected,
replaceFile: unexpected,
})
expect(await readFile(file, 'utf8')).toBe('new')
})
it('recreates a vanished Windows target with the already-protected temp', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const missing = Object.assign(new Error('target vanished'), { code: 'ENOENT' })
await writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: () => Promise.resolve(),
replaceFile: async () => { throw missing },
})
expect(await readFile(file, 'utf8')).toBe('new')
})
it('surfaces a Windows secure-replacement failure and cleans the staging directory', async () => {
const file = join(dir, 'a.txt')
await writeFile(file, 'old')
const denied = Object.assign(new Error('replace denied'), { code: 'EACCES' })
await expect(writeFileAtomic(file, 'new', 0o666, undefined, {
platform: 'win32',
copyFileDacl: () => Promise.resolve(),
replaceFile: async () => { throw denied },
})).rejects.toBe(denied)
expect(await readFile(file, 'utf8')).toBe('old')
expect((await readdir(dir)).filter(name => name.includes('.tmp'))).toEqual([])
})
it.skipIf(!posixModes)('creates new files owner-only by default', async () => {
const file = join(dir, 'a.txt')
await writeFileAtomic(file, 'hello', undefined, undefined)
expect((await stat(file)).mode & 0o777).toBe(0o600)

View File

@@ -0,0 +1,146 @@
/** Host-independent binding tests for the Win32 DACL and replacement helpers. */
import { toNamespacedPath } from 'node:path'
import { afterEach, describe, expect, it, vi } from 'vitest'
type GetFileSecurityW = (
path: string,
requestedInformation: number,
descriptor: Buffer | null,
length: number,
needed: [number],
) => number
type SetFileSecurityW = (path: string, securityInformation: number, descriptor: Buffer) => number
type ReplaceFileW = (
replaced: string,
replacement: string,
backup: null,
flags: number,
exclude: null,
reserved: null,
) => number
interface NativeMock {
getFileSecurityW: GetFileSecurityW
setFileSecurityW: SetFileSecurityW
replaceFileW: ReplaceFileW
getLastError: () => number
}
async function importWithNative(native: NativeMock): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => ({
default: {
load: () => ({
func: (definition: string) => {
if (definition.includes('GetFileSecurityW')) return native.getFileSecurityW
if (definition.includes('SetFileSecurityW')) return native.setFileSecurityW
if (definition.includes('ReplaceFileW')) return native.replaceFileW
if (definition.includes('GetLastError')) return native.getLastError
throw new Error(`unexpected native function: ${definition}`)
},
}),
},
}))
return import('../src/win32.ts')
}
function successfulNative(descriptor: Buffer): NativeMock & { installed: Buffer[]; replacements: string[][] } {
let lastError = 0
const installed: Buffer[] = []
const replacements: string[][] = []
return {
installed,
replacements,
getLastError: () => lastError,
getFileSecurityW: (_path, _requested, output, _length, needed) => {
needed[0] = descriptor.length
if (output === null) {
lastError = 122
return 0
}
descriptor.copy(output)
lastError = 0
return 1
},
setFileSecurityW: (_path, information, value) => {
expect(information).toBe(0x80000004)
installed.push(Buffer.from(value))
lastError = 0
return 1
},
replaceFileW: (replaced, replacement, backup, flags, exclude, reserved) => {
expect([backup, flags, exclude, reserved]).toEqual([null, 0, null, null])
replacements.push([replaced, replacement])
lastError = 0
return 1
},
}
}
afterEach(() => {
vi.doUnmock('koffi')
vi.resetModules()
})
describe('Windows file-security helpers', () => {
it('reads and installs a protected DACL before replacing the destination', async () => {
const descriptor = Buffer.from([1, 2, 3, 4])
const native = successfulNative(descriptor)
const { copyFileDaclWin32, readFileDaclWin32, replaceFileWin32 } = await importWithNative(native)
expect(await readFileDaclWin32('source')).toEqual(descriptor)
await copyFileDaclWin32('source', 'temp')
expect(native.installed).toEqual([descriptor])
await replaceFileWin32('target', 'temp')
expect(native.replacements).toEqual([[toNamespacedPath('target'), toNamespacedPath('temp')]])
})
it('maps descriptor-size probe failures to Node-style codes', async () => {
const cases = [[2, 'ENOENT'], [3, 'ENOENT'], [5, 'EACCES'], [9999, 'EIO']] as const
for (const [win32Code, code] of cases) {
const native = successfulNative(Buffer.from([1]))
native.getFileSecurityW = (_path, _requested, _output, _length, needed) => {
needed[0] = 0
return 0
}
native.getLastError = () => win32Code
const { readFileDaclWin32 } = await importWithNative(native)
await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code, win32Code, path: 'source' })
}
})
it('surfaces a descriptor read failure after the size probe', async () => {
const native = successfulNative(Buffer.from([1, 2]))
native.getFileSecurityW = (_path, _requested, _output, _length, needed) => {
needed[0] = 2
return 0
}
native.getLastError = () => 5
const { readFileDaclWin32 } = await importWithNative(native)
await expect(readFileDaclWin32('source')).rejects.toMatchObject({ code: 'EACCES', syscall: 'GetFileSecurityW' })
})
it('surfaces DACL installation and replacement failures', async () => {
const setFailure = successfulNative(Buffer.from([1]))
setFailure.setFileSecurityW = () => 0
setFailure.getLastError = () => 5
const setModule = await importWithNative(setFailure)
await expect(setModule.copyFileDaclWin32('source', 'temp')).rejects.toMatchObject({
code: 'EACCES',
syscall: 'SetFileSecurityW',
path: 'temp',
})
const replaceFailure = successfulNative(Buffer.from([1]))
replaceFailure.replaceFileW = () => 0
replaceFailure.getLastError = () => 2
const replaceModule = await importWithNative(replaceFailure)
await expect(replaceModule.replaceFileWin32('target', 'temp')).rejects.toMatchObject({
code: 'ENOENT',
syscall: 'ReplaceFileW',
path: 'target',
})
})
})

View File

@@ -9,14 +9,14 @@ Loading it INSTEAD OF `dsh-fs-local`, together with a [`ctx.sandboxPolicy`](../.
The per-call mode is the tool-stamped effective mode (session override or escalation grant), falling back to the deployment default:
- `read-only` — denies every mutation with the structured `FS_SANDBOX_DENIED`.
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
- `workspace-write` — allows a mutation only when the target canonicalizes under a writable root: the workspace root plus the platform temp areas (`/tmp`, `os.tmpdir()`), the SAME set the Seatbelt profile grants, derived from the one [`writableRoots`](../../sandbox/README.md) function so the fs fence and the bash runner cannot drift. Canonical spellings use a lexical fast path; an identity-based ancestor fallback recognizes alias-equivalent roots such as Windows long names and 8.3 names without treating unrelated prefixes as contained. The target is re-canonicalized immediately before delegating, so an ancestor symlink swapped since the tool resolved it is caught.
- `danger-full-access` — delegates unfenced.
## Threat model: a policy fence, not a kernel boundary
The fence is a check in TRUSTED code over a MODEL-CONTROLLED path — the operations are the seam's own (open, rename), only the target path is untrusted, so canonicalize-then-contain is the complete answer to this surface. This mirrors the `code-runtime` stance: containment, not a security boundary. Kernel-grade isolation of untrusted CODE stays `ctx.bash`'s job ([`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)). The residual TOCTOU (an ancestor symlink swapped between the containment re-check and the syscall) is narrowed by re-canonicalizing immediately before the write and is accepted for this threat model; a kernel-tight boundary needs `openat2`-class primitives not worth their portability cost here.
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox RFC](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
A denial is a structured `FsError` (`FS_SANDBOX_DENIED`, carrying the effective mode) — no stderr text inference (unlike bash's kernel denials), because an in-process fence knows exactly what it refused. The model-facing `[sandbox: file access denied under <mode> mode]` marker and the one-approved-wider retry live in the tool layer (`dsh-tool-fs`), exactly as bash's do. See [the cross-family fs sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md).
## Model Experience

View File

@@ -0,0 +1,76 @@
/**
* Path-containment mechanics for the filesystem sandbox. Canonical spellings
* take the fast lexical path; filesystem identity supplies the conservative
* fallback for alias-equivalent roots such as Windows 8.3 names and casing.
* @module @deepseek-ai/dsh-fs-sandbox/containment
*/
import type { BigIntStats } from 'node:fs'
import { stat } from 'node:fs/promises'
import { dirname, sep } from 'node:path'
const MISSING_CODES: ReadonlySet<NodeJS.ErrnoException['code']> = new Set(['ENOENT', 'ENOTDIR'])
function isMissing(error: unknown): boolean {
const code = (error as NodeJS.ErrnoException).code
return MISSING_CODES.has(code)
}
function comparablePath(path: string, caseSensitive: boolean): string {
return caseSensitive ? path : path.toLowerCase()
}
function isLexicallyUnder(path: string, root: string, caseSensitive: boolean): boolean {
const comparableTarget = comparablePath(path, caseSensitive)
const comparableRoot = comparablePath(root, caseSensitive)
if (comparableTarget === comparableRoot) return true
const prefix = comparableRoot.endsWith(sep) ? comparableRoot : comparableRoot + sep
return comparableTarget.startsWith(prefix)
}
async function statIfPresent(path: string): Promise<BigIntStats | undefined> {
try {
return await stat(path, { bigint: true })
} catch (error: unknown) {
/* v8 ignore else -- a non-missing stat failure requires a host permission or I/O fault after resolve reached this ancestor. */
if (isMissing(error)) return undefined
/* v8 ignore next -- requires a host permission or I/O fault after resolve already reached this ancestor. */
throw error
}
}
function sameIdentity(left: BigIntStats, right: BigIntStats): boolean {
return left.dev === right.dev && left.ino === right.ino
}
/**
* Determine whether a canonical target is a writable root or lies beneath it.
* The lexical fast path handles normal canonical spellings. When spellings
* differ, walk the target's existing ancestors and compare filesystem identity
* with the root; this recognizes Windows long-name/8.3 aliases and casing
* without weakening containment to a textual approximation.
* @param path - canonical target key, which may end in a missing suffix.
* @param root - canonical writable root.
* @param caseSensitive - whether lexical comparison preserves case; defaults
* to the host filesystem convention used by supported platforms.
* @returns whether the target is the root or a descendant of it.
*/
export async function isPathUnder(
path: string,
root: string,
caseSensitive = process.platform !== 'win32',
): Promise<boolean> {
if (isLexicallyUnder(path, root, caseSensitive)) return true
const rootInfo = await statIfPresent(root)
if (!rootInfo) return false
let ancestor = path
while (true) {
const ancestorInfo = await statIfPresent(ancestor)
if (ancestorInfo && sameIdentity(ancestorInfo, rootInfo)) return true
const parent = dirname(ancestor)
if (parent === ancestor) return false
ancestor = parent
}
}

View File

@@ -30,7 +30,6 @@
* @module @deepseek-ai/dsh-fs-sandbox
*/
import { sep } from 'node:path'
import { Context } from 'cordis'
import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local'
import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local'
@@ -39,6 +38,7 @@ import type { FsEditOutcome, FsEditRequest, FsTarget, FsVersion, FsWriteIntent,
import { writableRoots } from '@deepseek-ai/dsh-sandbox'
import type { SandboxMode } from '@deepseek-ai/dsh-sandbox'
import type {} from '@deepseek-ai/dsh-sandbox-policy'
import { isPathUnder } from './containment.ts'
/**
* Plugin config: the local backend's knobs, verbatim (only `cwd`, the resolve
@@ -48,13 +48,6 @@ import type {} from '@deepseek-ai/dsh-sandbox-policy'
*/
export type Config = LocalConfig
/** Whether `path` is `root` itself or lies beneath it (both already canonical). */
function isUnder(path: string, root: string): boolean {
if (path === root) return true
const prefix = root.endsWith(sep) ? root : root + sep
return path.startsWith(prefix)
}
/**
* Sandbox-enforcing filesystem backend. Registers as `ctx.fs` (loading it
* INSTEAD OF `dsh-fs-local`, together with a `ctx.sandboxPolicy`, is the whole
@@ -147,7 +140,14 @@ export class SandboxedFileSystem extends LocalFileSystem {
// symlink ancestor swapped since the tool resolved this target), and the
// mutation delegates with THIS fresh target — never the stale one.
const fresh = await this.resolve(target.displayPath)
if (!this.writableRoots.some(root => isUnder(fresh.targetKey, root))) {
let contained = false
for (const root of this.writableRoots) {
if (await isPathUnder(fresh.targetKey, root)) {
contained = true
break
}
}
if (!contained) {
throw new FsError(`cannot write "${target.displayPath}": file access denied under workspace-write mode`, 'FS_SANDBOX_DENIED')
}
return fresh

View File

@@ -0,0 +1,57 @@
/**
* Containment tests for lexical canonical paths and filesystem-identity aliases.
*/
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, parse } from 'node:path'
import { isPathUnder } from '../src/containment.ts'
let base: string
beforeEach(async () => {
base = await mkdtemp(join(tmpdir(), 'dsh-fssbx-containment-'))
})
afterEach(async () => {
await rm(base, { recursive: true, force: true })
})
describe('filesystem sandbox containment', () => {
it('accepts equal paths, descendants, and a filesystem-root boundary', async () => {
expect(await isPathUnder(base, base)).toBe(true)
expect(await isPathUnder(join(base, 'child'), base)).toBe(true)
expect(await isPathUnder(base, parse(base).root)).toBe(true)
})
it('uses case-insensitive lexical comparison for Windows-style containment', async () => {
expect(await isPathUnder(join(base.toUpperCase(), 'child'), base.toLowerCase(), false)).toBe(true)
expect(await isPathUnder(join(base, 'case-sensitive-child'), base, true)).toBe(true)
})
it('recognizes an alias-equivalent root by filesystem identity for a missing target', async () => {
const realRoot = join(base, 'real')
const aliasRoot = join(base, 'alias')
await mkdir(realRoot)
await symlink(realRoot, aliasRoot)
expect(await isPathUnder(join(await realpath(realRoot), 'missing', 'file.txt'), aliasRoot)).toBe(true)
})
it('denies unrelated and missing roots', async () => {
const allowed = join(base, 'allowed')
const outside = join(base, 'outside')
await mkdir(allowed)
await mkdir(outside)
expect(await isPathUnder(join(outside, 'file.txt'), allowed)).toBe(false)
expect(await isPathUnder(join(outside, 'file.txt'), join(base, 'missing-root'))).toBe(false)
})
it('treats a regular-file path segment as a missing target, not containment', async () => {
const allowed = join(base, 'allowed')
const blocker = join(base, 'blocker')
await mkdir(allowed)
await writeFile(blocker, 'not a directory')
expect(await isPathUnder(join(blocker, 'child.txt'), allowed)).toBe(false)
})
})

View File

@@ -12,7 +12,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
import { existsSync } from 'node:fs'
import { homedir, tmpdir } from 'node:os'
import { join } from 'node:path'
import { join, parse } from 'node:path'
import { Context } from 'cordis'
import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs'
import type { FsTarget } from '@deepseek-ai/dsh-fs'
@@ -167,16 +167,15 @@ describe('workspace-write containment', () => {
})
describe('workspace-write with the filesystem root as the workspace (a root ending in the path separator)', () => {
it('grants writes anywhere: containment against `/` allows any absolute path', async () => {
// A degenerate but valid config — workspaceRoot '/'. It exercises isUnder's
// separator-suffixed-root branch: `/` already ends in the separator, so the
// prefix stays `/` and every absolute path is contained.
it('grants writes anywhere on that volume', async () => {
// A degenerate but valid config: the filesystem root containing the target.
// It exercises the separator-suffixed-root branch on POSIX and Windows.
const rootCtx = new Context()
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: '/' })
await rootCtx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: parse(base).root })
const rootFiber = await rootCtx.plugin(SandboxedFileSystem, { cwd: workspace })
const rootFs = rootCtx.fs as SandboxedFileSystem
try {
const path = join(base, 'anywhere.txt') // under HOME, outside /tmp — allowed only via the `/` root
const path = join(base, 'anywhere.txt') // under HOME, outside temp — allowed only via the filesystem root
await rootFs.writeText(await rootFs.resolve(path), 'anywhere')
expect(await readFile(path, 'utf8')).toBe('anywhere')
} finally {

View File

@@ -12,6 +12,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { join } from 'node:path'
import { CallId } from '@deepseek-ai/dsh-llm'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools'
@@ -496,7 +497,7 @@ describe('glob results', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult('/sessions/s1/src/a.ts\n/elsewhere/b.ts\nrel/c.ts\n')
const result = await call(ctx, 'glob', { pattern: '*' }, { agent: agent('/sessions/s1') })
expect(text(result)).toBe('src/a.ts\n/elsewhere/b.ts\nrel/c.ts')
expect(text(result)).toBe(`${join('src', 'a.ts')}\n/elsewhere/b.ts\nrel/c.ts`)
})
it('validates arguments (blank pattern, blank path)', async () => {
@@ -578,7 +579,7 @@ describe('grep results', () => {
const { ctx, bash } = await setup()
bash.handler = () => runResult(`${matchLine('/sessions/s1/deep/a.ts', 2, 'hit')}\n`)
const result = await call(ctx, 'grep', { pattern: 'hit', path: '/sessions/s1' }, { agent: agent('/sessions/s1') })
expect(text(result)).toContain('deep/a.ts\nLine 2: hit')
expect(text(result)).toContain(`${join('deep', 'a.ts')}\nLine 2: hit`)
})
it('previews a long matched line at grepMaxLineBytes preserving UTF-8', async () => {
@@ -688,7 +689,7 @@ describe('presentation', () => {
describe('helpers', () => {
it('toWorkdirRelative maps inside-workdir absolutes and passes everything else through', () => {
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe('a/b.ts')
expect(toWorkdirRelative('/w/a/b.ts', '/w')).toBe(join('a', 'b.ts'))
expect(toWorkdirRelative('/w', '/w')).toBe('.')
expect(toWorkdirRelative('/other/b.ts', '/w')).toBe('/other/b.ts')
expect(toWorkdirRelative('/w-sibling/b.ts', '/w')).toBe('/w-sibling/b.ts')

View File

@@ -21,7 +21,7 @@ The plugin has no tunable configuration. `maxGoalRounds` belongs to the goal def
When an exact live agent is idle with an active, armed goal and remaining capacity, the driver first checkpoints pending goal mutations, then reserves `roundsStarted + 1` for the current `{ goalId, revision }`. It queues one `<goal_round>` prompt with `GoalMessageSource`. Admission through `agent/prompt-submit` verifies the complete queued record and current goal both before and after downstream prompt hooks; only the accepted `user/message` increments `roundsStarted`. A reservation rejected as stale does not consume the round number.
One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle.
One goal round owns one ordinary session turn, and that turn may contain several model/tool steps. The driver pairs a reservation only with a `message` turn carrying its exact `GoalMessageSource`; merge-extensible plugin turn triggers do not admit or replace that reservation. Human messages remain ordinary turns and do not consume the goal cap. If human work enters the inbox before a reservation or joins its pending batch, automatic work yields until that work settles; a pending automatic prompt in a mixed batch is rejected and re-reserved only after the agent becomes idle.
The retained prompt names the JSON-quoted objective and `round/maxGoalRounds`, treats the current workspace, tool results, and durable session state as authoritative, requires evidence before completion, and tells the model to leave the goal active when work remains. Quoting preserves multiline or tag-like objective text as data. Goal lifecycle mutations still require the independent authority checks in `dsh-tool-goal`.

View File

@@ -345,11 +345,17 @@ export function apply(ctx: Context): void {
switch (event.type) {
case 'turn/start':
state.openTurn = event.data.turn
if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source)
&& sameRound(event.data.trigger.source, state.attempt)) {
state.attempt.turn = event.data.turn
switch (event.data.trigger.kind) {
case 'message':
if (state.attempt !== undefined && isGoalRoundSource(event.data.trigger.source)
&& sameRound(event.data.trigger.source, state.attempt)) {
state.attempt.turn = event.data.turn
}
return
default:
// Injection and merge-extensible plugin triggers cannot admit a queued goal message.
return
}
return
case 'user/message':
if (state.attempt !== undefined && isGoalRoundSource(event.data.source)
&& sameRound(event.data.source, state.attempt)) {

View File

@@ -12,6 +12,13 @@ import { SessionId } from '@deepseek-ai/dsh-session'
import type { TurnEndReason } from '@deepseek-ai/dsh-session'
import * as goalSession from '../src/index.ts'
declare module '@deepseek-ai/dsh-session' {
interface TurnTriggerMap {
/** Test-only plugin turn with no message source. */
'test-metadata': { kind: 'test-metadata' }
}
}
type ScriptEntry = StreamChunk[] | Error | 'hang' | ((options: GenerateOptions) => StreamChunk[])
/** Small request-recording adapter with controllable failure and cancellation. */
@@ -322,6 +329,31 @@ describe('same-session goal driving', () => {
expect(requestText(test.adapter.requests[1]!)).toContain('<goal_round>')
})
it('ignores plugin-owned turn triggers while a goal round is queued', async () => {
const test = await harness([textResponse('goal answer')])
const warnings: string[] = []
test.ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof test.ctx.logger.warn
let inserted = false
test.ctx.on('agent/queued', (agent, _content, info) => {
if (agent !== test.agent || info.source.kind !== 'goal' || inserted) return
inserted = true
const lastStart = agent.session.events.findLast(event => event.type === 'turn/start')
const turn = (lastStart?.data.turn ?? 0) + 1
agent.session.append('turn/start', {
turn,
trigger: { kind: 'test-metadata' },
})
agent.session.append('turn/end', { turn, reason: { kind: 'completed' } })
})
test.ctx.goals.create(test.agent, { objective: 'ignore metadata', maxGoalRounds: 1 })
await waitForGoal(test.ctx, test.agent, goal => goal?.phase === 'blocked')
expect(inserted).toBe(true)
expect(test.adapter.requests).toHaveLength(1)
expect(warnings.some(warning => warning.includes('session/event listener threw'))).toBe(false)
})
it('makes a reserved round stale when a listener queues human work behind it', async () => {
const test = await harness([textResponse('human batch'), textResponse('later goal')])
let inserted = false
@@ -436,8 +468,10 @@ describe('same-session goal driving', () => {
expect(goal?.phase).toBe('active')
expect(test.adapter.requests).toHaveLength(1)
const turns = test.agent.session.events.filter(event => event.type === 'turn/start')
const goalTurn = turns.findIndex(event => event.data.trigger.source.kind === 'goal')
const injectedTurn = turns.findIndex(event => event.data.trigger.source.kind === 'plugin')
const goalTurn = turns.findIndex(event => event.data.trigger.kind === 'message'
&& event.data.trigger.source.kind === 'goal')
const injectedTurn = turns.findIndex(event => event.data.trigger.kind === 'injection'
&& event.data.trigger.source.kind === 'plugin')
expect(injectedTurn).toBeGreaterThan(goalTurn)
})

View File

@@ -39,7 +39,7 @@ Streaming is a raw chunk protocol (`block-start`, `text-delta`, `reasoning-delta
### Call configuration (`call-config.ts`)
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite).
`LlmCallConfig` is the provider + model + sampling scalars of one conversation's requests (`provider`, `model`, `temperature`, `maxTokens`, `stop` — each mapping 1:1 onto the same-named `GenerateOptions` field). It is per-conversation state recorded in the session log as part of the request header (see the dsh-session `request/header` events), never a silently-adjustable per-call knob: the `agent/request` waterfall proposes a replacement and the loop logs a real change. `callConfigEquals(a, b)` is the field-wise real-change detector; `deepFreeze(value)` is the ownership helper the loop applies to every built request before dispatch (`llm/stream` listeners and adapters read, never rewrite). `markAgentLoopRequest()` gives that exact object process-local loop provenance, and `isAgentLoopRequest()` lets observers distinguish it from independently logged auxiliary calls that may also be frozen and session-associated.
### App attribution (`attribution.ts`)

View File

@@ -6,6 +6,11 @@
* @module dsh-llm/call-config
*/
import type { GenerateOptions } from './types.ts'
/** Process-local identities of request objects assembled by dsh-agent-loop. */
const AGENT_LOOP_REQUESTS = new WeakSet<GenerateOptions>()
/**
* Provider + model + sampling scalars of one conversation's requests. Every field maps
* 1:1 onto the same-named `GenerateOptions` field; the loop builds requests
@@ -33,6 +38,25 @@ export function callConfigEquals(a: LlmCallConfig, b: LlmCallConfig): boolean {
return a.stop.length === b.stop.length && a.stop.every((s, i) => s === b.stop?.[i])
}
/**
* Mark one exact request object as assembled by dsh-agent-loop.
* @param request - loop-owned request envelope before LLM dispatch.
* @returns the same request object with process-local loop provenance.
*/
export function markAgentLoopRequest<T extends GenerateOptions>(request: T): T {
AGENT_LOOP_REQUESTS.add(request)
return request
}
/**
* Test whether the exact request object was assembled by dsh-agent-loop.
* @param request - request envelope observed at the LLM waterfall.
* @returns whether {@link markAgentLoopRequest} recorded this object.
*/
export function isAgentLoopRequest(request: GenerateOptions): boolean {
return AGENT_LOOP_REQUESTS.has(request)
}
/**
* Deep-freeze a value in place, guarding cycles, so later mutation throws.
* {@link AbortSignal} objects are deliberately skipped because they are the

View File

@@ -28,7 +28,7 @@ export * from './never.ts'
export * from './error.ts'
export * from './types.ts'
export { BlockAssembler } from './assembler.ts'
export { callConfigEquals, deepFreeze } from './call-config.ts'
export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
export type { LlmCallConfig } from './call-config.ts'
export { isLlmAdapterFailure, llmFailureOf } from './adapter-failure.ts'
@@ -42,11 +42,11 @@ declare module 'cordis' {
* Waterfall around every streaming model call (retry, replay, routing).
* Bound to the {@link LlmService}; call `next()` to reach the resolved
* adapter's stream, or yield your own chunks to short-circuit.
* @param options - the full request. A LOOP-built request arrives
* deep-frozen (mutation throws): its content is a pure function of the
* session log (the reconstructability Agent Note), so listeners read it, never
* rewrite it. A hand-built one-shot (compaction summarize) is the
* caller's own object and stays mutable here.
* @param options - the full request. A LOOP-built request carries the
* process-local {@link markAgentLoopRequest} identity and arrives deep-frozen
* (mutation throws): its content is a pure function of the session log (the
* reconstructability Agent Note), so listeners read it, never rewrite it.
* Hand-built calls own their mutability policy and do not carry that marker.
* @mode waterfall
*/
'llm/stream'(this: LlmService, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>

View File

@@ -5,7 +5,8 @@
*/
import { describe, expect, it } from 'vitest'
import { callConfigEquals, deepFreeze } from '../src/call-config.ts'
import { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from '../src/call-config.ts'
import type { GenerateOptions } from '../src/types.ts'
describe('callConfigEquals', () => {
it('compares every field, including the stop list element-wise', () => {
@@ -56,3 +57,19 @@ describe('deepFreeze', () => {
expect(Object.isFrozen(cyclic)).toBe(true)
})
})
describe('agent-loop request identity', () => {
it('marks only the exact request object and preserves its identity', () => {
const request: GenerateOptions = {
provider: 'mock',
model: 'model',
messages: [],
}
const copy = { ...request }
expect(isAgentLoopRequest(request)).toBe(false)
expect(markAgentLoopRequest(request)).toBe(request)
expect(isAgentLoopRequest(request)).toBe(true)
expect(isAgentLoopRequest(copy)).toBe(false)
})
})

View File

@@ -325,13 +325,19 @@ describe('probeTimeoutMs config', () => {
})
it('bounds the default probes: a launcher slower than the configured timeout reads as unusable', async () => {
// The same sleeping launcher passes under the default 5000ms budget and
// fails under a 250ms one — the config demonstrably reaches spawnSync.
// The same 1s launcher reads usable under a generous budget and unusable
// under a 250ms one — the config demonstrably reaches spawnSync. Both bounds
// keep a wide margin from the launcher's 1s runtime so a loaded host (where
// spawnSync blocks the worker and fork/exec latency inflates wall-clock)
// cannot flip either verdict; the vitest timeout clears the patient budget.
const dir = mkdtempSync(join(tmpdir(), 'dsh-slow-landlock-'))
const launcher = join(dir, 'landlock-run')
writeFileSync(launcher, '#!/bin/sh\nsleep 1\necho "landlock: fully enforced"\nexit 0\n', { mode: 0o755 })
const patient = await setup({}, { platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher })
const patient = await setup(
{ probeTimeoutMs: 15_000 },
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
)
expect(patient.sandbox.confine(['true'], RO).enforcement).toBe('full')
const impatient = await setup(
@@ -339,7 +345,7 @@ describe('probeTimeoutMs config', () => {
{ platform: 'linux', probeBwrap: () => false, landlockLauncher: launcher },
)
expect(() => impatient.sandbox.confine(['true'], RO)).toThrow(expect.objectContaining({ code: SANDBOX_UNAVAILABLE }))
})
}, 30_000)
})
describe('the default seatbelt probe (sandbox-exec contract)', () => {

View File

@@ -120,6 +120,7 @@ declare module 'cordis' {
* skipped for a sole candidate, whose own refusal remains the fail-closed end.
*/
export abstract class SandboxProvider extends Service {
/* v8 ignore next -- Windows has no sandbox backend to instantiate this service. */
constructor(ctx: Context) {
super(ctx, 'sandbox')
}

View File

@@ -71,7 +71,7 @@ class RecordingPort implements PromptPort {
}
}
describe('create-sdk terminal contract', () => {
describe.skipIf(process.platform === 'win32')('create-sdk terminal contract', () => {
it('renders package-manager-specific setup commands', () => {
const model = packageManagerTemplateModel(createPackageManager('yarn', '4.0.0'))
expect(CREATE_TEMPLATES.installQuestion.render(model)).toBe('Run yarn install and then build the project?\n')

View File

@@ -7,7 +7,7 @@ Launcher-side telemetry primitives for the dsh-sdk toolchain. This is a plain li
| `SecretRedactor` | Conservative safety backstop: replaces secret-shaped values (secret-like keys, known token shapes, PEM blocks, URL credentials, high-entropy opaque tokens) with a placeholder in both parsed values (`redactValue`) and raw text (`redactText`). Never drops a field or line. |
| `ConsentResolver` | Parses (never boots) a project `cordis.yml` and reads the telemetry entry's enabled/disabled state as consent; `DO_NOT_TRACK`/CI env force a hard opt-out. |
| `buildTelemetryPayload` | Assembles `{command, durationMs, success, cordisYmlContent, packageJsonContent}`, running the redactor over the full `cordis.yml` and `package.json` text. Never reads `.env`; `package.json` ships only alongside a `cordis.yml`, so a command run in a non-SDK directory never uploads that directory's unrelated manifest. |
| `getOrCreateAnonymousId` | Random UUID persisted in a per-user GLOBAL config file (never in the project, never derived from git). |
| `getOrCreateAnonymousId` | Random UUID persisted in the harness home resolved by [`@deepseek-ai/dsh-paths`](../../util/paths/README.md) (`$DSH_HOME` > `~/.dsh`), scoped to that home rather than the machine, never derived from git. |
| `TelemetryReporter` | Fire-and-forget send: `report()` never blocks or throws; delivery resolves on every path; `flush()` optionally drains in-flight sends within a cap. |
Consent is carried by the telemetry entry in `cordis.yml`, so disabling telemetry is disabling that entry. Telemetry reports by default and is off only when a present telemetry entry is explicitly `disabled`: a missing `cordis.yml` (first `create`), an enabled entry, or a `cordis.yml` with no telemetry entry all report. `DO_NOT_TRACK`/CI always deny. The no-config and absent-entry defaults are configurable on `ConsentResolver`.

View File

@@ -32,11 +32,13 @@
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,63 +1,50 @@
/**
* Per-machine anonymous telemetry id.
* Per-harness-home anonymous telemetry id.
*
* The id is a random UUID persisted in a per-user GLOBAL config file — never in
* the project, and never derived from the git remote, repository URL, or any
* other identifying source (a derived id would make "anonymous" a fiction). The
* same id is reused across projects on one machine so telemetry counts machines,
* not repositories.
* The id is a random UUID persisted directly in the harness home resolved by
* {@link resolveDshHome} (`$DSH_HOME` > `~/.dsh`), and never derived from the
* git remote, repository URL, or any other identifying source (a derived id
* would make "anonymous" a fiction). The id is scoped to the harness home, not
* the machine: every command sharing one `$DSH_HOME` reuses the same id, so the
* default `~/.dsh` counts per-OS-user home directories, while a relocated
* `$DSH_HOME` moves the id with the rest of the harness data — the single-root
* convention this package shares, not a telemetry-specific policy.
*
* @module @deepseek-ai/dsh-telemetry/anonymous-id
*/
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { homedir } from 'node:os'
import { dirname, join } from 'node:path'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
/** A machine-scoped anonymous telemetry id (random UUID v4). */
/** A harness-home-scoped anonymous telemetry id (random UUID v4). */
export type AnonymousId = Branded<'AnonymousId'>
/** Config directory name owned by the DeepSeek Harness across tools. */
const CONFIG_NAMESPACE = 'deepseek-harness'
/** Default file, inside the global config dir, storing the anonymous id. */
/** Default file, inside the harness home, storing the anonymous id. */
export const ANONYMOUS_ID_FILE_NAME = 'telemetry.json'
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
/** Ambient seams for locating and generating the id; every field has a default. */
export interface AnonymousIdOptions {
/** Environment consulted for `DSH_CONFIG_HOME`/`XDG_CONFIG_HOME`/`APPDATA`; defaults to `process.env`. */
/** Environment consulted for `DSH_HOME`; defaults to `process.env`. */
env?: NodeJS.ProcessEnv
/** Platform string used to pick the Windows path; defaults to `process.platform`. */
platform?: NodeJS.Platform
/** Home directory resolver; defaults to `os.homedir`. */
homeDir?: () => string
/** UUID generator; defaults to `crypto.randomUUID` (test seam). */
randomUUID?: () => string
}
/**
* Resolve the per-user global config directory for harness tooling.
* Precedence: `DSH_CONFIG_HOME` (explicit override) > `XDG_CONFIG_HOME` >
* platform default (`%APPDATA%` on Windows, else `~/.config`).
* @param options - environment, platform, and home-directory seams.
* @returns absolute config directory path for the harness namespace.
* Resolve the single-root harness home that stores the anonymous id.
* Delegates to {@link resolveDshHome} so telemetry shares the harness's one
* home-resolution policy (`DSH_HOME` > `~/.dsh`) instead of maintaining a
* second config-directory convention.
* @param options - environment seam.
* @returns absolute harness home path.
*/
export function globalConfigDir(options: AnonymousIdOptions = {}): string {
const env = options.env ?? process.env
const platform = options.platform ?? process.platform
const home = options.homeDir ?? homedir
if (env.DSH_CONFIG_HOME !== undefined && env.DSH_CONFIG_HOME.length > 0) return env.DSH_CONFIG_HOME
if (env.XDG_CONFIG_HOME !== undefined && env.XDG_CONFIG_HOME.length > 0) {
return join(env.XDG_CONFIG_HOME, CONFIG_NAMESPACE)
}
if (platform === 'win32' && env.APPDATA !== undefined && env.APPDATA.length > 0) {
return join(env.APPDATA, CONFIG_NAMESPACE)
}
return join(home(), '.config', CONFIG_NAMESPACE)
return resolveDshHome(undefined, options.env ?? process.env)
}
/** Read a valid persisted id from the store, or `undefined` when absent/corrupt. */
@@ -84,11 +71,11 @@ async function readPersistedId(file: string): Promise<AnonymousId | undefined> {
}
/**
* Return the machine's anonymous id, creating and persisting one on first use.
* Return the harness home's anonymous id, creating and persisting one on first use.
* Persistence is best-effort: a write failure still returns a usable id for the
* current run so telemetry is never blocked by config-dir permissions.
* @param options - config-location and UUID-generation seams.
* @returns the stable per-machine anonymous id.
* @returns the stable per-harness-home anonymous id.
*/
export async function getOrCreateAnonymousId(options: AnonymousIdOptions = {}): Promise<AnonymousId> {
const file = join(globalConfigDir(options), ANONYMOUS_ID_FILE_NAME)

View File

@@ -1,6 +1,7 @@
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { isAbsolute, join, resolve } from 'node:path'
import { defaultDshHome } from '@deepseek-ai/dsh-paths'
import { afterEach, describe, expect, it } from 'vitest'
import {
ANONYMOUS_ID_FILE_NAME,
@@ -23,37 +24,26 @@ afterEach(async () => {
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
describe('globalConfigDir', () => {
it('prefers an explicit DSH_CONFIG_HOME override', () => {
expect(globalConfigDir({ env: { DSH_CONFIG_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
it('prefers an explicit DSH_HOME override', () => {
expect(globalConfigDir({ env: { DSH_HOME: '/custom/dsh' } })).toBe('/custom/dsh')
})
it('falls back to XDG_CONFIG_HOME under the harness namespace', () => {
expect(globalConfigDir({ env: { XDG_CONFIG_HOME: '/xdg' } })).toBe(join('/xdg', 'deepseek-harness'))
})
it('uses %APPDATA% on Windows', () => {
expect(globalConfigDir({ env: { APPDATA: 'C:/Users/x/AppData/Roaming' }, platform: 'win32' }))
.toBe(join('C:/Users/x/AppData/Roaming', 'deepseek-harness'))
})
it('falls back to ~/.config on Windows without APPDATA and on posix', () => {
const home = () => '/home/dev'
expect(globalConfigDir({ env: {}, platform: 'win32', homeDir: home }))
.toBe(join('/home/dev', '.config', 'deepseek-harness'))
expect(globalConfigDir({ env: {}, platform: 'linux', homeDir: home }))
.toBe(join('/home/dev', '.config', 'deepseek-harness'))
it('falls back to ~/.dsh when DSH_HOME is unset', () => {
expect(globalConfigDir({ env: {} })).toBe(resolve(defaultDshHome()))
})
it('reads process.env by default', () => {
// No override supplied: the call must not throw and must return an absolute path.
expect(globalConfigDir()).toContain('deepseek-harness')
// The ambient DSH_HOME is unknown here, so assert only the invariant the
// resolver guarantees rather than a specific location.
expect(isAbsolute(globalConfigDir())).toBe(true)
})
})
describe('getOrCreateAnonymousId', () => {
it('creates, persists, and returns a UUID on first use', async () => {
const dir = await tempDir()
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
expect(id).toMatch(UUID)
const stored: unknown = JSON.parse(await readFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'utf8'))
expect(stored).toEqual({ anonymousId: id })
@@ -61,15 +51,15 @@ describe('getOrCreateAnonymousId', () => {
it('returns the same persisted id on subsequent calls', async () => {
const dir = await tempDir()
const first = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
const second = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
const first = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
const second = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
expect(second).toBe(first)
})
it('uses the injected UUID generator', async () => {
const dir = await tempDir()
const id = await getOrCreateAnonymousId({
env: { DSH_CONFIG_HOME: dir },
env: { DSH_HOME: dir },
randomUUID: () => '00000000-0000-4000-8000-000000000000',
})
expect(id).toBe('00000000-0000-4000-8000-000000000000')
@@ -78,23 +68,23 @@ describe('getOrCreateAnonymousId', () => {
it('regenerates when the stored file is corrupt JSON', async () => {
const dir = await tempDir()
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), 'not json', 'utf8')
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })
expect(id).toMatch(UUID)
})
it('regenerates when the stored value is not a valid UUID or object', async () => {
const dir = await tempDir()
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), JSON.stringify({ anonymousId: 'nope' }), 'utf8')
expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID)
expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID)
await writeFile(join(dir, ANONYMOUS_ID_FILE_NAME), '123', 'utf8')
expect(await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: dir } })).toMatch(UUID)
expect(await getOrCreateAnonymousId({ env: { DSH_HOME: dir } })).toMatch(UUID)
})
it('returns a usable id even when persistence fails', async () => {
const dir = await tempDir()
// A regular file where a directory is expected makes mkdir/writeFile fail.
await writeFile(join(dir, 'blocker'), 'x', 'utf8')
const id = await getOrCreateAnonymousId({ env: { DSH_CONFIG_HOME: join(dir, 'blocker') } })
const id = await getOrCreateAnonymousId({ env: { DSH_HOME: join(dir, 'blocker') } })
expect(id).toMatch(UUID)
})
})

View File

@@ -9,6 +9,7 @@
],
"references": [
{ "path": "../../util/brand" },
{ "path": "../../util/paths" },
{ "path": "../../support/invariants" }
]
}

View File

@@ -31,7 +31,7 @@ A root belongs to one encoding. Startup discovery and targeted lookup reject the
## Durability and crash semantics
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s a temporary file, publishes it without overwrite via a hard link, then `fsync`s the directory when the host supports it. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **Lazy materialization.** `create(meta)` writes nothing; on the first `append`, the backend writes and `fsync`s the encoded header and first batch in a temporary file. POSIX publishes it without overwrite via a hard link and `fsync`s the parent directory. Windows publishes it without overwrite via `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` and creates missing directories through the same write-through pattern. A created-but-never-appended session leaves nothing on disk and is absent from `list`.
- **Append-only.** Committed events (at or below a flushed `turn/end`) are never rewritten. Subsequent raw batches append lines; compressed batches append one frame. Both paths `fsync`, and a caught write or sync failure rolls the file back to its prior byte length.
- **Crash recovery — preserve valid tail work.** `load` validates every complete compressed frame and scans their decompressed JSONL. If the last frame is structurally incomplete, the reader keeps its complete decoded records, truncates from that frame's start, and re-encodes those records with the synthetic tool, step, and turn closers required by the shared [persistence contract](../../../.agents/notes/implemented/architecture/2026-06-14-session-persistence.md). Raw mode truncates from its first incomplete line. A checksum/decompression failure in a complete frame, or a defect at or before the last committed `turn/end`, is corruption and rejects.
- **Contiguous-seq.** `append` rejects a batch whose first `seq` does not continue the stored log, and rejects non-JSON-serializable `event.data` naming the offending event type.
@@ -62,5 +62,4 @@ JSONL storage does not mutate live request prefixes. A resumed loop can reuse pr
- **Compressed files are not directly line-readable** — use the backend to load them, or select `compression: 'none'` before writing a fresh root when text fixtures or external line readers are required.
- **Nothing deletes session files** — logs accumulate under `root` until removed externally (the seam has no deletion surface).
- **Single-process assumption** — per-session serialization and the write cursor live in this process; two processes appending to the same `root` are not coordinated.
- **Initial materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; a filesystem that cannot create hard links cannot host this backend.
- **Windows cannot `fsync` directory handles through Node** — the backend tolerates only Windows `EPERM` from directory `fsync`; file-content `fsync` remains mandatory, but a crash can lose a newly published directory entry on a host without an equivalent directory-sync primitive.
- **POSIX materialization requires hard-link support** — first append uses `link()` so same-id races fail instead of overwriting a committed log; Windows uses write-through rename without replacement.

View File

@@ -33,6 +33,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"koffi": "^3.1.0",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -8,7 +8,7 @@
import { Context } from 'cordis'
import z from 'schemastery'
import { open, mkdir, readFile, readdir, link, rm, truncate } from 'node:fs/promises'
import { open, mkdir, readFile, readdir, link, rm, stat as fsStat, truncate } from 'node:fs/promises'
import { dirname, resolve } from 'node:path'
import { randomBytes } from 'node:crypto'
import {
@@ -21,6 +21,7 @@ import {
type JsonlCompression,
} from './format.ts'
import { compressZstdFrame, decompressZstdFrame, scanZstdFrames } from './zstd.ts'
import { ensureDurableDirectoryWin32, publishNewFileWin32 } from './win32.ts'
export type { JsonlCompression } from './format.ts'
@@ -81,9 +82,6 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
private coordinator: PersistenceCoordinator<JsonlTornMarker>
private rootEncodingCheck: Promise<void> | undefined
/** Runtime host platform used to decide whether directory sync is supported. */
readonly internals: { platform: NodeJS.Platform } = { platform: process.platform }
constructor(ctx: Context, public config: Config) {
super(ctx)
// Resolve once so later process.cwd() changes cannot split one backend across roots.
@@ -254,32 +252,36 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
// --- materialization / append / repair (file mechanics) ---
/** Atomically write the header line + first batch (temp-write, fsync, collision-safe hard-link publish). */
/** Atomically write the header line + first batch (temp-write, fsync, publish). */
private async materialize(meta: SessionHeader, events: readonly SessionEvent[]): Promise<void> {
const dir = sessionDir(this.root, meta.cwd)
await mkdir(this.root, { recursive: true, mode: 0o700 })
await this.syncDir(dirname(this.root))
await mkdir(dir, { recursive: true, mode: 0o700 })
await this.syncDir(this.root)
const finalPath = logPath(this.root, meta.cwd, meta.id, this.compression)
// Materialization is the first write; an existing log is an id collision.
/* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
if (await this.exists(finalPath)) {
throw new Error(`refusing to materialize "${meta.id}": a log already exists on disk (load/resume it instead)`)
}
await this.rejectOppositeArtifact(meta.cwd, meta.id)
const content = await this.encodeMaterialization(meta, events)
const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
const handle = await open(tmp, 'wx', 0o600)
try {
await handle.writeFile(content)
await handle.sync()
} finally {
await handle.close()
/* v8 ignore next -- native Windows coverage exercises this platform dispatch; Linux covers the POSIX peer */
if (process.platform === 'win32') {
await this.materializeWin32(dir, finalPath, meta.id, content)
} else {
await this.materializePosix(dir, finalPath, meta.id, content)
}
// Publish with link()+unlink(): unlike rename(), link fails if another
// process materialized the same id first.
}
/* v8 ignore start -- Windows uses the Win32 durable-publish path; POSIX coverage exercises this peer. */
private async materializePosix(
dir: string,
finalPath: string,
id: SessionId,
content: Buffer | string,
): Promise<void> {
await mkdir(this.root, { recursive: true, mode: 0o700 })
await this.syncDirPosix(dirname(this.root))
await mkdir(dir, { recursive: true, mode: 0o700 })
await this.syncDirPosix(this.root)
await this.rejectExistingLog(finalPath, id)
const tmp = await this.writeSyncedTempFile(finalPath, content)
// Publish via link()+unlink(), NOT rename(): link fails with EEXIST if the
// final path already exists, so two processes materializing the same id
// concurrently cannot clobber each other. rename() would silently overwrite.
let linked = false
try {
await link(tmp, finalPath)
@@ -290,16 +292,64 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
/* v8 ignore next -- link failure is the TOCTOU/IO race guarded above; not reachable in test */
if (!linked) await rm(tmp, { force: true })
}
// The published link becomes crash-durable only after its directory fsync.
await this.syncDir(dir)
// Best-effort temp cleanup: the log is already published and durable, so a failure to
// remove the (now-redundant) temp hard link must not reject the append.
// link() succeeded — the log is published. fsync the directory so the new
// entry survives a power loss: the new link is not crash-durable until the
// parent directory's metadata is synced.
await this.syncDirPosix(dir)
// Best-effort temp cleanup: the log is already published and durable, so a
// failure to remove the (now-redundant) temp hard link must NOT reject the
// append. Swallow only the rm failure; nothing else of consequence runs here.
try {
await rm(tmp, { force: true })
} catch {
/* v8 ignore next -- redundant temp link; publish already durable, rm failure is an unreachable IO edge */
}
}
/* v8 ignore stop */
/* v8 ignore start -- native Windows coverage exercises this integration path */
private async materializeWin32(
dir: string,
finalPath: string,
id: SessionId,
content: Buffer | string,
): Promise<void> {
await ensureDurableDirectoryWin32(this.root)
await ensureDurableDirectoryWin32(dir)
await this.rejectExistingLog(finalPath, id)
const tmp = await this.writeSyncedTempFile(finalPath, content)
try {
await publishNewFileWin32(tmp, finalPath)
} catch (error) {
await rm(tmp, { force: true })
throw error
}
}
/* v8 ignore stop */
private async rejectExistingLog(finalPath: string, id: SessionId): Promise<void> {
// Never publish over an existing committed log: materialize is the first
// write of a session the backend believes is new. A file here means a
// different session shares this id on disk — reject loudly. (createCore
// already guards the create path, so this is unreachable-in-practice TOCTOU
// defense.)
/* v8 ignore next 3 -- createCore guards collisions before materialize; this is a TOCTOU backstop */
if (await this.exists(finalPath)) {
throw new Error(`refusing to materialize "${id}": a log already exists on disk (load/resume it instead)`)
}
}
private async writeSyncedTempFile(finalPath: string, content: Buffer | string): Promise<string> {
const tmp = `${finalPath}.${randomBytes(6).toString('hex')}.tmp`
const handle = await open(tmp, 'wx', 0o600)
try {
await handle.writeFile(content)
await handle.sync()
} finally {
await handle.close()
}
return tmp
}
/** Encode the header and first batch without combining their frame boundaries. */
private async encodeMaterialization(meta: SessionHeader, events: readonly SessionEvent[]): Promise<Buffer | string> {
@@ -317,22 +367,17 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
return this.compression === 'zstd' ? compressZstdFrame(body) : body
}
/** fsync a directory when the host exposes that durability primitive. */
private async syncDir(dir: string): Promise<void> {
/** fsync a POSIX directory so a just-created/renamed entry is crash-durable. */
/* v8 ignore start -- Windows uses write-through namespace operations; POSIX coverage exercises directory fsync. */
private async syncDirPosix(dir: string): Promise<void> {
const handle = await open(dir, 'r')
try {
try {
await handle.sync()
} catch (error: unknown) {
const code = (error as NodeJS.ErrnoException | null)?.code
// Node opens directories on Windows but its fsync binding rejects them.
// File-content fsync remains mandatory; only this unsupported primitive is skipped.
if (this.internals.platform !== 'win32' || code !== 'EPERM') throw error
}
await handle.sync()
} finally {
await handle.close()
}
}
/* v8 ignore stop */
/**
* Append and fsync event lines. On a partial write or sync failure, restore the
@@ -343,17 +388,37 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
const content = await this.encodeEventBatch(events)
const path = logPath(this.root, meta.cwd, meta.id, this.compression)
const handle = await open(path, 'a')
let closed = false
const closeAppendHandle = async (): Promise<void> => {
if (closed) return
closed = true
await handle.close()
}
try {
const { size: before } = await handle.stat()
try {
await handle.writeFile(content)
await handle.sync()
} catch (error) {
// Roll back whatever bytes landed so a retry starts from a clean EOF.
await handle.truncate(before)
await handle.sync()
try {
await closeAppendHandle()
await this.rollbackAppend(path, before)
} catch (rollbackError) {
throw new AggregateError([error, rollbackError], `failed to roll back append to "${path}"`)
}
throw error
}
} finally {
await closeAppendHandle()
}
}
private async rollbackAppend(path: string, size: number): Promise<void> {
const handle = await open(path, 'r+')
try {
await handle.truncate(size)
await handle.sync()
} finally {
await handle.close()
}
@@ -505,13 +570,36 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi
await handle.close()
return true
} catch (error) {
// Only ENOENT means absent. A permission/I/O error must surface, not be
// collapsed to `false` — otherwise load() reports "not found" and collision
// checks proceed under a false absence assumption.
if (isENOENT(error)) return false
// Only ENOENT means absent. A permission/I/O error must surface rather
// than letting load or collision checks proceed under false absence.
// Windows reports ENOENT, not ENOTDIR, for `regular-file/child`; verify
// the immediate parent so a blocked cwd bucket remains a storage fault.
/* v8 ignore else -- Windows reports file-valued parents as ENOENT; POSIX covers direct ENOTDIR. */
if (isENOENT(error)) {
await this.assertLogParentAllowsAbsence(path)
return false
}
/* v8 ignore next -- Windows repairs ENOTDIR from ENOENT above; POSIX covers direct ENOTDIR. */
throw error
}
}
/* v8 ignore start -- native Windows coverage exercises this repair; POSIX open reports ENOTDIR before this point. */
private async assertLogParentAllowsAbsence(path: string): Promise<void> {
try {
const parent = dirname(path)
const info = await fsStat(parent)
if (info.isDirectory()) return
const error = new Error(`ENOTDIR: parent path exists but is not a directory: ${parent}`) as NodeJS.ErrnoException
error.code = 'ENOTDIR'
error.path = parent
throw error
} catch (error) {
if (isENOENT(error)) return
throw error
}
}
/* v8 ignore stop */
}
export default SessionPersistenceJsonl

View File

@@ -0,0 +1,150 @@
/**
* Windows durable namespace helpers for the JSONL backend.
*
* POSIX publishes a newly-created log by creating a directory entry and then
* fsyncing the parent directory. Windows does not expose that parent-directory
* fsync contract through Node, so the Windows path uses the native durable
* namespace primitive instead: create a staging object in the target directory
* and publish it with `MoveFileExW(..., MOVEFILE_WRITE_THROUGH)` without
* replacement or cross-volume copy fallback.
*
* @module dsh-session-persistence-jsonl/win32
*/
import { mkdtemp, rm, stat } from 'node:fs/promises'
import { basename, join, parse, resolve, toNamespacedPath } from 'node:path'
type MoveFileExW = (existing: string, replacement: string, flags: number) => number
type GetLastError = () => number
interface Win32Bindings {
moveFileExW: MoveFileExW
getLastError: GetLastError
}
interface Win32ErrnoException extends NodeJS.ErrnoException {
win32Code: number
dest: string
}
const MOVEFILE_WRITE_THROUGH = 0x00000008
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
const ERROR_NOT_SAME_DEVICE = 17
const ERROR_FILE_EXISTS = 80
const ERROR_INVALID_NAME = 123
const ERROR_ALREADY_EXISTS = 183
let bindings: Win32Bindings | undefined
/** Load the small Win32 surface lazily so non-Windows processes never load Koffi. */
async function win32(): Promise<Win32Bindings> {
if (bindings !== undefined) return bindings
const koffi = (await import('koffi')).default
const kernel32 = koffi.load('kernel32.dll')
bindings = {
moveFileExW: kernel32.func('__stdcall', 'MoveFileExW', 'int', ['str16', 'str16', 'uint']) as MoveFileExW,
getLastError: kernel32.func('__stdcall', 'GetLastError', 'uint', []) as GetLastError,
}
return bindings
}
function errnoCode(win32Code: number): string {
switch (win32Code) {
case ERROR_FILE_NOT_FOUND:
case ERROR_PATH_NOT_FOUND:
return 'ENOENT'
case ERROR_ACCESS_DENIED:
return 'EACCES'
case ERROR_NOT_SAME_DEVICE:
return 'EXDEV'
case ERROR_FILE_EXISTS:
case ERROR_ALREADY_EXISTS:
return 'EEXIST'
case ERROR_INVALID_NAME:
return 'EINVAL'
default:
return 'EIO'
}
}
function win32Error(syscall: string, win32Code: number, path: string, dest: string): Win32ErrnoException {
const code = errnoCode(win32Code)
const error = new Error(`${syscall} ${code} (Win32 ${win32Code}): ${path} -> ${dest}`) as Win32ErrnoException
error.code = code
error.errno = win32Code
error.syscall = syscall
error.path = path
error.dest = dest
error.win32Code = win32Code
return error
}
function isENOENT(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'ENOENT'
}
function isEEXIST(error: unknown): boolean {
return (error as NodeJS.ErrnoException | null)?.code === 'EEXIST'
}
async function assertDirectory(path: string): Promise<boolean> {
try {
const info = await stat(path)
if (info.isDirectory()) return true
const error = new Error(`path exists but is not a directory: ${path}`) as NodeJS.ErrnoException
error.code = 'ENOTDIR'
error.path = path
throw error
} catch (error) {
if (isENOENT(error)) return false
throw error
}
}
/**
* Publish `existing` at `replacement` with Windows write-through rename
* semantics. The destination must not already exist; the move must stay within
* the volume (no copy fallback flag is set).
* @param existing - the synced staging path to move.
* @param replacement - the final path, which must not already exist.
*/
export async function publishNewFileWin32(existing: string, replacement: string): Promise<void> {
const api = await win32()
const ok = api.moveFileExW(toNamespacedPath(existing), toNamespacedPath(replacement), MOVEFILE_WRITE_THROUGH)
if (ok === 0) throw win32Error('MoveFileExW', api.getLastError(), existing, replacement)
}
/**
* Create `target` and its missing ancestors with durable Windows namespace
* publication. Each missing directory is first created as a random staging
* sibling, then moved to its final name with `MOVEFILE_WRITE_THROUGH`; races
* with another creator are accepted only after verifying the winner is a
* directory.
* @param target - the absolute directory path to create durably when absent.
*/
export async function ensureDurableDirectoryWin32(target: string): Promise<void> {
const absolute = resolve(target)
const root = parse(absolute).root
await assertDirectory(root)
const segments = absolute.slice(root.length).split(/[\\/]+/).filter(part => part.length > 0)
let current = root
for (const segment of segments) {
const next = join(current, segment)
if (!await assertDirectory(next)) await createLeafDirectoryWin32(current, next)
current = next
}
}
async function createLeafDirectoryWin32(parent: string, target: string): Promise<void> {
const staging = await mkdtemp(join(parent, `.dsh-mkdir-${basename(target)}-`))
try {
await publishNewFileWin32(staging, target)
} catch (error) {
await rm(staging, { recursive: true, force: true })
if (isEEXIST(error) && await assertDirectory(target)) return
throw error
}
}

View File

@@ -1,7 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import { appendFile, mkdtemp, mkdir, open, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import type { FileHandle } from 'node:fs/promises'
import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { isAbsolute, join, relative, resolve } from 'node:path'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
@@ -47,21 +46,6 @@ afterEach(async () => {
for (const d of dirs.splice(0)) await rm(d, { recursive: true, force: true })
})
async function rejectDirectorySync(code: string): Promise<void> {
const handle = await open(root, 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
vi.spyOn(proto, 'sync').mockImplementation(async function (this: FileHandle) {
if ((await this.stat()).isDirectory()) {
const error = new Error(`simulated directory fsync ${code}`) as NodeJS.ErrnoException
error.code = code
throw error
}
return realSync.call(this)
})
}
function appendClosedTurn(session: Session): void {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
@@ -358,26 +342,43 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => {
expect(loaded.events.map(e => e.seq)).toEqual([0, 1, 2, 3, 4, 5, 6, 7])
})
it('keeps file fsync mandatory while tolerating unsupported Windows directory fsync', async () => {
await rejectDirectorySync('EPERM')
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
backend.internals.platform = 'win32'
const m = meta('windows-directory-sync')
it('reports both the append failure and a failed rollback', async () => {
const m = meta('rollback-failure')
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).resolves.toBeUndefined()
expect((await ctx.sessionPersistence.load(m.id)).events).toEqual(oneTurnLog())
})
await ctx.sessionPersistence.append(m.id, oneTurnLog())
it.each([
['linux', 'EPERM'],
['win32', 'EIO'],
] as const)('surfaces directory fsync errors on %s with %s', async (platform, code) => {
await rejectDirectorySync(code)
const backend = ctx.sessionPersistence as SessionPersistenceJsonl
backend.internals.platform = platform
const m = meta(`directory-sync-${platform}-${code}`)
await ctx.sessionPersistence.create(m)
await expect(ctx.sessionPersistence.append(m.id, oneTurnLog())).rejects.toMatchObject({ code })
const path = rawLogPath(root, undefined, m.id)
const handle = await (await import('node:fs/promises')).open(path, 'r')
const proto = Object.getPrototypeOf(handle) as { sync: () => Promise<void> }
await handle.close()
const realSync = proto.sync
let failed = false
const syncSpy = vi.spyOn(proto, 'sync').mockImplementation(async function (this: unknown) {
if (!failed) { failed = true; throw new Error('simulated append fsync failure') }
return realSync.call(this)
})
const backend = ctx.sessionPersistence as unknown as {
rollbackAppend: (path: string, size: number) => Promise<void>
}
const realRollback = backend.rollbackAppend.bind(backend)
backend.rollbackAppend = () => Promise.reject(new Error('simulated rollback failure'))
try {
await ctx.sessionPersistence.append(m.id, [
{ type: 'turn/start', seq: 6, time: 9, data: { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } } },
] as SessionEvent[])
throw new Error('expected append to reject')
} catch (error) {
expect(error).toBeInstanceOf(AggregateError)
const aggregate = error as AggregateError
expect(aggregate.message).toContain(`failed to roll back append to "${path}"`)
expect(aggregate.errors).toHaveLength(2)
expect(aggregate.errors[0]).toMatchObject({ message: 'simulated append fsync failure' })
expect(aggregate.errors[1]).toMatchObject({ message: 'simulated rollback failure' })
} finally {
backend.rollbackAppend = realRollback
syncSpy.mockRestore()
}
})
it('load returns a meta copy: mutating it does not corrupt backend pathing', async () => {

View File

@@ -0,0 +1,169 @@
/**
* Unit tests for the Windows durable namespace helper with a mocked kernel32
* binding. The real JSONL suite exercises the helper on native Windows; these
* tests keep the Win32 error mapping and race handling covered on every host.
*/
import { afterEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
const MOVEFILE_WRITE_THROUGH = 0x00000008
const ERROR_FILE_NOT_FOUND = 2
const ERROR_PATH_NOT_FOUND = 3
const ERROR_ACCESS_DENIED = 5
const ERROR_NOT_SAME_DEVICE = 17
const ERROR_FILE_EXISTS = 80
const ERROR_INVALID_NAME = 123
const ERROR_ALREADY_EXISTS = 183
type MoveFileExW = (existing: string, replacement: string, flags: number, setLastError: (code: number) => void) => number
const roots: string[] = []
function stripNamespace(path: string): string {
if (path.startsWith('\\\\?\\UNC\\')) return `\\\\${path.slice('\\\\?\\UNC\\'.length)}`
if (path.startsWith('\\\\?\\')) return path.slice('\\\\?\\'.length)
return path
}
async function tempRoot(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'dsh-jsonl-win32-'))
roots.push(dir)
return dir
}
async function importWithMove(moveFileExW: MoveFileExW): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => {
let lastError = 0
const setLastError = (code: number): void => { lastError = code }
const move: MoveFileExW = (existing, replacement, flags, setError) => {
const ok = moveFileExW(existing, replacement, flags, setError)
lastError = ok === 0 ? lastError : 0
return ok
}
return {
default: {
load: () => ({
func: (_convention: string, name: string, result: string) => {
if (name === 'MoveFileExW') return (existing: string, replacement: string, flags: number) => {
expect(result).toBe('int')
const ok = move(existing, replacement, flags, setLastError)
return ok
}
return () => lastError
},
}),
},
}
})
return import('../src/win32.ts')
}
async function importWithError(code: number): Promise<typeof import('../src/win32.ts')> {
vi.resetModules()
vi.doMock('koffi', () => ({
default: {
load: () => ({
func: (_convention: string, name: string) => {
if (name === 'MoveFileExW') return () => 0
return () => code
},
}),
},
}))
return import('../src/win32.ts')
}
async function importWithFilesystemMove(): Promise<typeof import('../src/win32.ts')> {
return importWithMove((existing, replacement, flags, setLastError) => {
expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
const from = stripNamespace(existing)
const to = stripNamespace(replacement)
if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
renameSync(from, to)
return 1
})
}
afterEach(async () => {
vi.doUnmock('koffi')
vi.resetModules()
for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true })
})
describe('Windows durable namespace helpers', () => {
it('publishes a new file with write-through MoveFileExW semantics', async () => {
const { publishNewFileWin32 } = await importWithFilesystemMove()
const root = await tempRoot()
const tmp = join(root, 'log.tmp')
const final = join(root, 'log.jsonl')
await writeFile(tmp, 'content')
await publishNewFileWin32(tmp, final)
expect(existsSync(tmp)).toBe(false)
expect(readFileSync(final, 'utf8')).toBe('content')
})
it('maps Win32 publish failures to Node-style errno codes', async () => {
const cases = [
[ERROR_FILE_NOT_FOUND, 'ENOENT'],
[ERROR_PATH_NOT_FOUND, 'ENOENT'],
[ERROR_ACCESS_DENIED, 'EACCES'],
[ERROR_NOT_SAME_DEVICE, 'EXDEV'],
[ERROR_FILE_EXISTS, 'EEXIST'],
[ERROR_ALREADY_EXISTS, 'EEXIST'],
[ERROR_INVALID_NAME, 'EINVAL'],
[9999, 'EIO'],
] as const
for (const [win32Code, code] of cases) {
const { publishNewFileWin32 } = await importWithError(win32Code)
await expect(publishNewFileWin32('from', 'to')).rejects.toMatchObject({ code, win32Code, path: 'from', dest: 'to' })
}
})
it('creates missing directories through staging siblings and tolerates an already-created race', async () => {
const root = await tempRoot()
const raced = join(root, 'raced')
const { ensureDurableDirectoryWin32 } = await importWithMove((existing, replacement, flags, setLastError) => {
expect(flags).toBe(MOVEFILE_WRITE_THROUGH)
const from = stripNamespace(existing)
const to = stripNamespace(replacement)
if (to === raced) {
mkdirSync(to)
setLastError(ERROR_ALREADY_EXISTS)
return 0
}
if (!existsSync(from)) { setLastError(ERROR_FILE_NOT_FOUND); return 0 }
if (existsSync(to)) { setLastError(ERROR_ALREADY_EXISTS); return 0 }
renameSync(from, to)
return 1
})
await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
expect(existsSync(join(root, 'a', 'b'))).toBe(true)
await ensureDurableDirectoryWin32(join(root, 'a', 'b'))
await ensureDurableDirectoryWin32(raced)
expect(existsSync(raced)).toBe(true)
})
it('surfaces directory publication failures other than an existing-target race', async () => {
const { ensureDurableDirectoryWin32 } = await importWithError(ERROR_ACCESS_DENIED)
const root = await tempRoot()
await expect(ensureDurableDirectoryWin32(join(root, 'denied'))).rejects.toMatchObject({ code: 'EACCES' })
})
it('rejects a non-directory component instead of treating it as missing', async () => {
const { ensureDurableDirectoryWin32 } = await importWithFilesystemMove()
const root = await tempRoot()
const blocked = join(root, 'blocked')
writeFileSync(blocked, 'x')
await expect(ensureDurableDirectoryWin32(join(blocked, 'child'))).rejects.toMatchObject({ code: 'ENOTDIR' })
})
})

View File

@@ -476,7 +476,9 @@ describe('SessionPersistenceSqlite: edge cases', () => {
const walPath = await freshDbPath()
const bWal = await backend(walPath)
await bWal.ctx.sessionPersistence.create(meta('jm-wal'))
expect((openDatabase(walPath, 'wal').prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
const probe = openDatabase(walPath, 'wal')
expect((probe.prepare('PRAGMA journal_mode').get() as { journal_mode: string }).journal_mode).toBe('wal')
probe.close()
await bWal.dispose()
const deletePath = await freshDbPath()

View File

@@ -1,9 +1,9 @@
# session-query/ — session retrieval capability family
Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, surface classification, bounded event reads, lineage, and direct event relationships.
Trusted exact reads and relationship traces over live and durable session logs. The family contains one interface package that owns `ctx.sessionQuery`, logical-corpus precedence, title folding, surface classification, bounded event reads, lineage, and direct event relationships.
| Package | Role | ctx key |
|---|---|---|
| [`session-query/`](session-query/README.md) | Logical-corpus exact-read and relationship-tracing service | `ctx.sessionQuery` |
| [`session-query/`](session-query/README.md) | Logical-corpus title, event, lineage, and relationship reads | `ctx.sessionQuery` |
The family is independent of compaction: it reads canonical lineage, surface operations, and logged provenance but does not participate in compaction policy or execution. Full-text search remains a proposed SQLite package rather than a speculative provider seam in this interface package.

View File

@@ -5,12 +5,13 @@ Exact session-history retrieval and relationship tracing through `ctx.sessionQue
## Reads
- `listSessions()` reads current persistence metadata, merges live records with live precedence, and returns cloned records in deterministic newest-first order.
- `readTitle(sessionId)` loads one live-preferred or persisted log and folds its latest `session/title` event into a `SessionTitleSnapshot`; it returns `undefined` when the known session has no title.
- `listEvents(sessionId)` loads the live-preferred raw log and classifies each event as `current`, `shadowed`, or `log-only` with the shared `dsh-session` surface fold.
- `readEvent(request)` returns a cloned header, the full target event, and a bounded raw-seq window. `before` and `after` default to zero and may not exceed `readWindowMax`.
- `traceSession(sessionId)` reads the corpus once and returns immediate-to-outward ancestors plus deterministic recursive descendant trees. `complete: false` identifies the first missing parent; a target-connected cycle fails with `SESSION_QUERY_INVALID_LINEAGE`.
- `traceEvent(request)` loads the logical log once and returns direct positional replacements and direct logged provenance. `replacementChain` follows positional replacers to the final replacement; provenance links remain non-transitive.
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. An event read or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory history unreadable. Persisted event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations.
Persistence is optional and may mount or unmount dynamically. Cross-corpus listing and lineage tracing fail with `SESSION_QUERY_PERSISTENCE_FAILED` while mounted persistence is unreadable. A title, event read, or trace targeting a known live session does not consult persistence, so durable backend health cannot make current in-memory state unreadable. Persisted title and event operations list before loading and reject a metadata mismatch rather than combining inconsistent observations. `listSessions()` remains lightweight and does not load logs or index titles.
`listEvents()` and `traceEvent()` run the same one-pass `dsh-session` surface fold. A loaded log is valid only when event seqs are zero-based and contiguous, surface markers obey event-type eligibility, provenance arrays are nonempty and duplicate-free, references name earlier events, and each positional replacement names and cites every surface node it removes; every violation fails with `SESSION_QUERY_INVALID_SURFACE`.

View File

@@ -30,6 +30,7 @@
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
@@ -45,6 +46,7 @@
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"cordis": "^4.0.0-rc.7"
}

View File

@@ -7,6 +7,8 @@
import { Context, Service } from 'cordis'
import z from 'schemastery'
import type { SessionId } from '@deepseek-ai/dsh-session'
import { foldSessionTitle } from '@deepseek-ai/dsh-session-title'
import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title'
import type {
SessionEventReadRequest,
SessionEventRecord,
@@ -64,6 +66,16 @@ export class SessionQueryService extends Service {
return this._corpus.listSessions()
}
/**
* Fold the latest log-backed title from one live-preferred logical session.
* @param sessionId - live or persisted session id to read.
* @returns latest title snapshot, or `undefined` when the log has no title event.
*/
async readTitle(sessionId: SessionId): Promise<SessionTitleSnapshot | undefined> {
const loaded = await this._corpus.load(sessionId)
return foldSessionTitle(loaded.events)
}
/**
* List lightweight raw-log event records for one logical session.
* @param sessionId - live-preferred session id to read.

View File

@@ -6,6 +6,7 @@ import SessionPersistence from '@deepseek-ai/dsh-session-persistence'
import SessionQueryService, {
type SessionQueryErrorCode,
} from '@deepseek-ai/dsh-session-query'
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
function header(id: string, createdAt = 1, extra: Partial<SessionHeader> = {}): SessionHeader {
return { version: SESSION_FORMAT_VERSION, id: SessionId(id), createdAt, ...extra }
@@ -85,6 +86,58 @@ function rejectUnknown<T>(reason: unknown): Promise<T> {
}
describe('session-query exact reads', () => {
it('reads the latest title from one live-preferred or persisted log without widening listSessions', async () => {
const persistedHeader = header('persisted-title', 2)
const sharedHeader = header('shared-title', 3)
TestPersistence.reset([
{
meta: persistedHeader,
events: [{
type: 'session/title',
seq: 0,
time: 20,
data: {
title: 'Persisted title',
messageSeqs: [4],
source: { kind: 'fallback' },
},
}],
},
{
meta: sharedHeader,
events: [{
type: 'session/title',
seq: 0,
time: 30,
data: {
title: 'Stale durable title',
messageSeqs: [1],
source: { kind: 'fallback' },
},
}],
},
])
const ctx = await liveContext()
const shared = ctx.sessions.create(sharedHeader.id, { meta: { createdAt: 3 } })
shared.append('session/title', {
title: 'Live title',
messageSeqs: [7],
source: {
kind: 'provider',
provider: SessionTitleProviderId('query-test'),
},
})
await ctx.plugin(TestPersistence)
await expect(ctx.sessionQuery.readTitle(persistedHeader.id)).resolves.toMatchObject({
title: 'Persisted title', eventSeq: 0, updatedAt: 20,
})
await expect(ctx.sessionQuery.readTitle(shared.id)).resolves.toMatchObject({
title: 'Live title', eventSeq: 0,
})
expect(Object.keys((await ctx.sessionQuery.listSessions())[0]!)).toEqual(['header', 'live', 'persisted'])
})
it('lists live sessions deterministically and returns detached headers', async () => {
const ctx = await liveContext()
const older = ctx.sessions.create(SessionId('older'), { meta: { createdAt: 1 } })

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-title/session-title"
},
{
"path": "../../session-persistence/session-persistence"
},

View File

@@ -0,0 +1,12 @@
# session-title/ — log-backed session-title capability family
Durable session-title state, one optional asynchronous provider seam, and two opt-in model-backed implementations. The built-in first-message fallback is part of the service, so every composition can title a session without an auxiliary model call.
| Package | Role | ctx key |
|---|---|---|
| [`session-title/`](session-title/README.md) | Log fold, deterministic fallback, provider registry, and refresh API | `ctx.sessionTitle` |
| [`session-title-llm/`](session-title-llm/README.md) | Shared route, request logging, prompt, timeout, stream, and validation helper | — |
| [`session-title-first-message-llm/`](session-title-first-message-llm/README.md) | Optional provider using the first eligible human message | registers on `ctx.sessionTitle` |
| [`session-title-all-messages-llm/`](session-title-all-messages-llm/README.md) | Optional provider using every eligible human message | registers on `ctx.sessionTitle` |
Only one provider may register at a time. The shared demo spine mounts the fallback service but leaves both model providers outside default composition, so deployments choose auxiliary cost and retitling cadence explicitly.

View File

@@ -0,0 +1,26 @@
# @deepseek-ai/dsh-session-title-all-messages-llm
Optional `ctx.sessionTitle` provider that summarizes every eligible human message through `ctx.llm`. It registers the `all-user-messages` cadence and starts a new revision after each new human prompt, using seeded history as well as child-session prompts. A newer revision aborts and supersedes older work; even a provider that ignores cancellation cannot commit stale output.
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from each current logged main request, or set both to route title generation independently. If the final framed aggregate prompt exceeds `maxInputBytes`, the request fails instead of truncating history; automatic use warns and keeps the prior title.
## Model Experience
### All-messages title request
#### What the model sees
The title model receives the shared title instruction and a JSON array of all eligible human messages through the current revision, in log order with exact seqs. Seeded history is included.
#### Token effect
One auxiliary request may follow every new eligible prompt, bounded per request by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may add calls. The main agent request gains zero tokens.
#### KV Cache effect
No main-request invalidation. Auxiliary input grows or changes after each prompt, so provider-specific cache reuse ends at the first changed JSON token.
## Known Limitations and Deferred Work
- Input overflow retains the prior title; this provider has no summarization-of-summaries or retention policy for very long sessions.
- It treats all eligible human messages equally and offers no weighting, filtering, or manual-title precedence.

View File

@@ -0,0 +1,41 @@
{
"name": "@deepseek-ai/dsh-session-title-all-messages-llm",
"description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-title-llm": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-llm": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,36 @@
/** All-human-messages model provider for `ctx.sessionTitle`. */
import type { Context } from 'cordis'
import z from 'schemastery'
import {
registerSessionTitleLlmProvider,
SessionTitleLlmConfigFields,
} from '@deepseek-ai/dsh-session-title-llm'
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
export const name = 'session-title-all-messages-llm'
export const inject = ['sessionTitle', 'llm', 'sessions']
/** Required LLM policy; this plugin adds no defaults. */
export type Config = SessionTitleLlmConfig
/** Loader schema shared with the first-message provider. */
/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
export const Config: z<Config> = z.object({
targetWords: SessionTitleLlmConfigFields.targetWords,
targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters,
maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes,
maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens,
timeoutMs: SessionTitleLlmConfigFields.timeoutMs,
provider: SessionTitleLlmConfigFields.provider,
model: SessionTitleLlmConfigFields.model,
})
/* jscpd:ignore-end */
/**
* Register the all-user-messages model provider.
* @param ctx - context exposing session-title, LLM, and session services.
* @param config - required route, target, byte, token, and timeout policy.
*/
export function apply(ctx: Context, config: Config): void {
registerSessionTitleLlmProvider(ctx, config, name, 'all-user-messages', messages => messages)
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-all-messages-llm`.
* @module @deepseek-ai/dsh-session-title-all-messages-llm/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-all-messages-llm'
/** Cordis companion plugin name. */
export const name = 'session-title-all-messages-llm-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this thin provider delegates request and result validation to the shared
* title service and LLM helper and retains no independent mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,73 @@
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-all-messages-llm'
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
yield { type: 'text-delta', index: 0, text: 'All messages model title' }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const
const LLM_CONFIG = {
targetWords: 5,
targetCjkCharacters: 10,
maxInputBytes: 1_000,
maxOutputTokens: 32,
timeoutMs: 1_000,
} as const
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('all-messages LLM title provider', () => {
it('includes seeded history and the latest prompt while inheriting the logged request route', async () => {
const seeded = new Session(SessionId('seed-source'))
seeded.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const inherited = seeded.append('user/message', {
content: [{ type: 'text', text: 'inherited prompt' }], source: { kind: 'user' },
}, { surfaceOp: 'append' })
seeded.append('session/title', {
title: 'Inherited fallback', messageSeqs: [inherited.seq], source: { kind: 'fallback' },
})
seeded.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
const adapter = new RecordingAdapter()
ctx.llm.registerAdapter(['current-route'], adapter)
await ctx.plugin(providerPlugin, LLM_CONFIG)
const session = ctx.sessions.create(SessionId('all-plugin'), {
seed: seeded.events,
meta: { parentSession: seeded.id, seedLength: seeded.seq },
})
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
const latest = session.append('user/message', {
content: [{ type: 'text', text: 'latest prompt' }], source: { kind: 'user' },
}, { surfaceOp: 'append' })
await settle()
session.append('request/header', {
header: { config: { provider: 'current-route', model: 'current-model' } }, reason: 'resume',
})
await settle()
expect(adapter.requests[0]).toMatchObject({ provider: 'current-route', model: 'current-model' })
const content = adapter.requests[0]?.messages[0]?.content[0]
expect(content?.type === 'text' && content.text).toContain('inherited prompt')
expect(content?.type === 'text' && content.text).toContain('latest prompt')
expect(ctx.sessionTitle.get(session)).toMatchObject({
messageSeqs: [inherited.seq, latest.seq],
})
})
})

View File

@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../support/invariants" },
{ "path": "../../llm/llm" },
{ "path": "../session-title" },
{ "path": "../session-title-llm" }
]
}

View File

@@ -0,0 +1,26 @@
# @deepseek-ai/dsh-session-title-first-message-llm
Optional `ctx.sessionTitle` provider that summarizes the first eligible human message through `ctx.llm`. It registers the `first-message` cadence, runs automatically only when a fresh non-fork session first creates its fallback, and attributes the result to that message's exact seq. An automatic failure retains the fallback and is retried only through `ctx.sessionTitle.refresh()`.
The plugin uses the complete required [shared LLM configuration](../session-title-llm/README.md#configuration). Omit both `provider` and `model` to inherit the exact route from the current logged main request, or set both to route title generation independently.
## Model Experience
### First-message title request
#### What the model sees
The title model receives the shared title instruction and a JSON array containing only the first eligible human message. Later prompts and inherited fork history do not trigger another automatic call.
#### Token effect
At most one automatic auxiliary request is made for a fresh session, bounded by `maxInputBytes` and `maxOutputTokens`; explicit refreshes may make additional calls. The main agent request gains zero tokens.
#### KV Cache effect
No main-request invalidation. The auxiliary request uses the configured or logged route and has provider-specific cache behavior.
## Known Limitations and Deferred Work
- The first message alone may cease to represent a long-running session; use the all-messages provider when later prompts should retitle it.
- A fork keeps its inherited title and never runs this provider automatically, even when its seeded first message came from the parent.

View File

@@ -0,0 +1,44 @@
{
"name": "@deepseek-ai/dsh-session-title-first-message-llm",
"description": "First-message LLM provider plugin for DeepSeek Harness session titles",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./package.json": "./package.json"
},
"files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", "src"],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-session-title-llm": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-deepseek": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-session-title-llm": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,40 @@
/** First-human-message model provider for `ctx.sessionTitle`. */
import type { Context } from 'cordis'
import z from 'schemastery'
import {
registerSessionTitleLlmProvider,
SessionTitleLlmConfigFields,
} from '@deepseek-ai/dsh-session-title-llm'
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
export const name = 'session-title-first-message-llm'
export const inject = ['sessionTitle', 'llm', 'sessions']
/** Required LLM policy; this plugin adds no defaults. */
export type Config = SessionTitleLlmConfig
/** Loader schema shared with the all-messages provider. */
/* jscpd:ignore-start -- Loader requires each plugin to export its own statically walkable schema; the field validators remain shared. */
export const Config: z<Config> = z.object({
targetWords: SessionTitleLlmConfigFields.targetWords,
targetCjkCharacters: SessionTitleLlmConfigFields.targetCjkCharacters,
maxInputBytes: SessionTitleLlmConfigFields.maxInputBytes,
maxOutputTokens: SessionTitleLlmConfigFields.maxOutputTokens,
timeoutMs: SessionTitleLlmConfigFields.timeoutMs,
provider: SessionTitleLlmConfigFields.provider,
model: SessionTitleLlmConfigFields.model,
})
/* jscpd:ignore-end */
/**
* Register the first-message model provider.
* @param ctx - context exposing session-title, LLM, and session services.
* @param config - required route, target, byte, token, and timeout policy.
*/
export function apply(ctx: Context, config: Config): void {
registerSessionTitleLlmProvider(ctx, config, name, 'first-message', (messages) => {
const first = messages[0]
if (first === undefined) throw new Error('first-message title provider requires one human message')
return [first]
})
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-first-message-llm`.
* @module @deepseek-ai/dsh-session-title-first-message-llm/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-first-message-llm'
/** Cordis companion plugin name. */
export const name = 'session-title-first-message-llm-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this thin provider delegates request and result validation to the shared
* title service and LLM helper and retains no independent mutable state.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,120 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL } from 'node:url'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm'
let root: string | undefined
let context: Context | undefined
class LoaderAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
yield { type: 'text-delta', index: 0, text: 'Loader composed title' }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
afterEach(async () => {
await context?.fiber.dispose()
context = undefined
if (root !== undefined) await rm(root, { recursive: true, force: true })
root = undefined
})
async function loadComposition(): Promise<Context> {
root = await mkdtemp(join(tmpdir(), 'dsh-title-loader-'))
const configPath = join(root, 'cordis.yml')
await writeFile(configPath, [
"- name: '@deepseek-ai/dsh-llm'",
"- name: '@deepseek-ai/dsh-session'",
"- name: '@deepseek-ai/dsh-session-title'",
' config:',
' fallbackMaxWords: 5',
' fallbackMaxBytes: 40',
' maxTitleBytes: 80',
"- name: '@deepseek-ai/dsh-session-title-first-message-llm'",
' config:',
' targetWords: 5',
' targetCjkCharacters: 10',
' maxInputBytes: 1000',
' maxOutputTokens: 32',
' timeoutMs: 1000',
" provider: 'title-route'",
" model: 'title-model'",
'',
].join('\n'))
context = new Context()
context.baseUrl = pathToFileURL(root).href + '/'
await context.plugin(Loader)
context.loader.builtins.include = Include
const modules = new Map<string, unknown>([
['@deepseek-ai/dsh-llm', LlmService],
['@deepseek-ai/dsh-session', SessionStore],
['@deepseek-ai/dsh-session-title', SessionTitleService],
['@deepseek-ai/dsh-session-title-first-message-llm', providerPlugin],
])
context.loader.internal = {
version: 'v2',
async import(specifier: string) {
if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`)
return modules.get(specifier)
},
} as unknown as NonNullable<typeof context.loader.internal>
await context.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(configPath).href },
})
await context.loader.await()
return context
}
describe('session-title Loader composition', () => {
it('loads the service and one model provider with required deployment policy', async () => {
const ctx = await loadComposition()
const unloaded = [...ctx.loader.entries()]
.filter(entry => entry.fiber === undefined && !entry.disabled)
.map(entry => entry.options.name)
expect(unloaded).toEqual([])
const adapter = new LoaderAdapter()
ctx.llm.registerAdapter(['title-route'], adapter)
const session = ctx.sessions.create(SessionId('loader-title'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const message = session.append('user/message', {
content: [{ type: 'text', text: 'Compose a title through Loader' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
await new Promise(resolve => setTimeout(resolve, 0))
session.append('request/header', {
header: { config: { provider: 'main-route', model: 'main-model' } },
reason: 'initial',
})
await new Promise(resolve => setTimeout(resolve, 0))
expect(adapter.requests[0]).toMatchObject({ provider: 'title-route', model: 'title-model' })
expect(ctx.sessionTitle.get(session)).toMatchObject({
title: 'Loader composed title',
messageSeqs: [message.seq],
source: {
kind: 'provider',
provider: 'session-title-first-message-llm',
model: { provider: 'title-route', model: 'title-model' },
},
})
})
})

View File

@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService from '@deepseek-ai/dsh-llm'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService from '@deepseek-ai/dsh-session-title'
import * as FirstMessageTitleProvider from '@deepseek-ai/dsh-session-title-first-message-llm'
const contexts: Context[] = []
afterEach(async () => {
await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
})
describe.skipIf(!process.env.DEEPSEEK_API_KEY)('first-message title provider with real DeepSeek API', () => {
it('replaces the fallback with a short model title', async () => {
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(LlmService)
await ctx.plugin(LlmDeepSeek, { thinking: 'disabled' })
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, {
fallbackMaxWords: 5,
fallbackMaxBytes: 40,
maxTitleBytes: 80,
})
await ctx.plugin(FirstMessageTitleProvider, {
targetWords: 5,
targetCjkCharacters: 10,
maxInputBytes: 4_096,
maxOutputTokens: 64,
timeoutMs: 60_000,
provider: 'deepseek',
model: 'deepseek-v4-flash',
})
const session = ctx.sessions.create(SessionId('real-title-provider'))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const message = session.append('user/message', {
content: [{ type: 'text', text: 'Explain why append-only logs make session titles durable.' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const title = await ctx.sessionTitle.refresh(session)
expect(title).toMatchObject({
messageSeqs: [message.seq],
source: {
kind: 'provider',
provider: 'session-title-first-message-llm',
model: { provider: 'deepseek', model: 'deepseek-v4-flash' },
},
})
expect(title?.title.length).toBeGreaterThan(0)
expect(Buffer.byteLength(title?.title ?? '', 'utf8')).toBeLessThanOrEqual(80)
})
})

View File

@@ -0,0 +1,86 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmService, { LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session'
import SessionTitleService, { type SessionTitleProvider } from '@deepseek-ai/dsh-session-title'
import * as providerPlugin from '@deepseek-ai/dsh-session-title-first-message-llm'
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.requests.push(options)
yield { type: 'text-delta', index: 0, text: 'First-message model title' }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
const TITLE_CONFIG = { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 80 } as const
const LLM_CONFIG = {
targetWords: 5,
targetCjkCharacters: 10,
maxInputBytes: 1_000,
maxOutputTokens: 32,
timeoutMs: 1_000,
provider: 'title-route',
model: 'title-model',
} as const
async function settle(): Promise<void> {
await new Promise(resolve => setTimeout(resolve, 0))
}
describe('first-message LLM title provider', () => {
it('rejects an impossible empty provider request at its own boundary', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
let registered: SessionTitleProvider | undefined
vi.spyOn(ctx.sessionTitle, 'register').mockImplementation((provider) => {
registered = provider
return async () => undefined
})
providerPlugin.apply(ctx, LLM_CONFIG)
await expect(registered!.generate({
session: new Session(SessionId('empty-first-provider')),
messages: [],
signal: new AbortController().signal,
})).rejects.toThrow(/requires one human message/)
})
it('always selects only the first eligible human message, including explicit refresh', async () => {
const ctx = new Context()
await ctx.plugin(LlmService)
await ctx.plugin(SessionStore)
await ctx.plugin(SessionTitleService, TITLE_CONFIG)
const adapter = new RecordingAdapter()
ctx.llm.registerAdapter(['title-route'], adapter)
await ctx.plugin(providerPlugin, LLM_CONFIG)
const session = ctx.sessions.create(SessionId('first-plugin'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const first = session.append('user/message', {
content: [{ type: 'text', text: 'first input' }], source: { kind: 'user' },
}, { surfaceOp: 'append' })
await settle()
session.append('request/header', {
header: { config: { provider: 'main', model: 'main-model' } }, reason: 'initial',
})
await settle()
session.append('user/message', {
content: [{ type: 'text', text: 'second input must be ignored' }], source: { kind: 'user' },
}, { surfaceOp: 'append' })
await ctx.sessionTitle.refresh(session)
expect(adapter.requests).toHaveLength(2)
for (const options of adapter.requests) {
const content = options.messages[0]?.content[0]
expect(content?.type === 'text' && content.text).toContain('first input')
expect(content?.type === 'text' && content.text).not.toContain('second input must be ignored')
}
expect(ctx.sessionTitle.get(session)).toMatchObject({ messageSeqs: [first.seq] })
})
})

View File

@@ -0,0 +1,14 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": { "rootDir": "src", "outDir": "lib/types" },
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../support/invariants" },
{ "path": "../../llm/llm" },
{ "path": "../session-title" },
{ "path": "../session-title-llm" }
]
}

View File

@@ -0,0 +1,45 @@
# @deepseek-ai/dsh-session-title-llm
Shared implementation policy for model-backed session-title providers. It resolves the auxiliary route, frames exact selected human messages as JSON, records the exact dispatchable request, applies a language-aware title instruction, enforces input and output budgets, composes timeout and caller cancellation, assembles the stream, and returns normalized text with exact source seqs and model provenance.
This package is a library, not a Cordis plugin. The provider plugins call `registerSessionTitleLlmProvider()` with their cadence and message selector; it validates shared config and delegates each revision to `generateSessionTitleWithLlm()`, so registration, route, prompt, cancellation, and validation behavior cannot drift between them.
## Route and failure contract
`provider` and `model` overrides are optional but must be supplied together as non-empty strings. Without that pair, the helper uses the exact provider/model route captured from the current session's logged `request/header`; an explicit refresh before any route exists therefore needs overrides. The helper measures the final JSON-framed user prompt, including seq fields, wrappers, and JSON escaping, against `maxInputBytes` before logging or dispatch instead of truncating it. Timeout and caller cancellation are rechecked while consuming the stream and after it completes, so a late successful result cannot be accepted even if an interceptor or adapter ignores abort. Malformed or empty output, tool calls, and non-stop finish reasons also reject; the session-title service decides whether that rejection is an automatic warning or an explicit caller failure.
After route and input validation, the helper appends a log-only `session/title-llm-request` event before model dispatch. It contains the title-provider id, exact source seqs, route, system prompt, message list, and output-token cap used by the call. The append shares the title capability's per-session settlement queue, so a superseding request cannot collide with an earlier fallback, request record, or accepted-title flush. The dispatched envelope is deep-frozen to keep interceptors aligned with that record but deliberately lacks dsh-agent-loop's process-local request identity, so loop-only reconstruction observers do not compare it with the conversation header. A later model failure leaves that request record intact; validation failures that never become dispatchable requests do not create one. The event stays outside derived model history.
## Configuration
Every field is required except the paired route override; there are no library defaults.
| Key | Contract |
|---|---|
| `targetWords` | Positive target word count for non-CJK titles. |
| `targetCjkCharacters` | Positive target character count for Chinese, Japanese, or Korean titles. |
| `maxInputBytes` | Positive UTF-8 byte ceiling for the final JSON-framed user prompt. |
| `maxOutputTokens` | Positive auxiliary generation token cap. |
| `timeoutMs` | Positive end-to-end deadline within the runtime timer limit. |
| `provider`, `model` | Optional explicit route; both or neither. |
## Model Experience
### Auxiliary title request
#### What the model sees
The title model receives a fixed system instruction to return one concise unadorned title in the input language, including the configured word and CJK-character targets. Its one user message contains a JSON array of the exact selected human messages and their seqs.
#### Token effect
The auxiliary request consumes tokens according to selected input size and `maxOutputTokens`. It is separate from the main agent request and does not add title text or framing to agent history.
#### KV Cache effect
No main-request invalidation. Auxiliary cache reuse is provider-specific; the fixed instruction is reusable while the JSON message array changes with each revision.
## Known Limitations and Deferred Work
- The helper accepts text output only and rejects tool calls; structured-output adapters and provider-specific prompt variants are not exposed.
- It enforces a byte ceiling for the whole framed user prompt rather than clipping individual messages or applying a retention policy.

View File

@@ -1,6 +1,6 @@
{
"name": "@deepseek-ai/dsh-home",
"description": "Canonical DeepSeek Harness home-directory resolver",
"name": "@deepseek-ai/dsh-session-title-llm",
"description": "Shared LLM generation policy for DeepSeek Harness session-title providers",
"version": "0.0.1",
"private": true,
"type": "module",
@@ -28,10 +28,21 @@
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-invariants": "^0.0.1",
"cordis": "^4.0.0-rc.6"
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-timeout": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"cordis": "^4.0.0-rc.6"
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-timeout": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,298 @@
/**
* Shared route, framing, timeout, assembly, and validation policy for
* model-backed session-title providers.
* @module @deepseek-ai/dsh-session-title-llm
*/
import type { Context } from 'cordis'
import z from 'schemastery'
import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm'
import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
appendSessionTitleOutOfBand,
normalizeSessionTitle,
SessionTitleProviderId,
} from '@deepseek-ai/dsh-session-title'
import type {
SessionTitleAutomaticMode,
SessionTitleModelProvenance,
SessionTitleProviderRequest,
SessionTitleProviderResult,
SessionTitleUserMessage,
} from '@deepseek-ai/dsh-session-title'
/** Exact model-visible request recorded before one auxiliary title dispatch. */
export interface SessionTitleLlmRequestEventData {
/** Registered title-provider identity responsible for the request. */
readonly titleProvider: SessionTitleProviderId
/** Exact human `user/message` seqs represented in `messages`. */
readonly messageSeqs: number[]
/** Exact auxiliary LLM route. */
readonly route: SessionTitleModelProvenance
/** Exact auxiliary system prompt. */
readonly system: string
/** Exact auxiliary message list. */
readonly messages: Message[]
/** Exact auxiliary output-token cap. */
readonly maxTokens: number
}
declare module '@deepseek-ai/dsh-session' {
interface SessionEventMap {
/** Log-only pre-dispatch record of one session-title model request. */
'session/title-llm-request': SessionTitleLlmRequestEventData
}
interface OutOfBandSessionEventMap {
'session/title-llm-request': true
}
}
/** Capability-owned timeout reason code for auxiliary title requests. */
export const SESSION_TITLE_TIMEOUT_CODE = 'SESSION_TITLE_TIMEOUT'
/** Required deployment policy for one model-backed title plugin. */
export interface SessionTitleLlmConfig {
/** Target word count for non-CJK titles. */
readonly targetWords: number
/** Target character count for Chinese, Japanese, or Korean titles. */
readonly targetCjkCharacters: number
/** Maximum UTF-8 bytes in the final JSON-framed user prompt. */
readonly maxInputBytes: number
/** Auxiliary generation output-token cap. */
readonly maxOutputTokens: number
/** End-to-end auxiliary request deadline in milliseconds. */
readonly timeoutMs: number
/** Optional explicit provider route; must be paired with `model`. */
readonly provider?: string
/** Optional explicit model id; must be paired with `provider`. */
readonly model?: string
}
/** Validated immutable model-provider policy. */
export interface ResolvedSessionTitleLlmConfig extends SessionTitleLlmConfig {}
/** Shared Loader field schemas with no library defaults. */
export const SessionTitleLlmConfigFields = {
targetWords: z.number().step(1).min(1).required(),
targetCjkCharacters: z.number().step(1).min(1).required(),
maxInputBytes: z.number().step(1).min(1).required(),
maxOutputTokens: z.number().step(1).min(1).required(),
timeoutMs: z.number().step(1).min(1).max(MAX_TIMER_DELAY_MS).required(),
provider: z.string(),
model: z.string(),
}
/** Shared Loader schema with no library defaults. */
export const SessionTitleLlmConfigSchema: z<SessionTitleLlmConfig> = z.object(SessionTitleLlmConfigFields)
/** Complete configuration key set for direct construction validation. */
const CONFIG_KEYS: ReadonlySet<string> = new Set([
'targetWords',
'targetCjkCharacters',
'maxInputBytes',
'maxOutputTokens',
'timeoutMs',
'provider',
'model',
])
/** Validate one positive integer limit. */
function assertPositiveInteger(name: string, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`session-title-llm: ${name} must be a positive integer`)
}
}
/**
* Validate and detach required model-provider configuration.
* @param config - untrusted plugin configuration.
* @returns immutable policy with optional route absence preserved.
*/
export function resolveSessionTitleLlmConfig(
config: SessionTitleLlmConfig,
): ResolvedSessionTitleLlmConfig {
const candidate: unknown = config
if (candidate === null || typeof candidate !== 'object') {
throw new Error('session-title-llm: configuration is required')
}
const value = candidate as SessionTitleLlmConfig
for (const key of Object.keys(value)) {
if (!CONFIG_KEYS.has(key)) throw new Error(`session-title-llm: unknown config key "${key}"`)
}
assertPositiveInteger('targetWords', value.targetWords)
assertPositiveInteger('targetCjkCharacters', value.targetCjkCharacters)
assertPositiveInteger('maxInputBytes', value.maxInputBytes)
assertPositiveInteger('maxOutputTokens', value.maxOutputTokens)
assertPositiveInteger('timeoutMs', value.timeoutMs)
if (value.timeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(`session-title-llm: timeoutMs must not exceed ${MAX_TIMER_DELAY_MS}`)
}
const hasProvider = value.provider !== undefined
const hasModel = value.model !== undefined
if (hasProvider !== hasModel) {
throw new Error('session-title-llm: provider and model must be supplied together')
}
if (hasProvider
&& (typeof value.provider !== 'string' || value.provider.length === 0
|| typeof value.model !== 'string' || value.model.length === 0)) {
throw new Error('session-title-llm: provider and model overrides must be non-empty strings')
}
return deepFreeze({ ...value })
}
/** Select the provider-owned message subset from one fixed service revision. */
export type SessionTitleLlmMessageSelector = (
messages: readonly SessionTitleUserMessage[],
) => readonly SessionTitleUserMessage[]
/**
* Register one model-backed provider through the shared configuration and call policy.
* @param ctx - context exposing the title and LLM services.
* @param config - untrusted required deployment policy.
* @param id - stable plugin identity recorded in title provenance.
* @param automatic - provider-owned automatic generation cadence.
* @param selectMessages - exact source-message selection for one revision.
*/
export function registerSessionTitleLlmProvider(
ctx: Context,
config: SessionTitleLlmConfig,
id: string,
automatic: SessionTitleAutomaticMode,
selectMessages: SessionTitleLlmMessageSelector,
): void {
const resolved = resolveSessionTitleLlmConfig(config)
const titleProvider = SessionTitleProviderId(id)
ctx.sessionTitle.register({
id: titleProvider,
automatic,
async generate(request) {
return generateSessionTitleWithLlm(ctx, resolved, request, selectMessages(request.messages), titleProvider)
},
})
}
/** Resolve the explicit pair or the exact route captured from `request/header`. */
function resolveRoute(
config: ResolvedSessionTitleLlmConfig,
request: SessionTitleProviderRequest,
): SessionTitleModelProvenance {
if (config.provider !== undefined && config.model !== undefined) {
return { provider: config.provider, model: config.model }
}
if (request.route === undefined) {
throw new Error('session-title-llm: no logged request route is available; configure provider and model together')
}
return request.route
}
/** Stable language-aware system instruction shared by both provider plugins. */
function systemPrompt(config: ResolvedSessionTitleLlmConfig): string {
return [
'Create a concise title for an AI coding-assistant session from the supplied human messages.',
'Return only the title on one line, with no quotes, prefix, explanation, Markdown, or terminal control codes.',
'Use the language of the messages.',
`Aim for about ${config.targetWords} words in non-CJK languages or ${config.targetCjkCharacters} CJK characters.`,
].join('\n')
}
/** Frame exact messages as JSON so user text cannot break structural delimiters. */
function frameMessages(messages: readonly SessionTitleUserMessage[]): string {
return `Generate the session title from this JSON array of human messages:\n${JSON.stringify(messages)}`
}
/** Translate terminal finish reasons into an auxiliary-call failure. */
function finishError(finish: FinishReason): Error | undefined {
switch (finish.kind) {
case 'stop':
return undefined
case 'error':
case 'aborted': {
const error = new Error(finish.failure.message) as Error & { code?: string }
error.code = finish.failure.code
return error
}
case 'max-tokens':
return new Error('session-title-llm: title output reached maxOutputTokens')
case 'tool-calls':
return new Error('session-title-llm: title model unexpectedly requested a tool')
default:
return new Error(`session-title-llm: unsupported finish reason "${String((finish as { kind?: unknown }).kind)}"`)
}
}
/**
* Generate one title through the shared auxiliary LLM call.
* @param ctx - context exposing the registered LLM service.
* @param config - validated model-provider policy.
* @param request - service-owned session, route, message snapshot, and cancellation.
* @param selectedMessages - exact provider-selected subset to frame and attribute.
* @param titleProvider - registered title-provider identity recorded with the request.
* @returns normalized non-empty title, exact source seqs, and used model route.
*/
export async function generateSessionTitleWithLlm(
ctx: Context,
config: ResolvedSessionTitleLlmConfig,
request: SessionTitleProviderRequest,
selectedMessages: readonly SessionTitleUserMessage[],
titleProvider: SessionTitleProviderId,
): Promise<SessionTitleProviderResult> {
request.signal.throwIfAborted()
if (selectedMessages.length === 0) {
throw new Error('session-title-llm: at least one source message is required')
}
const framedInput = frameMessages(selectedMessages)
const inputBytes = Buffer.byteLength(framedInput, 'utf8')
if (inputBytes > config.maxInputBytes) {
throw new Error(`session-title-llm: input is ${inputBytes} bytes, exceeding maxInputBytes ${config.maxInputBytes}`)
}
const route = resolveRoute(config, request)
const messages: Message[] = [{
role: 'user',
content: [{ type: 'text', text: framedInput }],
}]
const system = systemPrompt(config)
using callDeadline = deadline(request.signal, config.timeoutMs, SESSION_TITLE_TIMEOUT_CODE)
const options: GenerateOptions = deepFreeze({
provider: route.provider,
model: route.model,
messages,
system,
maxTokens: config.maxOutputTokens,
sessionId: request.session.id,
signal: callDeadline.signal,
})
await appendSessionTitleOutOfBand(ctx, request.session, 'session/title-llm-request', {
titleProvider,
messageSeqs: selectedMessages.map(message => message.seq),
route,
system,
messages,
maxTokens: config.maxOutputTokens,
}, callDeadline.signal)
callDeadline.signal.throwIfAborted()
const assembler = new BlockAssembler()
for await (const chunk of ctx.llm.stream(options)) {
callDeadline.signal.throwIfAborted()
assembler.push(chunk)
}
callDeadline.signal.throwIfAborted()
const terminalError = finishError(assembler.finish)
if (terminalError !== undefined) throw terminalError
const blocks = assembler.message().content
if (blocks.some(block => block.type === 'tool-call')) {
throw new Error('session-title-llm: title output must contain text only')
}
const text = blocks
.filter((block): block is Extract<(typeof blocks)[number], { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join(' ')
const title = normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER)
if (title.length === 0) throw new Error('session-title-llm: title model produced no text')
return {
title,
messageSeqs: selectedMessages.map(message => message.seq),
model: route,
}
}

View File

@@ -0,0 +1,30 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title-llm`.
* @module @deepseek-ai/dsh-session-title-llm/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-llm'
/** Cordis companion plugin name. */
export const name = 'session-title-llm-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this stateless helper validates and freezes each auxiliary request before
* dispatch; deadline, stream, and provenance relationships are checked synchronously and by tests.
*/
const install: InvariantInstaller = () => {}
/**
* Register this package's invariant companion.
* @param ctx - Cordis context carrying the invariant service.
* @returns the installed registration's disposer after setup succeeds.
*/
export const apply = (ctx: Context): Promise<() => void> =>
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
/* jscpd:ignore-end */

View File

@@ -0,0 +1,365 @@
import { Context } from 'cordis'
import { describe, expect, it, vi } from 'vitest'
import LlmService, { CallId, isAgentLoopRequest, LlmAdapter } from '@deepseek-ai/dsh-llm'
import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import { SessionTitleProviderId } from '@deepseek-ai/dsh-session-title'
import type { SessionTitleProviderRequest } from '@deepseek-ai/dsh-session-title'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import {
generateSessionTitleWithLlm,
resolveSessionTitleLlmConfig,
SESSION_TITLE_TIMEOUT_CODE,
} from '@deepseek-ai/dsh-session-title-llm'
import type { SessionTitleLlmConfig } from '@deepseek-ai/dsh-session-title-llm'
class RecordingAdapter extends LlmAdapter {
readonly requests: GenerateOptions[] = []
constructor(
private readonly script: readonly StreamChunk[],
private readonly onDispatch?: () => void,
) {
super()
}
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
this.onDispatch?.()
this.requests.push(options)
yield * this.script
}
}
class CooperativeAdapter extends LlmAdapter {
override async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
const signal = options.signal
if (signal === undefined) throw new Error('expected title request signal')
await new Promise<never>((_resolve, reject) => {
const rejectAbort = (): void => {
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors -- exercise exact AbortSignal.reason propagation
reject(signal.reason)
}
if (signal.aborted) {
rejectAbort()
return
}
signal.addEventListener('abort', rejectAbort, { once: true })
})
}
}
class DelayedSuccessAdapter extends LlmAdapter {
constructor(private readonly delayMs: number) {
super()
}
override async * stream(): AsyncIterable<StreamChunk> {
await new Promise<void>(resolve => setTimeout(resolve, this.delayMs))
yield * SCRIPT
}
}
const SCRIPT: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'text' },
{ type: 'text-delta', index: 0, text: ' 五个字标题 ' },
{ type: 'finish', reason: { kind: 'stop' } },
]
const CONFIG = {
targetWords: 5,
targetCjkCharacters: 10,
maxInputBytes: 1_000,
maxOutputTokens: 32,
timeoutMs: 1_000,
} as const
const TITLE_PROVIDER = SessionTitleProviderId('test-title-provider')
let nextSession = 0
function request(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
const session = ctx.sessions.create(SessionId(`title-call-${++nextSession}`))
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
const first = session.append('user/message', {
content: [{ type: 'text', text: 'first prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
const second = session.append('user/message', {
content: [{ type: 'text', text: '第二个问题' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return {
session,
messages: [
{ seq: first.seq, text: 'first prompt' },
{ seq: second.seq, text: '第二个问题' },
],
route: { provider: 'current-route', model: 'current-model' },
signal,
}
}
function requestWithoutRoute(ctx: Context, signal = new AbortController().signal): SessionTitleProviderRequest {
const routed = request(ctx, signal)
return { session: routed.session, messages: routed.messages, signal }
}
async function withScript(script: readonly StreamChunk[]): Promise<{
ctx: Context
adapter: RecordingAdapter
}> {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(script)
ctx.llm.registerAdapter(['current-route'], adapter)
return { ctx, adapter }
}
describe('generateSessionTitleWithLlm', () => {
it('uses the exact logged route, language targets, full framed input, and output token cap', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
const providerRequest = request(ctx)
let requestWasLoggedAtDispatch = false
const adapter = new RecordingAdapter(SCRIPT, () => {
requestWasLoggedAtDispatch = providerRequest.session.events
.some(event => event.type === 'session/title-llm-request')
})
ctx.llm.registerAdapter(['current-route'], adapter)
const result = await generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)
expect(result).toEqual({
title: '五个字标题',
messageSeqs: providerRequest.messages.map(message => message.seq),
model: { provider: 'current-route', model: 'current-model' },
})
expect(requestWasLoggedAtDispatch).toBe(true)
expect(adapter.requests).toHaveLength(1)
const options = adapter.requests[0]!
expect(Object.isFrozen(options)).toBe(true)
expect(Object.isFrozen(options.messages)).toBe(true)
expect(isAgentLoopRequest(options)).toBe(false)
expect(options).toMatchObject({
provider: 'current-route',
model: 'current-model',
maxTokens: 32,
sessionId: providerRequest.session.id,
})
expect(options.system).toContain('5 words')
expect(options.system).toContain('10 CJK characters')
const prompt = options.messages[0]?.content[0]
expect(prompt?.type === 'text' && prompt.text).toContain('first prompt')
expect(prompt?.type === 'text' && prompt.text).toContain('第二个问题')
expect(providerRequest.session.events.findLast(event => event.type === 'session/title-llm-request')?.data)
.toEqual({
titleProvider: TITLE_PROVIDER,
messageSeqs: providerRequest.messages.map(message => message.seq),
route: { provider: 'current-route', model: 'current-model' },
system: options.system,
messages: options.messages,
maxTokens: 32,
})
})
it('uses paired explicit overrides and bounds the final framed input before model dispatch', async () => {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
const adapter = new RecordingAdapter(SCRIPT)
ctx.llm.registerAdapter(['explicit-route'], adapter)
const oversized = request(ctx)
const [selected] = oversized.messages
if (selected === undefined) throw new Error('expected one selected message')
const rawInputBytes = Buffer.byteLength(selected.text, 'utf8')
const config = resolveSessionTitleLlmConfig({
...CONFIG,
provider: 'explicit-route',
model: 'explicit-model',
maxInputBytes: rawInputBytes,
})
await expect(generateSessionTitleWithLlm(ctx, config, oversized, [selected], TITLE_PROVIDER))
.rejects.toThrow(/input.*bytes.*maxInputBytes/i)
expect(adapter.requests).toEqual([])
expect(oversized.session.events.some(event => event.type === 'session/title-llm-request')).toBe(false)
const withinLimit = resolveSessionTitleLlmConfig({ ...config, maxInputBytes: 1_000 })
const within = request(ctx)
await generateSessionTitleWithLlm(ctx, withinLimit, within, [within.messages[0]!], TITLE_PROVIDER)
expect(adapter.requests[0]).toMatchObject({
provider: 'explicit-route',
model: 'explicit-model',
})
})
it('requires every deployment limit and a complete optional route pair', () => {
expect(() => resolveSessionTitleLlmConfig(undefined as never)).toThrow(/configuration is required/)
expect(() => resolveSessionTitleLlmConfig(null as never)).toThrow(/configuration is required/)
expect(() => resolveSessionTitleLlmConfig('invalid' as never)).toThrow(/configuration is required/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, extra: true } as SessionTitleLlmConfig))
.toThrow(/unknown config key "extra"/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 0 }))
.toThrow(/targetWords.*positive integer/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, targetWords: 1.5 }))
.toThrow(/targetWords.*positive integer/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'only-provider' }))
.toThrow(/provider and model must be supplied together/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, model: 'only-model' }))
.toThrow(/provider and model must be supplied together/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: '', model: 'model' }))
.toThrow(/overrides must be non-empty strings/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: '' }))
.toThrow(/overrides must be non-empty strings/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 1, model: 'model' } as never))
.toThrow(/overrides must be non-empty strings/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, provider: 'provider', model: 1 } as never))
.toThrow(/overrides must be non-empty strings/)
expect(() => resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: MAX_TIMER_DELAY_MS + 1 }))
.toThrow(/timeoutMs must not exceed/)
expect(() => resolveSessionTitleLlmConfig(CONFIG)).not.toThrow()
})
it('rejects an absent route, empty selection, and pre-aborted caller before model dispatch', async () => {
const { ctx, adapter } = await withScript(SCRIPT)
const config = resolveSessionTitleLlmConfig(CONFIG)
const unrouted = requestWithoutRoute(ctx)
await expect(generateSessionTitleWithLlm(ctx, config, unrouted, unrouted.messages, TITLE_PROVIDER))
.rejects.toThrow(/no logged request route/)
const empty = request(ctx)
await expect(generateSessionTitleWithLlm(ctx, config, empty, [], TITLE_PROVIDER))
.rejects.toThrow(/at least one source message/)
const controller = new AbortController()
controller.abort(new Error('caller stopped'))
const aborted = request(ctx, controller.signal)
await expect(generateSessionTitleWithLlm(ctx, config, aborted, aborted.messages, TITLE_PROVIDER))
.rejects.toThrow('caller stopped')
expect(adapter.requests).toEqual([])
})
it.each([
[{ kind: 'error', failure: { message: 'provider failed', code: 'SERVER' } }, 'provider failed', 'SERVER'],
[{ kind: 'aborted', failure: { message: 'provider aborted', code: 'ABORTED' } }, 'provider aborted', 'ABORTED'],
] satisfies Array<[FinishReason, string, string]>)('preserves %s terminal failure details', async (reason, message, code) => {
const { ctx } = await withScript([{ type: 'finish', reason }])
const providerRequest = request(ctx)
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)).rejects.toMatchObject({ message, code })
expect(providerRequest.session.events.some(event => event.type === 'session/title-llm-request')).toBe(true)
})
it.each([
[{ kind: 'max-tokens' }, /reached maxOutputTokens/],
[{ kind: 'tool-calls' }, /unexpectedly requested a tool/],
[{ kind: 'future-finish' } as never, /unsupported finish reason "future-finish"/],
] satisfies Array<[FinishReason, RegExp]>)('rejects the terminal finish reason %s', async (reason, error) => {
const { ctx } = await withScript([{ type: 'finish', reason }])
const providerRequest = request(ctx)
await expect(generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig(CONFIG),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)).rejects.toThrow(error)
})
it('rejects tool-call blocks and a successful response with no text', async () => {
const toolScript: StreamChunk[] = [
{ type: 'block-start', index: 0, blockType: 'tool-call' },
{ type: 'tool-call-delta', index: 0, id: CallId('title-tool'), name: 'unexpected', argumentsDelta: '{}' },
{ type: 'finish', reason: { kind: 'stop' } },
]
const tool = await withScript(toolScript)
const toolRequest = request(tool.ctx)
await expect(generateSessionTitleWithLlm(
tool.ctx,
resolveSessionTitleLlmConfig(CONFIG),
toolRequest,
toolRequest.messages,
TITLE_PROVIDER,
)).rejects.toThrow(/output must contain text only/)
const reasoning = await withScript([
{ type: 'block-start', index: 0, blockType: 'reasoning' },
{ type: 'reasoning-delta', index: 0, text: 'no final title' },
{ type: 'finish', reason: { kind: 'stop' } },
])
const reasoningRequest = request(reasoning.ctx)
await expect(generateSessionTitleWithLlm(
reasoning.ctx,
resolveSessionTitleLlmConfig(CONFIG),
reasoningRequest,
reasoningRequest.messages,
TITLE_PROVIDER,
)).rejects.toThrow(/produced no text/)
})
it('aborts a cooperative model stream at the configured deadline', async () => {
vi.useFakeTimers()
try {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['current-route'], new CooperativeAdapter())
const providerRequest = request(ctx)
const pending = generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)
const rejected = expect(pending).rejects.toMatchObject({
code: SESSION_TITLE_TIMEOUT_CODE,
timeoutMs: 10,
})
await vi.advanceTimersByTimeAsync(10)
await rejected
} finally {
vi.useRealTimers()
}
})
it('rejects a successful stream that completes after the configured deadline', async () => {
vi.useFakeTimers()
try {
const ctx = new Context()
await ctx.plugin(SessionStore)
await ctx.plugin(LlmService)
ctx.llm.registerAdapter(['current-route'], new DelayedSuccessAdapter(20))
const providerRequest = request(ctx)
const pending = generateSessionTitleWithLlm(
ctx,
resolveSessionTitleLlmConfig({ ...CONFIG, timeoutMs: 10 }),
providerRequest,
providerRequest.messages,
TITLE_PROVIDER,
)
const rejected = expect(pending).rejects.toMatchObject({
code: SESSION_TITLE_TIMEOUT_CODE,
timeoutMs: 10,
})
await vi.advanceTimersByTimeAsync(20)
await rejected
} finally {
vi.useRealTimers()
}
})
})

View File

@@ -0,0 +1,17 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"],
"references": [
{ "path": "../../../vendor/cosmokit" },
{ "path": "../../../vendor/cordis" },
{ "path": "../../../vendor/schemastery" },
{ "path": "../../support/invariants" },
{ "path": "../../llm/llm" },
{ "path": "../../util/timeout" },
{ "path": "../session-title" }
]
}

View File

@@ -0,0 +1,52 @@
# @deepseek-ai/dsh-session-title
Log-backed session titles with an immediate deterministic fallback and one optional asynchronous provider. Every accepted revision is a log-only `session/title` event; `foldSessionTitle()` and `ctx.sessionTitle.get()` select the latest event and return its event seq and timestamp.
Only text blocks from human `user/message` events are eligible. The first eligible prompt schedules a fallback from its first words within the configured UTF-8 byte limit. Whitespace is normalized, terminal control sequences are removed, and truncation never splits a code point. Empty and non-text prompts wait for later eligible input.
## Service: `SessionTitleService` (ctx key: `sessionTitle`)
- `get(session)` folds the latest accepted title from a live or replayed log.
- `refresh(session, signal?)` materializes the fallback when needed, then explicitly runs the registered provider over the current eligible messages. Provider errors and caller cancellation reject; cancellation does not roll back a fallback append already entering durability.
- `register(provider)` installs the sole optional provider and returns its awaitable Cordis effect disposer. A second registration throws immediately; disposal aborts pending and active calls, waits for their settlement, and only then permits another provider to register.
Automatic work never delays the main agent response. A provider starts only after a marked loop-built request's exact route matches the current logged `request/header`, including when the unchanged header needs no new snapshot. Its late completion joins an open turn or uses a flushed zero-step `session-title` turn through `ctx.sessions.appendOutOfBand()`. Automatic failures warn and retain the latest title. New all-message revisions, provider disposal, session disposal, and explicit refresh abort older work, and a stale completion cannot append. Concurrent explicit refreshes reserve their order before fallback durability waits, while overlapping automatic and explicit fallback requests share one session-local in-flight append. Service and bundled model-provider records use `appendSessionTitleOutOfBand()` to share a per-session settlement queue, so a replacement request record waits for an earlier title write without serializing the superseded model call itself. Service teardown cancels queued work and drains calls that ignore cancellation before unloading completes.
Forks inherit title events in their seed unchanged. The first-message cadence does not automatically retitle a child; the all-messages cadence may append a new revision after the child receives a later human prompt.
## Configuration
All limits are required; the library supplies no defaults.
| Key | Contract |
|---|---|
| `fallbackMaxWords` | Positive maximum whitespace-delimited words in the deterministic fallback. |
| `fallbackMaxBytes` | Positive maximum UTF-8 bytes in the fallback; must not exceed `maxTitleBytes`. |
| `maxTitleBytes` | Positive maximum UTF-8 bytes accepted from any source. |
## Provider contract
A provider supplies a branded stable id, automatic mode (`first-message` or `all-user-messages`), and `generate(request)`. The request carries the live session, all eligible messages through one fixed revision, the current logged main-request route when available, and cancellation. The result identifies a non-empty title, unique ordered source-message seqs from that request, and optional model provenance. The service normalizes and validates the result before it becomes durable.
See the [session-title data structures](../../../docs/core-data-structures/session-title.md) and [implemented decision](../../../.agents/notes/implemented/feature/2026-07-21-log-backed-session-titles.md).
## Model Experience
### Session title state
#### What the model sees
Nothing. `session/title` is log-only and never enters the session surface, `deriveMessages()`, system prompt, tool schemas, or request prefix.
#### Token effect
The fallback and accepted provider revisions add zero tokens to the main agent request. An optional provider's separate auxiliary request is documented by that provider package.
#### KV Cache effect
None for the main request; title events do not change its reconstructed content or cache key.
## Known Limitations and Deferred Work
- Manual rename, title deletion, generated-versus-user precedence, search, and list indexing are outside this service.
- The provider registry deliberately accepts at most one implementation, so a deployment cannot compose competing title strategies without writing one provider that owns their precedence.

View File

@@ -0,0 +1,48 @@
{
"name": "@deepseek-ai/dsh-session-title",
"description": "Log-backed session title service and provider registry for the DeepSeek Harness",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "lib/index.js",
"types": "lib/types/index.d.ts",
"exports": {
".": {
"types": "./lib/types/index.d.ts",
"default": "./lib/index.js"
},
"./invariant": {
"types": "./lib/types/invariant.d.ts",
"default": "./lib/invariant.js"
},
"./src/*": "./src/*",
"./package.json": "./package.json"
},
"files": [
"lib/index.js",
"lib/invariant.js",
"lib/types/**/*.d.ts",
"lib/types/**/*.d.ts.map",
"src"
],
"license": "BSD-3-Clause",
"peerDependencies": {
"@deepseek-ai/dsh-brand": "^0.0.1",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"schemastery": "^3.18.0"
},
"devDependencies": {
"@deepseek-ai/dsh-brand": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^",
"@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -0,0 +1,727 @@
/**
* Log-backed session title service, deterministic fallback, and provider seam.
* @module @deepseek-ai/dsh-session-title
*/
import { Context, FiberState, Service, type Fiber } from 'cordis'
import z from 'schemastery'
import type { Branded } from '@deepseek-ai/dsh-brand'
import { deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm'
import type { GenerateOptions } from '@deepseek-ai/dsh-llm'
import type {
OutOfBandSessionEventType,
Session,
SessionEvent,
SessionEventMap,
} from '@deepseek-ai/dsh-session'
import { fallbackSessionTitle, normalizeSessionTitle } from './normalize.ts'
export { fallbackSessionTitle, normalizeSessionTitle, truncateTitleUtf8 } from './normalize.ts'
/** Identifies one session-title provider registration. */
export type SessionTitleProviderId = Branded<'SessionTitleProviderId'>
/**
* Brand a raw provider id.
* @param id - stable non-empty provider identifier supplied by a plugin.
* @returns the same string with the session-title provider brand.
*/
export function SessionTitleProviderId(id: string): SessionTitleProviderId {
return id as SessionTitleProviderId
}
/** Exact auxiliary model route that produced a title. */
export interface SessionTitleModelProvenance {
/** Registered LLM provider route. */
readonly provider: string
/** Provider model id. */
readonly model: string
}
/** Durable ownership record for an accepted session title. */
export type SessionTitleSource =
| { readonly kind: 'fallback' }
| {
readonly kind: 'provider'
readonly provider: SessionTitleProviderId
readonly model?: SessionTitleModelProvenance
}
/** Payload of the log-only `session/title` event. */
export interface SessionTitleEventData {
/** Normalized non-empty title text. */
readonly title: string
/** Exact human `user/message` seqs used to derive this title. */
readonly messageSeqs: number[]
/** Built-in fallback or registered-provider provenance. */
readonly source: SessionTitleSource
}
/** Latest folded title plus the title event's durable envelope facts. */
export interface SessionTitleSnapshot extends SessionTitleEventData {
/** Seq of the latest `session/title` event. */
readonly eventSeq: number
/** Timestamp of the latest `session/title` event. */
readonly updatedAt: number
}
/** Required deterministic fallback and accepted-title limits. */
export interface Config {
/** Maximum whitespace-delimited words in the built-in fallback. */
readonly fallbackMaxWords: number
/** Maximum UTF-8 bytes in the built-in fallback. */
readonly fallbackMaxBytes: number
/** Maximum UTF-8 bytes in any accepted title. */
readonly maxTitleBytes: number
}
declare module 'cordis' {
interface Context {
sessionTitle: SessionTitleService
}
}
declare module '@deepseek-ai/dsh-session' {
interface TurnTriggerMap {
/** Zero-step turn opened only to durably append a late title update. */
'session-title': { kind: 'session-title' }
}
interface SessionEventMap {
/**
* Latest-wins session title snapshot. Log-only: it never enters the model
* surface or derived history.
*/
'session/title': SessionTitleEventData
}
interface OutOfBandSessionEventMap {
'session/title': true
}
}
/** Per-session settlement tails for title-capability out-of-band writes. */
const SESSION_TITLE_WRITE_TAILS = new WeakMap<Session, Promise<void>>()
/** Convert either write outcome into a fulfilled queue tail. */
function settleSessionTitleWrite(): void {}
/**
* Serialize one title-capability out-of-band event with its session peers.
* Cancellation is checked when the write reaches the head of the queue; once
* the core append starts, its durability contract runs to completion.
* @param ctx - context exposing the live session store.
* @param session - exact live session that owns the title-capability event.
* @param type - plugin-declared log-only title event type.
* @param data - typed JSON payload for the event.
* @param signal - service or provider lifetime checked before publication starts.
* @returns the durably accepted event.
*/
export async function appendSessionTitleOutOfBand<T extends OutOfBandSessionEventType>(
ctx: Context,
session: Session,
type: T,
data: SessionEventMap[T],
signal: AbortSignal,
): Promise<SessionEvent<T>> {
const predecessor = SESSION_TITLE_WRITE_TAILS.get(session)
const run = Promise.resolve(predecessor).then(() => {
signal.throwIfAborted()
return ctx.sessions.appendOutOfBand(session, type, data, { kind: 'session-title' })
})
const tail = run.then(settleSessionTitleWrite, settleSessionTitleWrite)
SESSION_TITLE_WRITE_TAILS.set(session, tail)
try {
return await run
} finally {
if (SESSION_TITLE_WRITE_TAILS.get(session) === tail) {
SESSION_TITLE_WRITE_TAILS.delete(session)
}
}
}
/** One eligible human text message exposed to title providers. */
export interface SessionTitleUserMessage {
/** Source `user/message` event seq. */
readonly seq: number
/** Exact concatenated text-block content. */
readonly text: string
}
/** Automatic generation cadence owned by a registered provider. */
export type SessionTitleAutomaticMode = 'first-message' | 'all-user-messages'
/** Immutable input supplied to one title-provider call. */
export interface SessionTitleProviderRequest {
/** Live session being titled. */
readonly session: Session
/** All eligible human messages through this generation revision. */
readonly messages: readonly SessionTitleUserMessage[]
/** Exact current logged main-request route, when one has been recorded. */
readonly route?: SessionTitleModelProvenance
/** Cancellation for supersession, disposal, timeout composition, or the explicit caller. */
readonly signal: AbortSignal
}
/** Provider output before service-owned normalization and durable acceptance. */
export interface SessionTitleProviderResult {
/** Proposed title text. */
readonly title: string
/** Exact seqs from `request.messages` used by this result. */
readonly messageSeqs: readonly number[]
/** Auxiliary LLM route, when generation used a model. */
readonly model?: SessionTitleModelProvenance
}
/** One optional asynchronous title implementation registered with the service. */
export interface SessionTitleProvider {
/** Stable provider identity recorded in title provenance. */
readonly id: SessionTitleProviderId
/** When new human prompts start automatic generation. */
readonly automatic: SessionTitleAutomaticMode
/**
* Produce one title revision.
* @param request - message snapshot, current route, session, and cancellation.
* @returns proposed title plus exact input seqs and optional model provenance.
*/
generate(request: SessionTitleProviderRequest): Promise<SessionTitleProviderResult>
}
/**
* Collect human text-bearing user messages in log order.
* @param events - session log or persisted replay.
* @param throughSeq - optional inclusive event boundary.
* @returns eligible messages with exact source seqs.
*/
export function collectSessionTitleMessages(
events: readonly SessionEvent[],
throughSeq?: number,
): SessionTitleUserMessage[] {
const messages: SessionTitleUserMessage[] = []
for (const event of events) {
if (throughSeq !== undefined && event.seq > throughSeq) break
if (event.type !== 'user/message' || event.data.source.kind !== 'user') continue
const text = event.data.content
.filter((block): block is Extract<(typeof event.data.content)[number], { type: 'text' }> => block.type === 'text')
.map(block => block.text)
.join('\n')
if (normalizeSessionTitle(text, Number.MAX_SAFE_INTEGER).length === 0) continue
messages.push({ seq: event.seq, text })
}
return messages
}
/**
* Fold the latest logged title without consulting mutable metadata.
* @param events - live or persisted session log.
* @returns the latest immutable title snapshot, or `undefined`.
*/
export function foldSessionTitle(events: readonly SessionEvent[]): SessionTitleSnapshot | undefined {
const event = events.findLast(item => item.type === 'session/title')
if (event === undefined) return undefined
return deepFreeze({
title: event.data.title,
messageSeqs: [...event.data.messageSeqs],
source: event.data.source.kind === 'fallback'
? { kind: 'fallback' }
: {
kind: 'provider',
provider: event.data.source.provider,
...(event.data.source.model === undefined
? {}
: { model: { ...event.data.source.model } }),
},
eventSeq: event.seq,
updatedAt: event.time,
})
}
/** Service-owned resolved limits. */
interface ResolvedConfig {
readonly fallbackMaxWords: number
readonly fallbackMaxBytes: number
readonly maxTitleBytes: number
}
/** One exact provider registration generation. */
interface ProviderRegistration {
readonly provider: SessionTitleProvider
readonly active: Set<Promise<unknown>>
closing: boolean
}
/** Automatic work waiting for the matching main-request header. */
interface PendingAutomaticWork {
readonly registration: ProviderRegistration
readonly revision: number
readonly throughSeq: number
}
/** Provider call currently allowed to commit for one session. */
interface ActiveProviderWork extends PendingAutomaticWork {
readonly controller: AbortController
readonly signal: AbortSignal
}
/** Mutable concurrency state scoped to one live session. */
interface SessionTitleWorkState {
revision: number
fallback?: Promise<SessionTitleSnapshot | undefined>
pending?: PendingAutomaticWork
active?: ActiveProviderWork
}
/** Validate one positive integer configuration field. */
function assertPositiveInteger(name: keyof Config, value: number): void {
if (!Number.isInteger(value) || value <= 0) {
throw new Error(`session-title: ${name} must be a positive integer`)
}
}
/** Log-backed title fold plus asynchronous fallback generation. */
export class SessionTitleService extends Service {
static inject = ['sessions']
static Config: z<Config> = z.object({
fallbackMaxWords: z.number().step(1).min(1).required(),
fallbackMaxBytes: z.number().step(1).min(1).required(),
maxTitleBytes: z.number().step(1).min(1).required(),
})
private readonly config: ResolvedConfig
private readonly ownerFiber: Fiber
private registration: ProviderRegistration | undefined
private readonly work = new Map<Session, SessionTitleWorkState>()
private readonly lifetime = new AbortController()
private readonly inFlight = new Set<Promise<unknown>>()
constructor(ctx: Context, config: Config) {
super(ctx, 'sessionTitle')
this.ownerFiber = ctx.fiber
const candidate: unknown = config
if (candidate === null || typeof candidate !== 'object') {
throw new Error('session-title: configuration is required')
}
const value = candidate as Config
assertPositiveInteger('fallbackMaxWords', value.fallbackMaxWords)
assertPositiveInteger('fallbackMaxBytes', value.fallbackMaxBytes)
assertPositiveInteger('maxTitleBytes', value.maxTitleBytes)
if (value.fallbackMaxBytes > value.maxTitleBytes) {
throw new Error('session-title: fallbackMaxBytes must not exceed maxTitleBytes')
}
this.config = deepFreeze({ ...value })
ctx.effect(() => async () => {
this.lifetime.abort(new Error('session-title service disposed'))
if (this.registration !== undefined) this.registration.closing = true
this.registration = undefined
for (const state of this.work.values()) {
delete state.pending
state.active?.controller.abort(new Error('session-title service disposed'))
}
await this.drain(this.inFlight)
this.work.clear()
}, 'sessionTitle lifecycle')
ctx.on('session/event', (session, event) => {
switch (event.type) {
case 'user/message':
this.onUserMessage(session, event)
break
case 'request/header':
this.onRequestHeader(session, event)
break
default:
break
}
})
ctx.on('llm/stream', (options, next) => {
this.onMainRequest(options)
return next()
}, { global: true, prepend: true })
ctx.on('session/disposed', (session) => {
const state = this.work.get(session)
if (state === undefined) return
state.active?.controller.abort(new Error('session disposed during title generation'))
this.work.delete(session)
})
}
/**
* Read the latest folded title from one live or replayed session.
* @param session - session whose log is the title source of truth.
* @returns latest title snapshot, or `undefined` before eligible input.
*/
get(session: Session): SessionTitleSnapshot | undefined {
return foldSessionTitle(session.events)
}
/**
* Explicitly retry the registered provider, or materialize the built-in
* fallback when no provider is registered.
* @param session - exact live session to refresh.
* @param signal - optional caller cancellation; an in-progress fallback append may finish durably before rejection.
* @returns latest accepted title, or `undefined` when no eligible text exists.
*/
async refresh(session: Session, signal?: AbortSignal): Promise<SessionTitleSnapshot | undefined> {
signal?.throwIfAborted()
this.assertServiceActive()
if (this.ctx.sessions.get(session.id) !== session) {
throw new Error(`session "${session.id}" is not live in this store`)
}
const registration = this.registration
const messages = collectSessionTitleMessages(session.events)
const latest = messages.at(-1)
if (registration === undefined || registration.closing || latest === undefined) {
const fallback = await this.ensureFallback(session)
signal?.throwIfAborted()
return fallback
}
const state = this.stateFor(session)
const revision = this.supersede(state, 'explicit title refresh superseded older generation')
const work = this.activate({
registration,
revision,
throughSeq: latest.seq,
}, state, signal)
const config = session.requestHeader()?.config
const route = config === undefined ? undefined : { provider: config.provider, model: config.model }
return this.startProvider(session, work, route)
}
/**
* Register the sole optional title provider. Disposal aborts its pending and
* active work before another provider may register.
* @param provider - provider identity, cadence, and generation function.
* @returns exact Cordis effect disposer, which settles after active calls quiesce.
*/
register(provider: SessionTitleProvider): () => Promise<void> {
this.validateProvider(provider)
if (this.registration !== undefined) {
throw new Error(`session-title provider "${this.registration.provider.id}" is already registered`)
}
const registration: ProviderRegistration = {
provider,
active: new Set(),
closing: false,
}
const dispose = this.ctx.effect(function* (this: SessionTitleService) {
this.registration = registration
yield async () => {
registration.closing = true
for (const state of this.work.values()) {
if (state.pending?.registration === registration) delete state.pending
if (state.active?.registration === registration) {
state.active.controller.abort(new Error(`session-title provider "${provider.id}" was disposed`))
}
}
await this.drain(registration.active)
if (this.registration === registration) this.registration = undefined
}
}.bind(this), 'sessionTitle.register()')
return dispose
}
/** Schedule fallback creation and any provider cadence for one eligible event. */
private onUserMessage(session: Session, event: Extract<SessionEvent, { type: 'user/message' }>): void {
if (!this.serviceActive()) return
if (event.data.source.kind !== 'user' || collectSessionTitleMessages([event]).length === 0) return
const registration = this.registration
if (registration !== undefined && !registration.closing) {
const messages = collectSessionTitleMessages(session.events, event.seq)
const shouldSchedule = registration.provider.automatic === 'all-user-messages'
|| (session.header.parentSession === undefined && messages.length === 1 && this.get(session) === undefined)
if (shouldSchedule) {
const state = this.stateFor(session)
const revision = this.supersede(state, 'newer user message superseded title generation')
state.pending = { registration, revision, throughSeq: event.seq }
}
}
this.defer(async () => {
try {
await this.ensureFallback(session)
} catch (error: unknown) {
if (!this.serviceActive()) return
this.ctx.logger.warn(`session "${session.id}": fallback title update failed: ${String(error)}`)
}
})
}
/** Start pending automatic work only after its exact main-request route is logged. */
private onRequestHeader(session: Session, event: Extract<SessionEvent, { type: 'request/header' }>): void {
if (!this.serviceActive()) return
const state = this.work.get(session)
const pending = state?.pending
if (state === undefined || pending === undefined || pending.throughSeq >= event.seq) return
const route = {
provider: event.data.header.config.provider,
model: event.data.header.config.model,
}
this.startPending(session, state, pending, route)
}
/** Start unchanged-route work from the marked loop request after its header fold is current. */
private onMainRequest(options: GenerateOptions): void {
if (!this.serviceActive() || options.sessionId === undefined || !isAgentLoopRequest(options)) return
const session = this.ctx.sessions.get(options.sessionId)
const state = session === undefined ? undefined : this.work.get(session)
const pending = state?.pending
if (session === undefined || state === undefined || pending === undefined) return
const boundary = session.events.findLast(event => event.type === 'step/start' || event.type === 'step/end')
const route = session.requestHeader()?.config
if (boundary?.type !== 'step/start'
|| boundary.seq <= pending.throughSeq
|| route?.provider !== options.provider
|| route.model !== options.model) return
this.startPending(session, state, pending, { provider: options.provider, model: options.model })
}
/** Consume one pending revision and schedule its non-blocking provider call. */
private startPending(
session: Session,
state: SessionTitleWorkState,
pending: PendingAutomaticWork,
route: SessionTitleModelProvenance,
): void {
delete state.pending
this.defer(async () => {
if (this.registration !== pending.registration
|| pending.registration.closing
|| this.work.get(session) !== state
|| state.revision !== pending.revision) return
const work = this.activate(pending, state)
try {
await this.startProvider(session, work, route)
} catch (error: unknown) {
if (work.signal.aborted || !this.serviceActive()) return
this.ctx.logger.warn(`session "${session.id}": automatic title generation failed: ${String(error)}`)
}
})
}
/** Start one tracked provider call after publishing its active revision. */
private startProvider(
session: Session,
work: ActiveProviderWork,
route?: SessionTitleModelProvenance,
): Promise<SessionTitleSnapshot | undefined> {
const run = Promise.resolve().then(() => this.runProvider(session, work, route))
return this.track(run, work.registration)
}
/** Execute and durably accept one current provider revision. */
private async runProvider(
session: Session,
work: ActiveProviderWork,
route?: SessionTitleModelProvenance,
): Promise<SessionTitleSnapshot | undefined> {
try {
this.assertCurrent(session, work)
await this.ensureFallback(session)
this.assertCurrent(session, work)
const messages = collectSessionTitleMessages(session.events, work.throughSeq)
const result = await work.registration.provider.generate({
session,
messages,
...route === undefined ? {} : { route },
signal: work.signal,
})
this.assertCurrent(session, work)
const accepted = this.validateResult(result, messages)
await appendSessionTitleOutOfBand(this.ctx, session, 'session/title', {
title: accepted.title,
messageSeqs: [...accepted.messageSeqs],
source: {
kind: 'provider',
provider: work.registration.provider.id,
...accepted.model === undefined ? {} : { model: accepted.model },
},
}, work.signal)
return this.get(session)
} finally {
const state = this.work.get(session)
if (state?.active === work) delete state.active
}
}
/** Validate and normalize provider output against the supplied message snapshot. */
private validateResult(
result: unknown,
messages: readonly SessionTitleUserMessage[],
): SessionTitleProviderResult {
if (result === null || typeof result !== 'object') {
throw new Error('session-title provider returned an invalid result')
}
const candidate = result as Record<string, unknown>
if (typeof candidate.title !== 'string') throw new Error('session-title provider title must be a string')
const title = normalizeSessionTitle(candidate.title, this.config.maxTitleBytes)
if (title.length === 0) throw new Error('session-title provider returned an empty title')
if (!Array.isArray(candidate.messageSeqs) || candidate.messageSeqs.length === 0) {
throw new Error('session-title provider must identify at least one source message seq')
}
const messageSeqs: number[] = []
const order = new Map(messages.map((message, index) => [message.seq, index]))
let previous = -1
for (const seq of candidate.messageSeqs as unknown[]) {
if (typeof seq !== 'number') {
throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
}
const index = order.get(seq)
if (!Number.isSafeInteger(seq) || seq < 0 || index === undefined || index <= previous) {
throw new Error('session-title provider messageSeqs must be unique, ordered seqs from the request')
}
messageSeqs.push(seq)
previous = index
}
const modelCandidate = candidate.model
let model: SessionTitleModelProvenance | undefined
if (modelCandidate !== undefined) {
if (modelCandidate === null || typeof modelCandidate !== 'object') {
throw new Error('session-title provider model provenance requires non-empty provider and model')
}
const record = modelCandidate as Record<string, unknown>
if (typeof record.provider !== 'string' || record.provider.length === 0
|| typeof record.model !== 'string' || record.model.length === 0) {
throw new Error('session-title provider model provenance requires non-empty provider and model')
}
model = { provider: record.provider, model: record.model }
}
return {
title,
messageSeqs,
...(model === undefined ? {} : { model }),
}
}
/** Fail a completion whose provider, revision, session, or signal is stale. */
private assertCurrent(session: Session, work: ActiveProviderWork): void {
this.assertServiceActive()
work.signal.throwIfAborted()
const state = this.work.get(session)
/* v8 ignore next -- every supported supersession, provider disposal, and session disposal aborts
* the work signal before changing this state. */
if (this.registration !== work.registration
|| state?.active !== work
|| state.revision !== work.revision
|| this.ctx.sessions.get(session.id) !== session) {
throw new Error('session title generation state changed without cancellation')
}
}
/** Create and publish an active provider call from one fixed revision. */
private activate(
pending: PendingAutomaticWork,
state: SessionTitleWorkState,
upstream?: AbortSignal,
): ActiveProviderWork {
const controller = new AbortController()
const signal = upstream === undefined
? AbortSignal.any([controller.signal, this.lifetime.signal])
: AbortSignal.any([controller.signal, this.lifetime.signal, upstream])
const work: ActiveProviderWork = { ...pending, controller, signal }
state.active = work
return work
}
/** Abort older active work and reserve the next session-local revision. */
private supersede(state: SessionTitleWorkState, reason: string): number {
state.active?.controller.abort(new Error(reason))
delete state.pending
state.revision += 1
return state.revision
}
/** Return mutable work state for one session. */
private stateFor(session: Session): SessionTitleWorkState {
let state = this.work.get(session)
if (state === undefined) {
state = { revision: 0 }
this.work.set(session, state)
}
return state
}
/** Queue detached service work and retain it through service disposal. */
private defer(task: () => Promise<void>): void {
const run = Promise.resolve().then(async () => {
if (!this.serviceActive()) return
await task()
})
void this.track(run)
}
/** Retain one promise until settlement for service and optional provider teardown. */
private track<T>(run: Promise<T>, registration?: ProviderRegistration): Promise<T> {
this.inFlight.add(run)
registration?.active.add(run)
const settled = (): void => {
this.inFlight.delete(run)
registration?.active.delete(run)
}
void run.then(settled, settled)
return run
}
/** Await every current and settling promise in one lifecycle registry. */
private async drain(active: Set<Promise<unknown>>): Promise<void> {
while (active.size > 0) await Promise.allSettled([...active])
}
/** Whether the owning plugin fiber can still start or commit title work. */
private serviceActive(): boolean {
return !this.lifetime.signal.aborted
&& this.ownerFiber.uid !== null
&& this.ownerFiber.state === FiberState.ACTIVE
}
/** Reject work once the owning plugin fiber has begun unloading. */
private assertServiceActive(): void {
if (!this.serviceActive()) throw new Error('session-title service disposed')
}
/** Reject malformed provider registrations before publishing an effect. */
private validateProvider(provider: unknown): asserts provider is SessionTitleProvider {
if (provider === null || typeof provider !== 'object') {
throw new Error('session-title provider must be an object')
}
const candidate = provider as Record<string, unknown>
if (typeof candidate.id !== 'string' || candidate.id.length === 0) {
throw new Error('session-title provider id must be a non-empty string')
}
if (candidate.automatic !== 'first-message' && candidate.automatic !== 'all-user-messages') {
throw new Error('session-title provider automatic mode is invalid')
}
if (typeof candidate.generate !== 'function') {
throw new Error(`session-title provider "${candidate.id}" requires generate()`)
}
}
/** Create the first deterministic fallback if the session still lacks a title. */
private async ensureFallback(session: Session): Promise<SessionTitleSnapshot | undefined> {
this.assertServiceActive()
const current = this.get(session)
if (current !== undefined) return current
const [first] = collectSessionTitleMessages(session.events)
if (first === undefined) return undefined
const title = fallbackSessionTitle(
first.text,
this.config.fallbackMaxWords,
this.config.fallbackMaxBytes,
)
if (title.length === 0) return undefined
const state = this.stateFor(session)
if (state.fallback !== undefined) return state.fallback
const fallback = appendSessionTitleOutOfBand(this.ctx, session, 'session/title', {
title,
messageSeqs: [first.seq],
source: { kind: 'fallback' },
}, this.lifetime.signal).then(() => this.get(session))
state.fallback = fallback
try {
return await fallback
} finally {
delete state.fallback
}
}
}
export default SessionTitleService

View File

@@ -1,22 +1,22 @@
/**
* Package-owned invariant companion for `@deepseek-ai/dsh-home`.
* @module @deepseek-ai/dsh-home/invariant
* Package-owned invariant companion for `@deepseek-ai/dsh-session-title`.
* @module @deepseek-ai/dsh-session-title/invariant
*/
/* jscpd:ignore-start */
import type { Context } from 'cordis'
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
const PACKAGE_NAME = '@deepseek-ai/dsh-home'
const PACKAGE_NAME = '@deepseek-ai/dsh-session-title'
/** Cordis companion plugin name. */
export const name = 'home-invariant'
export const name = 'session-title-invariant'
/** Service required before the companion can reserve package ownership. */
export const inject = ['invariants']
/**
* No runtime invariant: this pure utility owns no event stream or mutable runtime data; its value
* algebra is enforced by unit tests.
* No runtime invariant: the service validates provider revisions before their single durable
* append, and its remaining provider lifecycle state is process-local and covered by package tests.
*/
const install: InvariantInstaller = () => {}

Some files were not shown because too many files have changed in this diff Show More