Merge latest master through rfc/pty into feature/persistent-pty-sessions

# Conflicts:
#	docs/module-graph.md
This commit is contained in:
Tianyi Cui
2026-07-22 21:21:58 +08:00
233 changed files with 8605 additions and 2468 deletions

View File

@@ -44,4 +44,3 @@ export function transportError<T>(error: unknown): RpcResult<T> {
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
}
}

View File

@@ -4,11 +4,11 @@ Per-session workspace instruction loading for `AGENTS.md`-compatible files. The
## Lifecycle
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by one configured instruction candidate in each directory from the project root to `agent.session.header.cwd`. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
The baseline is composed once per agent-loop instance on `agent/session-prefix`. It reads `$DSH_HOME/AGENTS.md` followed by, in each directory from the project root to `agent.session.header.cwd`, every existing base candidate and then every existing local-overlay candidate. Within one directory, candidates whose content is byte-identical after trimming leading and trailing whitespace collapse to the earliest candidate in configured order, so a `CLAUDE.md` that merely duplicates its sibling `AGENTS.md` is rendered once. The prefix is placed before all derived history, recorded in `EpochHeader.messagePrefix`, and reused verbatim for that loop instance. Because the plugin prepends its contribution before delegating, a later-registered skills catalog appears after workspace instructions.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. A new file is attached through the result's `additionalContexts`; a changed file or candidate switch appends a replacement; a missing final candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
The plugin also listens on `tools/post-execute` for successful first-party `read`, `write`, and `edit` calls. Each touch checks newly reached descendant scopes and every previously loaded scope. Each configured candidate name is an independent scope in its directory: a newly present file is attached through the result's `additionalContexts`; a changed file appends a replacement; a file that disappears or becomes a per-directory duplicate of an earlier candidate appends a removal notice. Native calls and Code Mode sub-dispatches share this path: `run_code` defers each nested context until its outer result, so the loop still appends updates after tool-call/result adjacency is complete. This follows structured filesystem activity rather than shell `cd`, because each local bash call starts a fresh shell and parsing arbitrary shell syntax would be unreliable.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It calls `ctx.fs.lstat` before resolving a candidate, rejecting a final-component symlink instead of following repository-owned links across the trust boundary. Once `lstat` identifies the winning regular-file candidate, a later resolve/stat failure makes that scope temporarily unavailable instead of falling through to a lower-priority name. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
Instruction reads use the optional `ctx.fs` provider. The plugin does not statically inject `fs`, so providerless product trees still boot and instruction loading becomes a no-op until a provider is present. It resolves each candidate and stats the result, so a final-component symlink is followed to its target: a link to a regular file loads that target's content, while a missing path or a non-file target (including a link to a directory) is a confirmed absence. A resolve or stat exception instead marks that candidate's scope temporarily unavailable. Prefix cancellation and dynamic tool cancellation propagate through resolution, metadata probes, and streaming reads. A provider failure after a file was loaded is treated as temporarily unavailable, not as proof that the file was deleted.
## Prompt Shape
@@ -40,15 +40,15 @@ These instructions apply to work under `packages/app`. Use them as guidance when
</system-reminder>
```
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. A candidate switch additionally names the old path. When no candidate remains, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
A same-file edit starts with `Updated instructions from: <path>` and says to use the new content instead of the previously loaded content. When a candidate disappears or becomes a per-directory duplicate of an earlier candidate, the message is `Instructions removed: <path>` followed by `The previously loaded instructions from this file no longer apply.` Literal `</system-reminder>` text inside an instruction file is escaped so file content cannot close the plugin-owned frame.
The plugin owns the complete `<system-reminder>` framing, and every `context/message` (from this plugin or any other) reaches the model verbatim as a user-role message with no wrapping.
## State And Refresh
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, previousPath?, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
Model-visible text contains no hidden state markers. Each dynamic context event instead carries JSON metadata with a versioned list of `{ action, scope, path, digest? }` changes. On every relevant tool touch, the plugin reconstructs loaded state from its visible session events and overlays a short in-memory pending window for context present on the immutable top-level `tools/result` but not yet appended by the loop. A matching durable `context/message` confirms the pending transition. If the owning `step/end` arrives before a matching context reaches the log, the plugin clears the pending transition and its version fast path so the next successful touch can load it again. Nested Code Mode results stage pending changes under the outer execution token for same-run duplicate suppression; the outer result rolls that state back and recommits only contexts that survived outer policy.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
An unchanged path and SHA-1 content digest is not injected again. A per-session, per-scope metadata cache stores only `{ path, version, digest, trimmedDigest }`: when the provider's opaque `FsVersion` and the effective visible state both match, reconciliation skips the content read; a changed version triggers a bounded read and SHA-1 confirmation before any model-visible update. The `trimmedDigest` — SHA-1 over the whitespace-trimmed content — is the per-directory duplicate key, so an unchanged file can still be removed when an earlier candidate converges on its content. Resume works because SHA-1 state is persisted in the session log, while an empty in-memory version cache merely causes one confirming read. Compaction re-arms a scope after its context event leaves the visible surface even when the cached version is unchanged. A removal is a tombstone, so a later candidate reappearance is loaded again. Only model-visible changes actually rendered within the byte budget enter metadata, pending state, and the version cache; an omitted change remains eligible for a later touch, while a same-digest version refresh updates metadata only.
The frozen baseline itself is not rewritten mid-instance. Its initial path/digest map is retained as comparison state; the next successful filesystem touch appends any baseline replacement or removal. A resumed loop recomposes the current baseline and also reconciles still-visible dynamic scopes during prefix composition. There is no file watcher, so an on-disk change becomes visible at the next successful `read`, `write`, or `edit` touch, or when a resumed loop composes its prefix.
@@ -61,18 +61,19 @@ export interface Config {
maxBytes: number
maxSourceBytes?: number
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
}
```
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory, the first existing candidate wins; with defaults, `AGENTS.md` is native and `CLAUDE.md` is the compatibility fallback. Candidate entries must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
`maxBytes` is required so each deployment makes its prompt-budget choice explicitly. `maxSourceBytes` limits each source instruction file before rendering and defaults to 1 MiB. `projectRootMarkers` defaults to `['.git']`, and `instructionFileCandidates` defaults to `['AGENTS.md', 'CLAUDE.md']`. In each project directory every existing candidate loads, and candidates whose content matches an earlier one after trimming surrounding whitespace are dropped, so with the defaults an `AGENTS.md` and a `CLAUDE.md` that share content render once (as `AGENTS.md`) while genuinely distinct siblings both apply. `localInstructionFileCandidates` defaults to `['AGENTS.local.md', 'CLAUDE.local.md']` and loads its existing overlays alongside the base files of the same directory (rendered after them) under the same per-directory dedup; an empty list disables the overlay. Candidate entries in both lists must be same-directory file names, so empty entries, `.`/`..`, and entries containing `/` or `\` are ignored.
The user-global file is always `$DSH_HOME/AGENTS.md`; the candidate list only controls project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
The user-global file is always `$DSH_HOME/AGENTS.md` with no local overlay; both candidate lists only control project scopes. `$DSH_HOME` defaults to `~/.dsh`, and configured `~`, `~/...`, and Windows-style `~\...` prefixes are expanded against the operating-system home directory. A non-positive or non-finite render budget disables both baseline and dynamic loading; configured `maxSourceBytes` must be a positive integer.
## Budgeting And Bounded Reads
Rendering preserves the most specific instruction files first. It drops whole broader files before truncating the most-specific file and emits a visible `Workspace instruction budget ...` notice naming omitted and truncated paths. The rendered bytes never exceed `maxBytes`.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored without falling through to a lower-priority same-directory candidate; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
Instruction content is read through `streamText()` under `maxSourceBytes`, even when provider metadata omits size or a file grows after its metadata probe. An oversized file is ignored; during dynamic reconciliation it is temporarily unavailable rather than removed. The plugin keeps no process-wide cache and never caches instruction prose. Its session-local scope cache uses provider versions only as a fast invalidation signal; after invalidation, SHA-1 over the bounded read remains the cross-provider content identity stored in structured session metadata.
## Model Experience
@@ -136,7 +137,7 @@ Append-only; newly visible content follows the reusable request prefix and does
#### What the model sees
A changed file produces `Updated instructions from: <path>` plus its replacement content; a candidate switch also names the previous path. A removed final candidate produces the removal notice below.
A changed file produces `Updated instructions from: <path>` plus its replacement content. A candidate that disappears or becomes a per-directory duplicate of an earlier candidate produces the removal notice below.
##### Removal notice
@@ -160,5 +161,7 @@ Append-only; newly visible content follows the reusable request prefix and does
- **Discovery follows structured fs tools, not shell navigation** — a `bash` command that changes directories does not trigger nested instruction discovery because shell syntax and per-call shell state are not a reliable filesystem seam.
- **Refresh is touch-driven** — there is no watcher; external edits become visible on the next successful first-party `read`, `write`, or `edit`, or when a resumed loop recomposes its prefix.
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; same-directory names such as `CLAUDE.local.md` require explicit `instructionFileCandidates` configuration.
- **Candidate semantics stay intentionally small** — lowercase names, `.claude/rules/`, and `@path` imports are not interpreted; project scopes load `AGENTS.local.md`/`CLAUDE.local.md` overlays by default, but the user-global `$DSH_HOME` scope has no local overlay and other custom names require explicit candidate configuration.
- **Per-directory dedup is content-based** — sibling candidates collapse only when byte-identical after trimming leading and trailing whitespace; a `CLAUDE.md` that symlinks its sibling `AGENTS.md` resolves to the same content and collapses like any duplicate, while a distinct real copy that has drifted from `AGENTS.md` loads in full alongside it.
- **Symlinked instruction files are followed across the trust boundary** — a candidate whose final component is a symlink is resolved and its target loaded, so a cloned repository can surface off-tree file content as lower-authority workspace guidance (it never overrides system, developer, or direct user instructions). Confine `ctx.fs` with the filesystem policy gate or an OS sandbox when loading untrusted repositories.
- **Instruction content is bounded, not summarized** — over-budget broad files are omitted and the most-specific file may be truncated; the plugin never asks a model to compress instruction prose.

View File

@@ -9,6 +9,7 @@ import { resolveDshHome } from '@deepseek-ai/dsh-paths'
const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const
const DEFAULT_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.md', 'CLAUDE.md'] as const
const DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES = ['AGENTS.local.md', 'CLAUDE.local.md'] as const
const DEFAULT_MAX_SOURCE_BYTES = 1_048_576
const RESERVED_PATH_SEGMENTS = new Set(['', '.', '..'])
@@ -22,8 +23,16 @@ export interface Config {
maxBytes: number
/** Maximum UTF-8 bytes read from one instruction file; larger files are ignored. */
maxSourceBytes?: number
/** Ordered same-directory project candidates; the first existing regular file wins in each scope. */
/**
* Ordered same-directory project candidates; every existing file loads, with
* per-directory trimmed-content duplicates collapsed to the earliest candidate.
*/
instructionFileCandidates?: string[]
/**
* Ordered same-directory local-overlay candidates loaded after the base files
* under the same per-directory trimmed-content dedup; empty disables the overlay.
*/
localInstructionFileCandidates?: string[]
}
export const Config: z<Config> = z.object({
@@ -32,6 +41,7 @@ export const Config: z<Config> = z.object({
maxBytes: z.number().required(),
maxSourceBytes: z.number().step(1).min(1).default(DEFAULT_MAX_SOURCE_BYTES),
instructionFileCandidates: z.array(z.string()).default([...DEFAULT_INSTRUCTION_FILE_CANDIDATES]),
localInstructionFileCandidates: z.array(z.string()).default([...DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES]),
})
/** Normalized instruction discovery configuration. */
@@ -39,6 +49,7 @@ export interface ResolvedDiscoveryConfig {
dshHome: string
projectRootMarkers: string[]
instructionFileCandidates: string[]
localInstructionFileCandidates: string[]
}
/** Normalized configuration used by discovery and reconciliation. */
@@ -66,17 +77,24 @@ export function resolveConfig(config: Config): ResolvedConfig {
* @returns normalized home, root markers, and instruction candidates.
*/
export function resolveDiscoveryConfig(
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates'>,
config: Pick<Config, 'dshHome' | 'projectRootMarkers' | 'instructionFileCandidates' | 'localInstructionFileCandidates'>,
): ResolvedDiscoveryConfig {
return {
dshHome: resolveDshHome(config.dshHome),
projectRootMarkers: config.projectRootMarkers ?? [...DEFAULT_PROJECT_ROOT_MARKERS],
instructionFileCandidates: resolveInstructionFileCandidates(config.instructionFileCandidates),
instructionFileCandidates: resolveInstructionFileCandidates(
config.instructionFileCandidates,
DEFAULT_INSTRUCTION_FILE_CANDIDATES,
),
localInstructionFileCandidates: resolveInstructionFileCandidates(
config.localInstructionFileCandidates,
DEFAULT_LOCAL_INSTRUCTION_FILE_CANDIDATES,
),
}
}
function resolveInstructionFileCandidates(candidates: string[] | undefined): string[] {
return (candidates ?? [...DEFAULT_INSTRUCTION_FILE_CANDIDATES]).filter(candidate => (
function resolveInstructionFileCandidates(candidates: string[] | undefined, fallback: readonly string[]): string[] {
return (candidates ?? [...fallback]).filter(candidate => (
!RESERVED_PATH_SEGMENTS.has(candidate) && !/[\\/]/.test(candidate)
))
}

View File

@@ -14,3 +14,15 @@ import { createHash } from 'node:crypto'
export function instructionContentSha1(content: string): string {
return createHash('sha1').update(content).digest('hex')
}
/**
* Compute the whitespace-insensitive identity used for per-directory duplicate
* suppression. Leading and trailing whitespace is trimmed before hashing so a
* symlinked or byte-copied sibling that differs only by surrounding whitespace
* still collapses to a single rendered file.
* @param content - exact UTF-8 instruction text.
* @returns SHA-1 digest of the trimmed content.
*/
export function trimmedInstructionDigest(content: string): string {
return instructionContentSha1(content.trim())
}

View File

@@ -5,13 +5,14 @@
*/
import { createReadStream } from 'node:fs'
import { lstat, stat } from 'node:fs/promises'
import { 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 type { FileSystem, FsInfo, FsTarget, FsVersion } from '@deepseek-ai/dsh-fs'
import { assertNever } from '@deepseek-ai/dsh-llm'
import { dshHomeDisplay } from '@deepseek-ai/dsh-paths'
import { resolveConfig, resolveDiscoveryConfig, type ResolvedConfig } from './config.ts'
import { renderWorkspaceContext, type RenderedWorkspaceContext } from './render.ts'
import { trimmedInstructionDigest } from './digest.ts'
import { decodeScopeKey, renderWorkspaceContext, USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE, type RenderedWorkspaceContext } from './render.ts'
/** An instruction candidate identified by absolute and model-facing paths. */
export interface InstructionFile {
@@ -32,7 +33,7 @@ interface DiscoveredInstructionFile extends InstructionFile {
version?: FsVersion
}
/** Provider metadata for a winning scope candidate before its content is read. */
/** Provider metadata for a probed scope candidate before its content is read. */
export interface ProbedInstructionFile extends InstructionFile {
target: FsTarget
version: FsVersion
@@ -44,6 +45,7 @@ interface DiscoverOptions {
dshHome?: string
projectRootMarkers?: string[]
instructionFileCandidates?: string[]
localInstructionFileCandidates?: string[]
signal?: AbortSignal
}
@@ -86,7 +88,9 @@ function isMissingPathError(error: unknown): boolean {
async function nodeStatFile(path: string, signal?: AbortSignal): Promise<StatFileProbe> {
try {
signal?.throwIfAborted()
const info = await lstat(path)
// stat (not lstat) follows a final-component symlink so a link to a regular
// file loads; a broken link surfaces as ENOENT and is treated as absent below.
const info = await stat(path)
signal?.throwIfAborted()
if (!info.isFile()) return { kind: 'absent' }
return { kind: 'present', info: { size: info.size } }
@@ -101,25 +105,15 @@ async function fsStatFile(
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<StatFileProbe> {
// TODO(instruction-symlink-race): replace this lstat -> resolve -> read
// protocol, including probeScopeInstruction below, with a provider-owned
// atomic no-follow read so the final component cannot change after validation.
let pathInfo: FsPathInfo | undefined
try {
pathInfo = await fileSystem.lstat(path, undefined, signal)
signal?.throwIfAborted()
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (pathInfo?.type !== 'file') return { kind: 'absent' }
// resolve() follows a final-component symlink to its target's stable identity;
// stat then classifies that target. A link to a regular file loads, while a
// missing path or non-file target (including a link to a directory) is absent.
try {
const target = await fileSystem.resolve(path, signalOptions(signal))
signal?.throwIfAborted()
const info = await fileSystem.stat(target, signal)
signal?.throwIfAborted()
if (info?.type !== 'file') return { kind: 'unavailable' }
if (info?.type !== 'file') return { kind: 'absent' }
return {
kind: 'present',
info: { target, version: info.version, ...info.size === undefined ? {} : { size: info.size } },
@@ -232,33 +226,32 @@ export function relativeDisplay(root: string, path: string): string {
return relative(root, path)
}
async function firstExistingInstructionFile(
async function allExistingInstructionFiles(
dir: string,
root: string,
instructionFileCandidates: readonly string[],
fileSystem?: FileSystem,
signal?: AbortSignal,
): Promise<DiscoveredInstructionFile | undefined> {
): Promise<DiscoveredInstructionFile[]> {
const found: DiscoveredInstructionFile[] = []
for (const candidate of instructionFileCandidates) {
const path = join(dir, candidate)
const probe = await statFile(path, fileSystem, signal)
switch (probe.kind) {
case 'present':
return {
absolutePath: path,
displayPath: relativeDisplay(root, path),
...probe.info,
}
case 'absent':
found.push({ absolutePath: path, displayPath: relativeDisplay(root, path), ...probe.info })
continue
// A missing candidate is skipped; a transient provider failure skips only
// that candidate so the remaining independent candidates still load.
case 'absent':
case 'unavailable':
return undefined
continue
/* v8 ignore next 2 -- StatFileProbe is closed; this arm only makes adding a kind a compile error. */
default:
return assertNever(probe, 'StatFileProbe')
assertNever(probe, 'StatFileProbe')
}
}
return undefined
return found
}
async function discoverInstructionFiles(
@@ -274,7 +267,7 @@ async function discoverInstructionFiles(
files.push(file)
}
const userGlobal = join(config.dshHome, 'AGENTS.md')
const userGlobal = join(config.dshHome, USER_GLOBAL_FILE)
const userGlobalProbe = await statFile(userGlobal, fileSystem, options.signal)
switch (userGlobalProbe.kind) {
case 'present':
@@ -295,16 +288,21 @@ async function discoverInstructionFiles(
const cwd = resolve(options.cwd)
const projectRoot = await findProjectRoot(cwd, config.projectRootMarkers, fileSystem, options.signal)
for (const dir of ancestorChain(projectRoot, cwd)) {
const file = await firstExistingInstructionFile(dir, projectRoot, config.instructionFileCandidates, fileSystem, options.signal)
if (file !== undefined) addFile(file)
for (const candidates of [config.instructionFileCandidates, config.localInstructionFileCandidates]) {
for (const file of await allExistingInstructionFiles(dir, projectRoot, candidates, fileSystem, options.signal)) {
addFile(file)
}
}
}
return files
}
/**
* Discover host-visible user-global and root-to-cwd instruction candidates.
* All present candidates in each directory are returned; trimmed-content
* duplicates are collapsed later, once content is read.
* @param options - cwd, home, root marker, and candidate configuration.
* @returns de-duplicated instruction paths in model precedence order.
* @returns path-deduplicated instruction candidates in model precedence order.
*/
export async function discoverBaselineInstructionFiles(options: DiscoverOptions): Promise<InstructionFile[]> {
return (await discoverInstructionFiles(options)).map(({ absolutePath, displayPath }) => ({ absolutePath, displayPath }))
@@ -316,7 +314,7 @@ async function* nodeTextChunks(path: string, signal?: AbortSignal): AsyncIterabl
}
async function readBounded(
file: DiscoveredInstructionFile,
file: { absolutePath: string; target?: FsTarget; size?: number },
maxSourceBytes: number,
fileSystem?: FileSystem,
signal?: AbortSignal,
@@ -347,6 +345,33 @@ async function readBounded(
}
}
/**
* Drop later candidates whose trimmed content duplicates an earlier sibling in
* the same directory. Different directories never collapse even when identical;
* within one directory the earliest candidate in discovery order is kept and its
* original bytes are rendered. A candidate that symlinks a sibling resolves to
* the same content and collapses here like any byte-identical real file.
* @param files - loaded files in discovery order.
* @returns the retained files in the same order.
*/
export function dedupInstructionFilesByDirectory(files: LoadedInstructionFile[]): LoadedInstructionFile[] {
const keptDigestsByDir = new Map<string, Set<string>>()
const kept: LoadedInstructionFile[] = []
for (const file of files) {
const dir = dirname(file.displayPath)
let digests = keptDigestsByDir.get(dir)
if (digests === undefined) {
digests = new Set()
keptDigestsByDir.set(dir, digests)
}
const digest = trimmedInstructionDigest(file.content)
if (digests.has(digest)) continue
digests.add(digest)
kept.push(file)
}
return kept
}
/**
* Discover, read, and render the baseline instruction chain.
* @param options - discovery, source-size, byte-budget, and cancellation configuration.
@@ -386,18 +411,19 @@ export async function loadBaselineInstructionSet(
})
}
}
if (loaded.length === 0) return undefined
const rendered = renderWorkspaceContext(loaded, { maxBytes: config.maxBytes })
const deduped = dedupInstructionFilesByDirectory(loaded)
if (deduped.length === 0) return undefined
const rendered = renderWorkspaceContext(deduped, { maxBytes: config.maxBytes })
const omitted = new Set(rendered.omitted.map(file => file.absolutePath))
return { rendered, included: loaded.filter(file => !omitted.has(file.absolutePath)) }
return { rendered, included: deduped.filter(file => !omitted.has(file.absolutePath)) }
}
/**
* Probe the current first-winning instruction candidate for one logical scope.
* @param scope - `user-global`, `.`, or a project-relative directory.
* Probe the current provider metadata for one per-candidate instruction scope.
* @param scope - a {@link candidateScopeKey} identifying a directory and candidate file.
* @param projectRoot - project root used to resolve and display project scopes.
* @param resolved - normalized plugin configuration.
* @param fileSystem - provider used for no-follow probing.
* @param fileSystem - provider used to resolve and stat scope candidates.
* @param signal - cancellation for provider probes.
* @returns present metadata, confirmed absence, or temporary unavailability.
*/
@@ -408,40 +434,32 @@ export async function probeScopeInstruction(
fileSystem: FileSystem,
signal?: AbortSignal,
): Promise<ScopeInstructionProbe> {
const dir = scope === 'user-global'
const { directory, candidateName } = decodeScopeKey(scope)
const dir = directory === USER_GLOBAL_DIRECTORY
? resolved.dshHome
: scope === '.' ? projectRoot : join(projectRoot, scope)
const candidates = scope === 'user-global' ? ['AGENTS.md'] : resolved.instructionFileCandidates
for (const candidate of candidates) {
const absolutePath = join(dir, candidate)
let pathInfo: FsPathInfo | undefined
try {
pathInfo = await fileSystem.lstat(absolutePath, undefined, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (pathInfo === undefined || pathInfo.type !== 'file') continue
let target: FsTarget
let info: FsInfo | undefined
try {
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
info = await fileSystem.stat(target, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
if (info?.type !== 'file') return { kind: 'unavailable' }
const file: ProbedInstructionFile = {
absolutePath,
displayPath: scope === 'user-global' ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
target,
version: info.version,
...info.size === undefined ? {} : { size: info.size },
}
return { kind: 'present', file }
: directory === '.' ? projectRoot : join(projectRoot, directory)
const absolutePath = join(dir, candidateName)
// resolve() follows a final-component symlink; stat then classifies the target.
// A non-file target (missing, or a link to a directory) is a confirmed absence;
// only a provider exception is reported as unavailable.
let target: FsTarget
let info: FsInfo | undefined
try {
target = await fileSystem.resolve(absolutePath, signalOptions(signal))
info = await fileSystem.stat(target, signal)
} catch {
signal?.throwIfAborted()
return { kind: 'unavailable' }
}
return { kind: 'absent' }
if (info?.type !== 'file') return { kind: 'absent' }
const file: ProbedInstructionFile = {
absolutePath,
displayPath: directory === USER_GLOBAL_DIRECTORY ? userGlobalDisplayPath(resolved.dshHome) : relativeDisplay(projectRoot, absolutePath),
target,
version: info.version,
...info.size === undefined ? {} : { size: info.size },
}
return { kind: 'present', file }
}
/**

View File

@@ -74,6 +74,7 @@ export function apply(ctx: Context, config: Config): void {
maxBytes: resolved.maxBytes,
maxSourceBytes: resolved.maxSourceBytes,
instructionFileCandidates: resolved.instructionFileCandidates,
localInstructionFileCandidates: resolved.localInstructionFileCandidates,
signal,
}, fileSystem)
const baseline = baselineInstructionState(instructions?.included ?? [])

View File

@@ -4,7 +4,7 @@
* @module @deepseek-ai/dsh-workspace-context/render
*/
import { dirname } from 'node:path'
import { basename, dirname } from 'node:path'
import type { InstructionFile, LoadedInstructionFile } from './files.ts'
const SYSTEM_REMINDER_OPEN = '<system-reminder>'
@@ -33,7 +33,6 @@ export interface WorkspaceInstructionChange {
action: 'set' | 'replace' | 'remove'
scope: string
path: string
previousPath?: string
digest?: string
}
@@ -62,8 +61,8 @@ function truncateUtf8(value: string, maxBytes: number): string {
function escapeInstructionContent(content: string): string {
// TODO(instruction-frame-paths): apply the same delimiter neutralization to
// every interpolated path, scope, and previous path; repository-controlled
// names can otherwise close the plugin-owned system-reminder frame.
// every interpolated path and scope; repository-controlled names can
// otherwise close the plugin-owned system-reminder frame.
return content.replaceAll(SYSTEM_REMINDER_CLOSE, '<\\/system-reminder>')
}
@@ -71,16 +70,65 @@ function sectionText(file: LoadedInstructionFile): string {
return `Instructions from: ${file.displayPath}\n\n${escapeInstructionContent(file.content)}`
}
/** Directory component that identifies the single user-global instruction scope. */
export const USER_GLOBAL_DIRECTORY = 'user-global'
/**
* File name of the single user-global instruction file under `$DSH_HOME`.
* Discovery (`$DSH_HOME/<name>`) and reconciliation (the user-global scope key's
* candidate component) both key on this name, so it lives in one place: were the
* two to disagree, the user-global instruction would load but never reconcile.
*/
export const USER_GLOBAL_FILE = 'AGENTS.md'
/**
* Derive the logical instruction scope from a model-facing path.
* @param displayPath - project-relative or user-global instruction path.
* @returns `user-global`, `.`, or the containing project-relative directory.
*/
export function scopeForDisplayPath(displayPath: string): string {
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return 'user-global'
if (displayPath === '~/.dsh/AGENTS.md' || displayPath === '$DSH_HOME/AGENTS.md') return USER_GLOBAL_DIRECTORY
return dirname(displayPath)
}
const SCOPE_SEPARATOR = '\u0000'
/**
* Compose the reconciliation key for one instruction candidate file.
* Each loaded candidate is tracked independently, so the key pairs the logical
* directory with the exact candidate file name behind a NUL separator that no
* directory path or file name can contain. Distinct candidates in one directory
* (`AGENTS.md` vs `CLAUDE.md`, a base file vs its `.local` overlay) therefore
* never collide in the scope-keyed state maps.
* @param directory - `user-global`, `.`, or a project-relative directory.
* @param candidateName - instruction file name within that directory.
* @returns the per-candidate logical scope key.
*/
export function candidateScopeKey(directory: string, candidateName: string): string {
return `${directory}${SCOPE_SEPARATOR}${candidateName}`
}
/**
* Derive the per-candidate scope key for a loaded instruction file.
* @param displayPath - project-relative or user-global instruction path.
* @returns the scope key pairing the file's directory with its name.
*/
export function instructionScopeKey(displayPath: string): string {
return candidateScopeKey(scopeForDisplayPath(displayPath), basename(displayPath))
}
/**
* Recover the directory and candidate name that {@link candidateScopeKey} encoded.
* @param scope - a per-candidate scope key.
* @returns the directory scope and the candidate file name within it.
*/
export function decodeScopeKey(scope: string): { directory: string; candidateName: string } {
const separator = scope.indexOf(SCOPE_SEPARATOR)
/* v8 ignore next -- every scope key is produced by candidateScopeKey, which always inserts the separator. */
if (separator < 0) return { directory: scope, candidateName: '' }
return { directory: scope.slice(0, separator), candidateName: scope.slice(separator + 1) }
}
function additionalSectionText(file: LoadedInstructionFile): string {
const scope = scopeForDisplayPath(file.displayPath)
return [
@@ -100,13 +148,10 @@ function changedSectionText(item: ChangeRenderItem): string {
if (change.action === 'remove') {
return `Instructions removed: ${change.path}\n\nThe previously loaded instructions from this file no longer apply.`
}
const description = change.previousPath === undefined
? 'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.'
: `The instructions previously loaded from \`${change.previousPath}\` no longer apply. Use the following content for \`${change.scope}\` instead.`
return [
`Updated instructions from: ${change.path}`,
'',
description,
'This file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.',
'',
escapeInstructionContent(file.content),
].join('\n')

