AGENTS.md · git:20260917.90bdb36 · 2026-09-17 · sha256 b37b54423db32768
AGENTS.md git:20260917.90bdb36A
Immutable. This exact content never changes and is served at /api/v1/blob/b37b54423db32768.
# AGENTS.md — working conventions for dsh-tui-pi
For agents doing work in this repository. End-user docs live in
[README.md](README.md); the full design in [ARCHITECTURE.md](ARCHITECTURE.md);
shared vocabulary in [CONTEXT.md](CONTEXT.md); decision records under
[docs/adr/](docs/adr/).
## Architecture at a glance
```
index.ts cordis plugin entry (apply + effect) — all wiring: commands,
footer, git, clock, bridge, theme hot-swap sink, shutdown
tui.ts UI shell: alt-screen, transcript ScrollView, dock, editor
rebuild on theme swap (themeRef mutable binding) +
transient footer notice slot (showNotice: stacked muted
lines above the footer, each auto-dismisses after 8s,
capped at 8; fed by the shared notice bridge)
messages.ts TranscriptRenderer: session events → components; ReplayOp
buffer + setTheme rebuild; chat-clean transcript (think/
tool/todo render in the live widgets, never here)
activity.ts ThinkPanel/ToolPanel: the fixed think/tool status panels
pinned above the chat input (one of each per run, refreshed
in place, hidden while empty; '1' = one row with identifier
+ elapsed + last line, '5'/'7'/'10'/'all' = boxed heights;
self-drawing, resize/theme-safe) + the panel helpers
(panelBodyText/clipRow/toolSubject/…)
session.ts DshSessionBridge: lazy create, followup, resume/replay,
cancel, O(1) stats, persistDefaultModel, subagent tracker
(tool-workflow + child events → AgentView rows → onLive;
child chunks fold a bounded live content tail)
dsh-events.ts local types/guards for tool-workflow/subagent/llm-retry
events (declaring packages not installed) + AgentView
live-widgets.ts LiveWidgets: the pinned surfaces around the chat input —
Todos panel + ThinkPanel + ToolPanel above (applyEvent
phase machine), ` ● ` last-request line + compact
running-agent lines below (content-line tail, right-
truncated; renderTodos/renderAgents/tickLive/setTheme;
show-when-content, clear-when-done)
append-system.ts APPEND_SYSTEM.md support (pi convention, dsh side
~/.dsh/APPEND_SYSTEM.md — a RUNTIME user file): install
seeds a fresh file from templates/APPEND_SYSTEM.md (the
English orchestrator template; content in the file, not
code); the dsh-tui-pi:todo-lifecycle section is ensured
there (idempotent marker, atomic) and migrated out of the
legacy ~/.dsh/AGENTS.md
commands.ts CommandService: parse + dual-channel dispatch + autocomplete
btw.ts /btw by-the-way side questions (TUI-owned, ADR 0001): arg
parsing (--model override), read-only recent-conversation
snapshot builder, single-flight queue, stream consumption +
controller over structural slices (unit-tested); idle main
line refuses; nothing enters the session log or the main line
btw-overlay.ts the btw overlay: framed panel bound to one run state (setText
streaming, Markdown once on settle), Esc close via the focus
contract, PanelHost two-step wiring (BtwOverlayWire)
notice-bridge.ts shared notice bridge — the ONLY channel for operator
traces (invalid dsh-tui.retention/resume settings values,
the settings-namespace registration failure, a missing
userQuestions service, the retention result): emitNotice
delivers straight to the TUI's sink once registered, else
queues bounded pending (FIFO, cap 16) drained in order on
registration; never registered (headless) → silently
dropped — deliberately no flush timer / stderr fallback
(a timer firing into a slow-starting TUI would write raw
bytes over the alt-screen). /reload-safe by module-cache
eviction + per-producer one-shot guards; a failed reload
rolls back with module state intact → still-pending is
consumed once by the restarted TUI (≤ 1 batch)
frame.ts FramedOverlay: shared top/bottom ─ border for every popup
ask-user.ts Ask User Question: pure state reducers (answer/declined
envelopes, double-Esc machine, flat-row layout) + the
DOCKED questions panel (Todos-panel box in the askUser dock
slot above the editor — takes focus while open, the app
keymap yields via dockedModalActive, no floating overlay)
+ per-question auto-answer timeouts (dual rule: idle
window + absolute cap, both <= 0 disable; recommended =
first option, plans never auto-approved, notes declared
in the answer envelope; countdown rides the panel footer)
+ ctx.userQuestions provider registration
custom-provider.ts /login "Custom provider…" entry: pure field parsers +
the chained EditField form composing a hand-declared
llm-pi-ai route (id/name/protocol/baseURL/models/key)
selectors.ts /model (2-stage, embeddable via PanelHost), /think, /theme
pickers
preset-dialog.ts /preset switch confirmation: the pure reducer + panel
(repair-dialog.ts pattern) offering FORK & switch (new
session seeded with the conversation — completedTurnSeed
slices the balanced prefix) / FRESH start / CANCEL whenever
a live session exists, plus performPresetSwitch — the
injectable three-way flow (confirm → forkCommit via
bridge.forkCurrentSession, or commit = selection + the
/new detach path); no live session applies directly
startup-info.ts startup config readout under the welcome banner (mcp
count, skills installed/total, collapsed plugin tree) +
the exit-hint pure helpers (--profile/--resume parsing,
resume-command formatting); collectStartupSummary is a
best-effort loader+filesystem snapshot
model-list.ts /model pure logic: row assembly (favorites pinned top, dim
Hidden section), filter matcher, toggle/hide-guard helpers —
data-in/data-out, unit-tested without a terminal
model-profiles.ts /profile-switch + /profile-cfg pure storage: $DSH_HOME/
model-profiles.json (atomic, self-healing reads, seeded
work/personal/other), name ops (create/rename/delete),
captureAgentsSnapshot (save-current; snapshot semantics:
non-empty entries override at compose time, empty/missing
keys fall back to the frontmatter baseline — applying
writes no agent file, see agent-runtime.ts);
directory pins — `.dsh-profile` dot file discovered walking
up from cwd (nearest wins), write/remove with a hand-edit
refusal guard, bindWorkspaceProfile (switch-time binding,
same guard) + boundProfileName (per-tree ● current)
profile.ts /profile-switch switcher + /profile-cfg manager
(agents.ts overlay pattern): switcher applies a profile
through the /model chain (bridge selection) + binds the
tree (.dsh-profile) — agent model/think values are NOT
written anywhere, the registry composes them at spawn
(src/agent-runtime.ts); `p` pins/unpins the cwd (.dsh-profile
— new sessions in this tree auto-load the pinned profile);
manager = roster table (n new, d double-press delete) →
FieldPanel (m model via the SAME favorites/hidden pickModel
table, t think, a agents sub-table, s save current, r
rename, v review)
agent-runtime.ts profile-aware agent runtime composition — the single import
seam for the dsh-subagent-registry contract
(workspaceProfileName / composeAgentRuntime /
readModelProfilesDoc): composed DISPLAY values (frontmatter
baseline ⊕ pinned-profile overrides) + the scoped EDIT path
(pin → existing profile: model-profiles.json per-agent
override, frontmatter untouched; else the frontmatter
baseline); probes the registry at load and falls back to
the built-in equivalent until it ships (per-field override,
missing keys fall back to the baseline, thinking
whitelisted against the effort ids)
settings.ts /settings browser: categories, schema walk, write chain,
add-provider flow (uses provider-catalog.ts)
sessions.ts /session panel + /resume picker (ordered by last update:
jsonl log mtimes via loadSessionLastUpdates — newest
mtime/size across BOTH log suffixes, retention's
vocabulary, best-effort fallback to createdAt; display
filter: active within maxAgeDays and log size >=
minBytes — knobs dual-layer configurable, same chain as
retention: settings.yaml `dsh-tui.resume.*` explicit
(USER layer via readSessionManagementExplicit) >
DSH_TUI_RESUME_MAX_AGE_DAYS/_MIN_BYTES env > defaults
30d/1KB, resolved per picker open by resolveResumeConfig)
history.ts /history browser (ADR 0003): the read-only two-pane
look-back — left TablePanel of the browsed session's
completed turns (live snapshot or sessionPersistence
inspect cold read; snapshot at open, never live-updating),
right pane rebuilt statically from the selected turn's
events with a self-managed scroll window (overlays get no
layout viewport, so pi-tui ScrollView cannot be used
there); `f` forks at the selected turn (turnSeedSlice →
confirmed dialog → bridge.forkCurrentSession on the CURRENT
preset, live and cold sessions alike; dialog in
history-fork.ts); HStack ≥100 columns / VStack below,
chosen per render; fixed-window geometry — exactly
overlayContentBudget() lines, short turns pad blank, only
a resize re-derives (mounted 90%×85%); a →/←/Esc focus
model between the panes (detail: ↑↓ line-scroll,
PgUp/PgDn or `[`/`]` page, other keys inert; Esc grades
detail → list → filter-clear → close; list cursor ▸
demotes to › while the detail is keyed); Enter/c refills
the editor with the turn's user prompt (plain setText,
never submitted; replaces any draft without confirmation —
prompt-less turns decline); `s` swaps the browsed session
through the /resume-style picker (`/` filters it); nothing
writes a log or takes write ownership
history-turns.ts /history pure fold: SessionEvent[] → completed
HistoryTurn[] (turn/start…turn/end brackets; unclosed
turns excluded; per-turn user prompts — claimed steer/
follow-up included, injected kinds not — step replies and
the tool-name tally) + the row filter; unit-tested
without a terminal
skills-manager.ts /skills panel (standalone skill browser): ~/.agents/skills
→ ~/.dsh/skills symlinks via an idempotent install chain
(skillSymlinkPaths + installSkillSymlink: same-source
no-op incl. relative-target equivalence, different-source
/ physical-dest refusal, dangling repair gated to
ENOENT/ELOOP) + per-item applyOneSkillChange (uninstall
is symmetric: only symlinks are removed, physical
file/dir refused) with an end-of-batch skillApplySummary
(short reasons, 3-item cap + "+N more"); a failing item
never aborts the remaining pending changes, the panel
rescans either way with the summary riding above the
rows, and an applying gate blocks Space/Enter mid-batch
retention.ts startup session-log janitor: pure selector (keep 100 /
30 days, 24h idle guard on the count rule, protected set
= current session ∪ in-flight /resume target, both exempt
and slot-free) + store walk (skips symlinks, lstat
semantics) + one-by-one fs.rm runner (rm failure → failed
count, never aborts the pass); thresholds dual-layer
configurable (settings.yaml `dsh-tui.retention.*`
explicit, read from the descriptor USER layer via
readSessionManagementExplicit — theme-settings.ts —
> DSH_TUI_RETENTION_MAX_COUNT/_MAX_AGE_DAYS/
_MIN_IDLE_HOURS env > defaults; precedence
settings > env > default, invalid settings emit one
notice each via the shared bridge and fall to the next
level, invalid env falls back silently; MAX_COUNT<=0 at
the winning layer disables — the escape hatch for
long-lived read-attach processes); result surfaces once
as a transient notice above the footer via the shared
notice bridge (emitNotice — src/notice-bridge.ts);
fire-and-forget in apply() behind a globalThis one-shot
(per process, /reload-safe); root resolved by the CORE
convention ($DSH_HOME/sessions); exports
SESSION_LOG_FILE_NAMES shared with the /resume walk
reload.ts /reload hot-reload (cordis-plugin-hmr style)
hotkeys.ts /hotkeys — keybindings.json contract + validation + the
select-panel manager (FieldPanel + EditField, /agents style)
theme-settings.ts dsh-tui settings namespace (applies: 'live') + watch sink
theme/ palette.ts (GitHub light/dark) + index.ts (buildTheme,
resolveTheme: env > preference > terminal detection —
COLORFGBG sync guess, then CSI 996n / OSC 11 query + live
CSI 997 follow for 'auto') + registry.ts (theme-file
layer: parseThemeFile validates the 15 required + 8 derived
JSON fields, scanThemeDir warn-skips invalid files,
discoverThemes merges builtin themes/ with the user theme
directory (~/.dsh/themes/, $DSH_HOME/themes when set) over
the builtins by name; resolveTheme's optional registry arg
resolves any registered name — /theme and DSH_TUI_THEME
accept them too, unknown names fall back to detection)
text.ts clipToWidth / visibleWidth — the only width vocabulary
```
Theme hot-switch chain (read this before touching anything theme-related):
`settings mutate → scope.watch → applyThemeRef (index.ts) → renderer.setTheme
(ReplayOp replay) + tui.applyTheme (canvas background + themeRef swap +
editor rebuild) → one throttled render frame`. `auto` also follows the
terminal: a CSI 996n/OSC 11 query refines the startup guess, and CSI 997
pushes repaint while the preference stays `auto` (see `stopTerminalFollow`).
Theme file layer: the 20 built-in palettes live as JSON under `themes/`
(10 light + 10 dark); user themes go in `~/.dsh/themes/` (or
`$DSH_HOME/themes/` when `DSH_HOME` is set) and override same-name builtins.
The palette contract is 15 required fields (name/dark + the canvas/fg/border/
status colors) plus 8 derived fields that are blended over `canvas` when
omitted (accentMuted 0.25 dark / 0.18 light, success/danger/attentionMuted
0.25, thinkingPanelBg 0.25 dark / 0.12 light, toolPanelBg = accentMuted,
panelBorder = borderDefault, panelBoxBorder 0.70). Registry tests live in
test/theme-registry.test.mjs; the JSON schema is documented in
docs/features/themes.md.
## Iron rules
1. **Render never re-scans.** Footer/stats read O(1) maintained counters only.
Never call `getBranch()/getEntries()` or walk the session log inside
`render()`. Every event does O(event) work.
2. **Streaming is `setText` on the existing component** — never
removeChild+addChild per delta. Markdown parses once on the assembled
`assistant/message`, never per token.
3. **All truncation goes through `clipToWidth`** (src/text.ts). Bare
`String.length` clipping is banned: CJK full-width = 2 columns, graphemes
never split. **Clip plain text BEFORE applying ANSI** — `clipToWidth`
counts SGR fragments as visible columns (verified on pi-tui 0.84.2);
`clipRow`/`clipPanelLine` (activity.ts) encodes that order.
4. **UI text is English-only** (user requirement). Chinese/emoji *content*
must render correctly — that means width-safe clipping everywhere.
5. **TypeScript constraints** (tsconfig): `NodeNext` +
`verbatimModuleSyntax` + `erasableSyntaxOnly` →
- relative imports carry the `.ts` extension (`./foo.ts`);
- `import type` for type-only imports; no enums/namespaces/parameter
properties (`constructor(private x)` is a compile error);
- `rewriteRelativeImportExtensions` handles the emit.
6. **Comments in English.** Commit messages in English, imperative mood.
7. **Never re-implement a dsh command.** Autocomplete comes from
`ctx.commands.list(agent)`, execution from `ctx.commands.execute`.
TUI-owned commands register both channels: `registerLocal` (direct dispatch
when no live agent — never mint a throwaway session) + `ctx.commands.register`.
8. **Overlay focus contract**: every overlay resolves back through
`restoreFocus` — callers must re-focus the *current* editor instance (it is
rebuilt on theme swap). Two-stage pickers show the new overlay before
hiding the old one (no focus flash). Stage-2 Esc abandons the whole pick.
9. **Resume replay split**: replay only `seq < firstLiveSeq` events —
live events arrive again through the `session/event` subscription and would
double-count. `assistant/chunk` is skipped in replay (the finalized message
carries the full text).
## Quality gates
- `pnpm check` (tsc --noEmit) must stay 0 errors.
- `pnpm test` runs **1367 tests** across 80 test files (per-file totals
below; verify after any new logic is added and update if numbers
move. New pure logic → new test file under `test/` against built
`lib/` (`node --test`, pretest builds). Update the totals and the
per-file list here. The suite is fully green.
- ask-user 119 + subagent-policy 44 + btw 45 + subagent-viewer 42 + sessions 44 + retention 40 + skills 36 +
- live 39 + history-viewer 33 + session-reconcile 33 + keymap 31 + model-profiles 31 + clipboard 26 + usage 26 + pending-echo 26 +
- login 25 + ask-user-timeout 24 + log-repair 24 + panels 24 + skills-manager 24 + startup-info 24 +
preset 22 + steer-flow 22 + theme 22 + model-list 21 +
- messages 20 + settings 21 + welcome 18 + provider-catalog 17 + agent-runtime 16 + hotkeys 16 + theme-canvas 16 + theme-settings 16 +
- text 15 + theme-registry 15 + agent-manager 14 + history 13 + footer-hints 13 +
stop-dialog 12 + session-ch-cache 12 + image-blocks 12 + custom-provider 12 +
- theme-switch 11 + preset-dialog 11 + preflight-projcache 12 + frame 11 +
i18n 10 + workspace-presets 9 + permission 9 + history-turns 9 + history-fork 9 + commands 9 + append-system 9 +
- notice-bridge 8 + font-detect 8 + dev-upgrade 8 + theme-picker 8 + language-ask 8 +
repair-session-log 7 + remote-tail 6 + quotes 7 + icons 7 + host-version 7 +
- hard-exit 3 + tokens 6 + subagent-status-tool 6 + reload 6 + queue-panel 6 + install-font 6 + bridge-stop 6 +
selection-copy 5 + markdown-latex 5 + mouse-mode 5 + projcache 5 + language-picker 5 +
transcript-search 4 + skill 4 + schema-model 3 + plugin-inject 2 + session-dir 2.
- e2e is tmux-driven: `tmux new-session -d -s dsh-tui -x 140 -y 36`, launch
`dsh --profile tui`, drive keys, `capture-pane` for assertions (scenarios
under `e2e/scenarios/`). Keep the 24-row terminal case in the matrix —
overlay maxHeights are tuned for it.
- Review cycle ("老法师"): findings are labeled by class — A (architecture /
UX / focus), B (behavioral bugs), C (cosmetic). Fixes land with regression
tests where the finding is testable. Do not start a new feature before the
review round on the current one is closed.
## Config safety — read before any test or e2e
- `~/.dsh/settings.yaml` and `~/.dsh/.credentials.yaml` are **LIVE
configuration** of the user's dsh. Tests and e2e runs that touch them MUST
snapshot both files first and restore them byte-for-byte afterwards (verify
with a diff; the e2e gate requires an empty diff).
- The user may be running their own dsh instances concurrently. The settings
file is **last-write-wins**: never assume you own it, never rewrite whole
sections from a stale snapshot. All writes go through
`settings.mutate(ns, pathOps, revision)` (optimistic concurrency, one retry
on `SettingsConflictError`) or service APIs that do their own
`settings.replace` (e.g. `agentDefaultModel.saveSelection`).
- The settings service mounts asynchronously in the injection fiber
(~144ms transient `undefined`): wait bounded (the theme-settings
registration promise pattern) and degrade, never hang startup.
## Known pi-tui 0.84.2 limitations (do not fight them)
- **Layout does not descend into a plain Container**: a Container without a
layout node renders by concatenation, so a nested ScrollView inside the
transcript can never obtain a viewport. The fixed think/tool panels with a
tail body (`dsh-tui.panelHeight`: '1' one row, '5'/'7'/'10' boxed rows, or
'all' with a bounded streaming tail and a 2000-line tool-result cap) are
the accepted design — no inner scrolling.
- **The canvas background is painted by our write-stream decorator, not by
components**: rows are written with erase-line (`\x1b[2K`) + content, so
unpainted rows would show the terminal's default background and a theme
switch would leave it frozen (most visible inside cmux/gostty).
`src/canvas-terminal.ts` wraps the `ProcessTerminal` handed to
`TuiAltScreen` and injects the canvas SGR at two points: before every
`\x1b[2K`/`\x1b[2J` (BCE — terminals fill erased regions with the current
SGR background, covering row tails) and after every background-clearing
SGR reset (`\x1b[0m`, `\x1b[m`, `\x1b[0;…m`, `\x1b[49m` — content after a
reset would otherwise print cells with the terminal default background,
punching holes into the canvas; color-sets whose params merely contain a
0 channel are NOT resets). Zero pi-tui patches. Diff-rendered rows the
renderer skips keep their last paint, which is why `applyTheme` must
force a full redraw via `requestRender(true)`. `DSH_TUI_TRANSPARENT=1`
(checked in `src/tui.ts`) opts back into the see-through canvas. The
alt-screen exit dump passes through unpainted (decorator shutoff on
`EXIT_ALT_SCREEN`).
- **SelectListTheme has no background hook for unselected rows**: the value
part of unselected rows renders raw (`renderItem` → `prefix + truncatedValue`),
so it cannot get the `canvasSubtle` backdrop. Only the selected row,
descriptions, scroll info and no-match line are themed.
- **A truncated label drops the selected-row backdrop after the cut**:
pi-tui 0.84.2's `truncateToWidth` terminates any truncated result with a
full `\x1b[0m` reset (`finalizeTruncatedResult`), so when a SELECTED skill
row's label is cut, everything after the cut — the spacing and the
description — loses the selected background/bold (the `\x1b[0m` ends
`selectedText`'s span early). Pre-existing upstream behaviour, unrelated
to the whole-line italic tweak (the italic off-code is `\x1b[23m`; the
non-truncated backdrop path is regression-tested in test/skills.test.mjs).
- **Input has no masking**: secret fields are rendered by our own
`EditField` dot-row renderer (maskLine) over a real Input; the value never
reaches the render output.
- **SettingsList's search input row is pushed raw** (settings-list.js, first
line with `enableSearch`), no theme injection. Also: its search matches
`label` only (fuzzyFilter) — English labels carry the searchable keywords;
`done(undefined)` closes a submenu without changing anything.