Merge remote-tracking branch 'origin/master' into worktree/llm-reasoning-effort

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/config-catalog.md
This commit is contained in:
Yichen Jiang
2026-07-26 13:13:31 +08:00
338 changed files with 15268 additions and 3386 deletions

View File

@@ -17,4 +17,4 @@ A UI integration is a client-driver plugin, not a loop change: it consumes the e
`user-approval`, `user-interaction`, and `tool-ask-user` live here because asking a human is a UI-backed product affordance, not part of the providerless core spine. `user-approval` owns the one-shot `ctx.approval` decision mechanism and its policy tier; answerers remain with the channel or automation transport that owns the agent. `user-interaction` remains provider-neutral (`ctx.userInteraction`), while `tool-ask-user` is its model-facing consumer and interactive app packages provide concrete providers.
The runnable app bundles that bake these interfaces into boot bins live in [`examples/`](../examples/README.md), composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md). `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.
The runnable app bundles composed over [`agent-spine-demo`](../examples/agent-spine-demo/README.md) live in [`examples/`](../examples/README.md) (`tui-demo`, `acp-demo`, `jsonrpc-demo`). `acp-demo` and `jsonrpc-demo` own boot bins; the `tui-demo` bundle is booted by the product [`dsh`](../../apps/cli/README.md) CLI. `ui/` keeps the reusable human/SDK channel plugins and shared `app-boot` glue; the automation-only ACP transport lives in [`acp/`](../acp/README.md). Each front door owns its stdout policy, and a leaf `cordis.yml` supplies backends and optional tools.

View File

@@ -1,17 +1,16 @@
# `@deepseek-ai/dsh-app-boot`
Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md), [`dsh-cli-demo`](../../examples/cli-demo/README.md), [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so the loader-failure lore lives once — under the per-file coverage gate — instead of drifting between published artifacts.
| Export | Role |
|---|---|
| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` |
| `parseResumeArg(argv)` | Split the `--resume <id>` / `--resume=<id>` flag out of the arguments, returning `{ resumeSessionId, rest }`; a valueless, empty, or repeated flag throws so a mistyped resume fails loud instead of silently starting fresh |
| `replaceResumeArg(argv, sessionId)` | Remove an existing resume flag and append one canonical `--resume <sessionId>` pair while preserving positional arguments |
| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) |
| `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) |
| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) |
| `loadPersonalPatches(binName, dir?)` | Parse the optional `config.yaml` in the Harness home (default [`resolveDshHome()`](../../util/paths/README.md): `$DSH_HOME`, else `~/.dsh`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount, then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `boot(binName, absoluteConfigPath, patches?, prepare?)` | Create the root context, run optional host preparation before plugins mount (e.g. `ctx.provide(RESUME_SESSION_ID_KEY, id)`), then mount the Loader/include tree, await it, assert entries loaded, and return the root context |
| `RESUME_SESSION_ID_KEY` | Context key a bin sets through `boot`'s `prepare` hook to hand a resume session id to the booted config; the config reads it as the bare identifier `resumeSessionId` in a `!!js` expression, so resuming needs no environment variable |
| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to its own source checkout; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot |
| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under |

View File

@@ -1,5 +1,5 @@
/**
* Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* Shared boot glue for the app bins (`dsh`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the
* optional personal overlay patches from the Harness home (`~/.dsh`), and drive the cordis Loader
* against a leaf `cordis.yml` until the whole tree has settled.
@@ -36,62 +36,6 @@ export function resolveConfigPath(
return resolve(dir, replayName)
}
/** CLI flag the interactive surface accepts to resume a persisted session by id. */
const RESUME_FLAG = '--resume'
/**
* Split a leading `--resume <id>` / `--resume=<id>` flag out of a CLI argument
* vector, returning the resumed session id (when the flag is present) and the
* remaining arguments with the flag and its value removed — so a positional
* config path stays readable regardless of the flag's position. A `--resume`
* with no following id, an empty id (`--resume=`), or a repeated `--resume`
* throws: a mistyped resume must fail loud, never silently start a fresh
* session. The id is not validated here; an unknown id fails loud downstream
* when the session cannot load.
* @param argv - the CLI arguments after subcommand dispatch.
* @returns the parsed resume id (or `undefined`) and the flag-stripped arguments.
*/
export function parseResumeArg(
argv: readonly string[],
): { resumeSessionId: string | undefined; rest: string[] } {
const rest: string[] = []
let resumeSessionId: string | undefined
let skipNext = false
for (const [i, arg] of argv.entries()) {
if (skipNext) {
skipNext = false
continue
}
const inlineValue = arg.startsWith(`${RESUME_FLAG}=`)
if (arg === RESUME_FLAG || inlineValue) {
if (resumeSessionId !== undefined) throw new Error(`${RESUME_FLAG} may be given only once`)
const value = inlineValue ? arg.slice(RESUME_FLAG.length + 1) : argv[i + 1]
// A following token that is itself resume syntax (`--resume --resume x`)
// is a missing id, not a session literally named `--resume…`.
if (value === undefined || value === '' || value === RESUME_FLAG || value.startsWith(`${RESUME_FLAG}=`)) {
throw new Error(`${RESUME_FLAG} requires a session id (e.g. ${RESUME_FLAG} <session-id>)`)
}
resumeSessionId = value
skipNext = !inlineValue // the space form consumed the following token as its value
continue
}
rest.push(arg)
}
return { resumeSessionId, rest }
}
/**
* Replace any existing resume flag with one canonical trailing `--resume <id>` pair.
* @param argv - current arguments after command dispatch.
* @param sessionId - selected session id.
* @returns flag-normalized arguments for a process replacement.
*/
export function replaceResumeArg(argv: readonly string[], sessionId: string): string[] {
if (sessionId.length === 0) throw new Error(`${RESUME_FLAG} requires a non-empty session id`)
const { rest } = parseResumeArg(argv)
return [...rest, RESUME_FLAG, sessionId]
}
/**
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
* ambient environment; other read failures are reported through `warn`.
@@ -212,6 +156,17 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
}
}
/**
* Context key a bin sets through {@link boot}'s `prepare` hook to hand a resume
* session id to the booted config: `ctx.provide(RESUME_SESSION_ID_KEY, id)`
* makes `id` readable as the bare identifier `resumeSessionId` in a config
* `!!js` expression. The value is the bin's already-parsed id (or `undefined`),
* so resuming a session needs no environment variable. A bin that never
* provides it leaves the identifier undeclared, so configs read it defensively
* (`typeof resumeSessionId === 'string' ? resumeSessionId : undefined`).
*/
export const RESUME_SESSION_ID_KEY = 'resumeSessionId'
/**
* Boot the Loader against `absoluteConfigPath` and return only after the whole
* tree settles. Entry names load through the Loader's internal module loader

View File

@@ -6,7 +6,7 @@ import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, parseResumeArg, replaceResumeArg, resolveConfigPath, type FailLoudProcess,
installFailLoud, loadEnv, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -30,40 +30,6 @@ describe('resolveConfigPath', () => {
})
})
describe('parseResumeArg', () => {
it('returns no resume id and passes arguments through when the flag is absent', () => {
expect(parseResumeArg([])).toEqual({ resumeSessionId: undefined, rest: [] })
expect(parseResumeArg(['custom.yml'])).toEqual({ resumeSessionId: undefined, rest: ['custom.yml'] })
})
it('parses the space form, the inline form, and leaves a positional config path in any position', () => {
expect(parseResumeArg(['--resume', 'sess-1'])).toEqual({ resumeSessionId: 'sess-1', rest: [] })
expect(parseResumeArg(['--resume=sess-2'])).toEqual({ resumeSessionId: 'sess-2', rest: [] })
expect(parseResumeArg(['--resume', 'sess-3', 'app.yml'])).toEqual({ resumeSessionId: 'sess-3', rest: ['app.yml'] })
expect(parseResumeArg(['app.yml', '--resume', 'sess-4'])).toEqual({ resumeSessionId: 'sess-4', rest: ['app.yml'] })
})
it('fails loud on a valueless, empty, or repeated flag rather than silently starting fresh', () => {
expect(() => parseResumeArg(['--resume'])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume='])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume', 'a', '--resume', 'b'])).toThrow('--resume may be given only once')
})
it('rejects resume syntax used as the flag value instead of resuming a session named like the flag', () => {
expect(() => parseResumeArg(['--resume', '--resume', 'sess'])).toThrow('--resume requires a session id')
expect(() => parseResumeArg(['--resume', '--resume=sess'])).toThrow('--resume requires a session id')
})
})
describe('replaceResumeArg', () => {
it('keeps positional arguments and replaces either existing flag form', () => {
expect(replaceResumeArg(['app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(replaceResumeArg(['--resume', 'old', 'app.yml'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(replaceResumeArg(['app.yml', '--resume=old'], 'next')).toEqual(['app.yml', '--resume', 'next'])
expect(() => replaceResumeArg([], '')).toThrow('non-empty session id')
})
})
describe('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()

View File

@@ -9,8 +9,11 @@ export class TestSessionQueryService extends SessionQueryService {
}
override searchEvents(
..._args: Parameters<SessionQueryService['searchEvents']>
...args: Parameters<SessionQueryService['searchEvents']>
): ReturnType<SessionQueryService['searchEvents']> {
return Promise.resolve({ items: [] })
return this.readSurface(args[0].sessionId).then(surface => ({
session: surface.session,
items: [],
}))
}
}

View File

@@ -675,7 +675,7 @@ describe('TUI terminal-state snapshots', () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-23T08:00:00.000Z'))
const earlier = { version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' }
const harness = await setupSnapshot({
config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' },
config: { resumeCommand: 'dsh --resume {session}' },
sessionPersistence: {
list: async () => [earlier],
load: async () => ({

View File

@@ -214,7 +214,7 @@ describe('TUI config', () => {
})
describe('resume command and /resume', () => {
const RESUME = 'RESUME_SESSION_ID={session} dsh'
const RESUME = 'dsh --resume {session}'
const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
({ version: 0, id: SessionId(id), createdAt, cwd })
const resumeEvents = (
@@ -242,7 +242,7 @@ describe('resume command and /resume', () => {
result.terminal.send('/exit')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('To resume this session: RESUME_SESSION_ID=main-session dsh')
expect(result.terminal.output).toContain('To resume this session: dsh --resume main-session')
expect(result.exit).toHaveBeenCalledWith(0)
await dispose(result)
})
@@ -1008,7 +1008,7 @@ describe('resume command and /resume', () => {
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('This host cannot hand off in place. Exit and run:')
expect(result.terminal.output).toContain('RESUME_SESSION_ID=fallback-session')
expect(result.terminal.output).toContain('dsh --resume fallback-session')
expect(result.terminal.stopped).toBe(0)
await dispose(result)
})