View File

@@ -10,7 +10,7 @@ import type { JsonValue, Session, SessionEvent } from '@deepseek-ai/dsh-session'
import type { FileSystem, FsVersion } from '@deepseek-ai/dsh-fs'
import type { ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools'
import type { ResolvedConfig } from './config.ts'
import { instructionContentSha1 } from './digest.ts'
import { instructionContentSha1, trimmedInstructionDigest } from './digest.ts'
import {
ancestorChain,
descendantDirsBetween,
@@ -21,8 +21,12 @@ import {
type LoadedInstructionFile,
} from './files.ts'
import {
candidateScopeKey,
decodeScopeKey,
instructionScopeKey,
renderInstructionChanges,
scopeForDisplayPath,
USER_GLOBAL_DIRECTORY,
USER_GLOBAL_FILE,
type ChangeRenderItem,
type WorkspaceInstructionChange,
} from './render.ts'
@@ -44,6 +48,11 @@ export interface InstructionVersionState {
path: string
version: FsVersion
digest: string
/**
* Trimmed-content identity ({@link trimmedInstructionDigest}) used to suppress
* per-directory duplicates on the metadata fast path without re-reading a sibling.
*/
trimmedDigest: string
}
/** Session-isolated fast-path state keyed by logical instruction scope. */
@@ -71,7 +80,6 @@ function workspaceContextHook(text: string, changes: WorkspaceInstructionChange[
action: change.action,
scope: change.scope,
path: change.path,
...change.previousPath !== undefined ? { previousPath: change.previousPath } : {},
...change.digest !== undefined ? { digest: change.digest } : {},
}))
const meta: JsonValue = { kind: 'workspace-instructions', version: 1, changes: serializedChanges }
@@ -112,13 +120,11 @@ function workspaceInstructionChanges(meta: JsonValue | undefined): WorkspaceInst
if (!isRecord(value)) continue
if (value.action !== 'set' && value.action !== 'replace' && value.action !== 'remove') continue
if (typeof value.scope !== 'string' || typeof value.path !== 'string') continue
if (value.previousPath !== undefined && typeof value.previousPath !== 'string') continue
if (value.digest !== undefined && typeof value.digest !== 'string') continue
changes.push({
action: value.action,
scope: value.scope,
path: value.path,
...value.previousPath !== undefined ? { previousPath: value.previousPath } : {},
...value.digest !== undefined ? { digest: value.digest } : {},
})
}
@@ -129,7 +135,6 @@ function sameInstructionChange(a: WorkspaceInstructionChange, b: WorkspaceInstru
return a.action === b.action
&& a.scope === b.scope
&& a.path === b.path
&& a.previousPath === b.previousPath
&& a.digest === b.digest
}
@@ -169,13 +174,18 @@ export function baselineInstructionState(files: LoadedInstructionFile[]): {
const digest = instructionContentSha1(file.content)
const change: WorkspaceInstructionChange = {
action: 'set',
scope: scopeForDisplayPath(file.displayPath),
scope: instructionScopeKey(file.displayPath),
path: file.displayPath,
digest,
}
changes.set(change.scope, change)
if (file.version !== undefined) {
versions.set(change.scope, { path: file.displayPath, version: file.version, digest })
versions.set(change.scope, {
path: file.displayPath,
version: file.version,
digest,
trimmedDigest: trimmedInstructionDigest(file.content),
})
}
}
return { changes, versions }
@@ -391,34 +401,67 @@ export async function reconcileInstructionContext(
// recomputing it after marker edits reinterprets the existing relative scope keys.
const projectRoot = await findProjectRoot(cwd, resolved.projectRootMarkers, fileSystem, options.signal)
const scopes = new Set<string>()
if (options.includeBaselineScopes) {
scopes.add('user-global')
for (const dir of ancestorChain(projectRoot, cwd)) scopes.add(relativeScope(projectRoot, dir))
const addDirScopes = (directory: string): void => {
for (const candidate of resolved.instructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
for (const candidate of resolved.localInstructionFileCandidates) scopes.add(candidateScopeKey(directory, candidate))
}
const addProjectScopes = (dir: string): void => {
addDirScopes(relativeScope(projectRoot, dir))
}
if (options.includeBaselineScopes) {
scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
for (const dir of ancestorChain(projectRoot, cwd)) addProjectScopes(dir)
}
for (const scope of effective.keys()) {
const { directory } = decodeScopeKey(scope)
if (directory === USER_GLOBAL_DIRECTORY) scopes.add(candidateScopeKey(USER_GLOBAL_DIRECTORY, USER_GLOBAL_FILE))
else addDirScopes(directory)
}
for (const scope of effective.keys()) scopes.add(scope)
if (options.touchedPath !== undefined) {
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) scopes.add(relativeScope(projectRoot, dir))
for (const dir of descendantDirsBetween(cwd, options.touchedPath)) addProjectScopes(dir)
}
const versions = versionStatesFor(session, versionCache)
const seenAbsolutePaths = new Set<string>()
// Per-directory trimmed-content identities kept so far this pass, iterated in
// candidate order (base before local); a later sibling matching an earlier one
// is a duplicate and is dropped or removed rather than rendered twice.
const keptTrimmedByDir = new Map<string, Set<string>>()
const registerKeptTrimmed = (directory: string, digest: string): boolean => {
let digests = keptTrimmedByDir.get(directory)
if (digests === undefined) {
digests = new Set()
keptTrimmedByDir.set(directory, digests)
}
if (digests.has(digest)) return true
digests.add(digest)
return false
}
const items: ChangeRenderItem[] = []
const versionUpdates: InstructionVersionUpdate[] = []
const pushRemoval = (scope: string, path: string): void => {
const change: WorkspaceInstructionChange = { action: 'remove', scope, path }
items.push({ change, file: { absolutePath: `removed:${scope}`, displayPath: path, content: '' } })
versionUpdates.push({ change })
}
for (const scope of scopes) {
const { directory } = decodeScopeKey(scope)
const previous = effective.get(scope)
const probe = await probeScopeInstruction(scope, projectRoot, resolved, fileSystem, options.signal)
if (probe.kind === 'unavailable') continue
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') {
versions.delete(scope)
continue
if (probe.kind === 'unavailable') {
// Last-good-state: the candidate stays effective, so its cached trimmed
// digest must keep occupying the directory's dedup slot — otherwise an
// identical later sibling would be emitted as a duplicate `set` until the
// next successful reconciliation removed it again.
const cached = versions.get(scope)
if (cached !== undefined && previous !== undefined && previous.action !== 'remove') {
registerKeptTrimmed(directory, cached.trimmedDigest)
}
const change: WorkspaceInstructionChange = { action: 'remove', scope, path: previous.path }
items.push({
change,
file: { absolutePath: `removed:${scope}`, displayPath: previous.path, content: '' },
})
versionUpdates.push({ change })
continue
}
if (probe.kind === 'absent') {
if (previous === undefined || previous.action === 'remove') versions.delete(scope)
else pushRemoval(scope, previous.path)
continue
}
const { file: probedFile } = probe
@@ -433,29 +476,39 @@ export async function reconcileInstructionContext(
&& previous.action !== 'remove'
&& previous.path === cached.path
&& previous.digest === cached.digest
) continue
) {
// Unchanged and previously rendered: keep it, but an earlier sibling that
// now matches its trimmed content makes this the duplicate to remove.
if (registerKeptTrimmed(directory, cached.trimmedDigest)) pushRemoval(scope, previous.path)
continue
}
const file = await readScopeInstruction(probedFile, resolved.maxSourceBytes, fileSystem, options.signal)
if (file === undefined) continue
const currentDigest = instructionContentSha1(file.content)
const trimmedDigest = trimmedInstructionDigest(file.content)
if (registerKeptTrimmed(directory, trimmedDigest)) {
// A distinct file whose trimmed content already appeared earlier in this
// directory: drop it, removing any copy that was previously rendered.
if (previous !== undefined && previous.action !== 'remove') pushRemoval(scope, previous.path)
else versions.delete(scope)
continue
}
const nextVersion: InstructionVersionState = {
path: file.displayPath,
version: probedFile.version,
digest: currentDigest,
trimmedDigest,
}
if (previous !== undefined && previous.action !== 'remove' && previous.path === file.displayPath && previous.digest === currentDigest) {
versions.set(scope, nextVersion)
continue
}
const action = previous === undefined || previous.action === 'remove' ? 'set' : 'replace'
const previousPath = action === 'replace' && previous !== undefined && previous.path !== file.displayPath
? previous.path
: undefined
const change: WorkspaceInstructionChange = {
action,
scope,
path: file.displayPath,
...previousPath === undefined ? {} : { previousPath },
digest: currentDigest,
}
items.push({ change, file })

View File

@@ -12,6 +12,7 @@ import type { Agent } from '@deepseek-ai/dsh-agent'
import AgentLoop from '@deepseek-ai/dsh-agent-loop'
import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek'
import * as WorkspaceContext from '@deepseek-ai/dsh-workspace-context'
import { candidateScopeKey } from '../src/render.ts'
import LocalFileSystem from '@deepseek-ai/dsh-fs-local'
import * as ToolFs from '@deepseek-ai/dsh-tool-fs'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -112,7 +113,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)('workspace context e2e: real mode
&& !Array.isArray(event.data.meta)
&& event.data.meta.kind === 'workspace-instructions')
expect(update?.type === 'context/message' && update.data.meta).toMatchObject({
changes: [{ action: 'replace', scope: '.', path: 'AGENTS.md' }],
changes: [{ action: 'replace', scope: candidateScopeKey('.', 'AGENTS.md'), path: 'AGENTS.md' }],
})
const updateText = update?.type === 'context/message'
? update.data.content.filter(block => block.type === 'text').map(block => block.text).join('')

View File

@@ -14,7 +14,7 @@ import { boot, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot'
const CLI_NAME = 'dsh-cli-demo'
const DEFAULT_CONFIG_PATH = './cordis.yml'
const OUTPUT_FORMATS = ['text', 'json', 'stream-json'] as const
const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] <task>\n`
const USAGE = `Usage: ${CLI_NAME} [--config path] [--output-format text|json|stream-json] (-p <task> | <task>)\n`
/** Supported CLI output encodings. */
export type OutputFormat = typeof OUTPUT_FORMATS[number]
@@ -73,6 +73,7 @@ interface ParsedArguments {
readonly config?: string
readonly 'output-format'?: string
readonly help?: boolean
readonly prompt?: string
}
readonly positionals: string[]
}
@@ -129,6 +130,7 @@ export function parseCliArgs(args: readonly string[]): CliCommand {
config: { type: 'string' },
'output-format': { type: 'string' },
help: { type: 'boolean' },
prompt: { type: 'string', short: 'p' },
},
allowPositionals: true,
strict: true,
@@ -138,12 +140,16 @@ export function parseCliArgs(args: readonly string[]): CliCommand {
}
if (parsed.values.help === true) return { kind: 'help' }
if (parsed.positionals.length !== 1) {
throw new CliArgumentError(`expected exactly one positional task, received ${parsed.positionals.length}`)
const prompt = parsed.values.prompt
if (prompt !== undefined && parsed.positionals.length > 0) {
throw new CliArgumentError('-p/--prompt and a positional task are mutually exclusive')
}
// Cardinality was checked above, so index zero exists.
if (prompt === undefined && parsed.positionals.length !== 1) {
throw new CliArgumentError(`expected exactly one positional task or -p, received ${parsed.positionals.length} positional(s)`)
}
// Cardinality was checked above, so the fallback index zero exists.
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const task = parsed.positionals[0]!
const task = prompt ?? parsed.positionals[0]!
if (task.trim().length === 0) throw new CliArgumentError('task must not be blank')
const requestedFormat = parsed.values['output-format'] ?? 'text'

View File

@@ -8,6 +8,15 @@ import { fileURLToPath } from 'node:url'
import { zstdDecompress } from 'node:zlib'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
* The consumer's mock model is an example-local TypeScript plugin (Node 22.19+ — the engines
* floor — strips types natively, so plain `node` loads it), its config carries a `disabled:
* true` unresolvable entry (the fail-loud entry-load guard must not mistake an intentionally
* fiber-less entry for a failed import), and the optional spill pair loads from the consumer
* install — so every passing boot proves all three alongside the CLI's own output contract.
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const cliBin = join(repoRoot, 'packages/examples/cli-demo/lib/bin.js')
const decompress = promisify(zstdDecompress)
@@ -17,6 +26,7 @@ const dshPackages = [
'bash/bash-local', 'bash/tool-bash', 'support/invariants', 'ui/app-boot',
'session-persistence/session-persistence', 'session-persistence/session-persistence-jsonl',
'context/workspace-context',
'spill/spill', 'spill/spill-local', 'spill/spill-policy', 'util/retention',
]
const vendorPackages = ['cordis', 'loader', 'include', 'timer', 'schemastery', 'cosmokit']
@@ -35,15 +45,18 @@ async function makeConsumer(): Promise<string> {
const nodeModules = join(dir, 'node_modules')
for (const rel of dshPackages) await linkPackage(join(repoRoot, 'packages', rel), nodeModules)
for (const rel of vendorPackages) await linkPackage(join(repoRoot, 'vendor', rel), nodeModules)
await writeFile(join(dir, 'mock-llm.mjs'), [
"import { LlmAdapter } from '@deepseek-ai/dsh-llm'",
await writeFile(join(dir, 'mock-llm.ts'), [
// Real type annotations: this file exists to prove plain Node's type
// stripping loads an example-local TS plugin from a built consumer.
"import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm'",
"import type { Context } from 'cordis'",
'class Mock extends LlmAdapter {',
' async * stream(options) {',
" const text = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
' async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {',
" const text: string = options.messages.flatMap(message => message.content).filter(block => block.type === 'text').at(-1)?.text ?? ''",
" yield { type: 'block-start', index: 0, blockType: 'text' }",
" if (text === 'hang') {",
" yield { type: 'text-delta', index: 0, text: 'partial' }",
' await new Promise((resolve, reject) => {',
' await new Promise<never>((resolve, reject) => {',
" const timer = setTimeout(() => reject(new Error('hang timeout')), 30000)",
" const onAbort = () => { clearTimeout(timer); reject(new Error('aborted')) }",
' if (options.signal.aborted) onAbort()',
@@ -60,12 +73,12 @@ async function makeConsumer(): Promise<string> {
'}',
"export const name = 'built-cli-mock'",
"export const inject = ['llm']",
"export function apply(ctx) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
"export function apply(ctx: Context) { ctx.llm.registerAdapter(['built-cli-mock'], new Mock()) }",
'',
].join('\n'))
await writeFile(join(dir, 'cordis.yml'), [
'- id: mock-llm',
" name: './mock-llm.mjs'",
" name: './mock-llm.ts'",
'- id: bash',
" name: '@deepseek-ai/dsh-bash-local'",
'- id: cli-agent',
@@ -76,6 +89,18 @@ async function makeConsumer(): Promise<string> {
" persona: 'built CLI test'",
" persistenceRoot: './.sessions'",
' workspaceContext: false',
'- id: spill-local',
" name: '@deepseek-ai/dsh-spill-local'",
'- id: spill-policy',
" name: '@deepseek-ai/dsh-spill-policy'",
' config:',
' maxInlineBytes: 50000',
// A `disabled: true` entry settles without a fiber by design; the fail-loud
// entry-load guard must not mistake it for a failed import. The nonexistent
// path makes that distinction observable while a clean run proves boot continued.
'- id: off',
" name: './does-not-exist.ts'",
' disabled: true',
'',
].join('\n'))
return dir

View File

@@ -162,15 +162,19 @@ describe('parseCliArgs', () => {
kind: 'run', configPath: 'custom.yml', outputFormat: 'stream-json', task: 'do it',
})
expect(parseCliArgs(['--', '-task'])).toMatchObject({ task: '-task' })
expect(parseCliArgs(['-p', 'flag task'])).toMatchObject({ task: 'flag task' })
expect(parseCliArgs(['--prompt', 'long-flag task'])).toMatchObject({ task: 'long-flag task' })
expect(parseCliArgs(['--help', 'ignored'])).toEqual({ kind: 'help' })
})
it('rejects missing, blank, extra, invalid-format, and unsupported flags', () => {
expect(() => parseCliArgs([])).toThrow('received 0')
expect(() => parseCliArgs([' '])).toThrow('must not be blank')
expect(() => parseCliArgs(['-p', ' '])).toThrow('must not be blank')
expect(() => parseCliArgs(['one', 'two'])).toThrow('received 2')
expect(() => parseCliArgs(['-p', 'task', 'positional'])).toThrow('mutually exclusive')
expect(() => parseCliArgs(['--output-format', 'xml', 'task'])).toThrow('unsupported output format')
expect(() => parseCliArgs(['-p', 'task'])).toThrow('Unknown option')
expect(() => parseCliArgs(['-x', 'task'])).toThrow('Unknown option')
})
})

View File

@@ -11,8 +11,16 @@ import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/
const NAME = 'dsh-tui-demo'
/* v8 ignore start -- thin self-executing composition over the unit-tested
dsh-app-boot helpers; exercised end-to-end by the keyless Loader-path and
built-bin smokes */
dsh-app-boot helpers; exercised end-to-end by the tui-agent PTY smoke and
the built-bin fail-loud smoke */
// Refuse pipes BEFORE booting: a compose-time throw inside the Loader tree is
// logged per-entry rather than rethrown, so a piped launch would otherwise
// settle into an idle UI-less process instead of exiting nonzero.
if (!process.stdin.isTTY || !process.stdout.isTTY) {
process.stderr.write(`${NAME}: the TUI requires stdin and stdout to be interactive TTYs; `
+ 'use the one-shot dsh-cli-demo bin for pipes and automation\n')
process.exit(1)
}
installFailLoud(NAME)
loadEnv(NAME)
await boot(NAME, resolveConfigPath(process.argv[2] ?? './cordis.yml', undefined))

View File

@@ -27,7 +27,6 @@ import * as uiTui from '@deepseek-ai/dsh-tui'
export const name = 'tui-demo'
const DEFAULT_PERSISTENCE_ROOT = './.sessions'
const DEFAULT_WELCOME = 'ready.'
/** App config routed to the spine, TUI, configured agent, and JSONL backend. */
export interface Config {
@@ -51,8 +50,15 @@ export interface Config {
persistenceRoot?: string
/** JSONL artifact encoding; defaults to checksummed Zstandard frames. */
persistenceCompression?: JsonlCompression
/** TUI subtitle rendered on start. Defaults to `ready.`. */
/** TUI transcript's optional first line; absent renders nothing on start. */
welcome?: string
/**
* Shell command template the TUI prints on exit and lists under `/resume`,
* with `{session}` replaced by the live session id (forwarded to the front
* door). Set it to a command that resumes via this app's env var, e.g.
* `RESUME_SESSION_ID={session} dsh`.
*/
resumeCommand?: string
/** Full-screen TUI presentation settings. */
ui?: uiTui.TuiConfig
/** Skill registry, local-provider, and model-facing consumer config. */
@@ -84,7 +90,8 @@ export const Config: z<Config> = z.object({
sessionTitle: agentCore.SessionTitleConfigSchema,
persistenceRoot: z.string().default(DEFAULT_PERSISTENCE_ROOT),
persistenceCompression: JsonlCompressionSchema,
welcome: z.string().default(DEFAULT_WELCOME),
welcome: z.string(),
resumeCommand: z.string(),
ui: uiTui.TuiConfigSchema,
skills: agentCore.SkillConfigSchema,
toolBash: agentCore.ToolBashConfigSchema,
@@ -115,7 +122,8 @@ export function composeTuiApp(ctx: Context, config: Config): void {
ctx.plugin(UserInteractionService)
ctx.plugin(uiTui, {
...config.ui,
welcome: config.welcome ?? DEFAULT_WELCOME,
...config.welcome === undefined ? {} : { welcome: config.welcome },
...config.resumeCommand === undefined ? {} : { resumeCommand: config.resumeCommand },
sessionId,
})
ctx.plugin(agentCore, {

View File

@@ -0,0 +1,98 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { mkdtemp, mkdir, rm, symlink, readFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
/**
* Published-entry smoke: run `lib/bin.js` under plain Node in a symlinked external consumer.
* The TUI app owns no non-TTY fallback, so the piped subprocess must refuse to boot with a
* nonzero exit and a stderr pointer at the one-shot CLI — the bin guards BEFORE the Loader
* because a compose-time throw inside the tree is logged per-entry, not rethrown. The consumer
* links only the bin's import chain (dsh-app-boot and its vendored Loader stack): the refusal
* fires before any config is read, so no plugin tree is needed. Missing-config fail-loud and
* full-boot coverage for the shared dsh-app-boot glue live in cli-demo's built-bin suite; it
* skips before build, and interactive TTY behavior is PTY-covered by examples/tui-agent (the
* one sanctioned PTY surface).
*/
const repoRoot = fileURLToPath(new URL('../../../../', import.meta.url))
const tuiBin = join(repoRoot, 'packages/examples/tui-demo/lib/bin.js')
// Symlink each package the bin imports at module load by package name so plain
// Node resolves its built `main`, matching an installed dependency rather than
// tsconfig paths.
const dshPackages = ['examples/tui-demo', 'ui/app-boot']
const vendorPackages = ['cordis', 'loader', 'include', 'schemastery', 'cosmokit']
async function pkgName(absDir: string): Promise<string> {
const json = JSON.parse(await readFile(join(absDir, 'package.json'), 'utf8')) as { name: string }
return json.name
}
/** Build a temporary external consumer with built workspace/vendor links. */
async function makeConsumer(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), 'tui-built-bin-'))
const nm = join(dir, 'node_modules')
for (const rel of dshPackages) {
const abs = join(repoRoot, 'packages', rel)
const target = join(nm, await pkgName(abs))
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
for (const v of vendorPackages) {
const abs = join(repoRoot, 'vendor', v)
const target = join(nm, await pkgName(abs))
await mkdir(dirname(target), { recursive: true })
await symlink(abs, target)
}
return dir
}
/** Run the built bin in `cwd` with PIPED stdio; resolve with output + exit code. */
function runBuiltBin(cwd: string): Promise<{ stdout: string; code: number; stderr: string }> {
return new Promise((resolve, reject) => {
// NO tsx — this is the published `node lib/bin.js` path (`--expose-internals`
// matches the demo command; the guard fires before the Loader needs it).
const child = spawn(process.execPath, ['--expose-internals', tuiBin, './cordis.yml'], {
cwd,
env: { ...process.env, DSH_HOME: join(cwd, '.dsh'), DSH_AGENTS_HOME: join(cwd, '.agents') },
stdio: ['pipe', 'pipe', 'pipe'],
})
let stdout = ''
let stderr = ''
child.stdout.setEncoding('utf8')
child.stdout.on('data', (c: string) => { stdout += c })
child.stderr.setEncoding('utf8')
child.stderr.on('data', (c: string) => { stderr += c })
const timer = setTimeout(() => {
child.kill('SIGKILL')
reject(new Error(`built bin did not exit within 25s. stdout:\n${stdout}\nstderr:\n${stderr}`))
}, 25_000)
child.on('exit', (code) => { clearTimeout(timer); resolve({ stdout, code: code ?? -1, stderr }) })
child.on('error', (err) => { clearTimeout(timer); reject(err) })
child.stdin.end()
})
}
let consumer: string | undefined
afterEach(async () => {
// Windows can briefly retain released handles after exit; retry removal.
if (consumer !== undefined) await rm(consumer, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 })
consumer = undefined
})
describe.skipIf(!existsSync(tuiBin))('dsh-tui-demo BUILT bin (node lib/bin.js, no tsx)', () => {
it('refuses pipes LOUD (non-zero exit + stderr) before booting the Loader', async () => {
consumer = await makeConsumer()
const { stdout, code, stderr } = await runBuiltBin(consumer)
expect(code).not.toBe(0)
expect(stderr).toContain('requires stdin and stdout to be interactive TTYs')
expect(stderr).toContain('dsh-cli-demo')
// The refusal happens before any plugin mounts: stdout stays silent.
expect(stdout).toBe('')
}, 30_000)
})

View File

@@ -33,6 +33,7 @@ describe('dsh-tui-demo app', () => {
persistenceRoot: '/tmp/tui-sessions',
persistenceCompression: 'none',
welcome: 'TUI ready',
resumeCommand: 'dsh --resume {session}',
ui: { color: false, maxToolOutputLines: 3 },
skills: { tool: { catalogDescriptionMaxLength: 8 } },
toolBash: { enableRunInBackground: false },
@@ -52,7 +53,12 @@ describe('dsh-tui-demo app', () => {
expect(calls[0]?.config).toBeUndefined()
expect(calls[2]?.config).toEqual({ root: '/tmp/tui-sessions', compression: 'none' })
const tuiConfig = calls[4]?.config as { sessionId: string }
expect(tuiConfig).toMatchObject({ welcome: 'TUI ready', color: false, maxToolOutputLines: 3 })
expect(tuiConfig).toMatchObject({
welcome: 'TUI ready',
resumeCommand: 'dsh --resume {session}',
color: false,
maxToolOutputLines: 3,
})
expect(tuiConfig.sessionId).toMatch(/^main-session-[0-9a-f-]{36}$/)
const spineConfig = calls[5]?.config as {
readonly agents: Array<Record<string, unknown>>
@@ -88,7 +94,8 @@ describe('dsh-tui-demo app', () => {
})
expect(calls[2]?.config).toEqual({ root: './.sessions' })
expect(calls[4]?.config).toEqual({ welcome: 'ready.', sessionId: 'persisted-session' })
// No configured welcome forwards none: the TUI banner sweeps in without a subtitle.
expect(calls[4]?.config).toEqual({ sessionId: 'persisted-session' })
expect((calls[5]?.config as { agents: Array<Record<string, unknown>> }).agents[0]).toMatchObject({
id: 'main',
resumeSessionId: 'persisted-session',

View File

@@ -33,7 +33,7 @@
"cordis": "^4.0.0-rc.7"
},
"dependencies": {
"@earendil-works/pi-ai": "^0.79.1",
"@earendil-works/pi-ai": "^0.81.1",
"schemastery": "^3.18.0"
},
"devDependencies": {

View File

@@ -4,13 +4,11 @@
* @module dsh-llm-pi-ai/adapter
*/
import {
getModels,
streamSimple,
} from '@earendil-works/pi-ai'
import { streamSimple } from '@earendil-works/pi-ai/compat'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
import type {
Api,
KnownProvider,
Model,
SimpleStreamOptions,
} from '@earendil-works/pi-ai'
@@ -33,7 +31,7 @@ export interface PiAiAdapterOptions {
* override, preserving the catalog's API/capability/compatibility metadata.
*/
function resolveModel(profile: PiAiProviderProfile, modelId: string): Model<Api> {
const model = getModels(profile.provider as KnownProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
if (model === undefined) {
throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL')
}
@@ -82,7 +80,7 @@ export class PiAiAdapter extends LlmAdapter {
if (profile === undefined) {
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
}
return Promise.resolve(getModels(profile.provider as KnownProvider).map(model => ({
return Promise.resolve(getBuiltinModels(profile.provider as BuiltinProvider).map(model => ({
provider,
id: model.id,
name: model.name,

View File

@@ -4,7 +4,7 @@
* @module dsh-llm-pi-ai/config
*/
import { getProviders } from '@earendil-works/pi-ai'
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { CacheRetention, ThinkingBudgets, ThinkingLevel, Transport } from '@earendil-works/pi-ai'
import z from 'schemastery'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
@@ -62,7 +62,7 @@ const profile = z.object({
apiKey: z.string(),
baseURL: z.string(),
headers: z.dict(z.string()),
reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh']),
reasoning: z.union(['minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
thinkingBudgets,
cacheRetention: z.union(['none', 'short', 'long']),
transport: z.union(['sse', 'websocket', 'websocket-cached', 'auto']),
@@ -84,7 +84,7 @@ export const Config: z<Config> = z.object({
*/
export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] {
if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
const supported = new Set<string>(getProviders())
const supported = new Set<string>(getBuiltinProviders())
const seen = new Set<string>()
return profiles.map((source) => {
const legacy = source as PiAiProviderProfile & {

View File

@@ -5,8 +5,8 @@ import { Context } from 'cordis'
import LlmService, { CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { getModels } from '@earendil-works/pi-ai'
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { resolveProfiles } from '../src/config.ts'
import { assemble } from './assemble.ts'
@@ -244,7 +244,7 @@ describe('PiAiAdapter provider routing', () => {
})
it('uses the resolved catalog context window for usage-based overflow detection', async () => {
const model = getModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash')
const model = getBuiltinModels('deepseek').find(candidate => candidate.id === 'deepseek-v4-flash')
if (model === undefined) throw new Error('deepseek-v4-flash missing from pi-ai test catalog')
const events = [
'{"choices":[{"delta":{"role":"assistant","content":""},"index":0,"finish_reason":null}]}',

View File

@@ -2,8 +2,10 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
const streamSimple = vi.hoisted(() => vi.fn())
vi.mock('@earendil-works/pi-ai', async (importOriginal) => {
const actual = await importOriginal<typeof import('@earendil-works/pi-ai')>()
// The 0.81 SDK moved `streamSimple` to the compat entry; the adapter imports it
// from there, so the mock must target the same specifier.
vi.mock('@earendil-works/pi-ai/compat', async (importOriginal) => {
const actual = await importOriginal<typeof import('@earendil-works/pi-ai/compat')>()
return { ...actual, streamSimple }
})

View File

@@ -1,11 +1,11 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { realpath } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { pathToFileURL, fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp'
import { deadline } from '@deepseek-ai/dsh-timeout'
import * as LspLocal from '@deepseek-ai/dsh-lsp-local'
import type { LspLocalServerConfig } from '@deepseek-ai/dsh-lsp-local'
@@ -38,12 +38,27 @@ function fakeServer(fakeEnv: Record<string, string> = {}, overrides: Partial<Lsp
}
/** Mount the real seam + lsp-local plugin driving one fake server. */
async function mount(fakeEnv: Record<string, string> = {}, overrides: Partial<LspLocalServerConfig> = {}): Promise<Context> {
async function mount(
fakeEnv: Record<string, string> = {},
overrides: Partial<LspLocalServerConfig> = {},
captureProvider?: (provider: LspProvider) => void,
): Promise<Context> {
const ctx = new Context()
await ctx.plugin(Lsp)
await ctx.plugin(LspLocal, {
servers: { fake: fakeServer(fakeEnv, overrides) },
})
const register = ctx.lsp.registerProvider.bind(ctx.lsp)
const registrationSpy = captureProvider === undefined
? undefined
: vi.spyOn(ctx.lsp, 'registerProvider').mockImplementation((provider) => {
captureProvider(provider)
return register(provider)
})
try {
await ctx.plugin(LspLocal, {
servers: { fake: fakeServer(fakeEnv, overrides) },
})
} finally {
registrationSpy?.mockRestore()
}
return ctx
}
@@ -247,10 +262,22 @@ describe('lsp-local end to end over a fake server', () => {
// The first query succeeds, then the server exits before the second arrives, leaving a dead
// instance in the pool. The next query must evict-and-replace it and still succeed, rather than
// failing once on the closed connection first.
const ctx = await mount({ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) })
let provider: LspProvider | undefined
const ctx = await mount(
{ LSP_FAKE_EXIT_AFTER_REPLY: '1', LSP_FAKE_DEF: JSON.stringify(locationJson(0)) },
{},
(registered) => { provider = registered },
)
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
// Wait past the fixture's post-reply exit so the pooled instance is observably dead.
await new Promise(resolve => setTimeout(resolve, 60))
if (provider === undefined) throw new Error('expected lsp-local to register a provider')
// This implementation-local test reaches the private pool only to synchronize with its actual
// close state. A fixed wall-clock sleep can expire before a CPU-starved child runs its exit timer.
const instances = (provider as unknown as {
readonly instances: ReadonlyMap<string, { readonly dead: boolean }>
}).instances
const instance = [...instances.values()][0]
if (instance === undefined) throw new Error('expected one pooled LSP instance')
await waitFor(async () => instance.dead)
expect(await ctx.lsp.query(query('goToDefinition'))).toMatchObject({ kind: 'locations' })
await ctx.fiber.dispose()
})

View File

@@ -391,6 +391,9 @@ describe('LocalSkillProvider', () => {
await empty.plugin(SkillService)
SkillLocal.apply(empty, {})
expect(await empty.skills.list()).toEqual([])
delete process.env.DSH_AGENTS_HOME
expect(new SkillLocal.LocalSkillProvider(empty, { dshHome: join(envHome, 'empty-dsh') }).name).toBe('local')
} finally {
if (previousDshHome === undefined) {
delete process.env.DSH_HOME

View File

@@ -138,8 +138,6 @@ export interface LoaderSmokeOptions {
readonly mode?: ExampleMode
/** Environment overrides layered over the parent and isolated DSH homes. */
readonly env?: Readonly<NodeJS.ProcessEnv>
/** Lines written to stdin before EOF; omitted means immediate EOF. */
readonly stdinLines?: readonly string[]
/** Process deadline override for harness tests. */
readonly processTimeoutMs?: number
/** Optional world-state setup run in the isolated cwd before process start. */
@@ -157,10 +155,10 @@ export interface LoaderSmokeResult {
}
/**
* Boot one real Loader tree from an isolated cwd, write the requested stdin
* script, close stdin, and await a clean exit. The helper owns process kill and
* temp-directory cleanup on every outcome, and picks src/lib via {@link resolveExampleLaunch}.
* @param options - example paths, mode, environment, stdin, and diagnostic identity.
* Boot one real Loader tree from an isolated cwd, close stdin immediately, and
* await a clean exit. The helper owns process kill and temp-directory cleanup on
* every outcome, and picks src/lib via {@link resolveExampleLaunch}.
* @param options - example paths, mode, environment, and diagnostic identity.
* @returns captured stdout and stderr after a zero exit.
*/
export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<LoaderSmokeResult> {
@@ -220,7 +218,7 @@ export async function runLoaderSmoke(options: LoaderSmokeOptions): Promise<Loade
})
/* v8 ignore stop */
child.stdin.end((options.stdinLines ?? []).map(line => `${line}\n`).join(''))
child.stdin.end()
})
await options.inspect?.(cwd)
return result

View File

@@ -11,7 +11,7 @@ const fixture = (name: string): string => fileURLToPath(new URL(`./fixtures/${na
const canonicalTempPath = (path: string): string => path.replace(/^\/private(?=\/var\/)/, '')
describe('runLoaderSmoke', () => {
it('isolates the process, writes stdin, captures output, and removes the cwd', async () => {
it('isolates the process, closes stdin, captures output, and removes the cwd', async () => {
const result = await runLoaderSmoke({
label: 'success fixture',
tempDirPrefix: 'loader-smoke-success-',
@@ -20,7 +20,6 @@ describe('runLoaderSmoke', () => {
tsconfigPath,
mode: 'src',
env: { LOADER_SMOKE_MARKER: 'present' },
stdinLines: ['one', 'two'],
})
const output = JSON.parse(result.stdout) as {
configPath: string
@@ -35,7 +34,7 @@ describe('runLoaderSmoke', () => {
configPath,
args: [configPath],
marker: 'present',
input: 'one\ntwo\n',
input: '',
})
expect(canonicalTempPath(output.dshHome)).toBe(canonicalTempPath(join(output.cwd, '.dsh')))
expect(canonicalTempPath(output.agentsHome)).toBe(canonicalTempPath(join(output.cwd, '.agents')))

View File

@@ -5,10 +5,14 @@ Shared boot glue for the app bins ([`dsh-tui-demo`](../../examples/tui-demo/READ
| 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 |
| `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) |
| `boot(binName, absoluteConfigPath)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context |
| `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?)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL with the optional overlay patches, await the whole tree, assert entries loaded, return the root context |
| `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 |
Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection.
@@ -16,16 +20,27 @@ Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve
This package carries no loader hooks and no dev-mode surface: the `dsh-scripts` launcher ([`sdk/scripts`](../../sdk/scripts/README.md), with the shared project model in [`sdk/helper`](../../sdk/helper/README.md)) owns process startup, tsx registration, and local-plugin source resolution, and consumes these helpers for the boot sequence itself.
## Personal config
A developer's machine-local preferences live outside every repository in the Harness home (default `~/.dsh`, overridable via `$DSH_HOME`; the single root [`resolveDshHome`](../../util/paths/README.md) resolves), consumed by the `dsh` CLI's TUI surface ([`apps/cli`](../../../apps/cli/README.md)); the demo bins boot their committed trees verbatim. Two optional files:
- **`.env`** — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient environment > project `.env` > personal `.env`.
- **`config.yaml`** — loader overlay patches applied over the shipped default config, with the same semantics as an include entry's `patches` (the committed Code Mode overlay is the template): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount — so a personal `apiKey` can reference the personal `.env`. A patch naming an entry id absent from the booted tree is skipped with a loader warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the overlay with `[]` or by deleting the file.
Subprocess test launchers point `DSH_HOME` at an isolated per-test directory so a developer's personal overlay can never leak into fixtures.
## Model Experience
Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application.
Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application; the one export that contributes model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot.
#### KV Cache effect
No direct invalidation; the named consumer owns any request-prefix changes.
No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSection` places one short line near the system prompt's head, before per-request content, so it does not invalidate the cache across turns, and any other request-prefix change is owned by the named consumer.
## Known Limitations and Deferred Work
- **Bare package specifiers depend on Loader internals** — production bins need `node --expose-internals` or the Loader's optional native fallback; an in-process caller without either must use resolvable relative/file specifiers or tsx path mapping.
- **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection.
- **Environment loading is cwd-scoped and optional** — the helper loads one `.env` file and warns on failure; it does not search parents, merge profiles, or validate required variables.
- **Personal config is patch-shaped** — an id-targeted patch replaces the entry's whole `config` rather than deep-merging, so a personal override restates the base fields it keeps.
- **Personal patches see only the booted file's own entries** — an overlay leaf that reaches its base through a nested include entry (the Code Mode configs) resolves personal patch ids against the overlay's top-level entries, not the included subtree.

View File

@@ -26,16 +26,24 @@
"src"
],
"license": "BSD-3-Clause",
"dependencies": {
"js-yaml": "^4.2.0"
},
"peerDependencies": {
"@cordisjs/plugin-include": "^1.0.4",
"@cordisjs/plugin-loader": "^1.0.0-rc.5",
"@deepseek-ai/dsh-invariants": "^0.0.1",
"@deepseek-ai/dsh-paths": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"devDependencies": {
"@cordisjs/plugin-include": "workspace:^",
"@cordisjs/plugin-loader": "workspace:^",
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-paths": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@types/js-yaml": "^4.0.9",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -1,15 +1,21 @@
/**
* Shared boot glue for the app bins (`dsh-tui-demo`, `dsh-cli-demo`, `dsh-acp-demo`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and
* drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled.
* `.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.
* @module @deepseek-ai/dsh-app-boot
*/
import { pathToFileURL } from 'node:url'
import { basename, dirname, resolve } from 'node:path'
import { readFileSync } from 'node:fs'
import { basename, dirname, join, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
import Include from '@cordisjs/plugin-include'
import Include, { type PatchOptions } from '@cordisjs/plugin-include'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service.
import type {} from '@deepseek-ai/dsh-system-prompt'
/**
* Resolve the config to boot. Replay swaps a `cordis.yml` basename for
@@ -30,6 +36,50 @@ 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 }
}
/**
* Load the optional gitignored `.env` from `dir`. Missing files fall back to the
* ambient environment; other read failures are reported through `warn`.
@@ -51,6 +101,62 @@ export function loadEnv(
}
}
/** File inside the Harness home holding the personal loader overlay patches. */
export const PERSONAL_CONFIG_FILENAME = 'config.yaml'
// The include's YAML dialect: `!!js` scalars become expression nodes the
// Loader interpolates against each entry's context at mount time. Personal
// patches are parsed with the same schema so they may reference `process.env`.
// Load-only: this schema never dumps, so no `predicate`/`represent`.
const jsExprType = new yaml.Type('tag:yaml.org,2002:js', {
kind: 'scalar',
resolve: data => typeof data === 'string',
construct: data => ({ __jsExpr: String(data) }),
})
const personalPatchesSchema = yaml.JSON_SCHEMA.extend(jsExprType)
/**
* Load the optional personal overlay patches (`config.yaml` under the Harness
* home). The file is a top-level YAML array of loader patch entries
* (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config overrides
* and `insert` lists, with `!!js` expressions allowed. A missing file means
* "no personal overlay"; an unreadable, unparsable, or non-array file throws —
* a present personal config that cannot apply is a misconfiguration and must
* fail loud at boot, never be silently skipped.
* @param binName - the diagnostic prefix on the thrown error.
* @param dir - the Harness home; defaults to {@link resolveDshHome} (`$DSH_HOME` or `~/.dsh`).
* @returns the parsed patches, or `undefined` when the file does not exist.
*/
export function loadPersonalPatches(
binName: string, dir: string = resolveDshHome(),
): PatchOptions[] | undefined {
const file = join(dir, PERSONAL_CONFIG_FILENAME)
let content: string
try {
content = readFileSync(file, 'utf8')
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined
throw new Error(`${binName}: failed to read personal patches ${file}: ${String(error)}`)
}
let parsed: unknown
try {
parsed = yaml.load(content, { schema: personalPatchesSchema })
} catch (error) {
throw new Error(`${binName}: failed to parse personal patches ${file}: ${String(error)}`)
}
if (!Array.isArray(parsed)) {
throw new Error(`${binName}: personal patches ${file} must be a top-level YAML array of loader patch entries`)
}
// A present personal config that cannot apply is a misconfiguration and must
// fail loud here — the include only warns per entry at mount.
parsed.forEach((entry, index) => {
if (typeof entry !== 'object' || entry === null || Array.isArray(entry)) {
throw new Error(`${binName}: personal patches entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`)
}
})
return parsed as PatchOptions[]
}
/**
* The slice of `process` {@link installFailLoud} needs — injectable so tests
* exercise the handler without registering on (or exiting) the real process.
@@ -109,18 +215,52 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void {
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).
* @param patches - optional overlay patches applied over the included tree
* (see {@link loadPersonalPatches}); an empty list mounts none.
* @returns the root context once every entry has started.
*/
export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
export async function boot(
binName: string, absoluteConfigPath: string, patches?: PatchOptions[],
): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
await ctx.plugin(Loader)
ctx.loader.builtins.include = Include
await ctx.loader.create({
name: 'cordis:include',
config: { path: pathToFileURL(absoluteConfigPath).href },
config: {
path: pathToFileURL(absoluteConfigPath).href,
...patches !== undefined && patches.length > 0 ? { patches } : {},
},
})
await ctx.loader.await()
assertEntriesLoaded(ctx, binName)
return ctx
}
/** Prompt-section name for the harness-source location line an app bin adds after boot. */
export const HARNESS_SOURCE_SECTION = 'harness:source'
/**
* Add a global prompt section naming the on-disk path to the harness source
* checkout the running bin was launched from, so the agent knows where its own
* source lives (the self-referential `dsh-tool-cordis` toolset reads and edits
* it). Call once on the settled boot context ({@link boot}); the section orders
* just after the harness identity opener (`-100`) and before the deployment
* persona (`0`). A booted tree with no `systemPrompt` service has no prompt to
* augment, so this is then a no-op that returns `undefined`. The section is
* registered against the `systemPrompt` service's fiber, so a dev HMR reload of
* that plugin drops it until the next boot.
* @param ctx - the settled boot context whose global system prompt to augment.
* @param sourceRoot - the absolute path to the harness checkout root.
* @returns the section disposer, or `undefined` when no `systemPrompt` service is mounted.
*/
export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() => void) | undefined {
const systemPrompt = ctx.get('systemPrompt')
if (systemPrompt === undefined) return undefined
return systemPrompt.section({
name: HARNESS_SOURCE_SECTION,
order: -99,
text: `Your own source code is the checkout at ${sourceRoot}; you can read it there to learn how dsh works and how to extend it.`,
})
}

View File

@@ -2,10 +2,11 @@ import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { Context } from 'cordis'
import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt'
import {
assertEntriesLoaded, boot, installFailLoud, loadEnv, resolveConfigPath,
type FailLoudProcess,
addHarnessSourceSection, assertEntriesLoaded, boot, HARNESS_SOURCE_SECTION,
installFailLoud, loadEnv, parseResumeArg, resolveConfigPath, type FailLoudProcess,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
@@ -29,6 +30,31 @@ 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('loadEnv', () => {
it('loads variables from .env in the given dir', () => {
const dir = tmp()
@@ -176,3 +202,56 @@ describe('boot', () => {
await expect(boot(NAME, join(dir, 'cordis.yml'))).rejects.toThrow(`${NAME}: plugin(s) failed to load: ./missing.mjs`)
})
})
describe('addHarnessSourceSection', () => {
const SOURCE_ROOT = `${sep}opt${sep}harness-src`
const EXPECTED = `Your own source code is the checkout at ${SOURCE_ROOT}; you can read it there to learn how dsh works and how to extend it.`
it('adds the source path between the harness identity and the deployment persona', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, { persona: 'You are a coding agent.' })
const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)
expect(dispose).toBeTypeOf('function')
const systemPrompt = ctx.get('systemPrompt')!
const rendered = renderPrompt(await systemPrompt.assemble())
expect(rendered).toContain(EXPECTED)
// Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards
// keep a drifted opener/persona string from a false pass through `-1 < n`.
const identityAt = rendered.indexOf('You are an AI agent powered by the DeepSeek Harness SDK.')
const sourceAt = rendered.indexOf(EXPECTED)
const personaAt = rendered.indexOf('You are a coding agent.')
expect(identityAt).toBeGreaterThanOrEqual(0)
expect(personaAt).toBeGreaterThanOrEqual(0)
expect(identityAt).toBeLessThan(sourceAt)
expect(sourceAt).toBeLessThan(personaAt)
} finally {
await ctx.fiber.dispose()
}
})
it('is a no-op returning undefined when no systemPrompt service is mounted', async () => {
const ctx = new Context()
try {
expect(addHarnessSourceSection(ctx, SOURCE_ROOT)).toBeUndefined()
} finally {
await ctx.fiber.dispose()
}
})
it('disposes the section it added, so a systemPrompt reload leaves no residue', async () => {
const ctx = new Context()
try {
await ctx.plugin(SystemPrompt, {})
const systemPrompt = ctx.get('systemPrompt')!
const dispose = addHarnessSourceSection(ctx, SOURCE_ROOT)!
const present = await systemPrompt.assemble()
expect(present.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(true)
dispose()
const gone = await systemPrompt.assemble()
expect(gone.sections.some(section => section.name === HARNESS_SOURCE_SECTION)).toBe(false)
} finally {
await ctx.fiber.dispose()
}
})
})

View File

@@ -0,0 +1,141 @@
/**
* Personal-config behavior of `dsh-app-boot`: the Harness home (`~/.dsh`)
* `config.yaml` overlay loader and `boot()` applying the personal overlay over
* a real Loader tree.
*/
import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import type { Context } from 'cordis'
import {
boot,
loadPersonalPatches,
PERSONAL_CONFIG_FILENAME,
} from '../src/index.ts'
const NAME = 'dsh-test-bin'
const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-personal-config-'))
describe('loadPersonalPatches', () => {
afterEach(() => {
delete process.env.DSH_HOME
})
it('returns undefined when no personal patches file exists', () => {
expect(loadPersonalPatches(NAME, tmp())).toBeUndefined()
})
it('parses a patch list and preserves !!js expressions as loader expression nodes', () => {
const dir = tmp()
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), [
'- id: tui-agent',
" name: '@deepseek-ai/dsh-tui-demo'",
' config:',
' model: !!js process.env.DSH_SPEC_MODEL',
'- insert:',
' - id: llm',
" name: '@deepseek-ai/dsh-llm-pi-ai'",
'',
].join('\n'))
const patches = loadPersonalPatches(NAME, dir)
expect(patches).toHaveLength(2)
expect(patches?.[0]).toMatchObject({
id: 'tui-agent',
config: { model: { __jsExpr: 'process.env.DSH_SPEC_MODEL' } },
})
expect(patches?.[1]?.insert).toHaveLength(1)
})
it('defaults its directory to the Harness home ($DSH_HOME)', () => {
const dir = tmp()
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: 1\n')
process.env.DSH_HOME = dir
expect(loadPersonalPatches(NAME)).toHaveLength(1)
})
it('fails loud on an unreadable file (a present personal config is never skipped)', () => {
const dir = tmp()
mkdirSync(join(dir, PERSONAL_CONFIG_FILENAME)) // a directory: present, unreadable as a file
expect(() => loadPersonalPatches(NAME, dir))
.toThrow(new RegExp(`^${NAME}: failed to read personal patches `))
})
it('fails loud on unparsable YAML and on a !!js tag with no expression body', () => {
const dir = tmp()
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'invalid: [unclosed\n')
expect(() => loadPersonalPatches(NAME, dir))
.toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- id: x\n config:\n a: !!js\n')
expect(() => loadPersonalPatches(NAME, dir))
.toThrow(new RegExp(`^${NAME}: failed to parse personal patches `))
})
it('fails loud when the file is not a top-level array or an entry is not an object', () => {
const dir = tmp()
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), 'id: not-a-list\n')
expect(() => loadPersonalPatches(NAME, dir))
.toThrow('must be a top-level YAML array of loader patch entries')
writeFileSync(join(dir, PERSONAL_CONFIG_FILENAME), '- just-a-string\n')
expect(() => loadPersonalPatches(NAME, dir))
.toThrow(`${NAME}: personal patches entry 1 in`)
})
})
describe('boot with personal patches', () => {
function writeTree(dir: string): string {
writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n')
writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n')
return join(dir, 'cordis.yml')
}
function entryConfig(ctx: Context, id: string): unknown {
return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config
}
it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => {
const dir = tmp()
const personal = tmp()
writeFileSync(join(personal, PERSONAL_CONFIG_FILENAME), [
'- id: noop',
' name: ./noop.mjs',
' config:',
' value: !!js process.env.DSH_APP_BOOT_PERSONAL_SPEC',
'- insert:',
' - id: personal-extra',
' name: ./noop.mjs',
'',
].join('\n'))
process.env['DSH_APP_BOOT_PERSONAL_SPEC'] = 'personal-value'
const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, personal))
try {
const noop = [...ctx.loader.entries()].find(entry => entry.options.id === 'noop')
// The mounted plugin received the interpolated environment value.
expect(noop?.fiber?.config).toEqual({ value: 'personal-value' })
expect([...ctx.loader.entries()].some(entry => entry.options.id === 'personal-extra')).toBe(true)
} finally {
await ctx.fiber.dispose()
delete process.env['DSH_APP_BOOT_PERSONAL_SPEC']
}
})
it('mounts no patch layer for an absent or empty personal overlay', async () => {
const dir = tmp()
const ctx = await boot(NAME, writeTree(dir), loadPersonalPatches(NAME, tmp()))
try {
expect(entryConfig(ctx, 'noop')).toEqual({ value: 'base' })
} finally {
await ctx.fiber.dispose()
}
const empty = tmp()
writeFileSync(join(empty, PERSONAL_CONFIG_FILENAME), '[]\n')
const ctxEmpty = await boot(NAME, writeTree(tmp()), loadPersonalPatches(NAME, empty))
try {
expect(entryConfig(ctxEmpty, 'noop')).toEqual({ value: 'base' })
} finally {
await ctxEmpty.fiber.dispose()
}
})
})

View File

@@ -19,6 +19,12 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../core/system-prompt"
},
{
"path": "../../util/paths"
}
]
}

View File

@@ -1,12 +1,12 @@
# @deepseek-ai/dsh-tui
The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the headless [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead.
The interactive terminal front door for DeepSeek Harness agents, built on [`@earendil-works/pi-tui`](https://www.npmjs.com/package/@earendil-works/pi-tui). It requires stdin and stdout TTYs; scripts and Loader pipes should use the one-shot [`@deepseek-ai/dsh-cli-demo`](../../examples/cli-demo/README.md) app instead.
The implemented [TUI feature Agent Note](../../../.agents/notes/implemented/feature/2026-07-17-dedicated-full-screen-tui-front-door.md) owns the front-door decision; the [terminal-state snapshot Agent Note](../../../.agents/notes/implemented/testing/2026-07-18-tui-terminal-state-snapshots.md) owns its verification strategy.
Interactive terminals on macOS, Linux, and Windows are supported. Windows uses pi-tui's native console VT-input handling, and the [Windows support Agent Note](../../../.agents/notes/implemented/feature/2026-07-20-windows-tui-support.md) owns the platform decision and ConPTY process verification.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
This package owns interactive terminal presentation and input only. It injects `agents`, [`commands`](../commands/README.md), `llm`, `systemPrompt`, `tokenMeter`, `tools`, and `userInteraction`, optionally reads a `skills` service (present only when one is mounted), then drives an agent created or resumed by app or developer code. Agent lifecycle, persistence, and the model-facing [`ask_user_question`](../tool-ask-user/README.md) tool remain separate composition entries.
The TUI rebuilds resumed history from the active session surface, renders Markdown responses and reasoning, applies each tool's `presentCall` / `presentResult` intent to terminal, diff, or generic cards, keeps the latest `todo/write` plan above the editor, and presents `ctx.userInteraction` questions in a wide bottom-left keyboard panel with progress, numbered options, and aligned descriptions. The latest logged session title becomes the header subtitle, with `welcome` before a title exists, and the terminal window title becomes `<session title> — <configured title>`. A durable `llm/retry` event retracts the failed step's live chunks and renders the scheduled retry count, delay, and failure in the transcript; success, exhaustion, and cancellation then settle through ordinary session events. The footer totals each logged model step's usage once, including failed attempts, while treating committed-message usage as a fallback for logs without a usage chunk. Its idle view compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route, displays `context unknown` when the adapter has no capacity metadata, and also shows tool-card mode and the current model with reasoning state; while the agent runs, an elapsed working indicator and `esc interrupt` replace that summary. Surface replacement events rebuild the transcript so compacted history does not reappear.
@@ -14,15 +14,23 @@ An embedding may provide `TuiRuntime.formatCwd` when its logical workspace label
Before model output, session events, tool presenters, questions, configuration, or diagnostics reach pi-tui's ANSI-aware renderers or the terminal title, the TUI renders C0 and C1 controls other than line feeds as visible `\xNN` text. Those sources cannot add terminal control sequences; the TUI and pi-tui retain ownership of terminal rendering and styling.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly and unknown commands produce a warning, with no automatic fallthrough to the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/cancel`, `/reasoning`, `/tools`, `/redraw`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
While the agent is running, ordinary editor submissions call `agent.steer()`; otherwise they call `agent.send()`. A slash at the start of the submitted line enters `ctx.commands` instead: known commands execute directly, unknown commands produce a warning, and neither path automatically reaches the model. A command producer may explicitly schedule agent work; [`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-surfaces) uses that contract for `/plan [message]`. The TUI registers `/help`, `/model`, `/clear`, `/reasoning`, `/tools`, `/redraw`, `/reload`, `/resume`, `/status`, and `/exit` as agent-scoped definitions; every other effective command joins autocomplete and `/help` dynamically, as do `/skill:` completions. A status line above the editor reports the turn phase the TUI derives from session events — waiting for the first token, thinking, responding, or executing tools — with the elapsed time in that phase and the running step total, refreshed each second, and ends with the `Enter sends steering, Esc cancels` hint; while steering messages wait to reach the model it inserts a `N queued ·` badge before the hint that clears as each drains. Ctrl+C or Escape cancels a running turn. Tool cards collapse long bodies into a configurable head/tail preview; Ctrl+O toggles every card between its preview and full output. Ctrl+R toggles reasoning, Ctrl+L redraws, and Ctrl+D exits while idle.
`/model` opens the advisory `ctx.llm` catalog as a keyboard selector: Up/Down moves, Enter selects, and Escape closes it. `/model <model>` still selects an unambiguous model id directly, while `/model <provider>/<model>` selects an exact target. The configured target or latest logged request header initializes the selector, and an unlisted current model remains visible because catalogs are advisory. Selection is local to this TUI session. Prompt assembly snapshots the target for one step, replaces `{{provider}}` and `{{model}}`, and applies the same pair through `agent/request`; a switch during assembly therefore starts with a later step. The request header durably records targets that reach the model, while an unused selection remains process-local.
`/reload` (EXPERIMENTAL, dev-only) re-reads every file-backed loader config tree and applies the diff to the running app — the HMR watcher's config path, invoked manually; it needs the cordis Loader in the context and degrades to a warning without one, runs only while the agent is idle, and refuses re-entry while a reload is in flight. Module-source hot reload remains watcher-owned. When a `skills` service is mounted, `/skill:<name> [instructions]` loads that skill's instructions into the conversation as a user turn; autocomplete lists the model-invocable skills, and any skill (including a model-disabled one) is loadable by its exact name.
The footer sums the session's reported usage as `↑<uncached input> ↓<output>`, followed by `cache <rate>%` once any input has been billed — the share of billed prompt tokens (uncached input plus cache reads and writes) served from the provider cache, rounded to a percent. It also compares token-meter pressure with `ctx.llm.resolveModelContext()` for the current route (omitting the context share when the adapter has no capacity metadata) and shows the current model and tool-card mode; the right side clips first when the footer is narrow.
`/status` adds a point-in-time diagnostics card to the transcript and remains available while the agent runs. It reports the session id, title, working directory, selected provider/model, reasoning-block visibility, agent state, event/turn/step/tool-call counts, exact input/output/cache token buckets, KV-cache hit rate, token-meter context use and capacity, creation time, and latest event time. Missing titles, models, cache input, or context capacity are labeled instead of inferred. The card is terminal-only and does not duplicate the compact footer.
When `resumeCommand` is set and a `sessionPersistence` backend is mounted, exiting prints the resume command for the current session (once it has been persisted, so an abandoned session yields no hint), and `/resume` lists this workspace's persisted sessions newest-first, each with its resume command and a marker on the current one. `{session}` in the template expands to the session id; the TUI only prints commands to copy and never resumes in place.
## Config
| Key | Default | Meaning |
|---|---|---|
| `welcome` | `ready.` | Header subtitle until the session has a logged title. |
| `welcome` | — | Banner subtitle line until the session has a logged title; unset, the banner sweeps in with no subtitle |
| `sessionId` | `main` | Exact shared agent/session identity driven by the terminal |
| `showReasoning` | `true` | Render reasoning blocks |
| `maxToolOutputLines` | `6` | Output lines retained across a collapsed tool card's head/tail preview |
@@ -35,6 +43,7 @@ While the agent is running, ordinary editor submissions call `agent.steer()`; ot
| `showHardwareCursor` | `false` | Show the hardware cursor at pi-tui's IME marker |
| `color` | `true` | Apply the built-in ANSI palette (see [Color](#color)) |
| `title` | `DeepSeek Harness` | Product suffix for the terminal window title. |
| `resumeCommand` | — | Shell command template for the exit hint and `/resume`, with `{session}` expanded to the session id; unset disables both. Needs a `sessionPersistence` backend |
```yaml
- id: terminal
@@ -82,6 +91,20 @@ The selector adds no messages. A target change may alter interpolated system-pro
Changing provider or model enters that target's cache domain; no cache reuse across distinct targets is assumed.
### Manual skill invocation
#### What the model sees
A `/skill:<name> [instructions]` submission loads the named skill and delivers one text block: a `<skill name="…">` element wrapping the skill's instructions — preceded, when the provider exposes a resource base, by a line locating the skill's relative resources — followed by any trailing instructions the user typed. Delivery follows the same send-while-idle / steer-while-running rule as ordinary input. The command, not the model, chooses the skill; model-disabled skills are omitted from autocomplete but stay loadable by exact name.
#### Token effect
The rendered skill block and trailing instructions are retained as one user turn under the agent loop's normal session-history and compaction rules; a repeated invocation appends the body again.
#### KV Cache effect
Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries.
### Interactive user-question answers
#### What the model sees
@@ -100,4 +123,5 @@ Append-only; newly visible content follows the reusable request prefix and does
- **One configured session owns the transcript and editor** — questions from other agents can still use the shared overlay provider, but session rendering and prompt input remain bound to `sessionId`.
- **Tool cards are text terminal presentations** — terminal, diff, and generic cards use tool-owned titles/content, but session content currently has no image block for inline image rendering.
- **Non-TTY operation is intentionally unsupported** — automation must use the headless app rather than expecting an internal fallback.
- **Non-TTY operation is intentionally unsupported** — app bundles that need automation must compose a one-shot or server front door (`dsh-cli-demo`, `dsh-acp`) rather than expecting an internal fallback.
- **Manual `/skill:` invocation always reloads the full skill body** — the TUI does not detect a skill already present in the conversation, so repeated invocations append its instructions again.

View File

@@ -34,13 +34,23 @@
"@deepseek-ai/dsh-llm": "^0.0.1",
"@deepseek-ai/dsh-llm-retry": "^0.0.1",
"@deepseek-ai/dsh-session": "^0.0.1",
"@deepseek-ai/dsh-session-persistence": "^0.0.1",
"@deepseek-ai/dsh-session-title": "^0.0.1",
"@deepseek-ai/dsh-skill": "^0.0.1",
"@deepseek-ai/dsh-system-prompt": "^0.0.1",
"@deepseek-ai/dsh-token-meter": "^0.0.1",
"@deepseek-ai/dsh-tools": "^0.0.1",
"@deepseek-ai/dsh-user-interaction": "^0.0.1",
"cordis": "^4.0.0-rc.7"
},
"peerDependenciesMeta": {
"@deepseek-ai/dsh-session-persistence": {
"optional": true
},
"@deepseek-ai/dsh-skill": {
"optional": true
}
},
"dependencies": {
"@earendil-works/pi-tui": "0.80.7",
"schemastery": "^3.18.0"
@@ -54,7 +64,9 @@
"@deepseek-ai/dsh-llm": "workspace:^",
"@deepseek-ai/dsh-llm-retry": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
"@deepseek-ai/dsh-session-persistence": "workspace:^",
"@deepseek-ai/dsh-session-title": "workspace:^",
"@deepseek-ai/dsh-skill": "workspace:^",
"@deepseek-ai/dsh-system-prompt": "workspace:^",
"@deepseek-ai/dsh-token-meter": "workspace:^",
"@deepseek-ai/dsh-tool-cordis": "workspace:^",

File diff suppressed because it is too large Load Diff

View File

@@ -8,7 +8,7 @@ import AgentRegistry, {
} from '@deepseek-ai/dsh-agent'
import type { ContentBlock, LlmModelContext, LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
import CommandService from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type Session, type SessionHeader } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -24,11 +24,16 @@ interface FakeAgent extends Agent {
export interface TuiHarnessOptions {
status?: AgentStatus
config?: Config
/** Leave the session event log empty instead of seeding one turn and step. */
omitInitialLifecycle?: boolean
/** Omit the harness's default `welcome`, exercising the banner sweep-reveal path. */
omitWelcome?: boolean
tools?: Record<string, ToolDefinition>
configureContext?: (ctx: Context) => Promise<void>
beforeMount?: (session: Session) => void
cwd?: string | null
formatCwd?: TuiRuntime['formatCwd']
/** Fake-agent creation options (`provider`/`model` seed the model selector's initial target). */
agentOptions?: AgentOptions
contextWindow?: number
contextTokens?: number
@@ -39,6 +44,8 @@ export interface TuiHarnessOptions {
listModels?: (provider: string) => Promise<LlmModelInfo[]>
resolveModelContext?: (provider: string, model: string) => Promise<LlmModelContext | undefined>
}
/** Provide a fake `sessionPersistence` service so resume surfaces can list sessions. */
sessionPersistence?: { list(): Promise<SessionHeader[]> }
}
export interface TuiHarness<TerminalType extends Terminal, Exit extends (code: number) => void> {
@@ -74,19 +81,6 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'DeepSeek V4 Pro' },
],
}
ctx.provide('llm', {
listProviders() {
return catalog.providers.map(provider => ({ ...provider }))
},
listModels(provider: string) {
return catalog.listModels?.(provider)
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
},
resolveModelContext(provider: string, model: string) {
return catalog.resolveModelContext?.(provider, model)
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
},
} as never)
ctx.provide('tokenMeter', {
measure() {
return { totalTokens: options.contextTokens ?? 0 }
@@ -102,17 +96,39 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
} else {
await options.configureContext(ctx)
}
// A configureContext may mount the real LlmService; only fill the
// advisory-catalog stub when none was provided.
if (ctx.get('llm') === undefined) {
ctx.provide('llm', {
listProviders() {
return catalog.providers.map(provider => ({ ...provider }))
},
listModels(provider: string) {
return catalog.listModels?.(provider)
?? Promise.resolve(catalog.models.filter(model => model.provider === provider).map(model => ({ ...model })))
},
resolveModelContext(provider: string, model: string) {
return catalog.resolveModelContext?.(provider, model)
?? Promise.resolve({ contextWindow: options.contextWindow ?? 128_000 })
},
} as never)
}
if (ctx.get('systemPrompt') === undefined) await ctx.plugin(SystemPrompt)
if (options.sessionPersistence !== undefined) {
ctx.provide('sessionPersistence', options.sessionPersistence as never)
}
const sessionId = SessionId('main-session')
const session = ctx.sessions.create(
sessionId,
options.cwd === null ? undefined : { meta: { cwd: options.cwd ?? '/workspace' } },
)
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn: 1, step: 1 })
if (options.omitInitialLifecycle !== true) {
session.append('turn/start', {
turn: 1,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('step/start', { turn: 1, step: 1 })
}
options.beforeMount?.(session)
const sent: ContentBlock[][] = []
const steered: ContentBlock[][] = []
@@ -142,13 +158,16 @@ export async function createTuiTestHarness<TerminalType extends Terminal, Exit e
}
ctx.agents.register(agent)
const controller = createTuiChat(ctx, Object.assign({
welcome: 'Coding agent ready.',
...options.omitWelcome === true ? {} : { welcome: 'Coding agent ready.' },
sessionId,
color: false,
}, options.config), {
terminal,
exit,
now: options.now ?? (() => 0),
// Default to the real clock (runtime.now falls back to Date.now) so the
// elapsed-status suites can drive time via timers or Date.now spies; a
// test pins the clock only by passing `now` explicitly.
...(options.now === undefined ? {} : { now: options.now }),
...(options.formatCwd === undefined ? {} : { formatCwd: options.formatCwd }),
})
return { ctx, session, agent, terminal, exit, controller }
@@ -174,7 +193,7 @@ export function appendUser(session: Session, text: string): void {
export function appendAssistant(
session: Session,
content: ContentBlock[],
usage?: { inputTokens: number; outputTokens: number },
usage?: { inputTokens: number; outputTokens: number; cacheReadTokens?: number; cacheWriteTokens?: number },
position: { turn: number; step: number } = { turn: 1, step: 1 },
): void {
session.append('assistant/message', {

View File

@@ -1,108 +1,99 @@
terminal 100x40 buffer=normal length=41 base=1 viewport=1
terminal 100x40 buffer=normal length=40 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=37 bufferRow=38
cursor hidden column=1 viewportRow=36 bufferRow=36
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-99 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 99-99 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 99-99 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 99-99 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-99 fg=bright-blue
5| <blank>
6| "▌ "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=green
7| "▌ ✓ pnpm run test:coverage "
5| "▌ ✓ pnpm run test:coverage "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-25 bold
8| "▌ Run the coverage gate "
6| "▌ Run the coverage gate "
style 0-0 fg=green
style 2-22 fg=bright-black
9| "▌ /workspace/project "
7| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
10| "▌ … +4 lines (Ctrl+O to expand) "
8| "▌ … +4 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
11| "▌ [exit 0] "
9| "▌ [exit 0] "
style 0-0 fg=green
style 2-9 dim
10| "▌ "
style 0-0 fg=green
11| <blank>
12| "▌ "
style 0-0 fg=green
13| <blank>
14| "▌ "
style 0-0 fg=green
15| "▌ ✓ Edit renderer "
13| "▌ ✓ Edit renderer "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-16 bold
16| "▌ src/view.ts "
14| "▌ src/view.ts "
style 0-0 fg=green
style 2-12 bold
17| "▌ - old line "
15| "▌ - old line "
style 0-0 fg=green
style 2-11 fg=red
18| "▌ … +5 lines (Ctrl+O to expand) "
16| "▌ … +5 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
19| "▌ + expect(screen).toMatchSnapshot() "
17| "▌ + expect(screen).toMatchSnapshot() "
style 0-0 fg=green
style 2-35 fg=green
18| "▌ "
style 0-0 fg=green
19| <blank>
20| "▌ "
style 0-0 fg=green
21| <blank>
22| "▌ "
style 0-0 fg=green
23| "▌ ✓ Delegate renderer audit "
21| "▌ ✓ Delegate renderer audit "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-26 bold
24| "▌ The renderer has explicit lifecycle ownership. "
22| "▌ The renderer has explicit lifecycle ownership. "
style 0-0 fg=green
23| "▌ "
style 0-0 fg=green
24| <blank>
25| "▌ "
style 0-0 fg=green
26| <blank>
27| "▌ "
style 0-0 fg=green
28| "▌ ✓ Read output from background task subagent-7 "
26| "▌ ✓ Read output from background task subagent-7 "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-46 bold
29| "▌ audit complete "
27| "▌ audit complete "
style 0-0 fg=green
30| "▌ [status: completed] "
28| "▌ [status: completed] "
style 0-0 fg=green
29| "▌ "
style 0-0 fg=green
30| <blank>
31| "▌ "
style 0-0 fg=green
32| <blank>
33| "▌ "
style 0-0 fg=green
34| "▌ ✓ Load skill dsh-code-review "
32| "▌ ✓ Load skill dsh-code-review "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-29 bold
35| "▌ Loaded review instructions. "
33| "▌ Loaded review instructions. "
style 0-0 fg=green
36| "▌ "
34| "▌ "
style 0-0 fg=green
35| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
36| " "
style 1-1 inverse
37| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
38| " "
style 1-1 inverse
39| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
40| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 42-99 dim
38| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 73-99 dim
39| <blank>

View File

@@ -1,127 +1,117 @@
terminal 100x40 buffer=normal length=50 base=10 viewport=10
terminal 100x40 buffer=normal length=48 base=8 viewport=8
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=37 bufferRow=47
cursor hidden column=1 viewportRow=37 bufferRow=45
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-99 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 99-99 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 99-99 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 99-99 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-99 fg=bright-blue
5| <blank>
6| "▌ "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=green
7| "▌ ✓ pnpm run test:coverage "
5| "▌ ✓ pnpm run test:coverage "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-25 bold
8| "▌ Run the coverage gate "
6| "▌ Run the coverage gate "
style 0-0 fg=green
style 2-22 fg=bright-black
9| "▌ /workspace/project "
7| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
10| "▌ packages/ui/tui 100% "
8| "▌ packages/ui/tui 100% "
style 0-0 fg=green
11| "▌ 4016 tests passed "
9| "▌ 4016 tests passed "
style 0-0 fg=green
12| "▌ 1 test skipped "
10| "▌ 1 test skipped "
style 0-0 fg=green
13| "▌ coverage complete "
11| "▌ coverage complete "
style 0-0 fg=green
14| "▌ [exit 0] "
12| "▌ [exit 0] "
style 0-0 fg=green
style 2-9 dim
13| "▌ "
style 0-0 fg=green
14| <blank>
15| "▌ "
style 0-0 fg=green
16| <blank>
17| "▌ "
style 0-0 fg=green
18| "▌ ✓ Edit renderer "
16| "▌ ✓ Edit renderer "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-16 bold
19| "▌ src/view.ts "
17| "▌ src/view.ts "
style 0-0 fg=green
style 2-12 bold
20| "▌ - old line "
18| "▌ - old line "
style 0-0 fg=green
style 2-11 fg=red
21| "▌ - keep "
19| "▌ - keep "
style 0-0 fg=green
style 2-7 fg=red
22| "▌ + new line "
20| "▌ + new line "
style 0-0 fg=green
style 2-11 fg=green
23| "▌ + keep "
21| "▌ + keep "
style 0-0 fg=green
style 2-7 fg=green
24| "▌ "
22| "▌ "
style 0-0 fg=green
25| "▌ tests/view.spec.ts "
23| "▌ tests/view.spec.ts "
style 0-0 fg=green
style 2-19 bold
26| "▌ + expect(screen).toMatchSnapshot() "
24| "▌ + expect(screen).toMatchSnapshot() "
style 0-0 fg=green
style 2-35 fg=green
25| "▌ "
style 0-0 fg=green
26| <blank>
27| "▌ "
style 0-0 fg=green
28| <blank>
29| "▌ "
style 0-0 fg=green
30| "▌ ✓ Delegate renderer audit "
28| "▌ ✓ Delegate renderer audit "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-26 bold
31| "▌ The renderer has explicit lifecycle ownership. "
29| "▌ The renderer has explicit lifecycle ownership. "
style 0-0 fg=green
30| "▌ "
style 0-0 fg=green
31| <blank>
32| "▌ "
style 0-0 fg=green
33| <blank>
34| "▌ "
style 0-0 fg=green
35| "▌ ✓ Read output from background task subagent-7 "
33| "▌ ✓ Read output from background task subagent-7 "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-46 bold
36| "▌ audit complete "
34| "▌ audit complete "
style 0-0 fg=green
37| "▌ [status: completed] "
35| "▌ [status: completed] "
style 0-0 fg=green
36| "▌ "
style 0-0 fg=green
37| <blank>
38| "▌ "
style 0-0 fg=green
39| <blank>
40| "▌ "
style 0-0 fg=green
41| "▌ ✓ Load skill dsh-code-review "
39| "▌ ✓ Load skill dsh-code-review "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-29 bold
42| "▌ Loaded review instructions. "
40| "▌ Loaded review instructions. "
style 0-0 fg=green
43| "▌ "
41| "▌ "
style 0-0 fg=green
44| <blank>
45| " Tool cards expanded. "
42| <blank>
43| " Tool cards expanded. "
style 1-20 fg=bright-black
44| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
45| " "
style 1-1 inverse
46| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
47| " "
style 1-1 inverse
48| "────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-99 dim
49| "/workspace/project ↑0 ↓0 0% context tools:expanded deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 41-99 dim
47| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:expanded"
style 0-43 dim
style 74-99 dim

View File

@@ -0,0 +1,29 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=4 bufferRow=4
viewport
0| " DEEPSEEK HARNESS"
style 1-1 fg=#4d6bfe bold
style 2-2 fg=#4772fe bold
style 3-3 fg=#4278ff bold
style 4-4 fg=#3c7fff bold
style 5-5 fg=#3685ff bold
style 6-6 fg=#308bff bold
style 7-7 fg=#2a92ff bold
style 8-8 fg=#2498ff bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
7-35| <blank>

View File

@@ -1,52 +1,42 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=15 bufferRow=15
cursor hidden column=1 viewportRow=13 bufferRow=13
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=yellow
7| "▌ ◌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
5| "▌ ◌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-95 bold
8| "▌ const second = await tools.bas "
6| "▌ const second = await tools.bas "
style 0-0 fg=yellow
style 2-31 bold
9| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
7| "▌ const first = await tools.bash({ command: 'echo CODE_ONE' }) "
style 0-0 fg=yellow
10| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) "
8| "▌ const second = await tools.bash({ command: 'echo CODE_TWO' }) "
style 0-0 fg=yellow
11| "▌ console.log(first, second) "
9| "▌ console.log(first, second) "
style 0-0 fg=yellow
12| "▌ return `${first}+${second}` "
10| "▌ return `${first}+${second}` "
style 0-0 fg=yellow
13| "▌ "
11| "▌ "
style 0-0 fg=yellow
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| " "
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| " "
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 38-95 dim
18-35| <blank>
15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
16-35| <blank>

View File

@@ -3,50 +3,44 @@ lifecycle started=1 stopped=0 progress=active
title "DSH snapshot"
cursor hidden column=1 viewportRow=17 bufferRow=17
viewport
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Show the live update. "
6| "▌ Show the live update. "
style 0-0 fg=bright-blue
9| "▌ "
7| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Reasoning "
8| <blank>
9| " Reasoning "
style 1-9 fg=bright-black italic
12| " Inspecting width and styles. "
10| " Inspecting width and styles. "
style 1-28 fg=bright-black italic
13| <blank>
14| " Assistant "
11| <blank>
12| " Assistant "
style 1-9 fg=bright-magenta bold
15| " Streaming visible state… "
13| " Streaming visible state… "
style 11-23 bold
14| <blank>
15| " ⠋ Responding 0s · total 0s — Enter sends steering, Esc cancels "
style 1-1 fg=bright-blue
style 3-62 fg=bright-black
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 fg=bright-blue
17| " "
style 1-1 inverse
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 fg=bright-blue
19| "◒ Working · 0s esc interrupt"
style 0-13 fg=bright-blue
style 83-95 dim
19| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
20-35| <blank>

View File

@@ -1,59 +1,49 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=18 bufferRow=18
cursor hidden column=1 viewportRow=16 bufferRow=16
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ ◌ Inspect cordis runtime: tools "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ ◌ Inspect cordis runtime: tools "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-32 bold
7| <blank>
8| "▌ "
5| <blank>
6| "▌ "
style 0-0 fg=yellow
9| "▌ ◌ Mount plugin into live cordis runtime "
7| "▌ ◌ Mount plugin into live cordis runtime "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-40 bold
10| "▌ { "
8| "▌ { "
style 0-0 fg=yellow
11| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
9| "▌ \"code\": \"return { name: 'snapshot-marker', apply(ctx) { ctx.provide('snapshotMarker', { "
style 0-0 fg=yellow
12| "▌ ready: true }) } }\" "
10| "▌ ready: true }) } }\" "
style 0-0 fg=yellow
13| "▌ } "
11| "▌ } "
style 0-0 fg=yellow
14| "▌ "
12| "▌ "
style 0-0 fg=yellow
15| <blank>
16| "▌ ◌ Unmount dyn-1 "
13| <blank>
14| "▌ ◌ Unmount dyn-1 "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-16 bold
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| " "
style 1-1 inverse
19| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
20| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 38-95 dim
21-35| <blank>
18| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
19-35| <blank>

View File

@@ -1,67 +1,63 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=1 progress=inactive
title "DSH snapshot"
cursor visible column=0 viewportRow=30 bufferRow=30
cursor visible column=0 viewportRow=31 bufferRow=31
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 91-91 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 91-91 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 91-91 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-91 fg=bright-blue
5| <blank>
6| " Keyboard shortcuts "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
10| " "
11| " /cancel — Cancel the active turn "
style 1-32 fg=bright-black
12| " /clear — Clear the transcript view (session history is unchanged) "
8| " "
9| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
13| " /exit — Exit after the active turn reaches idle "
10| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
11| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /model [[provider/]model] — Show or switch this session's model "
12| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
16| " /reasoning — Toggle reasoning blocks "
13| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
17| " /redraw — Invalidate components and redraw the terminal "
14| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| <blank>
20| " provider stream failed after partial output "
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
20| <blank>
21| " provider stream failed after partial output "
style 1-43 fg=red
21| <blank>
22| " The previous process ended during this turn. "
22| <blank>
23| " The previous process ended during this turn. "
style 1-44 fg=yellow
23| <blank>
24| " Unknown command: /unknown-advanced-command "
24| <blank>
25| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
25| "────────────────────────────────────────────────────────────────────────────────────────────"
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
26| " "
27| " "
style 1-1 inverse
27| "────────────────────────────────────────────────────────────────────────────────────────────"
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 34-91 dim
29-31| <blank>
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
30-31| <blank>

View File

@@ -1,56 +1,46 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=17 bufferRow=17
cursor hidden column=1 viewportRow=15 bufferRow=15
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=yellow
7| "▌ ◌ workflow: tui-matrix "
5| "▌ ◌ workflow: tui-matrix "
style 0-0 fg=yellow
style 2-2 fg=yellow bold
style 3-23 bold
8| "▌ phase('Inspect') "
6| "▌ phase('Inspect') "
style 0-0 fg=yellow
9| "▌ const reports = await parallel([ "
7| "▌ const reports = await parallel([ "
style 0-0 fg=yellow
10| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
8| "▌ () => agent('Audit layout', { label: 'layout', phase: 'Inspect' }), "
style 0-0 fg=yellow
11| "▌ … +1 lines (Ctrl+O to expand) "
9| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=yellow
style 2-30 dim
12| "▌ ]) "
10| "▌ ]) "
style 0-0 fg=yellow
13| "▌ phase('Verify') "
11| "▌ phase('Verify') "
style 0-0 fg=yellow
14| "▌ return { reports, verdict: 'covered' } "
12| "▌ return { reports, verdict: 'covered' } "
style 0-0 fg=yellow
15| "▌ "
13| "▌ "
style 0-0 fg=yellow
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| " "
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| " "
style 1-1 inverse
18| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
19| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 38-95 dim
20-35| <blank>
17| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
18-35| <blank>

View File

@@ -1,67 +1,63 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=26 bufferRow=26
cursor hidden column=1 viewportRow=27 bufferRow=27
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 91-91 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 91-91 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 91-91 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-91 fg=bright-blue
5| <blank>
6| " Keyboard shortcuts "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Keyboard shortcuts "
style 1-18 fg=bright-blue bold
7| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
5| " Enter send • Shift/Alt+Enter newline • Up/Down prompt history "
style 1-61 fg=bright-black
8| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
6| " Esc cancel active turn • Ctrl+O toggle tool cards • Ctrl+R toggle reasoning "
style 1-75 fg=bright-black
9| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
7| " Ctrl+C cancel while running; clear input or exit while idle • Ctrl+D exit "
style 1-73 fg=bright-black
10| " "
11| " /cancel — Cancel the active turn "
style 1-32 fg=bright-black
12| " /clear — Clear the transcript view (session history is unchanged) "
8| " "
9| " /clear — Clear the transcript view (session history is unchanged) "
style 1-65 fg=bright-black
13| " /exit — Exit after the active turn reaches idle "
10| " /exit — Exit after the active turn reaches idle "
style 1-47 fg=bright-black
14| " /help — Show keyboard shortcuts and commands "
11| " /help — Show keyboard shortcuts and commands "
style 1-44 fg=bright-black
15| " /model [[provider/]model] — Show or switch this session's model "
12| " /model [[provider/]model] — Show or switch this session's model "
style 1-63 fg=bright-black
16| " /reasoning — Toggle reasoning blocks "
13| " /reasoning — Toggle reasoning blocks "
style 1-36 fg=bright-black
17| " /redraw — Invalidate components and redraw the terminal "
14| " /redraw — Invalidate components and redraw the terminal "
style 1-55 fg=bright-black
15| " /reload — EXPERIMENTAL (dev): re-read loader config files and apply the diff (idle only) "
style 1-88 fg=bright-black
16| " /resume — List this workspace's resumable sessions "
style 1-50 fg=bright-black
17| " /status — Show detailed session diagnostics "
style 1-43 fg=bright-black
18| " /tools — Expand or collapse all tool cards "
style 1-42 fg=bright-black
19| <blank>
20| " provider stream failed after partial output "
19| " /skill:<name> [instructions] — load a skill into the conversation "
style 1-65 fg=bright-black
20| <blank>
21| " provider stream failed after partial output "
style 1-43 fg=red
21| <blank>
22| " The previous process ended during this turn. "
22| <blank>
23| " The previous process ended during this turn. "
style 1-44 fg=yellow
23| <blank>
24| " Unknown command: /unknown-advanced-command "
24| <blank>
25| " Unknown command: /unknown-advanced-command "
style 1-42 fg=yellow
25| "────────────────────────────────────────────────────────────────────────────────────────────"
26| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
26| " "
27| " "
style 1-1 inverse
27| "────────────────────────────────────────────────────────────────────────────────────────────"
28| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 34-91 dim
29-31| <blank>
29| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
30-31| <blank>

View File

@@ -3,33 +3,23 @@ lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=31 bufferRow=31
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 91-91 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 91-91 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 91-91 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-91 fg=bright-blue
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| " "
style 1-1 inverse
7| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
8| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 34-91 dim
9-12| <blank>
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
7-12| <blank>
13| " ╭ Select model ────────────────────────────────────────────────────────╮ "
style 10-81 fg=bright-blue
14| " │ → deepseek/deepseek-v4-flash DeepSeek V4 Flash — current │ "

View File

@@ -1,35 +1,25 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=8 bufferRow=8
cursor hidden column=1 viewportRow=6 bufferRow=6
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-91 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 91-91 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 91-91 fg=bright-blue
3| "│ deepseek-v4-pro • main-session │"
style 0-0 fg=bright-blue
style 2-33 dim
style 91-91 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-91 fg=bright-blue
5| <blank>
6| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
3| <blank>
4| " Model selected: deepseek/deepseek-v4-pro. New steps will use it. "
style 1-64 fg=bright-black
5| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
6| " "
style 1-1 inverse
7| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
8| " "
style 1-1 inverse
9| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
10| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-pro(reasoning:on)"
style 0-24 dim
style 36-91 dim
11-31| <blank>
8| "deepseek-v4-pro /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-41 dim
style 65-91 dim
9-31| <blank>

View File

@@ -3,23 +3,17 @@ lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=56 viewportRow=17 bufferRow=17
viewport
0| "╭──────────────────────────────────────────────────────╮"
style 0-55 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 55-55 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 55-55 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 55-55 fg=bright-blue
4| "╰──────────────────────────────────────────────────────╯"
style 0-55 fg=bright-blue
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────"
style 0-55 dim
4| " "
style 1-1 inverse
5| " "
6| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black

View File

@@ -3,27 +3,22 @@ lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=0 viewportRow=19 bufferRow=19
viewport
0| "╭──────────────────────────────────────────────────────╮"
style 0-55 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 55-55 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 55-55 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 55-55 fg=bright-blue
4| "╰──────────────────────────────────────────────────────╯"
style 0-55 fg=bright-blue
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| "────────────────────────────────────────────────────────"
style 0-55 dim
4| " "
style 1-1 inverse
5| "────────────────────────────────────────────────────────"
style 0-55 dim
6| " "
style 1-1 inverse
6| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context"
style 0-43 dim
style 46-55 dim
7| " "
8| " Question 1/3 (3 unanswered) · Coverage "
style 2-39 fg=bright-black

View File

@@ -0,0 +1,32 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=10 bufferRow=10
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Resumable sessions "
style 1-18 fg=bright-blue bold
5| " 2024-01-02 03:04 (current) "
style 1-16 fg=bright-black
style 17-26 fg=green
6| " RESUME_SESSION_ID=main-session dsh "
7| " 2024-01-01 00:00 "
style 1-16 fg=bright-black
8| " RESUME_SESSION_ID=earlier-session dsh "
9| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
10| " "
style 1-1 inverse
11| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
12| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 65-91 dim
13-31| <blank>

View File

@@ -1,48 +1,38 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=15 bufferRow=15
cursor hidden column=1 viewportRow=13 bufferRow=13
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Start then cancel. "
6| "▌ Start then cancel. "
style 0-0 fg=bright-blue
9| "▌ "
7| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Retrying model request (1/2) in 1000ms: temporary transport failure "
8| <blank>
9| " Retrying model request (1/2) in 1000ms: temporary transport failure "
style 1-67 fg=yellow
12| <blank>
13| " Turn cancelled. "
10| <blank>
11| " Turn cancelled. "
style 1-15 fg=yellow
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| " "
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| " "
style 1-1 inverse
16| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
17| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 38-95 dim
18-35| <blank>
15| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
16-35| <blank>

View File

@@ -1,45 +1,35 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=13 bufferRow=13
cursor hidden column=1 viewportRow=11 bufferRow=11
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Let the bounded policy exhaust. "
6| "▌ Let the bounded policy exhaust. "
style 0-0 fg=bright-blue
9| "▌ "
7| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " provider still unavailable "
8| <blank>
9| " provider still unavailable "
style 1-26 fg=red
10| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
11| " "
style 1-1 inverse
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| " "
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 38-95 dim
16-35| <blank>
13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
14-35| <blank>

View File

@@ -1,49 +1,39 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=16 bufferRow=16
cursor hidden column=1 viewportRow=14 bufferRow=14
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Recover this request. "
6| "▌ Recover this request. "
style 0-0 fg=bright-blue
9| "▌ "
7| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Retrying model request (1/2) in 500ms: provider rate limit "
8| <blank>
9| " Retrying model request (1/2) in 500ms: provider rate limit "
style 1-58 fg=yellow
12| <blank>
13| " Assistant "
10| <blank>
11| " Assistant "
style 1-9 fg=bright-magenta bold
14| " Recovered on the next bounded attempt. "
12| " Recovered on the next bounded attempt. "
13| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
14| " "
style 1-1 inverse
15| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
16| " "
style 1-1 inverse
17| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
18| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 38-95 dim
19-35| <blank>
16| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
17-35| <blank>

View File

@@ -1,45 +1,35 @@
terminal 96x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=13 bufferRow=13
cursor hidden column=1 viewportRow=11 bufferRow=11
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-95 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 95-95 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 95-95 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 95-95 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-95 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Recover this request. "
6| "▌ Recover this request. "
style 0-0 fg=bright-blue
9| "▌ "
7| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Retrying model request (1/2) in 500ms: provider rate limit "
8| <blank>
9| " Retrying model request (1/2) in 500ms: provider rate limit "
style 1-58 fg=yellow
10| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
11| " "
style 1-1 inverse
12| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
13| " "
style 1-1 inverse
14| "────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-95 dim
15| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 38-95 dim
16-35| <blank>
13| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 69-95 dim
14-35| <blank>

View File

@@ -0,0 +1,110 @@
terminal 56x36 buffer=normal length=36 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "Inspect session diagnostics — DSH snapshot"
cursor hidden column=1 viewportRow=32 bufferRow=32
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Inspect session diagnostics"
style 1-27 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ inspect this session "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Assistant "
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
12| "╭─ Session status ─────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-blue bold
style 17-55 dim
13| "│ Session: main-session │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
14| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
15| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
16| "│ Model: deepseek/deepseek-v4-pro (reasoning │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-55 dim
17| "│ shown) │"
style 0-0 dim
style 15-20 dim
style 55-55 dim
18| "│ │"
style 0-0 dim
style 55-55 dim
19| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
20| "│ tool call │"
style 0-0 dim
style 55-55 dim
21| "│ │"
style 0-0 dim
style 55-55 dim
22| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
23| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-26 fg=bright-blue
style 27-32 dim
style 55-55 dim
24| "│ + 250 write) │"
style 0-0 dim
style 55-55 dim
25| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-20 fg=bright-blue
style 21-32 dim
style 55-55 dim
26| "│ 128,000) │"
style 0-0 dim
style 55-55 dim
27| "│ │"
style 0-0 dim
style 55-55 dim
28| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
29| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 55-55 dim
30| "╰──────────────────────────────────────────────────────╯"
style 0-55 dim
31| "────────────────────────────────────────────────────────"
style 0-55 dim
32| " "
style 1-1 inverse
33| "────────────────────────────────────────────────────────"
style 0-55 dim
34| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 6"
style 0-55 dim
35| <blank>

View File

@@ -0,0 +1,99 @@
terminal 92x32 buffer=normal length=32 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "Inspect session diagnostics — DSH snapshot"
cursor hidden column=1 viewportRow=28 bufferRow=28
buffer
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Inspect session diagnostics"
style 1-27 fg=bright-black
2| " deepseek-v4-pro • main-session"
style 1-32 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
6| "▌ inspect this session "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| " Assistant "
style 1-9 fg=bright-magenta bold
10| " Session inspected. "
11| <blank>
12| "╭─ Session status ─────────────────────────────────────────────────╮"
style 0-2 dim
style 3-16 fg=bright-blue bold
style 17-67 dim
13| "│ Session: main-session │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
14| "│ Title: Inspect session diagnostics │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
15| "│ Directory: /workspace/project │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
16| "│ Model: deepseek/deepseek-v4-pro (reasoning shown) │"
style 0-0 dim
style 3-12 fg=bright-black
style 40-56 dim
style 67-67 dim
17| "│ │"
style 0-0 dim
style 67-67 dim
18| "│ Agent: idle · 6 events · 1 turn · 1 step · 1 tool call │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
19| "│ │"
style 0-0 dim
style 67-67 dim
20| "│ Tokens: 1,250 input + 340 output │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
21| "│ KV cache: [███████████░░░░░] 67% hit (3,000 read + 250 write) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-26 fg=bright-blue
style 27-32 dim
style 67-67 dim
22| "│ Context: [█████░░░░░░░░░░░] 33% used (42,000 / 128,000) │"
style 0-0 dim
style 3-12 fg=bright-black
style 15-15 dim
style 16-20 fg=bright-blue
style 21-32 dim
style 67-67 dim
23| "│ │"
style 0-0 dim
style 67-67 dim
24| "│ Created: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
25| "│ Active: 2026-07-22 09:10:11 UTC │"
style 0-0 dim
style 3-12 fg=bright-black
style 67-67 dim
26| "╰──────────────────────────────────────────────────────────────────╯"
style 0-67 dim
27| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
28| " "
style 1-1 inverse
29| "────────────────────────────────────────────────────────────────────────────────────────────"
style 0-91 dim
30| "deepseek-v4-pro /workspace/project ↑1.3k ↓340 cache 67% 33% context tools:collapsed"
style 0-57 dim
style 64-91 dim
31| <blank>

View File

@@ -1,40 +1,30 @@
terminal 44x18 buffer=normal length=18 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=11 bufferRow=11
cursor hidden column=1 viewportRow=9 bufferRow=9
buffer
0| "╭──────────────────────────────────────────╮"
style 0-43 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 43-43 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 43-43 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 43-43 fg=bright-blue
4| "╰──────────────────────────────────────────╯"
style 0-43 fg=bright-blue
5| <blank>
6| " Context · compact "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Context · compact "
style 1-17 dim
7| " Compacted summary: the prior command "
5| " Compacted summary: the prior command "
style 1-43 fg=bright-black
8| " completed and its details were retired "
6| " completed and its details were retired "
style 1-43 fg=bright-black
9| " from the active surface. "
7| " from the active surface. "
style 1-24 fg=bright-black
8| "────────────────────────────────────────────"
style 0-43 dim
9| " "
style 1-1 inverse
10| "────────────────────────────────────────────"
style 0-43 dim
11| " "
style 1-1 inverse
12| "────────────────────────────────────────────"
11| "deepseek-v4-flash /workspace/project ↑0 ↓0"
style 0-43 dim
13| " 0% context deepseek-v4-flash(reasoning:on)"
style 1-43 dim
14-17| <blank>
12-17| <blank>

View File

@@ -1,37 +1,27 @@
terminal 104x30 buffer=normal length=30 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=9 bufferRow=9
cursor hidden column=1 viewportRow=7 bufferRow=7
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-103 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 103-103 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 103-103 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 103-103 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-103 fg=bright-blue
5| <blank>
6| " Context · compact "
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| " Context · compact "
style 1-17 dim
7| " Compacted summary: the prior command completed and its details were retired from the active surface. "
5| " Compacted summary: the prior command completed and its details were retired from the active surface. "
style 1-100 fg=bright-black
6| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-103 dim
7| " "
style 1-1 inverse
8| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-103 dim
9| " "
style 1-1 inverse
10| "────────────────────────────────────────────────────────────────────────────────────────────────────────"
style 0-103 dim
11| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 46-103 dim
12-29| <blank>
9| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 77-103 dim
10-29| <blank>

View File

@@ -1,68 +1,59 @@
terminal 80x24 buffer=normal length=25 base=1 viewport=1
terminal 80x24 buffer=normal length=24 base=0 viewport=0
lifecycle started=1 stopped=0 progress=inactive
title "DSH snapshot"
cursor hidden column=1 viewportRow=21 bufferRow=22
cursor hidden column=1 viewportRow=20 bufferRow=20
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────╮"
style 0-79 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Snapshot agent ready."
style 1-21 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 79-79 fg=bright-blue
2| "│ Snapshot agent ready. │"
style 0-0 fg=bright-blue
style 2-22 fg=bright-black
style 79-79 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 79-79 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────╯"
style 0-79 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Old prompt with a long line that exercises wrapping before compaction. "
6| "▌ Old prompt with a long line that exercises wrapping before compaction. "
style 0-0 fg=bright-blue
7| "▌ "
style 0-0 fg=bright-blue
8| <blank>
9| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| "▌ "
style 0-0 fg=green
12| "▌ ✓ pnpm run test:coverage "
10| "▌ ✓ pnpm run test:coverage "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-25 bold
13| "▌ Run the coverage gate "
11| "▌ Run the coverage gate "
style 0-0 fg=green
style 2-22 fg=bright-black
14| "▌ /workspace/project "
12| "▌ /workspace/project "
style 0-0 fg=green
style 2-19 dim
15| "▌ packages/ui/tui 100% "
13| "▌ packages/ui/tui 100% "
style 0-0 fg=green
16| "▌ … +1 lines (Ctrl+O to expand) "
14| "▌ … +1 lines (Ctrl+O to expand) "
style 0-0 fg=green
style 2-30 dim
17| "▌ 1 test skipped "
15| "▌ 1 test skipped "
style 0-0 fg=green
18| "▌ coverage complete "
16| "▌ coverage complete "
style 0-0 fg=green
19| "▌ [exit 0] "
17| "▌ [exit 0] "
style 0-0 fg=green
style 2-9 dim
20| "▌ "
18| "▌ "
style 0-0 fg=green
19| "────────────────────────────────────────────────────────────────────────────────"
style 0-79 dim
20| " "
style 1-1 inverse
21| "────────────────────────────────────────────────────────────────────────────────"
style 0-79 dim
22| " "
style 1-1 inverse
23| "────────────────────────────────────────────────────────────────────────────────"
style 0-79 dim
24| "/workspace/pro ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-13 dim
style 22-79 dim
22| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 53-79 dim
23| <blank>

View File

@@ -1,87 +1,77 @@
terminal 100x34 buffer=normal length=40 base=6 viewport=6
terminal 100x34 buffer=normal length=38 base=4 viewport=4
lifecycle started=1 stopped=0 progress=inactive
title "Unsafe terminal title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
cursor hidden column=100 viewportRow=33 bufferRow=39
cursor hidden column=100 viewportRow=33 bufferRow=37
buffer
0| "╭──────────────────────────────────────────────────────────────────────────────────────────────────╮"
style 0-99 fg=bright-blue
1| "│ DEEPSEEK HARNESS │"
0| " DEEPSEEK HARNESS"
style 1-8 fg=bright-blue bold
style 10-16 bold
1| " Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m"
style 1-60 fg=bright-black
2| " deepseek-v4-flash • main-session"
style 1-34 dim
3| <blank>
4| "▌ "
style 0-0 fg=bright-blue
style 2-9 fg=bright-blue bold
style 11-17 bold
style 99-99 fg=bright-blue
2| "│ Unsafe welcome \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m │"
style 0-0 fg=bright-blue
style 2-61 fg=bright-black
style 99-99 fg=bright-blue
3| "│ deepseek-v4-flash • main-session │"
style 0-0 fg=bright-blue
style 2-35 dim
style 99-99 fg=bright-blue
4| "╰──────────────────────────────────────────────────────────────────────────────────────────────────╯"
style 0-99 fg=bright-blue
5| <blank>
6| "▌ "
style 0-0 fg=bright-blue
7| "▌ You "
5| "▌ You "
style 0-0 fg=bright-blue
style 2-4 fg=bright-blue bold
8| "▌ Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
6| "▌ Unsafe user \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=bright-blue
9| "▌ "
7| "▌ "
style 0-0 fg=bright-blue
10| <blank>
11| " Reasoning "
8| <blank>
9| " Reasoning "
style 1-9 fg=bright-black italic
12| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
10| " Unsafe reasoning \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-62 fg=bright-black italic
13| <blank>
14| " Assistant "
11| <blank>
12| " Assistant "
style 1-9 fg=bright-magenta bold
15| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
16| <blank>
17| "▌ "
13| " Unsafe assistant \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
14| <blank>
15| "▌ "
style 0-0 fg=green
18| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
16| "▌ ✓ Unsafe title \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-2 fg=green bold
style 3-61 bold
19| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
17| "▌ Unsafe description \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-65 fg=bright-black
20| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
18| "▌ /unsafe/\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
style 2-54 dim
21| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
19| "▌ Unsafe output \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 0-0 fg=green
22| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
20| "▌ [signal SIG\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m] "
style 0-0 fg=green
style 2-58 fg=red
23| "▌ "
21| "▌ "
style 0-0 fg=green
24| <blank>
25| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
22| <blank>
23| " Context · unsafe-\\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-62 dim
26| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
24| " Unsafe context \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-60 fg=bright-black
27| <blank>
28| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
25| <blank>
26| " Prompt blocked: Unsafe policy \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-75 fg=yellow
29| <blank>
30| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
27| <blank>
28| " Unsafe turn error \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 1-63 fg=red
31| <blank>
32| " "
33| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
29| <blank>
30| " "
31| " Question 1/1 (1 unanswered) · Unsafe header \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
style 2-90 fg=bright-black
34| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
35| " "
36| " 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
32| " Unsafe question \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m "
33| " "
34| " 1. Unsafe option \\x1b]2;snapshot-controlled\\x07\\x09\\x7f\\x9b31m Unsafe detail \\x1b]2;snapshot-c "
style 2-65 fg=bright-blue bold
style 67-97 fg=bright-black
37| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
35| " Tab custom answer • ↑/↓ navigate • Enter submit • Esc interrupt "
style 2-64 dim
38| " "
39| "/workspace/project ↑0 ↓0 0% context tools:compact deepseek-v4-flash(reasoning:on)"
style 0-24 dim
style 42-99 dim
36| " "
37| "deepseek-v4-flash /workspace/project ↑0 ↓0 0% context tools:collapsed"
style 0-43 dim
style 73-99 dim

View File

@@ -1,12 +1,13 @@
import { mkdir, readdir, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, describe, expect, it } from 'vitest'
import { afterAll, describe, expect, it, vi } from 'vitest'
import type { Context } from 'cordis'
import { agentEvents } from '@deepseek-ai/dsh-agent'
import { CallId, type ContentBlock } from '@deepseek-ai/dsh-llm'
import type {} from '@deepseek-ai/dsh-llm-retry'
import type { Session } from '@deepseek-ai/dsh-session'
import { SessionId } from '@deepseek-ai/dsh-session'
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
import ToolRegistry, { type ToolDefinition, type ToolResultView } from '@deepseek-ai/dsh-tools'
import * as ToolCordis from '@deepseek-ai/dsh-tool-cordis'
@@ -30,6 +31,7 @@ const CHECKPOINTS = [
'retry-recovered',
'retry-cancelled',
'retry-exhausted',
'banner-gradient',
'code-mode-pending',
'dynamic-workflow-pending',
'cordis-tools-pending',
@@ -45,6 +47,9 @@ const CHECKPOINTS = [
'model-switching',
'errors-and-help',
'disposed-terminal',
'resume-sessions',
'status-diagnostics',
'status-diagnostics-narrow',
] as const
type Checkpoint = typeof CHECKPOINTS[number]
@@ -56,9 +61,23 @@ async function checkpoint(
name: Checkpoint,
terminal: HeadlessTerminal,
options: TerminalSnapshotOptions = {},
bannerGradient = false,
): Promise<void> {
observedCheckpoints.add(name)
expect(terminal.themeViolations(), `${name} must remain theme-agnostic`).toEqual([])
const violations = terminal.themeViolations()
if (bannerGradient) {
// The banner paints its product name in the DeepSeek brand gradient with
// 24-bit foreground codes: the sole sanctioned truecolor. Require it to be
// present and to never leak a background or extended-palette color into the
// otherwise theme-agnostic UI.
expect(violations, `${name} must render the banner gradient`).not.toEqual([])
expect(
violations.every(entry => entry.endsWith('rgb-fg')),
`${name} must confine truecolor to the banner foreground`,
).toBe(true)
} else {
expect(violations, `${name} must remain theme-agnostic`).toEqual([])
}
const snapshot = await terminal.snapshot(options)
const path = join(SNAPSHOTS_DIR, `${name}.expected.txt`)
if (REFRESHING) {
@@ -308,6 +327,12 @@ describe('TUI terminal-state snapshots', () => {
await disposeSnapshot(harness)
})
it('paints the startup banner product name in the DeepSeek brand gradient on truecolor terminals', async () => {
const harness = await setupSnapshot({ config: { truecolor: true } })
await checkpoint('banner-gradient', harness.terminal, {}, true)
await disposeSnapshot(harness)
})
it('pins Code Mode run_code with its production presenter', async () => {
const harness = await setupSnapshot({ configureContext: configureAdvancedTools })
const call = {
@@ -596,6 +621,63 @@ describe('TUI terminal-state snapshots', () => {
await checkpoint('model-switching', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
it('lists this workspace\'s resumable sessions with their commands', async () => {
const harness = await setupSnapshot({
config: { resumeCommand: 'RESUME_SESSION_ID={session} dsh' },
sessionPersistence: { list: async () => [
{ version: 0, id: SessionId('main-session'), createdAt: Date.parse('2024-01-02T03:04:00Z'), cwd: '/workspace/project' },
{ version: 0, id: SessionId('earlier-session'), createdAt: Date.parse('2024-01-01T00:00:00Z'), cwd: '/workspace/project' },
] },
}, { columns: 92, rows: 32 })
harness.terminal.send('/resume')
harness.terminal.send('\r')
// `/resume` scans persistence asynchronously, so the listing renders a tick
// after submit (the unit suite waits the same way); settle, then flush.
await new Promise(resolve => setTimeout(resolve, 60))
await harness.terminal.flush()
await checkpoint('resume-sessions', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
})
it('pins the detailed session diagnostics card', async () => {
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(Date.parse('2026-07-22T09:10:11.000Z'))
const harness = await setupSnapshot({
contextWindow: 128_000,
contextTokens: 42_000,
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
beforeMount(session) {
appendUser(session, 'inspect this session')
appendAssistant(session, [{ type: 'text', text: 'Session inspected.' }], {
inputTokens: 1_250,
outputTokens: 340,
cacheReadTokens: 3_000,
cacheWriteTokens: 250,
})
session.append('tool/call', {
turn: 1,
step: 1,
callId: CallId('status-call'),
name: 'read',
arguments: '{"path":"README.md"}',
})
session.append('session/title', {
title: 'Inspect session diagnostics',
messageSeqs: [1],
source: { kind: 'fallback' },
})
},
}, { columns: 92, rows: 32 })
await renderAfter(harness, () => {
harness.terminal.send('/status')
harness.terminal.send('\r')
})
await checkpoint('status-diagnostics', harness.terminal, { includeScrollback: true })
await renderAfter(harness, () => { harness.terminal.resize(56, 36) })
await checkpoint('status-diagnostics-narrow', harness.terminal, { includeScrollback: true })
await disposeSnapshot(harness)
dateNow.mockRestore()
})
})
afterAll(async () => {

View File

@@ -4,9 +4,10 @@ import { describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Terminal } from '@earendil-works/pi-tui'
import AgentRegistry, { agentEvents, assembleContextFor, type Agent } from '@deepseek-ai/dsh-agent'
import type { LlmCallConfig } from '@deepseek-ai/dsh-llm'
import { type LlmCallConfig } from '@deepseek-ai/dsh-llm'
import CommandService, { type CommandInvocation } from '@deepseek-ai/dsh-commands'
import SessionStore, { SessionId } from '@deepseek-ai/dsh-session'
import SessionStore, { SessionId, type SessionHeader } from '@deepseek-ai/dsh-session'
import SkillService, { type SkillDefinition, type SkillSummary } from '@deepseek-ai/dsh-skill'
import type {} from '@deepseek-ai/dsh-session-title'
import type { ToolDefinition } from '@deepseek-ai/dsh-tools'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
@@ -14,6 +15,7 @@ import type {} from '@deepseek-ai/dsh-llm-retry'
import {
createTuiChat,
mountTui,
renderSkillInvocation,
resolveTuiConfig,
type TuiRuntime,
} from '../src/index.ts'
@@ -122,6 +124,15 @@ function provideTokenMeter(ctx: Context): void {
} as never)
}
/** Minimal advisory-catalog llm stub for tests composing their own context. */
function provideLlmCatalog(ctx: Context): void {
ctx.provide('llm', {
listProviders: () => [],
listModels: () => Promise.resolve([]),
resolveModelContext: () => Promise.resolve(undefined),
} as never)
}
describe('TUI config', () => {
it('defaults every direct-call TUI option', () => {
expect(resolveTuiConfig(undefined)).toEqual({
@@ -135,6 +146,7 @@ describe('TUI config', () => {
modelDialogMaxHeight: 20,
showHardwareCursor: false,
color: true,
truecolor: false,
title: 'DeepSeek Harness',
})
expect(resolveTuiConfig({
@@ -148,6 +160,7 @@ describe('TUI config', () => {
modelDialogMaxHeight: 16,
showHardwareCursor: true,
color: false,
truecolor: true,
title: 'DSH',
})).toEqual({
showReasoning: false,
@@ -160,11 +173,121 @@ describe('TUI config', () => {
modelDialogMaxHeight: 16,
showHardwareCursor: true,
color: false,
truecolor: true,
title: 'DSH',
})
})
})
describe('resume command and /resume', () => {
const RESUME = 'RESUME_SESSION_ID={session} dsh'
const header = (id: string, createdAt: number, cwd: string): SessionHeader =>
({ version: 0, id: SessionId(id), createdAt, cwd })
it('prints the resume command on exit once the session is persisted', async () => {
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: { list: async () => [header('main-session', 1000, '/workspace')] },
})
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.exit).toHaveBeenCalledWith(0)
await dispose(result)
})
it('omits the exit hint when the session is not yet persisted', async () => {
const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
result.terminal.send('/exit')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).not.toContain('To resume this session')
expect(result.exit).toHaveBeenCalledWith(0)
await dispose(result)
})
it('omits the exit hint when the session listing fails', async () => {
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: { list: () => Promise.reject(new Error('disk gone')) },
})
result.terminal.send('/exit')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).not.toContain('To resume this session')
expect(result.exit).toHaveBeenCalledWith(0)
await dispose(result)
})
it('lists this workspace\'s sessions newest-first and marks the current one', async () => {
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: {
list: async () => [
header('main-session', 1000, '/workspace'),
header('older-session', 500, '/workspace'),
header('newer-session', 2000, '/workspace'),
header('foreign-session', 3000, '/elsewhere'),
],
},
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
const output = result.terminal.output
expect(output).toContain('Resumable sessions')
expect(output).toContain('RESUME_SESSION_ID=main-session dsh')
expect(output).toContain('(current)')
expect(output).toContain('RESUME_SESSION_ID=newer-session dsh')
expect(output).not.toContain('foreign-session')
// Newest-first: the newer session's command precedes the current session's.
// Match the full resume command, not the bare id: the banner detail line
// echoes the current session id (`main-session`) above the listing.
expect(output.indexOf('RESUME_SESSION_ID=newer-session')).toBeLessThan(
output.indexOf('RESUME_SESSION_ID=main-session'),
)
expect(output.indexOf('RESUME_SESSION_ID=main-session')).toBeLessThan(
output.indexOf('RESUME_SESSION_ID=older-session'),
)
await dispose(result)
})
it('warns from /resume when resume is not configured', async () => {
const result = await setup({ cwd: '/workspace' })
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Resume is not configured')
await dispose(result)
})
it('warns from /resume when no persistence backend is mounted', async () => {
const result = await setup({ cwd: '/workspace', config: { resumeCommand: RESUME } })
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('no persistence backend is mounted')
await dispose(result)
})
it('notes from /resume when no workspace sessions are persisted yet', async () => {
const result = await setup({
cwd: '/workspace',
config: { resumeCommand: RESUME },
sessionPersistence: { list: async () => [header('foreign-session', 10, '/elsewhere')] },
})
result.terminal.send('/resume')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('No resumable sessions found')
await dispose(result)
})
})
describe('pi-tui chat lifecycle and transcript', () => {
it('uses the latest log-backed title for the header subtitle and terminal window', async () => {
const result = await setup({
@@ -203,6 +326,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
const result = await setup({
contextWindow: 100,
contextTokens: 42,
// Short cwd: the footer clips its right (context/tools) segment first,
// and the default worktree path would swallow it at 88 columns.
cwd: '/opt',
now: () => now,
beforeMount(session) {
appendUser(session, 'restored prompt')
@@ -229,13 +355,14 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('restored answer')
expect(result.terminal.output).toContain('write tests')
expect(result.terminal.output).toContain('↑1.3k ↓42')
expect(result.terminal.output).toContain('42% context tools:compact deepseek-v4-flash(reasoning:on)')
// Context resolution is async (resolveModelContext); settle before reading.
await tick()
expect(result.terminal.output).toContain('42% context tools:collapsed')
// Narrow terminals clip the right-hand context/tools segment first; the
// model-led left segment stays.
result.terminal.resize(52)
await tick()
expect(result.terminal.output).toContain('42% context deepseek-v4-flash(reasoning:on)')
result.terminal.resize(65)
await tick()
expect(result.terminal.output).toContain('↑1.3k ↓42 42% context deepseek-v4-flash(reasoning:on)')
expect(result.terminal.output).toContain('deepseek-v4-flash')
result.terminal.resize(88)
await tick()
@@ -324,8 +451,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('final live answer')
})
expect(result.terminal.output).toContain('◒ Working · 8s')
expect(result.terminal.output).toContain('esc interrupt')
expect(result.terminal.output).toContain('Enter sends steering, Esc cancels')
expect(result.terminal.output).toContain('Steering')
expect(result.terminal.output).toContain('user context')
expect(result.terminal.output).toContain('Prompt blocked')
@@ -347,7 +473,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
agentEvents(result.ctx, result.agent).emit('agent/status', 'idle')
await tick()
expect(result.terminal.output).toContain('↑1.8k ↓50')
expect(result.terminal.output).toContain('deepseek-v4-flash(reasoning:off)')
expect(result.terminal.output).toContain('deepseek-v4-flash')
expect(result.terminal.progress.at(-1)).toBe(false)
await dispose(result)
expect(result.terminal.stopped).toBe(1)
@@ -416,6 +542,170 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(result)
})
it('badges queued steering on the running status line and clears it as each drains', async () => {
// Pin a cwd free of the substring under test; the footer renders the path.
const result = await setup({ status: 'running', cwd: '/workspace' })
// Running with nothing queued: the plain steering hint, no badge.
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
expect(result.terminal.output).not.toContain('queued')
const queueSteering = (text: string): void => {
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text }], { source: { kind: 'user' }, steering: true })
}
const drainSteering = (text: string): void => {
result.session.append('steering/message', { turn: 1, content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
}
// A steering queue for a different agent never touches this status line.
const other = { ...result.agent, id: SessionId('other') } as Agent
result.terminal.output = ''
result.ctx.emit('agent/queued', other, [{ type: 'text', text: 'elsewhere' }], { source: { kind: 'user' }, steering: true })
await tick()
expect(result.terminal.output).not.toContain('queued')
// Two steering messages queue while the turn runs.
queueSteering('first')
result.terminal.output = ''
queueSteering('second')
await tick()
expect(result.terminal.output).toContain('2 queued · Enter sends steering, Esc cancels')
// A non-steering queue (an idle-style send) leaves the badge untouched.
result.terminal.output = ''
result.ctx.emit('agent/queued', result.agent, [{ type: 'text', text: 'sent' }], { source: { kind: 'user' }, steering: false })
drainSteering('first')
await tick()
expect(result.terminal.output).toContain('1 queued')
expect(result.terminal.output).not.toContain('2 queued')
// Draining the last queued message returns the plain hint.
result.terminal.output = ''
drainSteering('second')
await tick()
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
expect(result.terminal.output).not.toContain('queued')
// A drain with no matching queued entry is ignored rather than underflowing.
result.terminal.output = ''
drainSteering('continuation')
queueSteering('after')
await tick()
expect(result.terminal.output).toContain('1 queued')
// A loop-authored steering event (plugin source, no matching agent/queued)
// cannot consume a pending user slot, even when it drains first.
result.terminal.output = ''
result.session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'continue: goal not reached' }],
source: { kind: 'plugin', plugin: 'hooks' },
}, { surfaceOp: 'append' })
await tick()
expect(result.terminal.output).toContain('1 queued')
result.terminal.output = ''
drainSteering('after')
await tick()
expect(result.terminal.output).not.toContain('queued')
// The turn ending resets the badge, so the next running turn starts clean.
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
result.agent.status = 'running'
result.terminal.output = ''
result.ctx.emit('agent/status', result.agent, 'running')
await tick()
expect(result.terminal.output).toContain('— Enter sends steering, Esc cancels')
expect(result.terminal.output).not.toContain('queued')
await dispose(result)
})
it('derives the fine-grained turn phase from session lifecycle events', async () => {
// A live event before the turn runs has no status controller to move.
const idle = await setup()
// A steering queue arriving while idle has no status line to badge, so the
// refresh is a no-op beyond requesting a render.
idle.ctx.emit('agent/queued', idle.agent, [{ type: 'text', text: 'early' }], { source: { kind: 'user' }, steering: true })
idle.session.append('tool/call', { turn: 1, step: 0, callId: 'pre' as never, name: 'bash', arguments: '{}' })
await tick()
expect(idle.terminal.output).not.toContain('Executing tools')
expect(idle.terminal.output).not.toContain('queued')
await dispose(idle)
const result = await setup({ status: 'running' })
expect(result.terminal.output).toContain('Waiting for the first token')
result.terminal.output = ''
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 0, blockType: 'reasoning' } })
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'reasoning-delta', index: 0, text: 'mull it over' } })
await tick()
expect(result.terminal.output).toContain('Thinking')
result.terminal.output = ''
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'block-start', index: 1, blockType: 'text' } })
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 1, text: 'answering' } })
await tick()
expect(result.terminal.output).toContain('Responding')
result.terminal.output = ''
result.session.append('tool/call', { turn: 1, step: 0, callId: 'c1' as never, name: 'bash', arguments: '{}' })
await tick()
expect(result.terminal.output).toContain('Executing tools')
// The next step reopens the wait window and resets the executing label.
result.terminal.output = ''
result.session.append('step/start', { turn: 1, step: 1 })
await tick()
expect(result.terminal.output).toContain('Waiting for the first token')
expect(result.terminal.output).not.toContain('Executing tools')
await dispose(result)
})
it('refreshes the running status elapsed time on its own timer', async () => {
const result = await setup({ status: 'running' })
result.terminal.output = ''
// The loader repaints "0s" until the controller's own interval fires; a
// non-zero elapsed proves the refresh, not just the loader's animation.
await new Promise(resolve => setTimeout(resolve, 1_300))
expect(result.terminal.output).toMatch(/Waiting for the first token [1-9]s/)
await dispose(result)
})
it('shows minutes and seconds once a step passes a minute', async () => {
const result = await setup({ status: 'running' })
const base = Date.now()
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base + 95_000)
result.terminal.output = ''
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
await tick()
expect(result.terminal.output).toContain('total 1m')
nowSpy.mockRestore()
await dispose(result)
})
it('preserves the turn phase and elapsed time across a mid-turn color-scheme change', async () => {
const result = await setup({ status: 'running' })
const base = Date.now()
const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(base)
// Advance into `responding`, anchoring the phase clock at `base`.
result.session.append('assistant/chunk', { turn: 1, step: 0, chunk: { type: 'text-delta', index: 0, text: 'answering' } })
await tick()
// Four seconds later the terminal reports a light color scheme, rebuilding
// the status loader; the phase and its elapsed time must survive the rebuild.
nowSpy.mockReturnValue(base + 4_000)
result.terminal.output = ''
result.terminal.send('\x1b[?997;2n')
await tick()
await tick()
expect(result.terminal.output).toContain('Responding 4s')
expect(result.terminal.output).not.toContain('Waiting for the first token')
nowSpy.mockRestore()
await dispose(result)
})
it('renders the ANSI palette and every markdown/content style', async () => {
const result = await setup({
cwd: '/workspace',
@@ -519,6 +809,126 @@ describe('pi-tui chat lifecycle and transcript', () => {
await dispose(logicalResult)
})
it('shows the session cache hit rate in the footer and updates it live', async () => {
// Empty session: no input billed yet, so the cache segment is hidden.
// A cwd without "cache" in it keeps the negative assertion unambiguous.
const empty = await setup({ cwd: '/opt' })
expect(empty.terminal.output).toContain('↑0 ↓0')
expect(empty.terminal.output).not.toContain('cache')
await dispose(empty)
const result = await setup({
// Pin a short cwd so the footer never clips the cache segment: the
// default is process.cwd(), and a deep worktree path truncates
// `cache 60%` at the terminal width.
cwd: '/opt',
beforeMount(session) {
// Cold call: 10 billed input tokens, none served from cache.
appendAssistant(session, [{ type: 'text', text: 'cold' }], { inputTokens: 10, outputTokens: 5 })
},
})
expect(result.terminal.output).toContain('cache 0%')
result.terminal.output = ''
// Warm call lands live on the next step (same-step usage replaces rather
// than accumulates): 5 uncached + 30 cache-read + 5 cache-write billed
// input, so 30 of the 50 total prompt tokens are hits → 60%.
appendAssistant(result.session, [{ type: 'text', text: 'warm' }], {
inputTokens: 5,
outputTokens: 5,
cacheReadTokens: 30,
cacheWriteTokens: 5,
}, { turn: 1, step: 2 })
await tick()
expect(result.terminal.output).toContain('cache 60%')
expect(result.terminal.output).not.toContain('cache 0%')
await dispose(result)
})
it('shows detailed session diagnostics while the agent is running', async () => {
const timestamp = Date.parse('2026-07-22T09:10:11.000Z')
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp)
const result = await setup({
cwd: '/workspace/status',
contextWindow: 128_000,
contextTokens: 42_000,
config: { showReasoning: false },
agentOptions: { provider: 'deepseek', model: 'deepseek-v4-pro' },
beforeMount(session) {
session.append('session/title', {
title: 'Inspect status \u001B]2;unsafe\u0007',
messageSeqs: [1],
source: { kind: 'fallback' },
})
appendAssistant(session, [{ type: 'text', text: 'measured' }], {
inputTokens: 1_250,
outputTokens: 340,
cacheReadTokens: 3_000,
cacheWriteTokens: 250,
})
session.append('tool/call', {
turn: 1, step: 1, callId: 'status-call-1' as never, name: 'read', arguments: '{}',
})
session.append('tool/call', {
turn: 1, step: 1, callId: 'status-call-2' as never, name: 'write', arguments: '{}',
})
},
})
result.agent.status = 'running'
agentEvents(result.ctx, result.agent).emit('agent/status', 'running')
result.terminal.send('/status')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Session status')
expect(result.terminal.output).toContain('main-session')
expect(result.terminal.output).toContain('Inspect status \\x1b]2;unsafe\\x07')
expect(result.terminal.output).toContain('/workspace/status')
expect(result.terminal.output).toContain('deepseek/deepseek-v4-pro (reasoning hidden)')
expect(result.terminal.output).toContain('running · 6 events · 1 turn · 1 step · 2 tool calls')
expect(result.terminal.output).toContain('1,250 input + 340 output')
expect(result.terminal.output).toContain('[███████████░░░░░] 67% hit (3,000 read + 250 write)')
expect(result.terminal.output).toContain('[█████░░░░░░░░░░░] 33% used (42,000 / 128,000)')
expect(result.terminal.output).toContain('2026-07-22 09:10:11 UTC')
expect(result.terminal.output).not.toContain('\u001B]2;unsafe\u0007')
result.terminal.resize(56)
result.terminal.send('/redraw')
result.terminal.send('\r')
await tick()
await dispose(result)
dateNow.mockRestore()
})
it('labels unavailable status diagnostics without inventing values', async () => {
const timestamp = Date.parse('2026-07-22T10:11:12.000Z')
const dateNow = vi.spyOn(Date, 'now').mockReturnValue(timestamp)
const result = await setup({
cwd: null,
omitInitialLifecycle: true,
contextTokens: 7,
agentOptions: {},
catalog: {
providers: [],
models: [],
resolveModelContext: () => Promise.resolve(undefined),
},
})
result.terminal.send('/status')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('untitled')
expect(result.terminal.output).toContain('unset (reasoning shown)')
expect(result.terminal.output).toContain('idle · 0 events · 0 turns · 0 steps · 0 tool calls')
expect(result.terminal.output).toContain('n/a (0 read + 0 write)')
expect(result.terminal.output).toContain('7 used · capacity unknown')
expect(result.terminal.output).toContain('2026-07-22 10:11:12 UTC')
await dispose(result)
dateNow.mockRestore()
})
it('sends, steers, handles commands, global keys, and disposed-agent input', async () => {
const result = await setup()
@@ -540,17 +950,15 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\x03')
result.terminal.send('\x12')
result.terminal.send('\x0f')
result.terminal.send('/cancel')
result.terminal.send('\r')
expect(result.agent.cancelled).toContainEqual({ kind: 'user' })
result.agent.status = 'idle'
for (const command of ['/help', '/reasoning', '/tools', '/redraw']) {
for (const command of ['/help', '/reasoning', '/tools', '/redraw', '/reload']) {
result.terminal.send(command)
result.terminal.send('\r')
await tick()
}
for (const command of ['/clear', '/cancel', '/wat']) {
for (const command of ['/clear', '/wat']) {
result.terminal.send(command)
result.terminal.send('\r')
}
@@ -563,8 +971,9 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.terminal.output).toContain('Keyboard shortcuts')
expect(result.terminal.output).toContain('Reasoning blocks')
expect(result.terminal.output).toContain('Tool cards')
expect(result.terminal.output).toContain('already idle')
expect(result.terminal.output).toContain('Unknown command')
// /reload without a Loader in the context degrades to a warning.
expect(result.terminal.output).toContain('/reload needs the cordis Loader')
expect(result.exit).toHaveBeenCalledWith(0)
await result.controller.dispose()
await result.ctx.fiber.dispose()
@@ -630,7 +1039,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
expect(result.agent.steered).toEqual([])
initialContext.resolve({ contextWindow: 100 })
await tick()
expect(result.terminal.output).not.toContain('50% context tools:compact b1(reasoning:on)')
expect(result.terminal.output).not.toContain('50% context tools:collapsed')
result.terminal.send('/model')
result.terminal.send('\r')
@@ -641,7 +1050,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.agent.status = 'idle'
result.ctx.emit('agent/status', result.agent, 'idle')
await tick()
expect(result.terminal.output).toContain('25% context tools:compact b1(reasoning:on)')
expect(result.terminal.output).toContain('b1 ')
expect(result.terminal.output).toContain('25% context tools:collapsed')
const assembly = await result.ctx.systemPrompt.assemble(assembleContextFor(result.agent))
expect(assembly.variables).toMatchObject({ provider: 'beta', model: 'b1' })
@@ -686,7 +1096,8 @@ describe('pi-tui chat lifecycle and transcript', () => {
unset.terminal.send('\r')
await tick()
expect(unset.terminal.output).toContain('Model selected: alpha/a1')
expect(unset.terminal.output).toContain('context unknown tools:compact a1(reasoning:on)')
expect(unset.terminal.output).toContain('a1 ')
expect(unset.terminal.output).not.toContain('% context')
await dispose(unset)
const empty = await setup({ agentOptions: {}, catalog: { providers: [], models: [] } })
@@ -785,6 +1196,11 @@ describe('pi-tui chat lifecycle and transcript', () => {
description: 'Fail a plugin command',
handler: () => { throw new Error('plugin command exploded') },
})
result.ctx.commands.register({
name: 'plugin-error',
description: 'Return an error result',
handler: () => ({ kind: 'error' as const, text: 'plugin error result' }),
})
result.terminal.send('/plugin-check value ')
result.terminal.send('\r')
@@ -801,6 +1217,10 @@ describe('pi-tui chat lifecycle and transcript', () => {
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Command failed: plugin command exploded')
result.terminal.send('/plugin-error')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('plugin error result')
result.terminal.send('/help')
result.terminal.send('\r')
await tick()
@@ -810,6 +1230,7 @@ describe('pi-tui chat lifecycle and transcript', () => {
await result.controller.dispose()
expect(result.ctx.commands.list(result.agent).map(command => command.name)).toEqual([
'plugin-check',
'plugin-error',
'plugin-fail',
])
await result.ctx.fiber.dispose()
@@ -918,6 +1339,153 @@ describe('pi-tui chat lifecycle and transcript', () => {
})
})
describe('skill slash command', () => {
const withSkills = async (ctx: Context): Promise<void> => {
ctx.provide('tools', { get() { return undefined } } as never)
await ctx.plugin(SkillService)
const skills = ctx.get('skills')
if (skills === undefined) throw new Error('skills service not mounted')
skills.register({ name: 'demo-skill', description: 'Demo skill for tests', source: 'runtime', provider: 'runtime', content: 'Demo instructions body.' })
skills.register({ name: 'hidden-skill', description: 'Model-hidden skill', source: 'runtime', provider: 'runtime', content: 'Hidden instructions body.', disableModelInvocation: true })
}
it('offers non-hidden skills as slash completions and hides model-disabled ones', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill')
await tick()
expect(result.terminal.output).toContain('demo-skill')
expect(result.terminal.output).not.toContain('hidden-skill')
await dispose(result)
})
it('loads a skill as a user turn, appending typed instructions', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill:demo-skill')
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>' }]])
result.agent.status = 'running'
result.terminal.send('/skill:demo-skill focus on tests')
result.terminal.send('\r')
await tick()
expect(result.agent.steered).toEqual([[{ type: 'text', text: '<skill name="demo-skill">\nDemo instructions body.\n</skill>\n\nfocus on tests' }]])
await dispose(result)
})
it('invokes a model-disabled skill by its exact name', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill:hidden-skill')
result.terminal.send('\r')
await tick()
expect(result.agent.sent).toEqual([[{ type: 'text', text: '<skill name="hidden-skill">\nHidden instructions body.\n</skill>' }]])
await dispose(result)
})
it('reports an unknown skill and an empty skill name without sending', async () => {
const result = await setup({ configureContext: withSkills })
result.terminal.send('/skill:does-not-exist')
result.terminal.send('\r')
await tick()
result.terminal.send('/skill:')
result.terminal.send('\r')
await tick()
// A space right after the colon parses to an empty name, not a name of
// "focus"; the documented syntax puts the name immediately after the colon.
result.terminal.send('/skill: focus')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Unknown skill: does-not-exist')
expect(result.terminal.output).toContain('Usage: /skill:<name>')
expect(result.agent.sent).toEqual([])
await dispose(result)
})
it('warns when no skill service is mounted', async () => {
const result = await setup()
result.terminal.send('/skill:demo-skill')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Skills are not available')
expect(result.agent.sent).toEqual([])
await dispose(result)
})
it('surfaces skill lookup failures as an error notice', async () => {
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
list: () => Promise.reject(new Error('list boom')),
get: () => Promise.reject(new Error('get boom')),
} as never)
},
})
result.terminal.send('/skill:demo-skill')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('failed to load')
expect(result.terminal.output).toContain('get boom')
await dispose(result)
})
it('drops skill list and lookup results that settle after disposal', async () => {
const pendingList: Array<(value: SkillSummary[]) => void> = []
const pendingGet: Array<{ resolve: (value: SkillDefinition | undefined) => void; reject: (error: unknown) => void }> = []
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get() { return undefined } } as never)
ctx.provide('skills', {
list: () => new Promise<SkillSummary[]>((resolve) => { pendingList.push(resolve) }),
get: () => new Promise<SkillDefinition | undefined>((resolve, reject) => { pendingGet.push({ resolve, reject }) }),
} as never)
},
})
result.terminal.send('/skill:demo-skill')
result.terminal.send('\r')
await tick()
result.terminal.send('/skill:other-skill')
result.terminal.send('\r')
await tick()
await dispose(result)
for (const resolve of pendingList) resolve([{ name: 'late', description: 'late', source: 'runtime', provider: 'runtime' }])
pendingGet[0]?.resolve({ name: 'demo-skill', description: 'late', source: 'runtime', provider: 'runtime', content: 'late body' })
pendingGet[1]?.reject(new Error('late failure'))
await tick()
expect(result.agent.sent).toEqual([])
expect(result.terminal.output).not.toContain('late failure')
expect(result.terminal.output).not.toContain('late body')
})
})
describe('renderSkillInvocation', () => {
const skill: SkillDefinition = {
name: 'demo-skill',
description: 'Demo skill',
source: 'runtime',
provider: 'runtime',
content: 'Body text.',
}
it('renders directory, url, opaque, and absent resource bases', () => {
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'directory', path: '/skills/demo' } }, '')).toBe(
'<skill name="demo-skill">\nReferences in this skill are relative to /skills/demo.\n\nBody text.\n</skill>',
)
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'url', url: 'https://x/y' } }, 'go')).toBe(
'<skill name="demo-skill">\nReferences in this skill are relative to https://x/y.\n\nBody text.\n</skill>\n\ngo',
)
expect(renderSkillInvocation({ ...skill, resourceBase: { kind: 'opaque', description: 'held in memory' } }, '')).toBe(
'<skill name="demo-skill">\nheld in memory\n\nBody text.\n</skill>',
)
expect(renderSkillInvocation(skill, '')).toBe('<skill name="demo-skill">\nBody text.\n</skill>')
})
it('throws on an unknown resource base kind', () => {
expect(() => renderSkillInvocation({ ...skill, resourceBase: { kind: 'future' } as never }, '')).toThrow('unreachable variant')
})
})
describe('tool cards and surface replay', () => {
const tools: Record<string, ToolDefinition> = {
bash: {
@@ -1291,6 +1859,40 @@ describe('terminal mounting', () => {
await ctx.fiber.dispose()
})
it('degrades /reload to a warning when mounted as a real plugin without a Loader', async () => {
// Production shape: the TUI runs inside a plugin fiber, where a bare
// `ctx.loader` proxy read would THROW `cannot get property without
// inject` — only the non-throwing `ctx.get` lookup degrades gracefully.
const ctx = new Context()
provideTokenMeter(ctx)
provideLlmCatalog(ctx)
await ctx.plugin(SessionStore)
await ctx.plugin(AgentRegistry)
await ctx.plugin(CommandService)
await ctx.plugin(UserInteractionService)
ctx.provide('tools', { get: () => undefined } as never)
const session = ctx.sessions.create(SessionId('main'))
ctx.agents.register({
id: session.id, options: {}, session, status: 'idle', ctx,
send() {}, steer() {}, inject() {}, cancel() {}, whenIdle: () => Promise.resolve(),
})
const terminal = new FakeTerminal()
// Mirror dsh-tui's own inject (minus loader, the absence under test).
await ctx.plugin({
inject: ['agents', 'commands', 'userInteraction', 'tools', 'llm', 'tokenMeter'],
apply: (pluginCtx: Context) => {
mountTui(pluginCtx, { color: false }, { terminal, exit: vi.fn() })
},
})
await tick()
expect(terminal.started).toBe(1)
terminal.send('/reload')
terminal.send('\r')
await tick()
expect(terminal.output).toContain('/reload needs the cordis Loader')
await ctx.fiber.dispose()
})
it('waits for its configured agent before starting the TUI', async () => {
const ctx = new Context()
provideTokenMeter(ctx)
@@ -1472,4 +2074,175 @@ describe('terminal mounting', () => {
expect(terminal.output).toContain('\x1b[2mdeepseek-v4-flash')
await disposeTuiTestHarness(result)
})
it('runs /reload against every file-backed loader subtree, reports completion, and rejects re-entry while in flight', async () => {
const refreshed: string[] = []
let releaseRefresh!: () => void
const gate = new Promise<void>((resolve) => { releaseRefresh = resolve })
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get: () => undefined } as never)
// A structural Loader: two file-backed subtrees and one plain entry.
// The first subtree blocks on a gate so re-entry can be probed
// deterministically mid-flight.
ctx.provide('loader', {
entries: () => [
{ subtree: { refresh: async () => { refreshed.push('root'); await gate } } },
{},
{ subtree: { refresh: async () => { refreshed.push('nested') } } },
],
} as never)
},
})
result.terminal.send('/reload')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Reloading 2 config tree(s)')
// Second /reload while the first is gated: refused, no extra refreshes.
result.terminal.send('/reload')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('A config reload is already running.')
expect(refreshed.sort()).toEqual(['nested', 'root'])
releaseRefresh()
await tick()
expect(result.terminal.output).toContain('Config reload complete.')
// The guard released: a third /reload runs again.
result.terminal.send('/reload')
result.terminal.send('\r')
await tick()
expect(refreshed).toHaveLength(4)
await dispose(result)
})
it('reports a /reload failure if a refresh ever rejects', async () => {
const result = await setup({
configureContext: async (ctx) => {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('loader', {
entries: () => [{ subtree: { refresh: () => Promise.reject(new Error('disk gone')) } }],
} as never)
},
})
result.terminal.send('/reload')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('Config reload failed: disk gone')
// The failure arm also releases the re-entrancy guard.
result.terminal.send('/reload')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).not.toContain('A config reload is already running.')
await dispose(result)
})
it('refuses /reload while the agent is running and allows it back at idle', async () => {
const refreshed: string[] = []
const result = await setup({
status: 'running',
configureContext: async (ctx) => {
ctx.provide('tools', { get: () => undefined } as never)
ctx.provide('loader', {
entries: () => [{ subtree: { refresh: async () => { refreshed.push('tree') } } }],
} as never)
},
})
result.terminal.send('/reload')
result.terminal.send('\r')
await tick()
expect(result.terminal.output).toContain('/reload requires an idle agent (status: running).')
expect(refreshed).toHaveLength(0)
// Back at idle the same command runs.
result.agent.status = 'idle'
result.terminal.send('/reload')
result.terminal.send('\r')
await tick()
expect(refreshed).toHaveLength(1)
expect(result.terminal.output).toContain('Config reload complete.')
await dispose(result)
})
})
describe('banner sweep reveal', () => {
it('renders the product name through the brand-gradient path when truecolor is enabled', async () => {
// The product name carries a per-letter 24-bit gradient from the brand
// indigo to light blue; the per-letter layout is pinned by the
// `banner-gradient` terminal snapshot.
const result = await setup({ config: { color: true, truecolor: true } })
expect(result.terminal.output).toContain('\x1b[38;2;77;107;254m')
expect(result.terminal.output).toContain('\x1b[38;2;36;152;255m')
expect(result.terminal.output).toContain('HARNESS')
await dispose(result)
})
it('sweeps the whole borderless banner in when no welcome is configured, ending complete', async () => {
const intervals = vi.spyOn(globalThis, 'setInterval')
const cleared = vi.spyOn(globalThis, 'clearInterval')
const result = await setup({ omitWelcome: true })
const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType<typeof setInterval>
// Run the sweep to natural completion — it clears its own timer at the end.
const done = (): boolean => cleared.mock.calls.some(call => call[0] === revealHandle)
const deadline = Date.now() + 5000
while (!done() && Date.now() < deadline) await tick()
intervals.mockRestore()
cleared.mockRestore()
// The finished banner carries the title and the model • session detail.
expect(result.terminal.output).toContain('DEEPSEEK')
expect(result.terminal.output).toContain('HARNESS')
expect(result.terminal.output).toContain('main-session')
// Borderless: no box-drawing frame around the banner.
expect(result.terminal.output).not.toContain('╭')
expect(result.terminal.output).not.toContain('╮')
// A mid-sweep frame rendered a clipped title: `DEEPSEEK` with no `HARNESS`
// on the same line.
const clipped = result.terminal.output
.split('\n')
.some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS'))
expect(clipped).toBe(true)
await dispose(result)
})
it('renders a configured welcome verbatim in a complete banner with no sweep', async () => {
const result = await setup()
await tick()
expect(result.terminal.output).toContain('Coding agent ready.')
expect(result.terminal.output).toContain('DEEPSEEK')
expect(result.terminal.output).not.toContain('╭')
// No reveal frames: the banner is drawn whole from the first render, so no
// clipped-title frame ever appears.
const clipped = result.terminal.output
.split('\n')
.some(line => line.includes('DEEPSEEK') && !line.includes('HARNESS'))
expect(clipped).toBe(false)
await dispose(result)
})
it('omits the subtitle line entirely when no welcome is configured', async () => {
const result = await setup({ omitWelcome: true })
const deadline = Date.now() + 5000
while (!result.terminal.output.includes('main-session') && Date.now() < deadline) await tick()
// Banner is title + detail only — no subtitle between them.
expect(result.terminal.output).toContain('deepseek-v4-flash')
expect(result.terminal.output).not.toContain('ready.')
await dispose(result)
})
it('stops a mid-sweep animation on dispose', async () => {
// The output-stability probe alone is insensitive to a leaked interval
// (pi-tui's stopped guard silences post-stop renders), so capture the
// reveal's own interval handle and assert dispose clears exactly it.
const intervals = vi.spyOn(globalThis, 'setInterval')
const result = await setup({ omitWelcome: true })
const revealHandle = intervals.mock.results.at(-1)?.value as ReturnType<typeof setInterval>
expect(revealHandle).toBeDefined()
const cleared = vi.spyOn(globalThis, 'clearInterval')
await dispose(result)
expect(cleared.mock.calls.some(call => call[0] === revealHandle)).toBe(true)
intervals.mockRestore()
cleared.mockRestore()
const settled = result.terminal.output.length
await tick()
await tick()
expect(result.terminal.output.length).toBe(settled)
})
})

View File

@@ -23,6 +23,9 @@
{
"path": "../../core/session"
},
{
"path": "../../session-persistence/session-persistence"
},
{
"path": "../../session-title/session-title"
},
@@ -41,6 +44,9 @@
{
"path": "../commands"
},
{
"path": "../../skill/skill"
},
{
"path": "../user-interaction"
},